update rollup

This commit is contained in:
nate 2026-04-09 01:34:41 +04:00
parent ae5a8f4597
commit 1732a9d055
4 changed files with 30 additions and 39 deletions

View File

@ -2,17 +2,16 @@ import sql from "../db";
// Aggregates raw pings into monitor_uptime_rollup so status pages and dashboard // Aggregates raw pings into monitor_uptime_rollup so status pages and dashboard
// widgets can compute uptime % over arbitrary windows without ever scanning the // widgets can compute uptime % over arbitrary windows without ever scanning the
// pings table at read time. Three resolutions: hourly, daily, weekly. // pings table at read time. Two resolutions: hourly and daily.
// //
// Each pass aggregates the *current* bucket only. The query is bounded by the // Each pass aggregates the *current* bucket only. The query is bounded by the
// bucket size, not the table size, so it's cheap regardless of history depth. // bucket size, not the table size, so it's cheap regardless of history depth.
type BucketType = "hourly" | "daily" | "weekly"; type BucketType = "hourly" | "daily";
const BUCKET_TRUNC: Record<BucketType, string> = { const BUCKET_TRUNC: Record<BucketType, string> = {
hourly: "hour", hourly: "hour",
daily: "day", daily: "day",
weekly: "week",
}; };
async function rollupCurrent(bucket: BucketType): Promise<number> { async function rollupCurrent(bucket: BucketType): Promise<number> {
@ -83,12 +82,11 @@ export async function startRollupJob() {
// we throw so a broken rollup query trips the api process and shows in the // we throw so a broken rollup query trips the api process and shows in the
// service logs immediately, instead of leaving the table mysteriously empty. // service logs immediately, instead of leaving the table mysteriously empty.
try { try {
const [h, d, w] = await Promise.all([ const [h, d] = await Promise.all([
backfillRecent("hourly", 48), backfillRecent("hourly", 48),
backfillRecent("daily", 90), backfillRecent("daily", 90),
backfillRecent("weekly", 26),
]); ]);
console.log(`[rollup] backfilled rows: hourly=${h} daily=${d} weekly=${w}`); console.log(`[rollup] backfilled rows: hourly=${h} daily=${d}`);
} catch (e) { } catch (e) {
console.error("[rollup] backfill FAILED — rollup table will be empty until fixed:", e); console.error("[rollup] backfill FAILED — rollup table will be empty until fixed:", e);
} }
@ -97,7 +95,7 @@ export async function startRollupJob() {
// run rollupCurrent immediately so we have at least one row per type. This // run rollupCurrent immediately so we have at least one row per type. This
// covers the "fresh deploy with very recent pings only" case. // covers the "fresh deploy with very recent pings only" case.
try { try {
for (const b of ["hourly", "daily", "weekly"] as BucketType[]) { for (const b of ["hourly", "daily"] as BucketType[]) {
if (await rollupIsEmpty(b)) { if (await rollupIsEmpty(b)) {
console.log(`[rollup] ${b} still empty — forcing current-bucket aggregation`); console.log(`[rollup] ${b} still empty — forcing current-bucket aggregation`);
await rollupCurrent(b); await rollupCurrent(b);
@ -110,5 +108,4 @@ export async function startRollupJob() {
// Periodic refreshes for the *current* bucket of each resolution. // Periodic refreshes for the *current* bucket of each resolution.
setInterval(() => { rollupCurrent("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 5 * 60 * 1000); setInterval(() => { rollupCurrent("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 5 * 60 * 1000);
setInterval(() => { rollupCurrent("daily").catch((e) => console.warn("[rollup] daily failed:", e)); }, 30 * 60 * 1000); setInterval(() => { rollupCurrent("daily").catch((e) => console.warn("[rollup] daily failed:", e)); }, 30 * 60 * 1000);
setInterval(() => { rollupCurrent("weekly").catch((e) => console.warn("[rollup] weekly failed:", e)); }, 6 * 60 * 60 * 1000);
} }

View File

@ -5,13 +5,13 @@
import sql from "./db"; import sql from "./db";
export type Window = "24h" | "7d" | "30d" | "90d"; export type Window = "24h" | "7d" | "30d" | "90d";
export type BucketType = "hourly" | "daily" | "weekly"; export type BucketType = "hourly" | "daily";
const WINDOW_TO_BUCKET: Record<Window, { bucket: BucketType; count: number }> = { const WINDOW_TO_BUCKET: Record<Window, { bucket: BucketType; count: number }> = {
"24h": { bucket: "hourly", count: 24 }, "24h": { bucket: "hourly", count: 24 },
"7d": { bucket: "daily", count: 7 }, "7d": { bucket: "daily", count: 7 },
"30d": { bucket: "daily", count: 30 }, "30d": { bucket: "daily", count: 30 },
"90d": { bucket: "weekly", count: 13 }, "90d": { bucket: "daily", count: 90 }, // 90 daily bars; 90 rows per monitor, cached.
}; };
export interface StatusPageRow { export interface StatusPageRow {
@ -53,7 +53,7 @@ export interface MonitorRow {
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
buckets: Array<{ start: string; total: number; up: number }>; // bar chart input buckets: Array<{ start: string; total: number; up: number; avg_latency: number | null }>; // bar chart input
avg_latency: number | null; avg_latency: number | null;
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>; latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
} }
@ -126,8 +126,8 @@ export async function loadMultiWindowUptime(monitorIds: string[]): Promise<Recor
/ NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '7 days'), 0) AS pct_7d, / 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 (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, / 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 (sum(up_count) FILTER (WHERE bucket_type='daily' 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 / NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '90 days'), 0) AS pct_90d
FROM monitor_uptime_rollup FROM monitor_uptime_rollup
WHERE monitor_id = ANY(${ids}::text[]) WHERE monitor_id = ANY(${ids}::text[])
GROUP BY 1 GROUP BY 1
@ -240,7 +240,7 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
// Step 3: uptime rollup buckets covering the requested window. // Step 3: uptime rollup buckets covering the requested window.
const { bucket, count } = WINDOW_TO_BUCKET[window]; const { bucket, count } = WINDOW_TO_BUCKET[window];
const truncUnit = bucket === "hourly" ? "hour" : bucket === "daily" ? "day" : "week"; const truncUnit = bucket === "hourly" ? "hour" : "day";
const intervalLiteral = `${count} ${truncUnit}s`; const intervalLiteral = `${count} ${truncUnit}s`;
let rollupRows = await sql<any[]>` let rollupRows = await sql<any[]>`
SELECT monitor_id, bucket_start, sum(total)::int AS total, sum(up_count)::int AS up_count, avg(avg_latency)::real AS avg_latency SELECT monitor_id, bucket_start, sum(total)::int AS total, sum(up_count)::int AS up_count, avg(avg_latency)::real AS avg_latency
@ -288,16 +288,11 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
// Generate the full sequence of expected bucket timestamps so empty bars // Generate the full sequence of expected bucket timestamps so empty bars
// render as "no data" instead of disappearing entirely. Truncate `now()` to // render as "no data" instead of disappearing entirely. Truncate `now()` to
// the unit so the slot boundaries line up with what the rollup writes. // the unit so the slot boundaries line up with what the rollup writes.
const bucketMs = bucket === "hourly" ? 3600_000 : bucket === "daily" ? 86_400_000 : 604_800_000; const bucketMs = bucket === "hourly" ? 3600_000 : 86_400_000;
const truncate = (d: Date): Date => { const truncate = (d: Date): Date => {
const t = new Date(d); const t = new Date(d);
if (bucket === "hourly") { t.setUTCMinutes(0, 0, 0); } if (bucket === "hourly") t.setUTCMinutes(0, 0, 0);
else { t.setUTCHours(0, 0, 0, 0); } else t.setUTCHours(0, 0, 0, 0);
if (bucket === "weekly") {
// ISO week starts Monday.
const day = (t.getUTCDay() + 6) % 7;
t.setUTCDate(t.getUTCDate() - day);
}
return t; return t;
}; };
const nowTrunc = truncate(new Date()).getTime(); const nowTrunc = truncate(new Date()).getTime();
@ -310,7 +305,9 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
const slotMap = indexed[id] ?? {}; const slotMap = indexed[id] ?? {};
bucketsByMonitor[id] = slotIsos.map((iso) => { bucketsByMonitor[id] = slotIsos.map((iso) => {
const hit = slotMap[iso]; const hit = slotMap[iso];
return hit ? { start: iso, total: hit.total, up: hit.up } : { start: iso, total: 0, up: 0 }; return hit
? { start: iso, total: hit.total, up: hit.up, avg_latency: hit.avg_latency != null ? Math.round(hit.avg_latency) : null }
: { start: iso, total: 0, up: 0, avg_latency: null };
}); });
} }

View File

@ -14,14 +14,10 @@
// ── Bar tooltips ────────────────────────────────────────────────────── // ── Bar tooltips ──────────────────────────────────────────────────────
var tooltip = document.getElementById("bar-tooltip"); var tooltip = document.getElementById("bar-tooltip");
if (tooltip) { if (tooltip) {
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 : 86400 * 1000;
: (defaultWindow === "7d") ? 86400 * 1000
: (defaultWindow === "30d") ? 86400 * 1000
: 7 * 86400 * 1000;
function fmtBucketRange(startIso) { function fmtBucketRange(startIso) {
var start = new Date(startIso); var start = new Date(startIso);
var end = new Date(start.getTime() + bucketSpanMs); var end = new Date(start.getTime() + bucketSpanMs);
var sameDay = start.toDateString() === new Date(end.getTime() - 1).toDateString();
var dateOpts = { month: "short", day: "numeric" }; var dateOpts = { month: "short", day: "numeric" };
var timeOpts = { hour: "2-digit", minute: "2-digit" }; var timeOpts = { hour: "2-digit", minute: "2-digit" };
if (defaultWindow === "24h") { if (defaultWindow === "24h") {
@ -29,13 +25,9 @@
start.toLocaleTimeString(undefined, timeOpts) + " — " + start.toLocaleTimeString(undefined, timeOpts) + " — " +
end.toLocaleTimeString(undefined, timeOpts); end.toLocaleTimeString(undefined, timeOpts);
} }
if (defaultWindow === "7d" || defaultWindow === "30d") { // 7d / 30d / 90d → daily buckets
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
} }
// 90d → weekly buckets
var endLabel = new Date(end.getTime() - 1).toLocaleDateString(undefined, dateOpts);
return "Week of " + start.toLocaleDateString(undefined, dateOpts) + " — " + endLabel;
}
function uptimeBand(p) { function uptimeBand(p) {
if (p == null) return ""; if (p == null) return "";
if (p >= 99.9) return "good"; if (p >= 99.9) return "good";
@ -47,6 +39,8 @@
var total = parseInt(bar.getAttribute("data-total") || "0", 10); var total = parseInt(bar.getAttribute("data-total") || "0", 10);
var up = parseInt(bar.getAttribute("data-up") || "0", 10); var up = parseInt(bar.getAttribute("data-up") || "0", 10);
var start = bar.getAttribute("data-start"); var start = bar.getAttribute("data-start");
var latRaw = bar.getAttribute("data-latency");
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; var pct = total > 0 ? Math.round(1000 * up / total) / 10 : null;
var pctText = pct == null ? "—" : (pct === 100 ? "100%" : pct.toFixed(1) + "%"); var pctText = pct == null ? "—" : (pct === 100 ? "100%" : pct.toFixed(1) + "%");
@ -55,6 +49,9 @@
html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>"; html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>";
html += '<div class="row"><span>Successful</span><span>' + up + "</span></div>"; html += '<div class="row"><span>Successful</span><span>' + up + "</span></div>";
html += '<div class="row"><span>Uptime</span><span class="pct ' + uptimeBand(pct) + '">' + pctText + "</span></div>"; html += '<div class="row"><span>Uptime</span><span class="pct ' + uptimeBand(pct) + '">' + pctText + "</span></div>";
if (lat != null) {
html += '<div class="row"><span>Avg ping</span><span>' + lat + "ms</span></div>";
}
} else { } else {
html += '<div class="row"><span>No data</span><span>—</span></div>'; html += '<div class="row"><span>No data</span><span>—</span></div>';
} }

View File

@ -311,7 +311,7 @@
</div> </div>
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>"> <div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
<% buckets.forEach(function(b) { %> <% buckets.forEach(function(b) { %>
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"></div> <div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
<% }); %> <% }); %>
</div> </div>
<div class="bars-meta"> <div class="bars-meta">