diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts index 27f4fd7..148fd2f 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -44,7 +44,10 @@ export interface MonitorRow { group_id: string | null; position: number; display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded') - current_state: "up" | "down" | "unknown"; + // 'paused' means the monitor was disabled in the dashboard — the runner has + // stopped checking it, and the public page should treat it as planned + // maintenance rather than an outage. + current_state: "up" | "down" | "unknown" | "paused"; 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 @@ -103,12 +106,17 @@ export async function loadMonitors( barFrequency: BucketType = "daily", barCount: number = 90, ): Promise { - // Step 1: page → monitors with display overrides + group + position. + // Step 1: page → monitors with display overrides + group + position. Pull + // m.enabled too so we can render disabled monitors as "Maintenance" on the + // public page (the runner stops checking them when disabled, so their + // region_states would otherwise drift to a stale "up" — visitors should + // see this as planned downtime, not phantom uptime). const monitorRows = await sql` SELECT spm.monitor_id AS id, COALESCE(spm.display_name, m.name) AS display_name, m.url, + m.enabled AS enabled, spm.group_id, spm.position, spm.display_mode AS spm_display_mode @@ -356,9 +364,13 @@ export async function loadMonitors( } // Multi-window uptime is a straight read from the windowTotals accumulator. + // We deliberately do NOT round to 2 decimals here — the formatter on the + // public page truncates (not rounds) to 2 decimals so a value like 99.9999% + // doesn't visually round up to "100.00%". Pre-rounding here would erase that + // information before the formatter ever sees it. const multiWindow: Record = {}; const toPct = (up: number, total: number): number | null => - total > 0 ? +(100 * up / total).toFixed(2) : null; + total > 0 ? (100 * up / total) : null; for (const id of ids) { const wt = windowTotals[id]; multiWindow[id] = wt @@ -376,12 +388,18 @@ export async function loadMonitors( const anyUp = region_states.some((s) => s.state === "up"); current_state = anyDown ? "down" : anyUp ? "up" : "unknown"; } + // A disabled monitor is in operator-declared maintenance — runner has + // stopped checking it. Override whatever the last region state was so the + // public page reads "Maintenance" instead of a stale "Operational". + if (m.enabled === false) current_state = "paused"; const buckets = bucketsByMonitor[m.id] ?? []; let uptime_pct: number | null = null; if (buckets.length > 0) { const tot = buckets.reduce((a, b) => a + b.total, 0); const upT = buckets.reduce((a, b) => a + b.up, 0); - uptime_pct = tot > 0 ? +(100 * upT / tot).toFixed(2) : null; + // Full precision — the display layer truncates (not rounds) to 2 decimals + // so any downtime, however small, never visually rounds up to 100%. + uptime_pct = tot > 0 ? (100 * upT / tot) : null; } const avg_latency = fastestLatency[m.id] ?? null; // Per-monitor display mode override → page default → 'expanded'. diff --git a/apps/status/src/static/expand.js b/apps/status/src/static/expand.js index f7680d6..a70129d 100644 --- a/apps/status/src/static/expand.js +++ b/apps/status/src/static/expand.js @@ -50,8 +50,14 @@ var latRaw = bar.getAttribute("data-latency"); var lat = latRaw == null ? null : parseInt(latRaw, 10); if (!start) return; - var pct = total > 0 ? Math.round(1000 * up / total) / 10 : null; - var pctText = pct == null ? "—" : (pct === 100 ? "100%" : pct.toFixed(1) + "%"); + // Full precision pct so the formatter can decide. Anything below 100% gets + // 2 truncated (not rounded) decimals — same rule as the page-level uptime + // numbers, so a bucket with one failed check never displays as "100%". + var pct = total > 0 ? (100 * up / total) : null; + var pctText; + if (pct == null) pctText = "—"; + else if (pct >= 100) pctText = "100%"; + else pctText = (Math.floor(pct * 100) / 100).toFixed(2) + "%"; var html = '
' + fmtBucketRange(start) + "
"; if (total > 0) { html += '
Checks' + total + "
"; diff --git a/apps/status/src/views/page.ejs b/apps/status/src/views/page.ejs index 205c86b..006514e 100644 --- a/apps/status/src/views/page.ejs +++ b/apps/status/src/views/page.ejs @@ -16,16 +16,23 @@ function fmtPct(p) { if (p == null) return '—'; - return p === 100 ? '100%' : p.toFixed(2) + '%'; + // Only show "100%" when the value is *exactly* 100. Anything below — even + // 99.9999% — must show 2 decimals so visitors can see there was downtime. + // Truncate (floor) rather than round, otherwise 99.9999 would render as + // "100.00" and silently swallow the downtime. + if (p >= 100) return '100%'; + return (Math.floor(p * 100) / 100).toFixed(2) + '%'; } function statusLabel(s) { if (s === 'up') return 'Operational'; if (s === 'down') return 'Down'; + if (s === 'paused') return 'Under maintenance'; return 'Unknown'; } function statusColor(s) { - if (s === 'up') return 'var(--bar-up)'; - if (s === 'down') return 'var(--bar-down)'; + if (s === 'up') return 'var(--bar-up)'; + if (s === 'down') return 'var(--bar-down)'; + if (s === 'paused') return 'var(--bar-paused)'; return 'var(--muted)'; } function bucketColor(b) { @@ -42,20 +49,30 @@ } function fmtUptime(p) { if (p == null) return '—'; - if (p === 100) return '100%'; - return p.toFixed(2) + '%'; + // Only "100%" if the monitor was up for every single check in the window. + // Truncate (not round) below that so 99.9999% never displays as "100.00". + if (p >= 100) return '100%'; + return (Math.floor(p * 100) / 100).toFixed(2) + '%'; } // Overall status: down if any monitor is down, degraded if any partial, else up. + // Paused monitors are operator-declared maintenance — they don't count + // toward "down" or "degraded", but we surface a small note in the banner + // when at least one is in maintenance so visitors aren't confused. let overall = 'up'; + let paused_count = 0; for (const m of monitors) { + if (m.current_state === 'paused') { paused_count++; continue; } const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up'); if (m.current_state === 'down') { overall = 'down'; break; } if (partial && overall !== 'down') overall = 'degraded'; } - const overallText = overall === 'up' ? 'All systems operational' - : overall === 'degraded' ? 'Some systems degraded' - : 'Major outage in progress'; + let overallText = overall === 'up' ? 'All systems operational' + : overall === 'degraded' ? 'Some systems degraded' + : 'Major outage in progress'; + if (paused_count > 0) { + overallText += ' — ' + paused_count + (paused_count === 1 ? ' service' : ' services') + ' under maintenance'; + } const overallColor = overall === 'up' ? 'var(--bar-up)' : overall === 'degraded' ? 'var(--bar-partial)' : 'var(--bar-down)'; @@ -77,6 +94,7 @@ --bg: #ffffff; --fg: #1e293b; --muted: #64748b; --card: #f8fafc; --border: #e2e8f0; --accent: #0284c7; --bar-up: #10b981; --bar-down: #ef4444; --bar-partial: #f59e0b; --bar-empty: #e5e7eb; + --bar-paused: #6366f1; --overall-fg: #ffffff; } /* OLED-friendly dark mode: pure black background for power saving, but a @@ -87,14 +105,14 @@ html:not(.light) { --bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418; --border: #1f2026; --accent: #3b9bd1; - --bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026; + --bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026; --bar-paused: #5b6cb8; --overall-fg: #f1f5f9; } } html.dark { --bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418; --border: #1f2026; --accent: #3b9bd1; - --bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026; + --bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026; --bar-paused: #5b6cb8; --overall-fg: #f1f5f9; } * { box-sizing: border-box; } @@ -119,6 +137,13 @@ .monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .monitor-meta { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; color: var(--muted); } .uptime-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); } + .maintenance-pill { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.15rem 0.55rem; border-radius: 999px; + font-size: 0.7rem; font-weight: 600; + color: var(--bar-paused); border: 1px solid var(--bar-paused); + background: transparent; text-transform: uppercase; letter-spacing: 0.04em; + } .bars { display: flex; gap: 0.1rem; height: 32px; margin-top: 0.75rem; align-items: stretch; position: relative; } .bar { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; } .bar:hover { opacity: 0.8; } @@ -287,8 +312,12 @@ <%= m.display_name %>
- <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> - <%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %> + <% if (m.current_state === 'paused') { %> + Maintenance + <% } else { %> + <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> + <%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %> + <% } %>