feat(web): metric monitor rule builder and per-server state table

This commit is contained in:
2026-09-17 09:11:23 +00:00
parent 3cb8463dc7
commit 4158696388
7 changed files with 213 additions and 10 deletions
+41 -4
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { api, Incident, Rollup } from "@/lib/api";
import { Button, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { HeartbeatUrlPanel } from "@/components/monitors/HeartbeatUrlPanel";
import { MetricServersTable } from "@/components/monitors/MetricServersTable";
import {
Slot,
StatusChip,
@@ -18,6 +19,7 @@ import {
formatMs,
formatPct,
markIncidents,
metricCopy,
relativeTime,
slotChartColor,
slotLabel,
@@ -269,14 +271,17 @@ function Figure({ label, value, unit, tone = "" }: { label: string; value: strin
);
}
function IncidentRow({ incident }: { incident: Incident }) {
function IncidentRow({ incident, hostname }: { incident: Incident; hostname?: string }) {
const open = !incident.resolved_at;
const started = new Date(incident.started_at);
return (
<div className="grid grid-cols-[14px_minmax(0,1fr)] items-start gap-3 border-t border-border-soft px-5 py-3 first:border-t-0">
<span className={`mt-1.5 block h-2 w-2 rounded-full ${open ? "bg-danger ring-4 ring-danger/20" : "bg-text-tertiary"}`} />
<div className="min-w-0">
<p className="text-[13.5px] text-text-primary">{incident.cause || "Check failed"}</p>
<p className="text-[13.5px] text-text-primary">
{incident.cause || "Check failed"}
{hostname && <span className="ml-2 font-mono text-[11px] text-text-tertiary">· {hostname}</span>}
</p>
<p className="mt-1 flex flex-wrap gap-2 font-mono text-[11px] text-text-tertiary">
<span>
{started.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}
@@ -380,6 +385,14 @@ export default function MonitorDetailPage() {
enabled: !!monitor && monitor.runner !== "server",
});
const { data: metricServers } = useQuery({
queryKey: ["monitors", monitorId, "servers"],
queryFn: () => api.monitorServers(monitorId),
refetchInterval: 30_000,
enabled: monitor?.type === "metric",
});
const hostnameByServerId = new Map((metricServers ?? []).map((s) => [s.server_id, s.hostname ?? s.server_id]));
const {
mutate: deleteMonitor,
isPending: isDeleting,
@@ -466,6 +479,18 @@ export default function MonitorDetailPage() {
monitor.runner === "server" ? "Control plane" : servers?.find((s) => s.server_id === monitor.runner)?.hostname || monitor.runner;
const alertChannels = (monitor.channel_ids ?? []).map((id) => channels?.find((c) => c.channel_id === id));
const isMetric = monitor.type === "metric";
let ruleSummary = "";
if (isMetric && monitor.target.metric) {
const kind = monitor.target.metric;
const copy = metricCopy[kind];
const value = copy.unit ? `${monitor.target.threshold ?? ""}${copy.unit}` : "";
const forMin = Math.round((monitor.for_sec ?? 0) / 60);
const selectorEntries = Object.entries(monitor.target.selector ?? {});
const on = selectorEntries.length > 0 ? selectorEntries.map(([k, v]) => `${k}=${v}`).join(", ") : "all servers";
ruleSummary = `${copy.label} ${value} for ${forMin} min on ${on}`.replace(/\s+/g, " ").trim();
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/monitors" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
@@ -593,9 +618,21 @@ export default function MonitorDetailPage() {
No incidents recorded. Every check has passed since this monitor was created.
</p>
) : (
incidents.map((inc) => <IncidentRow key={inc.incident_id} incident={inc} />)
incidents.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
hostname={inc.server_id ? hostnameByServerId.get(inc.server_id) ?? inc.server_id : undefined}
/>
))
)}
</Panel>
{isMetric && (
<Panel title="Servers" aside={ruleSummary} padded={false}>
<MetricServersTable monitorId={monitorId} />
</Panel>
)}
</div>
<div className="flex flex-col gap-5">
@@ -613,7 +650,7 @@ export default function MonitorDetailPage() {
Rotate token
</Button>
</Panel>
) : (
) : isMetric ? null : (
<Panel title="Check">
<dl className="text-[13px]">
<Row label="Runs from" value={runnerName} />
+3 -1
View File
@@ -204,7 +204,9 @@ function MonitorRow({ monitor, rollups, incidents }: MonitorRowData) {
? monitor.state.last_ping_at
? `Last ping ${relativeTime(monitor.state.last_ping_at)}`
: "Waiting for first ping"
: targetSummary(monitor)}
: monitor.type === "metric"
? (monitor.state.message ?? "Awaiting first check")
: targetSummary(monitor)}
</span>
</div>
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { StatusChip, relativeTime } from "@/components/monitors/MonitorVisuals";
/*
* Per-server state for a metric monitor's rule. Polls the same way the
* detail page's other queries do - a 30s refetchInterval - rather than a
* bespoke subscription for one panel.
*/
const order: Record<string, number> = { down: 0, pending: 1, up: 2 };
export function MetricServersTable({ monitorId }: { monitorId: string }) {
const { data, isLoading } = useQuery({
queryKey: ["monitors", monitorId, "servers"],
queryFn: () => api.monitorServers(monitorId),
refetchInterval: 30_000,
});
const rows = [...(data ?? [])].sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
if (isLoading) {
return (
<div className="flex justify-center py-10">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (rows.length === 0) {
return <p className="px-5 py-12 text-center text-sm text-text-secondary">No servers match this rule&apos;s tags.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-[13px]">
<thead>
<tr className="border-b border-border-soft text-[10px] uppercase tracking-[0.14em] text-text-tertiary">
<th className="px-5 py-2.5 font-medium">Server</th>
<th className="px-5 py-2.5 font-medium">Status</th>
<th className="px-5 py-2.5 font-medium">Value</th>
<th className="px-5 py-2.5 font-medium">Since</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.server_id} className="border-t border-border-soft first:border-t-0">
<td className="px-5 py-2.5">
<Link href={`/servers/${row.server_id}`} className="text-text-primary hover:text-accent hover:underline">
{row.hostname ?? row.server_id}
</Link>
</td>
<td className="px-5 py-2.5">
<StatusChip status={row.status} />
</td>
<td className="px-5 py-2.5 font-mono text-text-secondary">{row.message ?? "-"}</td>
<td className="px-5 py-2.5 font-mono text-text-tertiary">
{row.breach_since ? relativeTime(row.breach_since) : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+68 -3
View File
@@ -3,9 +3,10 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, MonitorInput, MonitorType } from "@/lib/api";
import { api, MetricKind, Monitor, MonitorInput, MonitorType } from "@/lib/api";
import { Button } from "@/components/ui";
import { formatDuration } from "@/components/monitors/MonitorVisuals";
import { formatDuration, metricCopy } from "@/components/monitors/MonitorVisuals";
import { TagRestriction } from "@/components/apikeys/TagRestriction";
/*
* The form is grouped the way the detail page reads it back: what is checked,
@@ -23,6 +24,7 @@ const typeCopy: Partial<Record<MonitorType, { title: string; blurb: string; targ
icmp: { title: "Ping", blurb: "Pings a host and measures round trip", target: "Host" },
tls: { title: "TLS", blurb: "Reads a certificate and counts days left", target: "Host and port" },
heartbeat: { title: "Heartbeat", blurb: "Your job calls a URL; alert when it stops.", target: "Ping URL" },
metric: { title: "Server metric", blurb: "Alert on disk, memory, load, units or reboots across tagged servers.", target: "Rule" },
};
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
@@ -109,6 +111,11 @@ export function MonitorForm({
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
const [periodMin, setPeriodMin] = useState(Math.round((initial?.target.period_sec ?? 3600) / 60));
const [graceMin, setGraceMin] = useState(Math.round((initial?.target.grace_sec ?? 300) / 60));
const [metric, setMetric] = useState<MetricKind>(initial?.target.metric ?? "disk_pct");
const [threshold, setThreshold] = useState<number>(initial?.target.threshold ?? 90);
const [mount, setMount] = useState(initial?.target.mount ?? "");
const [forMin, setForMin] = useState(Math.round((initial?.for_sec ?? 300) / 60));
const [selector, setSelector] = useState<Record<string, string>>(initial?.target.selector ?? {});
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
/* The group is free text, so the existing groups are offered as suggestions
@@ -136,11 +143,18 @@ export function MonitorForm({
} else if (type === "heartbeat") {
target.period_sec = periodMin * 60;
target.grace_sec = graceMin * 60;
} else if (type === "metric") {
target.metric = metric;
target.selector = selector;
if (metricCopy[metric].unit) target.threshold = threshold;
if (metricCopy[metric].mount && mount) target.mount = mount;
} else {
target.host = host;
target.port = port;
}
onSubmit({ name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
const input: MonitorInput = { name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds };
if (type === "metric") input.for_sec = forMin * 60;
onSubmit(input);
}
/* The server rejects a type change to or from heartbeat or metric, so an
@@ -302,6 +316,57 @@ export function MonitorForm({
</Field>
</div>
)}
{type === "metric" && (
<>
<Field label="Metric">
<select className={inputClass} value={metric} onChange={(e) => setMetric(e.target.value as MetricKind)}>
{(Object.keys(metricCopy) as MetricKind[]).map((k) => (
<option key={k} value={k}>
{metricCopy[k].label}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{metricCopy[metric].unit && (
<Field label={`Threshold${metricCopy[metric].unit ? ` (${metricCopy[metric].unit})` : ""}`}>
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
/>
</Field>
)}
{metricCopy[metric].mount && (
<Field label="Mount">
<input
className={`${inputClass} font-mono`}
value={mount}
onChange={(e) => setMount(e.target.value)}
placeholder="Any mount"
/>
</Field>
)}
<Field label="For (minutes)" help="How long the breach must hold before it alerts.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={forMin}
onChange={(e) => setForMin(Number(e.target.value))}
min={1}
/>
</Field>
</div>
<Field label="Servers">
<TagRestriction selector={selector} onChange={setSelector} />
<span className="mt-1.5 block text-xs text-text-tertiary">Leave empty to watch every server.</span>
</Field>
</>
)}
</Section>
<div className="flex flex-col gap-5">
+15 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Incident, Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
import { Incident, MetricKind, Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
/*
* Shared vocabulary for the monitors screens.
@@ -82,11 +82,25 @@ export function formatMs(ms: number | null): string {
return `${Math.round(ms)} ms`;
}
/** Label and unit per metric kind, shared by the rule builder and the detail
* page's summary line. `mount` marks a kind that also carries a mountpoint. */
export const metricCopy: Record<MetricKind, { label: string; unit?: string; mount?: boolean }> = {
disk_pct: { label: "Disk used", unit: "%", mount: true },
disk_free_gb: { label: "Disk free below", unit: "GB", mount: true },
mem_pct: { label: "Memory used", unit: "%" },
load_per_core: { label: "Load per core", unit: "×" },
unit_failed: { label: "Systemd unit failed" },
container_unhealthy: { label: "Container unhealthy" },
reboot_pending_days: { label: "Reboot pending for", unit: "days" },
agent_offline_min: { label: "Agent offline for", unit: "minutes" },
};
export function targetSummary(m: Monitor): string {
if (m.type === "http") return m.target.url ?? "";
if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`;
if (m.type === "icmp") return m.target.host ?? "";
if (m.type === "heartbeat") return "Ping URL";
if (m.type === "metric") return m.target.metric ? metricCopy[m.target.metric].label : "";
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
}
+16
View File
@@ -121,6 +121,18 @@ export interface Incident {
server_id?: string;
}
/** Per-server state for a metric monitor's rule, one row per matching server. */
export interface MonitorServerState {
monitor_id: string;
server_id: string;
hostname?: string;
status: MonitorStatus;
breach_since?: string;
value: number;
message?: string;
updated_at: string;
}
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
export interface MonitorSample {
monitor_id: string;
@@ -1035,6 +1047,10 @@ export const api = {
return request<Incident[]>(`/monitors/${monitorId}/incidents`);
},
monitorServers(monitorId: string): Promise<MonitorServerState[]> {
return request<MonitorServerState[]>(`/monitors/${monitorId}/servers`);
},
getMonitorUptime(monitorId: string): Promise<Rollup[]> {
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
},
File diff suppressed because one or more lines are too long