feat: Monitor grath zoom

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
+103 -8
View File
@@ -10,6 +10,7 @@ import {
Slot,
StatusChip,
avgLatency,
buildSampleSlots,
buildSlots,
displayStatus,
formatDuration,
@@ -31,6 +32,63 @@ import {
const CHART_W = 480;
const CHART_H = 158;
/*
* The ranges. 24h and 48h are drawn from the hourly rollups, which are the
* permanent record; anything shorter than an hour cannot be, so the three
* short ranges read individual check results instead. Those expire after 48
* hours, which is why no range longer than that is offered from samples.
*
* Bucket sizes are chosen to land near 50-70 bars, so the tape has the same
* texture whichever range is selected.
*/
interface Range {
label: string;
minutes: number;
/** "rollups" is hourly and permanent; "samples" is per check and expires. */
source: "rollups" | "samples";
bucketMs: number;
}
const RANGES: Range[] = [
{ label: "48h", minutes: 48 * 60, source: "rollups", bucketMs: 3600_000 },
{ label: "24h", minutes: 24 * 60, source: "rollups", bucketMs: 3600_000 },
{ label: "12h", minutes: 12 * 60, source: "samples", bucketMs: 600_000 },
{ label: "8h", minutes: 8 * 60, source: "samples", bucketMs: 600_000 },
{ label: "1h", minutes: 60, source: "samples", bucketMs: 60_000 },
];
function rangeTitle(r: Range): string {
const hours = r.minutes / 60;
return hours === 1 ? "Last hour" : `Last ${hours} hours`;
}
function bucketLabel(bucketMs: number): string {
if (bucketMs >= 3600_000) return "1 hour per bar";
return `${Math.round(bucketMs / 60_000)} min per bar`;
}
function RangePicker({ value, onChange }: { value: Range; onChange: (r: Range) => void }) {
return (
<div className="flex overflow-hidden rounded-sm border border-border" role="group" aria-label="Chart range">
{RANGES.map((r) => (
<button
key={r.label}
type="button"
onClick={() => onChange(r)}
aria-pressed={r.label === value.label}
className={`border-l border-border px-2.5 py-1 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors first:border-l-0 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
r.label === value.label
? "bg-accent/15 text-accent"
: "bg-surface-2 text-text-secondary hover:bg-surface hover:text-text-primary"
}`}
>
{r.label}
</button>
))}
</div>
);
}
function niceCeiling(ms: number): number {
if (ms <= 0) return 100;
const steps = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
@@ -43,7 +101,7 @@ function niceCeiling(ms: number): number {
* hovered hour gets a real popover, anchored to its own bar.
*/
function SlotPopover({ slot, index, count }: { slot: Slot; index: number; count: number }) {
const end = new Date(slot.at.getTime() + 3600_000);
const end = new Date(slot.at.getTime() + slot.spanMs);
const day = slot.at.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
const span = `${slot.at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}${end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
@@ -84,7 +142,7 @@ function SlotPopover({ slot, index, count }: { slot: Slot; index: number; count:
);
}
function History({ slots }: { slots: Slot[] }) {
function History({ slots, note }: { slots: Slot[]; note?: string }) {
const [hovered, setHovered] = useState<number | null>(null);
const latencies = slots.map((s) => s.latency).filter((v): v is number => v !== null);
const scale = niceCeiling(Math.max(...latencies, 0) * 1.15);
@@ -110,7 +168,16 @@ function History({ slots }: { slots: Slot[] }) {
const firstAt = slots[0]?.at;
const midAt = slots[Math.floor(slots.length / 2)]?.at;
const tick = (d?: Date) => (d ? d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" }) : "");
/* A weekday on a one-hour window is noise: every bar is the same day. */
const spanned = slots.length * (slots[0]?.spanMs ?? 3600_000);
const tick = (d?: Date) =>
d
? d.toLocaleString(undefined, {
...(spanned > 6 * 3600_000 ? { weekday: "short" as const } : {}),
hour: "2-digit",
minute: "2-digit",
})
: "";
return (
<div>
@@ -181,7 +248,7 @@ function History({ slots }: { slots: Slot[] }) {
<span className="flex items-center gap-2">
<span className="block h-0.5 w-3.5 bg-accent" /> Response time
</span>
<span className="text-text-tertiary">Gaps mean no checks ran</span>
<span className="text-text-tertiary">{note ?? "Gaps mean no checks ran"}</span>
</div>
</div>
);
@@ -250,6 +317,7 @@ export default function MonitorDetailPage() {
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const [range, setRange] = useState<Range>(RANGES[0]);
const toast = useToast();
const { data: monitor, isLoading } = useQuery({
@@ -264,6 +332,17 @@ export default function MonitorDetailPage() {
refetchInterval: 60_000,
});
/* Only fetched for the ranges that read it — the 24h and 48h views are
served by the rollups the page already holds. The 1h view refreshes on
the check interval's order rather than the rollup's: at one minute per
bar, a 60s poll is the difference between live and a bar behind. */
const { data: samples } = useQuery({
queryKey: ["monitors", monitorId, "samples", range.minutes],
queryFn: () => api.getMonitorSamples(monitorId, range.minutes),
enabled: range.source === "samples",
refetchInterval: range.minutes <= 60 ? 15_000 : 30_000,
});
const { data: incidents } = useQuery({
queryKey: ["monitors", monitorId, "incidents"],
queryFn: () => api.getMonitorIncidents(monitorId),
@@ -329,7 +408,13 @@ export default function MonitorDetailPage() {
}
const all: Rollup[] = rollups ?? [];
const slots = buildSlots(all);
const slots =
range.source === "rollups"
? buildSlots(all, Math.round(range.minutes / 60))
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs);
/* Samples expire after 48h and only start accruing once a check runs, so an
empty short range is a real answer and not a failure to load. */
const emptyRange = range.source === "samples" && (samples ?? []).length === 0;
const status = displayStatus(monitor);
const pct24 = uptimePct(all.slice(-24));
const pct30d = uptimePct(all);
@@ -409,11 +494,21 @@ export default function MonitorDetailPage() {
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">Last 48 hours</h2>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">1 hour per bar</span>
<h2 className="text-[15px] font-semibold text-text-primary">
{rangeTitle(range)}
</h2>
<div className="flex items-center gap-3">
<span className="hidden font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary sm:inline">
{bucketLabel(range.bucketMs)}
</span>
<RangePicker value={range} onChange={setRange} />
</div>
</div>
<div className="p-5">
<History slots={slots} />
<History
slots={slots}
note={emptyRange ? "No check results recorded in this window yet" : undefined}
/>
</div>
<div className="grid grid-cols-2 border-t border-border-soft sm:grid-cols-4">
<Figure
+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";
+12
View File
@@ -95,6 +95,14 @@ export interface Incident {
cause?: string;
}
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
export interface MonitorSample {
monitor_id: string;
at: string;
up: boolean;
latency_ms: number;
}
export interface Rollup {
monitor_id: string;
period_start: string;
@@ -670,6 +678,10 @@ export const api = {
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
},
getMonitorSamples(monitorId: string, minutes: number): Promise<MonitorSample[]> {
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
},
listChannels(): Promise<NotificationChannel[]> {
return request<NotificationChannel[]>("/channels");
},