From 27d8630611c176493004cf7b5a77fa96122bc6de Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 02:23:11 +0400 Subject: [PATCH] fix: choose buckets --- apps/api/src/routes/status_pages.ts | 7 ++++++ apps/shared/db.ts | 4 +++ apps/status/src/data.ts | 33 ++++++++++++++----------- apps/status/src/static/expand.js | 6 ++--- apps/status/src/views/page.ejs | 9 +++---- apps/web/src/routes/dashboard.ts | 4 +++ apps/web/src/views/status-page-edit.ejs | 20 +++++++++++++++ 7 files changed, 60 insertions(+), 23 deletions(-) diff --git a/apps/api/src/routes/status_pages.ts b/apps/api/src/routes/status_pages.ts index 4349e05..f829968 100644 --- a/apps/api/src/routes/status_pages.ts +++ b/apps/api/src/routes/status_pages.ts @@ -5,6 +5,7 @@ import sql from "../db"; 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 DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]); +const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]); const StatusPageBody = t.Object({ 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()), default_window: t.Optional(Window), 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 }))), footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))), 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 ( account_id, slug, title, description, theme, password_hash, index_search, 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 ) VALUES ( @@ -133,6 +137,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" }) ${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? 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.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90}, ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, ${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), default_window = COALESCE(${body.default_window ?? null}, default_window), 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, footer_text = COALESCE(${body.footer_text ?? null}, footer_text), og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url), diff --git a/apps/shared/db.ts b/apps/shared/db.ts index eb877b6..fac68ef 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -155,6 +155,10 @@ export async function migrate(sql: any) { // Display mode: 'compact' = one-line rows with click-to-expand details, // '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'`; + // 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` CREATE TABLE IF NOT EXISTS status_page_groups ( diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts index 312f09c..d68614e 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -7,13 +7,6 @@ import sql from "./db"; export type Window = "24h" | "7d" | "30d" | "90d"; export type BucketType = "hourly" | "daily"; -const WINDOW_TO_BUCKET: Record = { - "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 { id: string; account_id: string; @@ -28,6 +21,8 @@ export interface StatusPageRow { show_cert_expiry: boolean; default_window: Window; display_mode: "compact" | "expanded"; + bar_frequency: BucketType; + bar_count: number; custom_css: string | null; footer_text: string | null; og_image_url: string | null; @@ -153,7 +148,13 @@ export async function loadGroups(pageId: string): Promise { `; } -export async function loadMonitors(pageId: string, window: Window, pageDisplayMode: "compact" | "expanded" = "expanded"): Promise { +export async function loadMonitors( + pageId: string, + window: Window, + pageDisplayMode: "compact" | "expanded" = "expanded", + barFrequency: BucketType = "daily", + barCount: number = 90, +): Promise { // Step 1: page → monitors with display overrides + group + position. const monitorRows = await sql` 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 - // region in the result so JS can pick the fastest region per monitor and - // emit per-bucket latency from just that region (status pages are - // customer-facing, we show our best foot forward). - const { bucket, count } = WINDOW_TO_BUCKET[window]; + // Step 3: uptime rollup buckets covering the requested window. The bar + // frequency + count are admin-controlled per page (independent of the + // multi-window uptime cells). We keep region in the result so JS can pick + // the fastest region per monitor and emit per-bucket latency from just + // 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 intervalLiteral = `${count} ${truncUnit}s`; let rollupRows = await sql` @@ -432,7 +435,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window? const win = (window ?? page.default_window) as Window; // 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. - 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); if (!m) return null; @@ -493,7 +496,7 @@ export async function loadPagePayload(slug: string, window?: Window): Promise m.display_mode === 'compact'); - const windowLabel = page.default_window === '24h' ? 'Last 24 hours' - : page.default_window === '7d' ? 'Last 7 days' - : page.default_window === '30d' ? 'Last 30 days' - : 'Last 90 days'; + const windowLabel = page.bar_frequency === 'hourly' + ? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's')) + : ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's')); function fmtTimestamp(iso) { const d = new Date(iso); return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); @@ -366,7 +365,7 @@ - + <% if (page.auto_refresh_s > 0) { %> diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 67b910c..ac0f02d 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -728,6 +728,8 @@ export const dashboard = new Elysia() theme: b.theme || "auto", default_window: b.default_window || "24h", 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_powered_by: !!b.show_powered_by, index_search: !!b.index_search, @@ -756,6 +758,8 @@ export const dashboard = new Elysia() theme: b.theme || "auto", default_window: b.default_window || "24h", 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_powered_by: !!b.show_powered_by, index_search: !!b.index_search, diff --git a/apps/web/src/views/status-page-edit.ejs b/apps/web/src/views/status-page-edit.ejs index f2dad31..d091d93 100644 --- a/apps/web/src/views/status-page-edit.ejs +++ b/apps/web/src/views/status-page-edit.ejs @@ -79,6 +79,26 @@ +
+
+ + +
+
+ + +
+
+

The heartbeat bar shows the last N hours or days. The four uptime cells (24h / 7d / 30d / 90d) are independent and always present.

+

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).