fix uptime %

This commit is contained in:
nate 2026-04-09 06:30:08 +04:00
parent 91ca996e74
commit 2db6c3402c
3 changed files with 71 additions and 18 deletions

View File

@ -44,7 +44,10 @@ export interface MonitorRow {
group_id: string | null; group_id: string | null;
position: number; position: number;
display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded') 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 }>; region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
uptime_pct: number | null; // for the page's default_window uptime_pct: number | null; // for the page's default_window
uptime: MultiWindowUptime; // 24h / 7d / 30d / 90d row uptime: MultiWindowUptime; // 24h / 7d / 30d / 90d row
@ -103,12 +106,17 @@ export async function loadMonitors(
barFrequency: BucketType = "daily", barFrequency: BucketType = "daily",
barCount: number = 90, barCount: number = 90,
): Promise<MonitorRow[]> { ): Promise<MonitorRow[]> {
// 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<any[]>` const monitorRows = await sql<any[]>`
SELECT SELECT
spm.monitor_id AS id, spm.monitor_id AS id,
COALESCE(spm.display_name, m.name) AS display_name, COALESCE(spm.display_name, m.name) AS display_name,
m.url, m.url,
m.enabled AS enabled,
spm.group_id, spm.group_id,
spm.position, spm.position,
spm.display_mode AS spm_display_mode 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. // 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<string, MultiWindowUptime> = {}; const multiWindow: Record<string, MultiWindowUptime> = {};
const toPct = (up: number, total: number): number | null => 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) { for (const id of ids) {
const wt = windowTotals[id]; const wt = windowTotals[id];
multiWindow[id] = wt multiWindow[id] = wt
@ -376,12 +388,18 @@ export async function loadMonitors(
const anyUp = region_states.some((s) => s.state === "up"); const anyUp = region_states.some((s) => s.state === "up");
current_state = anyDown ? "down" : anyUp ? "up" : "unknown"; 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] ?? []; const buckets = bucketsByMonitor[m.id] ?? [];
let uptime_pct: number | null = null; let uptime_pct: number | null = null;
if (buckets.length > 0) { if (buckets.length > 0) {
const tot = buckets.reduce((a, b) => a + b.total, 0); const tot = buckets.reduce((a, b) => a + b.total, 0);
const upT = buckets.reduce((a, b) => a + b.up, 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; const avg_latency = fastestLatency[m.id] ?? null;
// Per-monitor display mode override → page default → 'expanded'. // Per-monitor display mode override → page default → 'expanded'.

View File

@ -50,8 +50,14 @@
var latRaw = bar.getAttribute("data-latency"); var latRaw = bar.getAttribute("data-latency");
var lat = latRaw == null ? null : parseInt(latRaw, 10); var lat = latRaw == null ? null : parseInt(latRaw, 10);
if (!start) return; if (!start) return;
var pct = total > 0 ? Math.round(1000 * up / total) / 10 : null; // Full precision pct so the formatter can decide. Anything below 100% gets
var pctText = pct == null ? "—" : (pct === 100 ? "100%" : pct.toFixed(1) + "%"); // 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 = '<div class="head">' + fmtBucketRange(start) + "</div>"; var html = '<div class="head">' + fmtBucketRange(start) + "</div>";
if (total > 0) { if (total > 0) {
html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>"; html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>";

View File

@ -16,16 +16,23 @@
function fmtPct(p) { function fmtPct(p) {
if (p == null) return '—'; 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) { function statusLabel(s) {
if (s === 'up') return 'Operational'; if (s === 'up') return 'Operational';
if (s === 'down') return 'Down'; if (s === 'down') return 'Down';
if (s === 'paused') return 'Under maintenance';
return 'Unknown'; return 'Unknown';
} }
function statusColor(s) { function statusColor(s) {
if (s === 'up') return 'var(--bar-up)'; if (s === 'up') return 'var(--bar-up)';
if (s === 'down') return 'var(--bar-down)'; if (s === 'down') return 'var(--bar-down)';
if (s === 'paused') return 'var(--bar-paused)';
return 'var(--muted)'; return 'var(--muted)';
} }
function bucketColor(b) { function bucketColor(b) {
@ -42,20 +49,30 @@
} }
function fmtUptime(p) { function fmtUptime(p) {
if (p == null) return '—'; if (p == null) return '—';
if (p === 100) return '100%'; // Only "100%" if the monitor was up for every single check in the window.
return p.toFixed(2) + '%'; // 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. // 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 overall = 'up';
let paused_count = 0;
for (const m of monitors) { 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'); 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 (m.current_state === 'down') { overall = 'down'; break; }
if (partial && overall !== 'down') overall = 'degraded'; if (partial && overall !== 'down') overall = 'degraded';
} }
const overallText = overall === 'up' ? 'All systems operational' let overallText = overall === 'up' ? 'All systems operational'
: overall === 'degraded' ? 'Some systems degraded' : overall === 'degraded' ? 'Some systems degraded'
: 'Major outage in progress'; : '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)' const overallColor = overall === 'up' ? 'var(--bar-up)'
: overall === 'degraded' ? 'var(--bar-partial)' : overall === 'degraded' ? 'var(--bar-partial)'
: 'var(--bar-down)'; : 'var(--bar-down)';
@ -77,6 +94,7 @@
--bg: #ffffff; --fg: #1e293b; --muted: #64748b; --card: #f8fafc; --bg: #ffffff; --fg: #1e293b; --muted: #64748b; --card: #f8fafc;
--border: #e2e8f0; --accent: #0284c7; --border: #e2e8f0; --accent: #0284c7;
--bar-up: #10b981; --bar-down: #ef4444; --bar-partial: #f59e0b; --bar-empty: #e5e7eb; --bar-up: #10b981; --bar-down: #ef4444; --bar-partial: #f59e0b; --bar-empty: #e5e7eb;
--bar-paused: #6366f1;
--overall-fg: #ffffff; --overall-fg: #ffffff;
} }
/* OLED-friendly dark mode: pure black background for power saving, but a /* OLED-friendly dark mode: pure black background for power saving, but a
@ -87,14 +105,14 @@
html:not(.light) { html:not(.light) {
--bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418; --bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418;
--border: #1f2026; --accent: #3b9bd1; --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; --overall-fg: #f1f5f9;
} }
} }
html.dark { html.dark {
--bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418; --bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418;
--border: #1f2026; --accent: #3b9bd1; --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; --overall-fg: #f1f5f9;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -119,6 +137,13 @@
.monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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); } .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); } .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; } .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 { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; }
.bar:hover { opacity: 0.8; } .bar:hover { opacity: 0.8; }
@ -287,8 +312,12 @@
<span class="name"><%= m.display_name %></span> <span class="name"><%= m.display_name %></span>
</div> </div>
<div class="monitor-meta"> <div class="monitor-meta">
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %> <% if (m.current_state === 'paused') { %>
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span> <span class="maintenance-pill" title="This service is paused for maintenance — checks are not running.">Maintenance</span>
<% } else { %>
<% 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>
<% } %>
<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> <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> </div>
</button> </button>