fix uptime %
This commit is contained in:
parent
91ca996e74
commit
2db6c3402c
|
|
@ -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'.
|
||||||
|
|
|
||||||
|
|
@ -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>";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue