From 601c918e9fb58f43ab367132c5a959e1e5b8a684 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 16:26:01 +0400 Subject: [PATCH] feat: improve status page --- apps/api/src/routes/status_pages.ts | 7 +- apps/shared/db.ts | 3 + apps/status/src/data.ts | 134 ++++++++++++++- apps/status/src/index.ts | 27 ++- apps/status/src/views/page.ejs | 216 ++++++++++++++++++++---- apps/web/src/routes/dashboard.ts | 34 +++- apps/web/src/views/status-page-edit.ejs | 34 +++- 7 files changed, 408 insertions(+), 47 deletions(-) diff --git a/apps/api/src/routes/status_pages.ts b/apps/api/src/routes/status_pages.ts index 78e1725..32b94c8 100644 --- a/apps/api/src/routes/status_pages.ts +++ b/apps/api/src/routes/status_pages.ts @@ -4,6 +4,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 StatusPageBody = t.Object({ slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }), @@ -16,6 +17,7 @@ const StatusPageBody = t.Object({ show_response_time: t.Optional(t.Boolean()), show_cert_expiry: t.Optional(t.Boolean()), default_window: t.Optional(Window), + display_mode: t.Optional(DisplayMode), 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 }))), @@ -121,14 +123,14 @@ export const statusPages = new Elysia({ prefix: "/status-pages" }) [row] = await sql` 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, + show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode, custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s ) VALUES ( ${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null}, ${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.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'}, ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, ${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60} ) @@ -184,6 +186,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" }) show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time), 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), 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 09f5840..eb37078 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -152,6 +152,9 @@ export async function migrate(sql: any) { ) `; await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`; + // 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'`; 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 03c0787..5c253d2 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -27,6 +27,7 @@ export interface StatusPageRow { show_response_time:boolean; show_cert_expiry: boolean; default_window: Window; + display_mode: "compact" | "expanded"; custom_css: string | null; footer_text: string | null; og_image_url: string | null; @@ -34,6 +35,13 @@ export interface StatusPageRow { auto_refresh_s: number; } +export interface MultiWindowUptime { + d24: number | null; + d7: number | null; + d30: number | null; + d90: number | null; +} + export interface MonitorRow { id: string; display_name: string; @@ -43,11 +51,70 @@ export interface MonitorRow { current_state: "up" | "down" | "unknown"; region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>; uptime_pct: number | null; // for the page's default_window + uptime: MultiWindowUptime; // 24h / 7d / 30d / 90d row buckets: Array<{ start: string; total: number; up: number }>; // bar chart input avg_latency: number | null; latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>; } +// Single SQL pass that produces all four uptime windows for a set of monitors. +// Reads only the rollup table; falls back to a pings aggregate when the rollup +// has nothing for these monitors yet (same pattern as loadMonitors). +export async function loadMultiWindowUptime(monitorIds: string[]): Promise> { + const empty: Record = {}; + if (monitorIds.length === 0) return empty; + for (const id of monitorIds) empty[id] = { d24: null, d7: null, d30: null, d90: null }; + + const ids = sql.array(monitorIds); + + let rows = await sql` + SELECT monitor_id, + (sum(up_count) FILTER (WHERE bucket_type='hourly' AND bucket_start > now() - interval '24 hours'))::float + / NULLIF(sum(total) FILTER (WHERE bucket_type='hourly' AND bucket_start > now() - interval '24 hours'), 0) AS pct_24h, + (sum(up_count) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '7 days'))::float + / NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '7 days'), 0) AS pct_7d, + (sum(up_count) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '30 days'))::float + / NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '30 days'), 0) AS pct_30d, + (sum(up_count) FILTER (WHERE bucket_type='weekly' AND bucket_start > now() - interval '90 days'))::float + / NULLIF(sum(total) FILTER (WHERE bucket_type='weekly' AND bucket_start > now() - interval '90 days'), 0) AS pct_90d + FROM monitor_uptime_rollup + WHERE monitor_id = ANY(${ids}::text[]) + GROUP BY 1 + `; + + // Fallback when the rollup is empty: aggregate directly from pings. Bounded + // by the 90d window so it's still cheap. + if (rows.length === 0) { + rows = await sql` + SELECT monitor_id, + (count(*) FILTER (WHERE up AND checked_at > now() - interval '24 hours'))::float + / NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '24 hours'), 0) AS pct_24h, + (count(*) FILTER (WHERE up AND checked_at > now() - interval '7 days'))::float + / NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '7 days'), 0) AS pct_7d, + (count(*) FILTER (WHERE up AND checked_at > now() - interval '30 days'))::float + / NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '30 days'), 0) AS pct_30d, + (count(*) FILTER (WHERE up AND checked_at > now() - interval '90 days'))::float + / NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '90 days'), 0) AS pct_90d + FROM pings + WHERE monitor_id = ANY(${ids}::text[]) + AND checked_at > now() - interval '90 days' + GROUP BY 1 + `; + } + + const out = empty; + const toPct = (v: any): number | null => v == null ? null : +(Number(v) * 100).toFixed(2); + for (const r of rows) { + out[r.monitor_id] = { + d24: toPct(r.pct_24h), + d7: toPct(r.pct_7d), + d30: toPct(r.pct_30d), + d90: toPct(r.pct_90d), + }; + } + return out; +} + export interface GroupRow { id: string; name: string; @@ -192,7 +259,10 @@ export async function loadMonitors(pageId: string, window: Window): Promise` SELECT monitor_id, region, bucket_start, avg_latency FROM monitor_uptime_rollup @@ -237,6 +307,7 @@ export async function loadMonitors(pageId: string, window: Window): Promise { + const page = await loadStatusPage(slug); + if (!page) return null; + // Confirm the monitor is actually attached to this page (and load any + // page-specific overrides at the same time). + const [link] = await sql` + SELECT spm.monitor_id, COALESCE(spm.display_name, m.name) AS display_name, m.url, spm.group_id, spm.position + FROM status_page_monitors spm + JOIN monitors m ON m.id = spm.monitor_id + WHERE spm.status_page_id = ${page.id} AND spm.monitor_id = ${monitorId} + `; + if (!link) return null; + + 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); + const m = monitors.find((x) => x.id === monitorId); + if (!m) return null; + + // Incidents touching this monitor (any status), most recent 20. + const incidentRows = await sql` + SELECT i.* + FROM incidents i + JOIN incident_monitors im ON im.incident_id = i.id + WHERE im.monitor_id = ${monitorId} AND i.account_id = ${page.account_id} + ORDER BY i.started_at DESC + LIMIT 20 + `; + let incidents: IncidentSummary[] = []; + if (incidentRows.length > 0) { + const ids = incidentRows.map((i) => i.id); + const updates = await sql` + SELECT DISTINCT ON (incident_id) incident_id, body_html + FROM incident_updates + WHERE incident_id = ANY(${sql.array(ids)}::uuid[]) + ORDER BY incident_id, created_at DESC + `; + const latestByIncident: Record = {}; + for (const u of updates) latestByIncident[u.incident_id] = u.body_html; + incidents = incidentRows.map((i) => ({ + id: i.id, + title: i.title, + status: i.status, + severity: i.severity, + pinned: i.pinned, + started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at), + resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null, + latest_update_html: latestByIncident[i.id] ?? null, + })); + } + + return { monitor: m, incidents, generated_at: new Date().toISOString() }; +} + export interface PagePayload { page: Omit & { has_password: boolean }; groups: GroupRow[]; diff --git a/apps/status/src/index.ts b/apps/status/src/index.ts index 1d5370b..ef4e932 100644 --- a/apps/status/src/index.ts +++ b/apps/status/src/index.ts @@ -2,7 +2,7 @@ import { Elysia } from "elysia"; import { Eta } from "eta"; import { resolve } from "path"; import sql from "./db"; -import { loadStatusPage, loadPagePayload, type Window } from "./data"; +import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data"; import { renderRss } from "./render/rss"; import { renderBadge, badgeFromState } from "./render/badge"; import { cached } from "./cache"; @@ -129,6 +129,31 @@ const app = new Elysia() }); }) + // Per-monitor detail JSON for the click-to-expand UI in compact mode. + // Path is /:slug/monitor/:idWithExt where idWithExt is e.g. "abc123.json". + // We strip the .json suffix in the handler — same trick as the slug route to + // dodge memoirist's "two params at the same position" rule. + .get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => { + if (!allow(params.slug, clientIp(request))) return rateLimited(); + const idWithExt = params.idWithExt; + const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt; + const win = (query as any)?.window as Window | undefined; + const cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`; + const payload = await cached(cacheKey, 60, () => loadMonitorDetail(params.slug, monitorId, win)); + if (!payload) { + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(payload), { + headers: { + "content-type": "application/json", + "cache-control": "public, max-age=30, s-maxage=60", + }, + }); + }) + // PWA manifest .get("/:slug/manifest.json", async ({ params }) => { const page = await loadStatusPage(params.slug); diff --git a/apps/status/src/views/page.ejs b/apps/status/src/views/page.ejs index 359d5fc..1e4581b 100644 --- a/apps/status/src/views/page.ejs +++ b/apps/status/src/views/page.ejs @@ -34,6 +34,17 @@ if (b.up === 0) return 'var(--bar-down)'; return 'var(--bar-partial)'; } + function uptimeBand(p) { + if (p == null) return 'empty'; + if (p >= 99.9) return 'good'; + if (p >= 99.0) return 'warn'; + return 'bad'; + } + function fmtUptime(p) { + if (p == null) return '—'; + if (p === 100) return '100%'; + return p.toFixed(2) + '%'; + } // Overall status: down if any monitor is down, degraded if any partial, else up. let overall = 'up'; @@ -106,6 +117,25 @@ .bar { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; } .bar:hover { opacity: 0.8; } .bars-meta { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; } + /* Multi-window uptime row: four labelled cells side by side. */ + .uptime-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; margin-top: 0.75rem; } + .uptime-cell { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.65rem; } + .uptime-cell .label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; } + .uptime-cell .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; } + .uptime-cell.good .value { color: var(--bar-up); } + .uptime-cell.warn .value { color: var(--bar-partial); } + .uptime-cell.bad .value { color: var(--bar-down); } + .uptime-cell.empty .value { color: var(--muted); } + /* Compact mode: each monitor is a one-line button. Detail expands beneath. */ + .monitor.compact { padding: 0; overflow: hidden; } + .monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.85rem 1.25rem; cursor: pointer; background: transparent; border: none; color: inherit; width: 100%; text-align: left; font-family: inherit; font-size: inherit; } + .monitor-row:hover { background: rgba(255,255,255,0.02); } + .monitor-row .chev { color: var(--muted); transition: transform 0.15s; flex-shrink: 0; } + .monitor.expanded-state .monitor-row .chev { transform: rotate(90deg); } + .monitor-detail { padding: 0 1.25rem 1rem; border-top: 1px solid var(--border); display: none; } + .monitor.expanded-state .monitor-detail { display: block; padding-top: 1rem; } + .monitor-detail .skeleton { height: 80px; background: linear-gradient(90deg, var(--card), var(--bg), var(--card)); background-size: 200% 100%; animation: shimmer 1.2s infinite; border-radius: 6px; } + @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; } .region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); } .region.up { color: var(--green); border-color: rgba(16,185,129,0.3); } @@ -152,6 +182,13 @@ <% } %> + <% + const isCompact = page.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'; + %> <% groupOrder.forEach(function(gid) { const list = grouped[gid]; if (!list || list.length === 0) return; @@ -159,43 +196,63 @@ %> <% if (groupName) { %>
<%= groupName %>
<% } %>
- <% list.forEach(function(m) { %> -
-
-
- - <%= m.display_name %> -
-
- <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> - <%= fmtPct(m.uptime_pct) %> + <% list.forEach(function(m) { + const buckets = m.buckets || []; + const hasData = buckets.some(b => b.total > 0); + const u = m.uptime || { d24: null, d7: null, d30: null, d90: null }; + %> + <% if (isCompact) { %> +
+ +
+
- <% - const buckets = m.buckets || []; - 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 hasData = buckets.some(b => b.total > 0); - %> -
- <% buckets.forEach(function(b) { %> -
- <% }); %> -
-
- <%= windowLabel %> - <%= hasData ? fmtPct(m.uptime_pct) + ' uptime' : 'awaiting data' %> -
- <% if (m.region_states && m.region_states.length > 1) { %> -
- <% m.region_states.forEach(function(r) { %> - <%= r.region %> + <% } else { %> +
+
+
+ + <%= m.display_name %> +
+
+ <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> + <%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %> +
+
+
+ <% buckets.forEach(function(b) { %> +
<% }); %>
- <% } %> -
+
+ <%= windowLabel %> + <%= hasData ? 'uptime over window' : 'awaiting data' %> +
+
+
24h
<%= fmtUptime(u.d24) %>
+
7d
<%= fmtUptime(u.d7) %>
+
30d
<%= fmtUptime(u.d30) %>
+
90d
<%= fmtUptime(u.d90) %>
+
+ <% if (m.region_states && m.region_states.length > 1) { %> +
+ <% m.region_states.forEach(function(r) { %> + <%= r.region %> + <% }); %> +
+ <% } %> +
+ <% } %> <% }); %>
<% }); %> @@ -220,6 +277,99 @@ + <% if (isCompact) { %> + + <% } %> + <% if (page.auto_refresh_s > 0) { %>