feat(web): metric monitor rule builder and per-server state table
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user