fix: choose buckets
This commit is contained in:
parent
db9c63a096
commit
27d8630611
|
|
@ -5,6 +5,7 @@ import sql from "../db";
|
||||||
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
||||||
const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]);
|
const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]);
|
||||||
const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]);
|
const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]);
|
||||||
|
const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]);
|
||||||
|
|
||||||
const StatusPageBody = t.Object({
|
const StatusPageBody = t.Object({
|
||||||
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
||||||
|
|
@ -18,6 +19,8 @@ const StatusPageBody = t.Object({
|
||||||
show_cert_expiry: t.Optional(t.Boolean()),
|
show_cert_expiry: t.Optional(t.Boolean()),
|
||||||
default_window: t.Optional(Window),
|
default_window: t.Optional(Window),
|
||||||
display_mode: t.Optional(DisplayMode),
|
display_mode: t.Optional(DisplayMode),
|
||||||
|
bar_frequency: t.Optional(BarFrequency),
|
||||||
|
bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })),
|
||||||
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
||||||
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||||
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
||||||
|
|
@ -126,6 +129,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
||||||
INSERT INTO status_pages (
|
INSERT INTO status_pages (
|
||||||
account_id, slug, title, description, theme, password_hash, index_search,
|
account_id, slug, title, description, theme, password_hash, index_search,
|
||||||
show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode,
|
show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode,
|
||||||
|
bar_frequency, bar_count,
|
||||||
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
|
@ -133,6 +137,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
||||||
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
||||||
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
||||||
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'},
|
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'},
|
||||||
|
${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90},
|
||||||
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
|
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
|
||||||
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
||||||
)
|
)
|
||||||
|
|
@ -189,6 +194,8 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
||||||
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
||||||
default_window = COALESCE(${body.default_window ?? null}, default_window),
|
default_window = COALESCE(${body.default_window ?? null}, default_window),
|
||||||
display_mode = COALESCE(${body.display_mode ?? null}, display_mode),
|
display_mode = COALESCE(${body.display_mode ?? null}, display_mode),
|
||||||
|
bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency),
|
||||||
|
bar_count = COALESCE(${body.bar_count ?? null}, bar_count),
|
||||||
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
||||||
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
||||||
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,10 @@ export async function migrate(sql: any) {
|
||||||
// Display mode: 'compact' = one-line rows with click-to-expand details,
|
// Display mode: 'compact' = one-line rows with click-to-expand details,
|
||||||
// 'expanded' = full detail card always visible (default).
|
// 'expanded' = full detail card always visible (default).
|
||||||
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS display_mode TEXT NOT NULL DEFAULT 'expanded'`;
|
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS display_mode TEXT NOT NULL DEFAULT 'expanded'`;
|
||||||
|
// Heartbeat bar settings: granularity + how many bars. Independent from
|
||||||
|
// default_window (which still drives the primary uptime % + multi-window cells).
|
||||||
|
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS bar_frequency TEXT NOT NULL DEFAULT 'daily'`;
|
||||||
|
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS bar_count INTEGER NOT NULL DEFAULT 90`;
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS status_page_groups (
|
CREATE TABLE IF NOT EXISTS status_page_groups (
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,6 @@ import sql from "./db";
|
||||||
export type Window = "24h" | "7d" | "30d" | "90d";
|
export type Window = "24h" | "7d" | "30d" | "90d";
|
||||||
export type BucketType = "hourly" | "daily";
|
export type BucketType = "hourly" | "daily";
|
||||||
|
|
||||||
const WINDOW_TO_BUCKET: Record<Window, { bucket: BucketType; count: number }> = {
|
|
||||||
"24h": { bucket: "hourly", count: 24 },
|
|
||||||
"7d": { bucket: "daily", count: 7 },
|
|
||||||
"30d": { bucket: "daily", count: 30 },
|
|
||||||
"90d": { bucket: "daily", count: 90 }, // 90 daily bars; 90 rows per monitor, cached.
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface StatusPageRow {
|
export interface StatusPageRow {
|
||||||
id: string;
|
id: string;
|
||||||
account_id: string;
|
account_id: string;
|
||||||
|
|
@ -28,6 +21,8 @@ export interface StatusPageRow {
|
||||||
show_cert_expiry: boolean;
|
show_cert_expiry: boolean;
|
||||||
default_window: Window;
|
default_window: Window;
|
||||||
display_mode: "compact" | "expanded";
|
display_mode: "compact" | "expanded";
|
||||||
|
bar_frequency: BucketType;
|
||||||
|
bar_count: number;
|
||||||
custom_css: string | null;
|
custom_css: string | null;
|
||||||
footer_text: string | null;
|
footer_text: string | null;
|
||||||
og_image_url: string | null;
|
og_image_url: string | null;
|
||||||
|
|
@ -153,7 +148,13 @@ export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadMonitors(pageId: string, window: Window, pageDisplayMode: "compact" | "expanded" = "expanded"): Promise<MonitorRow[]> {
|
export async function loadMonitors(
|
||||||
|
pageId: string,
|
||||||
|
window: Window,
|
||||||
|
pageDisplayMode: "compact" | "expanded" = "expanded",
|
||||||
|
barFrequency: BucketType = "daily",
|
||||||
|
barCount: number = 90,
|
||||||
|
): Promise<MonitorRow[]> {
|
||||||
// Step 1: page → monitors with display overrides + group + position.
|
// Step 1: page → monitors with display overrides + group + position.
|
||||||
const monitorRows = await sql<any[]>`
|
const monitorRows = await sql<any[]>`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -188,11 +189,13 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: uptime rollup buckets covering the requested window. We keep
|
// Step 3: uptime rollup buckets covering the requested window. The bar
|
||||||
// region in the result so JS can pick the fastest region per monitor and
|
// frequency + count are admin-controlled per page (independent of the
|
||||||
// emit per-bucket latency from just that region (status pages are
|
// multi-window uptime cells). We keep region in the result so JS can pick
|
||||||
// customer-facing, we show our best foot forward).
|
// the fastest region per monitor and emit per-bucket latency from just
|
||||||
const { bucket, count } = WINDOW_TO_BUCKET[window];
|
// that region (status pages are customer-facing — show the best line).
|
||||||
|
const bucket: BucketType = barFrequency;
|
||||||
|
const count = Math.max(1, Math.min(180, barCount));
|
||||||
const truncUnit = bucket === "hourly" ? "hour" : "day";
|
const truncUnit = bucket === "hourly" ? "hour" : "day";
|
||||||
const intervalLiteral = `${count} ${truncUnit}s`;
|
const intervalLiteral = `${count} ${truncUnit}s`;
|
||||||
let rollupRows = await sql<any[]>`
|
let rollupRows = await sql<any[]>`
|
||||||
|
|
@ -432,7 +435,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
||||||
const win = (window ?? page.default_window) as Window;
|
const win = (window ?? page.default_window) as Window;
|
||||||
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state
|
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state
|
||||||
// logic in one place. Cheap because we're querying for one ID.
|
// logic in one place. Cheap because we're querying for one ID.
|
||||||
const monitors = await loadMonitors(page.id, win, page.display_mode);
|
const monitors = await loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count);
|
||||||
const m = monitors.find((x) => x.id === monitorId);
|
const m = monitors.find((x) => x.id === monitorId);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
|
|
||||||
|
|
@ -493,7 +496,7 @@ export async function loadPagePayload(slug: string, window?: Window): Promise<Pa
|
||||||
const win = (window ?? page.default_window) as Window;
|
const win = (window ?? page.default_window) as Window;
|
||||||
const [groups, monitors, incidents] = await Promise.all([
|
const [groups, monitors, incidents] = await Promise.all([
|
||||||
loadGroups(page.id),
|
loadGroups(page.id),
|
||||||
loadMonitors(page.id, win, page.display_mode),
|
loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count),
|
||||||
loadIncidents(page.id),
|
loadIncidents(page.id),
|
||||||
]);
|
]);
|
||||||
const { password_hash, ...publicPage } = page;
|
const { password_hash, ...publicPage } = page;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
// class on click. Reads its config from window.PINGQL_PAGE.
|
// class on click. Reads its config from window.PINGQL_PAGE.
|
||||||
(function () {
|
(function () {
|
||||||
var cfg = window.PINGQL_PAGE || {};
|
var cfg = window.PINGQL_PAGE || {};
|
||||||
var defaultWindow = cfg.default_window || "24h";
|
var barFrequency = cfg.bar_frequency || "daily";
|
||||||
|
|
||||||
// ── Click to expand/collapse ──────────────────────────────────────────
|
// ── Click to expand/collapse ──────────────────────────────────────────
|
||||||
var rows = document.querySelectorAll(".monitor .monitor-row");
|
var rows = document.querySelectorAll(".monitor .monitor-row");
|
||||||
|
|
@ -23,13 +23,13 @@
|
||||||
var tooltip = document.getElementById("bar-tooltip");
|
var tooltip = document.getElementById("bar-tooltip");
|
||||||
if (!tooltip) return;
|
if (!tooltip) return;
|
||||||
|
|
||||||
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 : 86400 * 1000;
|
var bucketSpanMs = (barFrequency === "hourly") ? 3600 * 1000 : 86400 * 1000;
|
||||||
function fmtBucketRange(startIso) {
|
function fmtBucketRange(startIso) {
|
||||||
var start = new Date(startIso);
|
var start = new Date(startIso);
|
||||||
var end = new Date(start.getTime() + bucketSpanMs);
|
var end = new Date(start.getTime() + bucketSpanMs);
|
||||||
var dateOpts = { month: "short", day: "numeric" };
|
var dateOpts = { month: "short", day: "numeric" };
|
||||||
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
||||||
if (defaultWindow === "24h") {
|
if (barFrequency === "hourly") {
|
||||||
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
||||||
start.toLocaleTimeString(undefined, timeOpts) + " — " +
|
start.toLocaleTimeString(undefined, timeOpts) + " — " +
|
||||||
end.toLocaleTimeString(undefined, timeOpts);
|
end.toLocaleTimeString(undefined, timeOpts);
|
||||||
|
|
|
||||||
|
|
@ -257,10 +257,9 @@
|
||||||
// page-level isCompact is kept only as a hint for whether to emit the
|
// page-level isCompact is kept only as a hint for whether to emit the
|
||||||
// expand JS at all.
|
// expand JS at all.
|
||||||
const anyCompact = monitors.some(m => m.display_mode === 'compact');
|
const anyCompact = monitors.some(m => m.display_mode === 'compact');
|
||||||
const windowLabel = page.default_window === '24h' ? 'Last 24 hours'
|
const windowLabel = page.bar_frequency === 'hourly'
|
||||||
: page.default_window === '7d' ? 'Last 7 days'
|
? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's'))
|
||||||
: page.default_window === '30d' ? 'Last 30 days'
|
: ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's'));
|
||||||
: 'Last 90 days';
|
|
||||||
function fmtTimestamp(iso) {
|
function fmtTimestamp(iso) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
@ -366,7 +365,7 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="bar-tooltip" role="tooltip"></div>
|
<div id="bar-tooltip" role="tooltip"></div>
|
||||||
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, default_window: page.default_window, show_response_time: !!page.show_response_time }) %>;</script>
|
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, default_window: page.default_window, bar_frequency: page.bar_frequency, show_response_time: !!page.show_response_time }) %>;</script>
|
||||||
<script src="/_static/expand.js?v=<%= it.expandJsHash %>" defer></script>
|
<script src="/_static/expand.js?v=<%= it.expandJsHash %>" defer></script>
|
||||||
|
|
||||||
<% if (page.auto_refresh_s > 0) { %>
|
<% if (page.auto_refresh_s > 0) { %>
|
||||||
|
|
|
||||||
|
|
@ -728,6 +728,8 @@ export const dashboard = new Elysia()
|
||||||
theme: b.theme || "auto",
|
theme: b.theme || "auto",
|
||||||
default_window: b.default_window || "24h",
|
default_window: b.default_window || "24h",
|
||||||
display_mode: b.display_mode || "expanded",
|
display_mode: b.display_mode || "expanded",
|
||||||
|
bar_frequency: b.bar_frequency || "daily",
|
||||||
|
bar_count: Number(b.bar_count) || 90,
|
||||||
show_response_time: !!b.show_response_time,
|
show_response_time: !!b.show_response_time,
|
||||||
show_powered_by: !!b.show_powered_by,
|
show_powered_by: !!b.show_powered_by,
|
||||||
index_search: !!b.index_search,
|
index_search: !!b.index_search,
|
||||||
|
|
@ -756,6 +758,8 @@ export const dashboard = new Elysia()
|
||||||
theme: b.theme || "auto",
|
theme: b.theme || "auto",
|
||||||
default_window: b.default_window || "24h",
|
default_window: b.default_window || "24h",
|
||||||
display_mode: b.display_mode || "expanded",
|
display_mode: b.display_mode || "expanded",
|
||||||
|
bar_frequency: b.bar_frequency || "daily",
|
||||||
|
bar_count: Number(b.bar_count) || 90,
|
||||||
show_response_time: !!b.show_response_time,
|
show_response_time: !!b.show_response_time,
|
||||||
show_powered_by: !!b.show_powered_by,
|
show_powered_by: !!b.show_powered_by,
|
||||||
index_search: !!b.index_search,
|
index_search: !!b.index_search,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Heartbeat bar granularity</label>
|
||||||
|
<select name="bar_frequency" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [['daily','One bar per day'],['hourly','One bar per hour']].forEach(function([v, label]) { %>
|
||||||
|
<option value="<%= v %>" <%= (p.bar_frequency || 'daily') === v ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Number of bars</label>
|
||||||
|
<select name="bar_count" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [12, 24, 48, 7, 14, 30, 60, 90, 120, 180].forEach(function(n) { %>
|
||||||
|
<option value="<%= n %>" <%= Number(p.bar_count || 90) === n ? 'selected' : '' %>><%= n %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 -mt-3">The heartbeat bar shows the last N hours or days. The four uptime cells (24h / 7d / 30d / 90d) are independent and always present.</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
|
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
|
||||||
<p class="text-xs text-gray-600 mb-2">Tick to attach. Drag to reorder. "Show as" overrides the name on this page only. "Mode" picks compact or expanded for that one monitor (or leave blank to use the page default).</p>
|
<p class="text-xs text-gray-600 mb-2">Tick to attach. Drag to reorder. "Show as" overrides the name on this page only. "Mode" picks compact or expanded for that one monitor (or leave blank to use the page default).</p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue