feat: Monitor groups and chart information
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 6m35s

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
+7
View File
@@ -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"
},
+5 -1
View File
@@ -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
}
+8 -4
View File
@@ -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"`
+33
View File
@@ -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 {
+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>
+27 -4
View File
@@ -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>
+4 -2
View File
@@ -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>
))}
+3
View File
@@ -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;