feat: improve status page

This commit is contained in:
2026-04-08 16:26:01 +04:00
parent 60037edf21
commit 601c918e9f
7 changed files with 408 additions and 47 deletions
+133 -1
View File
@@ -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<Record<string, MultiWindowUptime>> {
const empty: Record<string, MultiWindowUptime> = {};
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<any[]>`
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<any[]>`
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<Moni
});
}
// Step 4: tiny recent latency history for the sparkline (last 30 hourly buckets).
// Step 4: multi-window uptime row (24h / 7d / 30d / 90d) per monitor.
const multiWindow = await loadMultiWindowUptime(ids);
// Step 5: tiny recent latency history for the sparkline (last 30 hourly buckets).
const latRows = await sql<any[]>`
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<Moni
current_state,
region_states,
uptime_pct,
uptime: multiWindow[m.id] ?? { d24: null, d7: null, d30: null, d90: null },
buckets,
avg_latency,
latency_history: latencyByMonitorList[m.id] ?? [],
@@ -282,6 +353,67 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS
return { active, recent };
}
export interface MonitorDetailPayload {
monitor: MonitorRow;
incidents: IncidentSummary[]; // recent incidents that touch this monitor
generated_at: string;
}
export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> {
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<any[]>`
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<any[]>`
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<any[]>`
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<string, string> = {};
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<StatusPageRow, "password_hash"> & { has_password: boolean };
groups: GroupRow[];
+26 -1
View File
@@ -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);
+183 -33
View File
@@ -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 @@
</div>
<% } %>
<%
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) { %><div class="group-title"><%= groupName %></div><% } %>
<div class="monitors">
<% list.forEach(function(m) { %>
<div class="monitor">
<div class="monitor-head">
<div class="monitor-name">
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
<span class="name"><%= m.display_name %></span>
</div>
<div class="monitor-meta">
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
<span class="uptime-pct"><%= fmtPct(m.uptime_pct) %></span>
<% 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) { %>
<div class="monitor compact" data-monitor-id="<%= m.id %>">
<button type="button" class="monitor-row" aria-expanded="false">
<div class="monitor-name">
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
<span class="name"><%= m.display_name %></span>
</div>
<div class="monitor-meta">
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span>
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</button>
<div class="monitor-detail" data-loaded="false">
<div class="skeleton"></div>
</div>
</div>
<%
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);
%>
<div class="bars" title="<%= statusLabel(m.current_state) %>">
<% buckets.forEach(function(b) { %>
<div class="bar" style="background: <%= bucketColor(b) %>;" title="<%= b.total > 0 ? (Math.round(100 * b.up / b.total) + '% up') : 'no data' %>"></div>
<% }); %>
</div>
<div class="bars-meta">
<span><%= windowLabel %></span>
<span><%= hasData ? fmtPct(m.uptime_pct) + ' uptime' : 'awaiting data' %></span>
</div>
<% if (m.region_states && m.region_states.length > 1) { %>
<div class="regions">
<% m.region_states.forEach(function(r) { %>
<span class="region <%= r.state %>"><%= r.region %></span>
<% } else { %>
<div class="monitor" data-monitor-id="<%= m.id %>">
<div class="monitor-head">
<div class="monitor-name">
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
<span class="name"><%= m.display_name %></span>
</div>
<div class="monitor-meta">
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span>
</div>
</div>
<div class="bars" title="<%= statusLabel(m.current_state) %>">
<% buckets.forEach(function(b) { %>
<div class="bar" style="background: <%= bucketColor(b) %>;" title="<%= b.total > 0 ? (Math.round(100 * b.up / b.total) + '% up') : 'no data' %>"></div>
<% }); %>
</div>
<% } %>
</div>
<div class="bars-meta">
<span><%= windowLabel %></span>
<span><%= hasData ? 'uptime over window' : 'awaiting data' %></span>
</div>
<div class="uptime-row">
<div class="uptime-cell <%= uptimeBand(u.d24) %>"><div class="label">24h</div><div class="value"><%= fmtUptime(u.d24) %></div></div>
<div class="uptime-cell <%= uptimeBand(u.d7) %>"><div class="label">7d</div><div class="value"><%= fmtUptime(u.d7) %></div></div>
<div class="uptime-cell <%= uptimeBand(u.d30) %>"><div class="label">30d</div><div class="value"><%= fmtUptime(u.d30) %></div></div>
<div class="uptime-cell <%= uptimeBand(u.d90) %>"><div class="label">90d</div><div class="value"><%= fmtUptime(u.d90) %></div></div>
</div>
<% if (m.region_states && m.region_states.length > 1) { %>
<div class="regions">
<% m.region_states.forEach(function(r) { %>
<span class="region <%= r.state %>"><%= r.region %></span>
<% }); %>
</div>
<% } %>
</div>
<% } %>
<% }); %>
</div>
<% }); %>
@@ -220,6 +277,99 @@
</footer>
</main>
<% if (isCompact) { %>
<script>
// Click-to-expand for compact display mode. First click on a monitor row
// fetches /<slug>/monitor/<id>.json once and renders the detail inline;
// subsequent clicks just toggle visibility.
(function() {
const slug = <%~ JSON.stringify(page.slug) %>;
const defaultWindow = <%~ JSON.stringify(page.default_window) %>;
const showResponseTime = <%~ JSON.stringify(!!page.show_response_time) %>;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function statusColor(s) {
return s === 'up' ? 'var(--bar-up)' : s === 'down' ? 'var(--bar-down)' : 'var(--muted)';
}
function bucketColor(b) {
if (!b || b.total === 0) return 'var(--bar-empty)';
if (b.up === b.total) return 'var(--bar-up)';
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) + '%';
}
function windowLabel(w) {
return w === '24h' ? 'Last 24 hours' : w === '7d' ? 'Last 7 days' : w === '30d' ? 'Last 30 days' : 'Last 90 days';
}
function renderDetail(payload) {
const m = payload.monitor;
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
const buckets = m.buckets || [];
const hasData = buckets.some(b => b.total > 0);
const barsHtml = buckets.map(b =>
`<div class="bar" style="background: ${bucketColor(b)};" title="${b.total > 0 ? Math.round(100 * b.up / b.total) + '% up' : 'no data'}"></div>`
).join('');
const regionsHtml = (m.region_states && m.region_states.length > 1)
? `<div class="regions">${m.region_states.map(r => `<span class="region ${r.state}">${escapeHtml(r.region)}</span>`).join('')}</div>`
: '';
const latencyHtml = showResponseTime && m.avg_latency != null ? `<span>${m.avg_latency}ms · </span>` : '';
const incidentsHtml = (payload.incidents && payload.incidents.length > 0)
? `<div class="bars-meta" style="margin-top:0.75rem"><span>Recent incidents</span><span>${payload.incidents.length}</span></div>`
: '';
return `
<div class="bars" title="${m.current_state}">${barsHtml}</div>
<div class="bars-meta"><span>${windowLabel(defaultWindow)}</span><span>${latencyHtml}${hasData ? 'uptime over window' : 'awaiting data'}</span></div>
<div class="uptime-row">
<div class="uptime-cell ${uptimeBand(u.d24)}"><div class="label">24h</div><div class="value">${fmtUptime(u.d24)}</div></div>
<div class="uptime-cell ${uptimeBand(u.d7)}"><div class="label">7d</div><div class="value">${fmtUptime(u.d7)}</div></div>
<div class="uptime-cell ${uptimeBand(u.d30)}"><div class="label">30d</div><div class="value">${fmtUptime(u.d30)}</div></div>
<div class="uptime-cell ${uptimeBand(u.d90)}"><div class="label">90d</div><div class="value">${fmtUptime(u.d90)}</div></div>
</div>
${regionsHtml}
${incidentsHtml}
`;
}
document.querySelectorAll('.monitor.compact').forEach(card => {
const id = card.dataset.monitorId;
const button = card.querySelector('.monitor-row');
const detail = card.querySelector('.monitor-detail');
button.addEventListener('click', async () => {
const isOpen = card.classList.toggle('expanded-state');
button.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (isOpen && detail.dataset.loaded === 'false') {
try {
const r = await fetch('/' + slug + '/monitor/' + encodeURIComponent(id) + '.json', { cache: 'no-store' });
if (!r.ok) {
detail.innerHTML = '<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
return;
}
const payload = await r.json();
detail.innerHTML = renderDetail(payload);
detail.dataset.loaded = 'true';
} catch (e) {
detail.innerHTML = '<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
}
}
});
});
})();
</script>
<% } %>
<% if (page.auto_refresh_s > 0) { %>
<script>
// Auto-refresh data without a full page reload. Polls /<slug>.json and