From 2fab784ba77085ebc6e09a84da4bd398de64cf58 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 10:30:23 +0000 Subject: [PATCH] feat: Monitor groups and chart information --- server/internal/api/docs/openapi.json | 7 ++ server/internal/api/monitors.go | 6 +- server/internal/models/monitor.go | 12 +- server/internal/services/monitors.go | 33 +++++ web/app/(app)/monitors/[id]/page.tsx | 91 ++++++++++++-- web/app/(app)/monitors/page.tsx | 135 ++++++++++++++++++++- web/components/monitors/MonitorForm.tsx | 31 ++++- web/components/monitors/MonitorVisuals.tsx | 6 +- web/lib/api.ts | 3 + 9 files changed, 299 insertions(+), 25 deletions(-) diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index e4391cb..15354c7 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -1085,6 +1085,10 @@ "enabled": { "type": "boolean" }, + "group": { + "description": "Group is a display-only label. It buckets rows on the monitors page and\nhas no effect on scheduling, alerting or scope; an empty group means the\nmonitor is listed on its own under \"Ungrouped\".", + "type": "string" + }, "instance_id": { "type": "string" }, @@ -4208,6 +4212,9 @@ "enabled": { "type": "boolean" }, + "group": { + "type": "string" + }, "interval_sec": { "type": "integer" }, diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 32b3caa..2adc7a5 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -111,7 +111,7 @@ func getMonitor(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Monitor ID" -// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update" +// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update" // @Success 204 // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse @@ -121,6 +121,7 @@ func getMonitor(c *gin.Context) { func updateMonitor(c *gin.Context) { var body struct { Name *string `json:"name"` + Group *string `json:"group"` Type *string `json:"type"` Target *models.MonitorTarget `json:"target"` IntervalSec *int `json:"interval_sec"` @@ -137,6 +138,9 @@ func updateMonitor(c *gin.Context) { if body.Name != nil { upd["name"] = *body.Name } + if body.Group != nil { + upd["group"] = *body.Group + } if body.Type != nil { upd["type"] = *body.Type } diff --git a/server/internal/models/monitor.go b/server/internal/models/monitor.go index 940bad2..97e70ed 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -43,10 +43,14 @@ type MonitorState struct { } type Monitor struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - InstanceID string `bson:"instance_id" json:"instance_id"` - MonitorID string `bson:"monitor_id" json:"monitor_id"` - Name string `bson:"name" json:"name"` + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + MonitorID string `bson:"monitor_id" json:"monitor_id"` + Name string `bson:"name" json:"name"` + // Group is a display-only label. It buckets rows on the monitors page and + // has no effect on scheduling, alerting or scope; an empty group means the + // monitor is listed on its own under "Ungrouped". + Group string `bson:"group,omitempty" json:"group,omitempty"` Type string `bson:"type" json:"type"` Target MonitorTarget `bson:"target" json:"target"` IntervalSec int `bson:"interval_sec" json:"interval_sec"` diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index db9b5ec..6f06dea 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "strings" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/checker" @@ -21,6 +22,22 @@ func monCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 5*time.Second) } +// MaxMonitorGroupLen bounds the display-only group label. It is a heading on +// the monitors page, not an identifier, so the cap is about the layout rather +// than storage. +const MaxMonitorGroupLen = 48 + +// normaliseGroup collapses the ways two people write the same group. Grouping +// is by exact string, so " Production " and "Production" must not become two +// headings. +func normaliseGroup(g string) (string, error) { + g = strings.Join(strings.Fields(g), " ") + if len([]rune(g)) > MaxMonitorGroupLen { + return "", fmt.Errorf("group must be %d characters or fewer", MaxMonitorGroupLen) + } + return g, nil +} + func SpecFor(m *models.Monitor) checker.Spec { return checker.Spec{ Type: m.Type, @@ -126,6 +143,11 @@ func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error if err := validateRunner(instanceID, m.Runner); err != nil { return nil, err } + group, err := normaliseGroup(m.Group) + if err != nil { + return nil, err + } + m.Group = group m.InstanceID = instanceID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() @@ -158,6 +180,17 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M) error { return err } } + if raw, present := upd["group"]; present { + g, ok := raw.(string) + if !ok { + return fmt.Errorf("group must be a string") + } + group, err := normaliseGroup(g) + if err != nil { + return err + } + upd["group"] = group + } if raw, present := upd["runner"]; present { runner, ok := raw.(string) if !ok { diff --git a/web/app/(app)/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx index 604ac40..8da3ee9 100644 --- a/web/app/(app)/monitors/[id]/page.tsx +++ b/web/app/(app)/monitors/[id]/page.tsx @@ -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 ( +
+
+

{day}

+

{span}

+ {slot.pct === null ? ( +

No checks ran

+ ) : ( +
+
Uptime
+
= 99.5 ? "text-success" : slot.pct >= 80 ? "text-warning" : "text-danger"}`} + > + {slot.pct.toFixed(1)}% +
+
Response
+
{formatMs(slot.latency)}
+
Checks
+
{slot.checks}
+
+ )} +
+
+ ); +} + function History({ slots }: { slots: Slot[] }) { + const [hovered, setHovered] = useState(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[] }) { ))} -
- {slots.map((s) => ( -
setHovered(null)} + > + {slots.map((s, i) => ( + ))} + {hovered !== null && slots[hovered] && ( + <> + + + + )}
{monitor.type} + {monitor.group && ( + + {monitor.group} + + )}

{targetSummary(monitor)}

{monitor.state.message &&

{monitor.state.message}

} diff --git a/web/app/(app)/monitors/page.tsx b/web/app/(app)/monitors/page.tsx index 4547db3..b1d8594 100644 --- a/web/app/(app)/monitors/page.tsx +++ b/web/app/(app)/monitors/page.tsx @@ -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; ); } +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(); + 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([]); + + 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 = { 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 ( + + ); +} + 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 = { 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() {

Uptime 24h · response

-
- {monitors.map((m, i) => ( - - ))} -
+ {grouped ? ( +
+ {groups.map((group) => { + const isCollapsed = collapsed.includes(group.name); + return ( +
+ toggle(group.name)} /> + {!isCollapsed && + group.rows.map(({ monitor, rollups }) => ( + + ))} +
+ ); + })} +
+ ) : ( +
+ {rows.map(({ monitor, rollups }) => ( + + ))} +
+ )} )} diff --git a/web/components/monitors/MonitorForm.tsx b/web/components/monitors/MonitorForm.tsx index bd98417..ec5fec9 100644 --- a/web/components/monitors/MonitorForm.tsx +++ b/web/components/monitors/MonitorForm.tsx @@ -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(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(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({
- - setName(e.target.value)} placeholder="Billing API" required /> - +
+ + setName(e.target.value)} placeholder="Billing API" required /> + + + setGroup(e.target.value)} + placeholder="Production" + list="monitor-groups" + maxLength={48} + /> + + {knownGroups.map((g) => ( + + +
Kind diff --git a/web/components/monitors/MonitorVisuals.tsx b/web/components/monitors/MonitorVisuals.tsx index 55263fb..5dff71b 100644 --- a/web/components/monitors/MonitorVisuals.tsx +++ b/web/components/monitors/MonitorVisuals.tsx @@ -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 (
{slots.map((s) => ( -
+
))} diff --git a/web/lib/api.ts b/web/lib/api.ts index 4eb6bbf..c6409fb 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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;