feat: improve status page

This commit is contained in:
nate 2026-04-08 16:26:01 +04:00
parent 60037edf21
commit 601c918e9f
7 changed files with 408 additions and 47 deletions

View File

@ -4,6 +4,7 @@ import sql from "../db";
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]);
const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]);
const StatusPageBody = t.Object({
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
@ -16,6 +17,7 @@ const StatusPageBody = t.Object({
show_response_time: t.Optional(t.Boolean()),
show_cert_expiry: t.Optional(t.Boolean()),
default_window: t.Optional(Window),
display_mode: t.Optional(DisplayMode),
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
@ -121,14 +123,14 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
[row] = await sql`
INSERT INTO status_pages (
account_id, slug, title, description, theme, password_hash, index_search,
show_powered_by, show_response_time, show_cert_expiry, default_window,
show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode,
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
)
VALUES (
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'},
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'},
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
)
@ -184,6 +186,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time),
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
default_window = COALESCE(${body.default_window ?? null}, default_window),
display_mode = COALESCE(${body.display_mode ?? null}, display_mode),
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),

View File

@ -152,6 +152,9 @@ export async function migrate(sql: any) {
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
// Display mode: 'compact' = one-line rows with click-to-expand details,
// 'expanded' = full detail card always visible (default).
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS display_mode TEXT NOT NULL DEFAULT 'expanded'`;
await sql`
CREATE TABLE IF NOT EXISTS status_page_groups (

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[];

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);

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

View File

@ -636,7 +636,7 @@ export const dashboard = new Elysia()
`;
if (!page) return redirect("/dashboard/status-pages");
const monitors = await sql`
SELECT monitor_id FROM status_page_monitors WHERE status_page_id = ${params.id}
SELECT monitor_id, display_name FROM status_page_monitors WHERE status_page_id = ${params.id}
`;
const allMonitors = await sql`
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
@ -650,6 +650,21 @@ export const dashboard = new Elysia()
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
// The form posts per-monitor display name overrides as display_name[<id>].
// Bun's body parser surfaces them as nested object keys (display_name) or
// as flat "display_name[id]" string keys depending on parser version, so
// handle both. Empty strings mean "no override" — don't send them.
const displayNames: Record<string, string> = {};
if (b.display_name && typeof b.display_name === "object" && !Array.isArray(b.display_name)) {
for (const [k, v] of Object.entries(b.display_name)) {
if (typeof v === "string" && v.trim()) displayNames[k] = v.trim();
}
} else {
for (const k of Object.keys(b)) {
const m = k.match(/^display_name\[(.+)\]$/);
if (m && typeof b[k] === "string" && b[k].trim()) displayNames[m[1]!] = b[k].trim();
}
}
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
@ -662,13 +677,14 @@ export const dashboard = new Elysia()
description: b.description || null,
theme: b.theme || "auto",
default_window: b.default_window || "24h",
display_mode: b.display_mode || "expanded",
show_response_time: !!b.show_response_time,
show_powered_by: !!b.show_powered_by,
index_search: !!b.index_search,
password: b.password || undefined,
custom_css: b.custom_css || null,
footer_text: b.footer_text || null,
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i, display_name: displayNames[id] ?? null })),
}),
});
} catch {}
@ -680,6 +696,17 @@ export const dashboard = new Elysia()
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
const displayNames: Record<string, string> = {};
if (b.display_name && typeof b.display_name === "object" && !Array.isArray(b.display_name)) {
for (const [k, v] of Object.entries(b.display_name)) {
if (typeof v === "string" && v.trim()) displayNames[k] = v.trim();
}
} else {
for (const k of Object.keys(b)) {
const m = k.match(/^display_name\[(.+)\]$/);
if (m && typeof b[k] === "string" && b[k].trim()) displayNames[m[1]!] = b[k].trim();
}
}
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
@ -689,12 +716,13 @@ export const dashboard = new Elysia()
description: b.description || null,
theme: b.theme || "auto",
default_window: b.default_window || "24h",
display_mode: b.display_mode || "expanded",
show_response_time: !!b.show_response_time,
show_powered_by: !!b.show_powered_by,
index_search: !!b.index_search,
custom_css: b.custom_css || null,
footer_text: b.footer_text || null,
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i, display_name: displayNames[id] ?? null })),
};
// Only send `password` if the user actually typed something. An empty box
// means "leave the existing password as-is" — sending null would clear it.

View File

@ -4,7 +4,11 @@
<%
const p = it.page || {};
const allMonitors = it.allMonitors || [];
const attached = new Set((it.page?.monitors || []).map(m => m.monitor_id));
const attachedRows = (it.page?.monitors || []);
const attached = new Set(attachedRows.map(m => m.monitor_id));
// monitor_id → existing display_name override (or empty)
const displayNames = {};
for (const r of attachedRows) displayNames[r.monitor_id] = r.display_name || '';
%>
<main class="max-w-3xl mx-auto px-8 py-10">
@ -52,19 +56,35 @@
<% }) %>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Display mode</label>
<select name="display_mode" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% [['expanded','Expanded — show full detail per monitor'],['compact','Compact — one line per monitor, click to expand']].forEach(function([v, label]) { %>
<option value="<%= v %>" <%= (p.display_mode || 'expanded') === v ? 'selected' : '' %>><%= label %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
<p class="text-xs text-gray-600 mb-2">Optional "Show as" field overrides the monitor name on this status page only. Leave blank to use the monitor's real name.</p>
<% if (allMonitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> first.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allMonitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attached.has(m.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= m.name %></span>
</label>
<div class="space-y-2">
<% allMonitors.forEach(function(m) {
const isAttached = attached.has(m.id);
const displayName = displayNames[m.id] || '';
%>
<div class="flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
<label class="flex items-center gap-2 cursor-pointer min-w-0 flex-1">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= isAttached ? 'checked' : '' %>>
<span class="text-sm text-gray-300 truncate"><%= m.name %></span>
</label>
<input type="text" name="display_name[<%= m.id %>]" value="<%= displayName %>" placeholder="Show as (optional)"
class="text-xs bg-gray-950 border border-gray-800 rounded px-2 py-1 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 w-48 shrink-0">
</div>
<% }) %>
</div>
<% } %>