From 8f7ac6bb4b1350aa8ca50e7e91b751350181a238 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 16:38:11 +0400 Subject: [PATCH] update: status page, api --- apps/api/src/routes/status_pages.ts | 6 +- apps/shared/db.ts | 2 + apps/status/src/data.ts | 69 ++++++++++---- apps/status/src/views/page.ejs | 121 ++++++++++++++++++------ apps/web/src/routes/dashboard.ts | 101 ++++++++++++++------ apps/web/src/views/status-page-edit.ejs | 62 ++++++++++-- 6 files changed, 275 insertions(+), 86 deletions(-) diff --git a/apps/api/src/routes/status_pages.ts b/apps/api/src/routes/status_pages.ts index 32b94c8..4349e05 100644 --- a/apps/api/src/routes/status_pages.ts +++ b/apps/api/src/routes/status_pages.ts @@ -31,6 +31,7 @@ const StatusPageBody = t.Object({ monitor_id: t.String(), group_index: t.Optional(t.Nullable(t.Number())), display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))), + display_mode: t.Optional(t.Nullable(DisplayMode)), position: t.Optional(t.Number()), }))), }); @@ -53,7 +54,7 @@ async function replaceGroupsAndMonitors( pageId: string, accountId: string, groups: { name: string; position?: number }[] | undefined, - monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined, + monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; display_mode?: "compact" | "expanded" | null; position?: number }[] | undefined, ) { if (groups !== undefined) { await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`; @@ -92,12 +93,13 @@ async function replaceGroupsAndMonitors( monitor_id: m.monitor_id, group_id: groupId, display_name: m.display_name ?? null, + display_mode: m.display_mode ?? null, position: m.position ?? i, }); } if (rows.length > 0) { await sql` - INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")} + INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "display_mode", "position")} `; } } diff --git a/apps/shared/db.ts b/apps/shared/db.ts index eb37078..eb877b6 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -177,6 +177,8 @@ export async function migrate(sql: any) { ) `; await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`; + // Per-monitor display mode override. NULL = inherit status_pages.display_mode. + await sql`ALTER TABLE status_page_monitors ADD COLUMN IF NOT EXISTS display_mode TEXT`; await sql` CREATE TABLE IF NOT EXISTS incidents ( diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts index 5c253d2..29d05ce 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -48,6 +48,7 @@ export interface MonitorRow { url: string; group_id: string | null; position: number; + display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded') 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 @@ -121,6 +122,13 @@ export interface GroupRow { position: number; } +export interface IncidentUpdateRow { + id: string; + status: string; + body_html: string; + created_at: string; +} + export interface IncidentSummary { id: string; title: string; @@ -129,7 +137,7 @@ export interface IncidentSummary { pinned: boolean; started_at: string; resolved_at: string | null; - latest_update_html: string | null; + updates: IncidentUpdateRow[]; // full timeline, newest first } export async function loadStatusPage(slug: string): Promise { @@ -145,7 +153,7 @@ export async function loadGroups(pageId: string): Promise { `; } -export async function loadMonitors(pageId: string, window: Window): Promise { +export async function loadMonitors(pageId: string, window: Window, pageDisplayMode: "compact" | "expanded" = "expanded"): Promise { // Step 1: page → monitors with display overrides + group + position. const monitorRows = await sql` SELECT @@ -153,7 +161,8 @@ export async function loadMonitors(pageId: string, window: Window): Promise 0 ? Math.round(latAcc.sum / latAcc.n) : null; + // Per-monitor display mode override → page default → 'expanded'. + const display_mode = (m.spm_display_mode === 'compact' || m.spm_display_mode === 'expanded') + ? m.spm_display_mode + : pageDisplayMode; return { id: m.id, display_name: m.display_name, url: m.url, group_id: m.group_id, position: m.position, + display_mode, current_state, region_states, uptime_pct, @@ -327,15 +341,24 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS if (incidents.length === 0) return { active: [], recent: [] }; const ids = incidents.map((i) => i.id); - // Latest update html per incident. - const latestUpdates = await sql` - SELECT DISTINCT ON (incident_id) incident_id, body_html, status, created_at + // Full timeline per incident (newest first), so the public page can show the + // entire course of events on both active and resolved incidents. + const allUpdates = await sql` + SELECT id, incident_id, status, body_html, created_at FROM incident_updates WHERE incident_id = ANY(${sql.array(ids)}::uuid[]) - ORDER BY incident_id, created_at DESC + ORDER BY created_at DESC `; - const latestByIncident: Record = {}; - for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html; + const updatesByIncident: Record = {}; + for (const u of allUpdates) { + if (!updatesByIncident[u.incident_id]) updatesByIncident[u.incident_id] = []; + updatesByIncident[u.incident_id]!.push({ + id: u.id, + status: u.status, + body_html: u.body_html, + created_at: u.created_at instanceof Date ? u.created_at.toISOString() : String(u.created_at), + }); + } const enriched: IncidentSummary[] = incidents.map((i) => ({ id: i.id, @@ -345,7 +368,7 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS 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, + updates: updatesByIncident[i.id] ?? [], })); const active = enriched.filter((i) => i.pinned && !i.resolved_at); @@ -375,11 +398,11 @@ 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); + const monitors = await loadMonitors(page.id, win, page.display_mode); const m = monitors.find((x) => x.id === monitorId); if (!m) return null; - // Incidents touching this monitor (any status), most recent 20. + // Incidents touching this monitor (any status), most recent 20, full timeline. const incidentRows = await sql` SELECT i.* FROM incidents i @@ -391,14 +414,22 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window? 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 + const allUpdates = await sql` + SELECT id, incident_id, status, body_html, created_at FROM incident_updates WHERE incident_id = ANY(${sql.array(ids)}::uuid[]) - ORDER BY incident_id, created_at DESC + ORDER BY created_at DESC `; - const latestByIncident: Record = {}; - for (const u of updates) latestByIncident[u.incident_id] = u.body_html; + const updatesByIncident: Record = {}; + for (const u of allUpdates) { + if (!updatesByIncident[u.incident_id]) updatesByIncident[u.incident_id] = []; + updatesByIncident[u.incident_id]!.push({ + id: u.id, + status: u.status, + body_html: u.body_html, + created_at: u.created_at instanceof Date ? u.created_at.toISOString() : String(u.created_at), + }); + } incidents = incidentRows.map((i) => ({ id: i.id, title: i.title, @@ -407,7 +438,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window? 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, + updates: updatesByIncident[i.id] ?? [], })); } @@ -428,7 +459,7 @@ export async function loadPagePayload(slug: string, window?: Window): Promise<%= overallText %> + <% + // Shared timeline render — used for both active (top of page) and past + // (bottom) incidents. Standard status-page pattern: every update from + // every status the incident has been in, newest first. + function renderIncident(i) { + const klass = i.resolved_at ? `incident ${i.severity} resolved` : `incident ${i.severity}`; + const startedFmt = fmtTimestamp(i.started_at); + const resolvedFmt = i.resolved_at ? fmtTimestamp(i.resolved_at) : null; + let html = `
`; + html += `
${escapeHtmlSSR(i.title)}
`; + html += `
${i.status}`; + html += `Started ${startedFmt}`; + if (resolvedFmt) html += ` · Resolved ${resolvedFmt}`; + html += `
`; + if (i.updates && i.updates.length > 0) { + html += `
`; + for (const u of i.updates) { + html += `
`; + html += `
${u.status}${fmtTimestamp(u.created_at)}
`; + html += `
${u.body_html}
`; + html += `
`; + } + html += `
`; + } + html += `
`; + return html; + } + // Eta doesn't have a built-in HTML escape helper exposed at template + // scope, so define a tiny one inline. Only used for incident titles. + function escapeHtmlSSR(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + %> <% if (incidents.active.length > 0) { %>
- <% incidents.active.forEach(function(i) { %> -
-
<%= i.title %>
-
<%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %>
- <% if (i.latest_update_html) { %>
<%~ i.latest_update_html %>
<% } %> -
- <% }); %> + <% incidents.active.forEach(function(i) { %><%~ renderIncident(i) %><% }); %>
<% } %> <% - const isCompact = page.display_mode === 'compact'; + // Per-monitor mode now lives on each MonitorRow.display_mode (already + // resolved against the page-level fallback by the data layer). The + // page-level isCompact is kept only as a hint for whether to emit the + // expand JS at all. + const anyCompact = monitors.some(m => 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'; + function fmtTimestamp(iso) { + const d = new Date(iso); + return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + } %> <% groupOrder.forEach(function(gid) { const list = grouped[gid]; @@ -200,8 +250,9 @@ const buckets = m.buckets || []; const hasData = buckets.some(b => b.total > 0); const u = m.uptime || { d24: null, d7: null, d30: null, d90: null }; + const monitorIsCompact = m.display_mode === 'compact'; %> - <% if (isCompact) { %> + <% if (monitorIsCompact) { %>