update: status page, api
This commit is contained in:
+50
-19
@@ -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;
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
%>
|
||||
<% 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>
|
||||
|
||||
Reference in New Issue
Block a user