feat: Monitor groups and chart information

This commit is contained in:
2026-08-24 10:30:23 +00:00
parent 83cdf92575
commit 2fab784ba7
9 changed files with 299 additions and 25 deletions
+82 -9
View File
@@ -16,6 +16,7 @@ import {
formatMs,
formatPct,
relativeTime,
slotLabel,
statusStripe,
targetSummary,
uptimePct,
@@ -36,7 +37,55 @@ function niceCeiling(ms: number): number {
return steps.find((s) => s >= ms) ?? Math.ceil(ms / 10000) * 10000;
}
/*
* The bar readout. A native title attribute arrives a second late, cannot show
* the latency alongside the uptime, and is invisible to keyboard users — so the
* 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 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" })}`;
/* Anchored to the bar, but the first and last few bars would push a centred
card off the chart, so the edges pin instead of centring. */
const frac = count > 1 ? index / (count - 1) : 0.5;
const shift = frac < 0.14 ? "0%" : frac > 0.86 ? "-100%" : "-50%";
return (
<div
role="tooltip"
/* Anchored at the top of the plot rather than above it: the chart
box clips its overflow, so a card floated outside would vanish. */
className="pointer-events-none absolute top-0 z-20 w-max"
style={{ left: `${frac * 100}%`, transform: `translateX(${shift})` }}
>
<div className="rounded-sm border border-border bg-surface/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-text-tertiary">{day}</p>
<p className="mt-0.5 font-mono text-[11.5px] tabular-nums text-text-primary">{span}</p>
{slot.pct === null ? (
<p className="mt-1.5 text-[11.5px] text-text-secondary">No checks ran</p>
) : (
<dl className="mt-1.5 grid grid-cols-[auto_auto] gap-x-3 gap-y-0.5 text-[11.5px]">
<dt className="text-text-tertiary">Uptime</dt>
<dd
className={`text-right font-mono tabular-nums ${slot.pct >= 99.5 ? "text-success" : slot.pct >= 80 ? "text-warning" : "text-danger"}`}
>
{slot.pct.toFixed(1)}%
</dd>
<dt className="text-text-tertiary">Response</dt>
<dd className="text-right font-mono tabular-nums text-text-primary">{formatMs(slot.latency)}</dd>
<dt className="text-text-tertiary">Checks</dt>
<dd className="text-right font-mono tabular-nums text-text-primary">{slot.checks}</dd>
</dl>
)}
</div>
</div>
);
}
function History({ slots }: { slots: Slot[] }) {
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);
@@ -72,20 +121,39 @@ function History({ slots }: { slots: Slot[] }) {
</div>
))}
<div className="absolute inset-x-2.5 bottom-6 top-2.5 flex items-end gap-0.5">
{slots.map((s) => (
<div
{/* z-10 so the bars sit above the trace and stay hoverable. */}
<div
className="absolute inset-x-2.5 bottom-6 top-2.5 z-10 flex items-end gap-0.5"
onMouseLeave={() => setHovered(null)}
>
{slots.map((s, i) => (
<button
key={s.at.getTime()}
className="h-full flex-1"
title={s.pct === null ? "no checks ran" : `${s.pct.toFixed(1)}% up`}
style={{ display: "flex", alignItems: "flex-end" }}
type="button"
className="flex h-full flex-1 items-end focus:outline-none"
onMouseEnter={() => setHovered(i)}
onFocus={() => setHovered(i)}
onBlur={() => setHovered(null)}
aria-label={slotLabel(s)}
>
<div
className={`w-full rounded-[1px] ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
<span
className={`w-full rounded-[1px] transition-opacity ${
hovered !== null && hovered !== i ? "opacity-50" : ""
} ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
/>
</div>
</button>
))}
{hovered !== null && slots[hovered] && (
<>
<span
className="pointer-events-none absolute inset-y-0 w-px bg-text-tertiary/40"
style={{ left: `${(hovered / Math.max(slots.length - 1, 1)) * 100}%` }}
aria-hidden
/>
<SlotPopover slot={slots[hovered]} index={hovered} count={slots.length} />
</>
)}
</div>
<svg
@@ -290,6 +358,11 @@ export default function MonitorDetailPage() {
<span className="rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
{monitor.type}
</span>
{monitor.group && (
<span className="rounded-sm border border-border-soft bg-surface-2 px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary">
{monitor.group}
</span>
)}
</div>
<p className="mt-1.5 break-all font-mono text-xs text-text-tertiary">{targetSummary(monitor)}</p>
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
+130 -5
View File
@@ -1,5 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, Rollup } from "@/lib/api";
@@ -27,6 +28,10 @@ import {
* Uptime is per monitor, so the rollups are fetched per monitor. A fleet is
* tens of checks, not thousands, and the alternative is a list endpoint that
* embeds history for every row whether or not anyone looks at it.
*
* Groups are display only — a label on the monitor, nothing schedules or
* alerts by it. A fleet that never sets one sees the flat list it had before,
* with no "Ungrouped" heading over the whole page.
*/
const ROW = "grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,2fr)_170px] sm:gap-5";
@@ -68,6 +73,103 @@ function FleetMeter({ counts, uptime }: { counts: Record<DisplayStatus, number>;
);
}
const COLLAPSE_KEY = "vantage.monitors.collapsedGroups";
const UNGROUPED = "Ungrouped";
interface MonitorGroup {
name: string;
rows: { monitor: Monitor; rollups: Rollup[] }[];
}
/** Alphabetical, with the ungrouped remainder last so it reads as a leftover. */
function groupMonitors(rows: { monitor: Monitor; rollups: Rollup[] }[]): MonitorGroup[] {
const byName = new Map<string, MonitorGroup["rows"]>();
for (const row of rows) {
const name = row.monitor.group?.trim() || UNGROUPED;
const bucket = byName.get(name);
if (bucket) bucket.push(row);
else byName.set(name, [row]);
}
return [...byName.entries()]
.map(([name, groupRows]) => ({ name, rows: groupRows }))
.sort((a, b) => {
if (a.name === UNGROUPED) return 1;
if (b.name === UNGROUPED) return -1;
return a.name.localeCompare(b.name);
});
}
/* Collapse is per browser, not per account: it is which sections this person
has folded away, and a round trip to store it would be a write on every
click. */
function useCollapsedGroups() {
const [collapsed, setCollapsed] = useState<string[]>([]);
useEffect(() => {
try {
const raw = window.localStorage.getItem(COLLAPSE_KEY);
if (raw) setCollapsed(JSON.parse(raw) as string[]);
} catch {
/* A malformed or unavailable store just means nothing is folded. */
}
}, []);
const toggle = useCallback((name: string) => {
setCollapsed((prev) => {
const next = prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name];
try {
window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next));
} catch {
/* Not being able to remember it is not a reason to refuse the click. */
}
return next;
});
}, []);
return { collapsed, toggle };
}
function GroupHeader({
group,
collapsed,
onToggle,
}: {
group: MonitorGroup;
collapsed: boolean;
onToggle: () => void;
}) {
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
for (const { monitor } of group.rows) counts[displayStatus(monitor)] += 1;
const pct = uptimePct(group.rows.flatMap(({ rollups }) => rollups.slice(-24)));
return (
<button
type="button"
onClick={onToggle}
aria-expanded={!collapsed}
className="flex w-full items-center gap-3 border-b border-border-soft bg-surface-2 px-4 py-2.5 text-left transition-colors hover:bg-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
>
<span className={`font-mono text-[10px] text-text-tertiary transition-transform ${collapsed ? "" : "rotate-90"}`} aria-hidden>
</span>
<span className="truncate font-mono text-[11px] uppercase tracking-[0.16em] text-text-secondary">{group.name}</span>
<span className="font-mono text-[11px] tabular-nums text-text-tertiary">
{group.rows.length} {group.rows.length === 1 ? "check" : "checks"}
</span>
{counts.down > 0 && (
<span className="rounded-sm border border-danger/40 bg-danger/10 px-1.5 font-mono text-[10px] uppercase tracking-[0.08em] text-danger">
{counts.down} down
</span>
)}
<span className="ml-auto font-mono text-[11px] tabular-nums text-text-secondary">
{formatPct(pct)}
{pct !== null && <span className="text-text-tertiary">%</span>}
</span>
</button>
);
}
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
const status = displayStatus(monitor);
const slots = buildSlots(rollups);
@@ -122,6 +224,12 @@ export default function MonitorsPage() {
})),
});
const { collapsed, toggle } = useCollapsedGroups();
const rows = (monitors ?? []).map((m, i) => ({ monitor: m, rollups: uptimeQueries[i]?.data ?? [] }));
const grouped = rows.some(({ monitor }) => !!monitor.group?.trim());
const groups = groupMonitors(rows);
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
for (const m of monitors ?? []) counts[displayStatus(m)] += 1;
@@ -176,11 +284,28 @@ export default function MonitorsPage() {
<p className="text-right font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Uptime 24h · response</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{monitors.map((m, i) => (
<MonitorRow key={m.monitor_id} monitor={m} rollups={uptimeQueries[i]?.data ?? []} />
))}
</div>
{grouped ? (
<div className="flex flex-col gap-3">
{groups.map((group) => {
const isCollapsed = collapsed.includes(group.name);
return (
<div key={group.name} className="overflow-hidden rounded-lg border border-border bg-surface">
<GroupHeader group={group} collapsed={isCollapsed} onToggle={() => toggle(group.name)} />
{!isCollapsed &&
group.rows.map(({ monitor, rollups }) => (
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
))}
</div>
);
})}
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{rows.map(({ monitor, rollups }) => (
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
))}
</div>
)}
</>
)}
</div>