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
// 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
// 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> = {
hourly: "hour",
daily: "day",
weekly: "week",
};
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
// service logs immediately, instead of leaving the table mysteriously empty.
try {
const [h, d, w] = await Promise.all([
const [h, d] = await Promise.all([
backfillRecent("hourly", 48),
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) {
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
// covers the "fresh deploy with very recent pings only" case.
try {
for (const b of ["hourly", "daily", "weekly"] as BucketType[]) {
for (const b of ["hourly", "daily"] as BucketType[]) {
if (await rollupIsEmpty(b)) {
console.log(`[rollup] ${b} still empty — forcing current-bucket aggregation`);
await rollupCurrent(b);
@ -110,5 +108,4 @@ export async function startRollupJob() {
// 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("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";
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 }> = {
"24h": { bucket: "hourly", count: 24 },
"7d": { bucket: "daily", count: 7 },
"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 {
@ -53,7 +53,7 @@ export interface MonitorRow {
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
buckets: Array<{ start: string; total: number; up: number; avg_latency: number | null }>; // bar chart input
avg_latency: number | null;
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,
(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
(sum(up_count) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '90 days'))::float
/ NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '90 days'), 0) AS pct_90d
FROM monitor_uptime_rollup
WHERE monitor_id = ANY(${ids}::text[])
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.
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`;
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
@ -288,16 +288,11 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
// Generate the full sequence of expected bucket timestamps so empty bars
// render as "no data" instead of disappearing entirely. Truncate `now()` to
// 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 t = new Date(d);
if (bucket === "hourly") { t.setUTCMinutes(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);
}
if (bucket === "hourly") t.setUTCMinutes(0, 0, 0);
else t.setUTCHours(0, 0, 0, 0);
return t;
};
const nowTrunc = truncate(new Date()).getTime();
@ -310,7 +305,9 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
const slotMap = indexed[id] ?? {};
bucketsByMonitor[id] = slotIsos.map((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,27 +14,19 @@
// ── Bar tooltips ──────────────────────────────────────────────────────
var tooltip = document.getElementById("bar-tooltip");
if (tooltip) {
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000
: (defaultWindow === "7d") ? 86400 * 1000
: (defaultWindow === "30d") ? 86400 * 1000
: 7 * 86400 * 1000;
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 : 86400 * 1000;
function fmtBucketRange(startIso) {
var start = new Date(startIso);
var end = new Date(start.getTime() + bucketSpanMs);
var sameDay = start.toDateString() === new Date(end.getTime() - 1).toDateString();
var dateOpts = { month: "short", day: "numeric" };
var timeOpts = { hour: "2-digit", minute: "2-digit" };
var dateOpts = { month: "short", day: "numeric" };
var timeOpts = { hour: "2-digit", minute: "2-digit" };
if (defaultWindow === "24h") {
return start.toLocaleDateString(undefined, dateOpts) + ", " +
start.toLocaleTimeString(undefined, timeOpts) + " — " +
end.toLocaleTimeString(undefined, timeOpts);
}
if (defaultWindow === "7d" || defaultWindow === "30d") {
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;
// 7d / 30d / 90d → daily buckets
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
}
function uptimeBand(p) {
if (p == null) return "";
@ -44,9 +36,11 @@
}
function showTooltipForBar(bar, evt) {
var total = parseInt(bar.getAttribute("data-total") || "0", 10);
var up = parseInt(bar.getAttribute("data-up") || "0", 10);
var total = parseInt(bar.getAttribute("data-total") || "0", 10);
var up = parseInt(bar.getAttribute("data-up") || "0", 10);
var start = bar.getAttribute("data-start");
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) + "%");
@ -55,6 +49,9 @@
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>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 {
html += '<div class="row"><span>No data</span><span>—</span></div>';
}

View File

@ -311,7 +311,7 @@
</div>
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
<% 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 class="bars-meta">