update: status page, api

This commit is contained in:
2026-04-08 16:38:11 +04:00
parent 5b3994b042
commit 8f7ac6bb4b
6 changed files with 275 additions and 86 deletions
+50 -19
View File
@@ -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<StatusPageRow | null> {
@@ -145,7 +153,7 @@ export async function loadGroups(pageId: string): Promise<GroupRow[]> {
`;
}
export async function loadMonitors(pageId: string, window: Window): Promise<MonitorRow[]> {
export async function loadMonitors(pageId: string, window: Window, pageDisplayMode: "compact" | "expanded" = "expanded"): Promise<MonitorRow[]> {
// Step 1: page → monitors with display overrides + group + position.
const monitorRows = await sql<any[]>`
SELECT
@@ -153,7 +161,8 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
COALESCE(spm.display_name, m.name) AS display_name,
m.url,
spm.group_id,
spm.position
spm.position,
spm.display_mode AS spm_display_mode
FROM status_page_monitors spm
JOIN monitors m ON m.id = spm.monitor_id
WHERE spm.status_page_id = ${pageId}
@@ -298,12 +307,17 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
}
const latAcc = latencyByMonitor[m.id];
const avg_latency = latAcc && latAcc.n > 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<any[]>`
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<any[]>`
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<string, string> = {};
for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html;
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
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<any[]>`
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<any[]>`
SELECT DISTINCT ON (incident_id) incident_id, body_html
const allUpdates = await sql<any[]>`
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<string, string> = {};
for (const u of updates) latestByIncident[u.incident_id] = u.body_html;
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
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<Pa
const win = (window ?? page.default_window) as Window;
const [groups, monitors, incidents] = await Promise.all([
loadGroups(page.id),
loadMonitors(page.id, win),
loadMonitors(page.id, win, page.display_mode),
loadIncidents(page.id),
]);
const { password_hash, ...publicPage } = page;
+93 -28
View File
@@ -141,18 +141,33 @@
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
.incidents { margin-bottom: 2rem; }
.incident { background: var(--card); border-left: 4px solid var(--amber); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.incident.critical { border-left-color: var(--red); }
.incident.major { border-left-color: var(--amber); }
.incident { background: var(--card); border-left: 4px solid var(--bar-partial); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.incident.critical { border-left-color: var(--bar-down); }
.incident.major { border-left-color: var(--bar-partial); }
.incident.minor { border-left-color: var(--muted); }
.incident.resolved { border-left-color: var(--bar-up); opacity: 0.85; }
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
.incident-body p { margin: 0.5rem 0; }
.incident-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
.incident-meta .pill { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 999px; border: 1px solid var(--border); margin-right: 0.4rem; text-transform: capitalize; }
.incident-meta .pill.investigating { color: var(--bar-partial); border-color: rgba(245,158,11,0.3); }
.incident-meta .pill.identified { color: var(--bar-partial); border-color: rgba(245,158,11,0.3); }
.incident-meta .pill.monitoring { color: var(--accent); border-color: rgba(14,165,233,0.3); }
.incident-meta .pill.resolved { color: var(--bar-up); border-color: rgba(16,185,129,0.3); }
/* Full incident timeline (newest at top). One block per update. */
.incident-timeline { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.incident-update { padding: 0.5rem 0; border-bottom: 1px dashed var(--border); }
.incident-update:last-child { border-bottom: none; padding-bottom: 0; }
.incident-update .head { font-size: 0.75rem; color: var(--muted); margin-bottom: 0.2rem; }
.incident-update .head .status { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 4px; font-weight: 600; text-transform: capitalize; margin-right: 0.4rem; }
.incident-update .head .status.investigating { background: rgba(245,158,11,0.15); color: var(--bar-partial); }
.incident-update .head .status.identified { background: rgba(245,158,11,0.15); color: var(--bar-partial); }
.incident-update .head .status.monitoring { background: rgba(14,165,233,0.15); color: var(--accent); }
.incident-update .head .status.resolved { background: rgba(16,185,129,0.15); color: var(--bar-up); }
.incident-update .body { font-size: 0.875rem; }
.incident-update .body p { margin: 0.25rem 0; }
.incident-update .body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.past-incidents { margin-top: 3rem; }
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
.past { padding: 0.75rem 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 1rem; }
.past-title { font-weight: 500; }
.past-meta { color: var(--muted); font-size: 0.8rem; }
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
@@ -170,24 +185,59 @@
<span><%= overallText %></span>
</div>
<%
// 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 = `<div id="incident-${i.id}" class="${klass}">`;
html += `<div class="incident-title">${escapeHtmlSSR(i.title)}</div>`;
html += `<div class="incident-meta"><span class="pill ${i.status}">${i.status}</span>`;
html += `Started ${startedFmt}`;
if (resolvedFmt) html += ` · Resolved ${resolvedFmt}`;
html += `</div>`;
if (i.updates && i.updates.length > 0) {
html += `<div class="incident-timeline">`;
for (const u of i.updates) {
html += `<div class="incident-update">`;
html += `<div class="head"><span class="status ${u.status}">${u.status}</span><span>${fmtTimestamp(u.created_at)}</span></div>`;
html += `<div class="body">${u.body_html}</div>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
%>
<% if (incidents.active.length > 0) { %>
<div class="incidents">
<% incidents.active.forEach(function(i) { %>
<div id="incident-<%= i.id %>" class="incident <%= i.severity %>">
<div class="incident-title"><%= i.title %></div>
<div class="incident-meta"><%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %></div>
<% if (i.latest_update_html) { %><div class="incident-body"><%~ i.latest_update_html %></div><% } %>
</div>
<% }); %>
<% incidents.active.forEach(function(i) { %><%~ renderIncident(i) %><% }); %>
</div>
<% } %>
<%
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) { %>
<div class="monitor compact" data-monitor-id="<%= m.id %>">
<button type="button" class="monitor-row" aria-expanded="false">
<div class="monitor-name">
@@ -260,14 +311,7 @@
<% if (incidents.recent.length > 0) { %>
<div class="past-incidents">
<h2>Past incidents</h2>
<% incidents.recent.forEach(function(i) { %>
<div class="past">
<div>
<div class="past-title"><%= i.title %></div>
<div class="past-meta"><%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %></div>
</div>
</div>
<% }); %>
<% incidents.recent.forEach(function(i) { %><%~ renderIncident(i) %><% }); %>
</div>
<% } %>
@@ -277,7 +321,7 @@
</footer>
</main>
<% if (isCompact) { %>
<% if (anyCompact) { %>
<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;
@@ -326,8 +370,29 @@
? `<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>` : '';
// Render the full incident timeline for any incidents touching this monitor.
function fmtTs(iso) {
const d = new Date(iso);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function renderIncidentClient(i) {
const klass = i.resolved_at ? `incident ${i.severity} resolved` : `incident ${i.severity}`;
let h = `<div class="${klass}" style="margin-top:1rem"><div class="incident-title">${escapeHtml(i.title)}</div>`;
h += `<div class="incident-meta"><span class="pill ${i.status}">${i.status}</span>Started ${fmtTs(i.started_at)}`;
if (i.resolved_at) h += ` · Resolved ${fmtTs(i.resolved_at)}`;
h += `</div>`;
if (i.updates && i.updates.length > 0) {
h += `<div class="incident-timeline">`;
for (const u of i.updates) {
h += `<div class="incident-update"><div class="head"><span class="status ${u.status}">${u.status}</span><span>${fmtTs(u.created_at)}</span></div><div class="body">${u.body_html}</div></div>`;
}
h += `</div>`;
}
h += `</div>`;
return h;
}
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>`
? payload.incidents.map(renderIncidentClient).join('')
: '';
return `
<div class="bars" title="${m.current_state}">${barsHtml}</div>