feat: improve status page
This commit is contained in:
+133
-1
@@ -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[];
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user