feat: Monitor grath zoom
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user