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
@@ -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 ?? ""}`;
}