feat: Monitor grath zoom
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 6m31s

This commit is contained in:
2026-08-24 10:58:50 +00:00
parent 2fab784ba7
commit 22b99ff895
11 changed files with 395 additions and 14 deletions
+40 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { Monitor, MonitorStatus, Rollup } from "@/lib/api";
import { Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
/*
* Shared vocabulary for the monitors screens.
@@ -109,8 +109,10 @@ export function formatDuration(fromIso: string, toIso?: string): string {
/* ------------------------------------------------------------------- tape */
export interface Slot {
/** Start of the hour this slot covers. */
/** Start of the period this slot covers. */
at: Date;
/** Length of that period. An hour for a rollup slot, less for a sample bucket. */
spanMs: number;
/** Percentage of checks that passed, or null when no check ran. */
pct: number | null;
checks: number;
@@ -141,6 +143,7 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
const r = byHour.get(at.getTime());
slots.push({
at,
spanMs: 3600_000,
checks: r?.checks ?? 0,
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
@@ -149,6 +152,41 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
return slots;
}
/**
* The same tape, bucketed from individual check results rather than hourly
* rollups — this is what the sub-hour ranges are drawn from, because an hourly
* rollup cannot say anything about a window shorter than an hour.
*
* Buckets are built from the clock like buildSlots, for the same reason: a
* window with no checks in it has to read as a gap and not shorten the tape.
* `endAt` is passed rather than read from the clock so every series on one
* screen shares an edge.
*/
export function buildSampleSlots(samples: MonitorSample[], windowMs: number, bucketMs: number, endAt = Date.now()): Slot[] {
const count = Math.max(Math.round(windowMs / bucketMs), 1);
const end = Math.floor(endAt / bucketMs) * bucketMs + bucketMs;
const start = end - count * bucketMs;
const totals = Array.from({ length: count }, () => ({ checks: 0, up: 0, latency: 0 }));
for (const sample of samples) {
const t = new Date(sample.at).getTime();
if (t < start || t >= end) continue;
const bucket = totals[Math.floor((t - start) / bucketMs)];
if (!bucket) continue;
bucket.checks += 1;
if (sample.up) bucket.up += 1;
bucket.latency += sample.latency_ms;
}
return totals.map((bucket, i) => ({
at: new Date(start + i * bucketMs),
spanMs: bucketMs,
checks: bucket.checks,
pct: bucket.checks > 0 ? (bucket.up / bucket.checks) * 100 : null,
latency: bucket.checks > 0 ? bucket.latency / bucket.checks : null,
}));
}
function slotColor(s: Slot): string {
if (s.pct === null) return "bg-border-soft";
if (s.pct >= 99.5) return "bg-success";