feat: Monitor groups and chart information
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function MonitorForm({
|
||||
error?: Error | null;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [group, setGroup] = useState(initial?.group ?? "");
|
||||
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
|
||||
const [url, setUrl] = useState(initial?.target.url ?? "");
|
||||
const [host, setHost] = useState(initial?.target.host ?? "");
|
||||
@@ -107,6 +108,11 @@ export function MonitorForm({
|
||||
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
|
||||
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
/* The group is free text, so the existing groups are offered as suggestions
|
||||
rather than a fixed list — grouping is a label people invent, and a
|
||||
select would mean adding one before it could be used. */
|
||||
const { data: allMonitors } = useQuery({ queryKey: ["monitors"], queryFn: () => api.listMonitors() });
|
||||
const knownGroups = Array.from(new Set((allMonitors ?? []).map((m) => m.group).filter((g): g is string => !!g))).sort();
|
||||
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
@@ -128,7 +134,7 @@ export function MonitorForm({
|
||||
target.host = host;
|
||||
target.port = port;
|
||||
}
|
||||
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
onSubmit({ name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
}
|
||||
|
||||
const runnerName = runner === "server" ? "the control plane" : servers?.find((s) => s.server_id === runner)?.hostname || "an agent";
|
||||
@@ -141,9 +147,26 @@ export function MonitorForm({
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,400px)]">
|
||||
<Section title="Check" hint={typeCopy[type].target}>
|
||||
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
|
||||
</Field>
|
||||
<Field label="Group" help="Optional heading on the monitors page. Nothing else reads it.">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={group}
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
placeholder="Production"
|
||||
list="monitor-groups"
|
||||
maxLength={48}
|
||||
/>
|
||||
<datalist id="monitor-groups">
|
||||
{knownGroups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
|
||||
|
||||
@@ -162,7 +162,9 @@ function slotHeight(s: Slot): number {
|
||||
return 55 + (s.pct - 80) * 2.2;
|
||||
}
|
||||
|
||||
function slotTitle(s: Slot): string {
|
||||
/** One line of plain text for a slot — the tape's tooltip and the chart's
|
||||
* accessible name for a bar, so both read the same hour the same way. */
|
||||
export function slotLabel(s: Slot): string {
|
||||
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
if (s.pct === null) return `${when} · no checks ran`;
|
||||
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
|
||||
@@ -179,7 +181,7 @@ export function Tape({ slots, height = "h-9", live = true }: { slots: Slot[]; he
|
||||
return (
|
||||
<div className={`relative flex ${height} items-end gap-px rounded-sm bg-well p-[3px]`}>
|
||||
{slots.map((s) => (
|
||||
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotTitle(s)}>
|
||||
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotLabel(s)}>
|
||||
<div className={`w-full rounded-[1px] ${slotColor(s)}`} style={{ height: `${slotHeight(s)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface MonitorState {
|
||||
export interface Monitor {
|
||||
monitor_id: string;
|
||||
name: string;
|
||||
/** Display-only heading on the monitors page. Empty means ungrouped. */
|
||||
group?: string;
|
||||
type: MonitorType;
|
||||
target: MonitorTarget;
|
||||
interval_sec: number;
|
||||
@@ -75,6 +77,7 @@ export interface Monitor {
|
||||
|
||||
export interface MonitorInput {
|
||||
name: string;
|
||||
group?: string;
|
||||
type: MonitorType;
|
||||
target: MonitorTarget;
|
||||
interval_sec: number;
|
||||
|
||||
Reference in New Issue
Block a user