Add ping and TCP monitor types

Adds ping and TCP monitor creation APIs, worker collectors, network checks UI, dashboard monitor status support, and progress documentation.
This commit is contained in:
Keith Smith
2026-05-23 15:01:57 -06:00
parent 597ff18c2a
commit 16932957b2
13 changed files with 577 additions and 35 deletions
+250
View File
@@ -0,0 +1,250 @@
import { FormEvent, useState } from "react";
import { Activity, Edit3, PlugZap, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
import type { Monitor } from "../types/api";
interface NetworkChecksPageProps {
token: string;
monitors: Monitor[];
onChanged: () => Promise<void>;
}
type NetworkCheckType = "ping" | "tcp";
export function NetworkChecksPage({ token, monitors, onChanged }: NetworkChecksPageProps) {
const networkChecks = monitors.filter((monitor) => monitor.monitor_type === "ping" || monitor.monitor_type === "tcp");
const [checkType, setCheckType] = useState<NetworkCheckType>("ping");
const [name, setName] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState(443);
const [timeoutSeconds, setTimeoutSeconds] = useState(5);
const [intervalSeconds, setIntervalSeconds] = useState(60);
const [failureThreshold, setFailureThreshold] = useState(3);
const [alertEnabled, setAlertEnabled] = useState(true);
const [editingMonitorId, setEditingMonitorId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
if (editingMonitorId) {
await api.updateMonitor(token, editingMonitorId, {
name,
target: checkType === "tcp" ? `${host}:${port}` : host,
interval_seconds: intervalSeconds,
config: checkType === "tcp" ? { host, port, timeout_seconds: timeoutSeconds } : { timeout_seconds: timeoutSeconds },
});
} else if (checkType === "tcp") {
await api.createTcpMonitor(token, {
name,
host,
port,
timeout_seconds: timeoutSeconds,
interval_seconds: intervalSeconds,
create_asset: true,
alert_enabled: alertEnabled,
alert_severity: "warning",
failure_threshold: failureThreshold,
});
} else {
await api.createPingMonitor(token, {
name,
host,
timeout_seconds: timeoutSeconds,
interval_seconds: intervalSeconds,
create_asset: true,
alert_enabled: alertEnabled,
alert_severity: "warning",
failure_threshold: failureThreshold,
});
}
resetForm();
await onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save network check");
} finally {
setSubmitting(false);
}
}
function startEdit(monitor: Monitor) {
const nextType = monitor.monitor_type === "tcp" ? "tcp" : "ping";
setEditingMonitorId(monitor.id);
setCheckType(nextType);
setName(monitor.name);
setHost(nextType === "tcp" ? String(monitor.config?.host ?? monitor.target.split(":")[0] ?? "") : monitor.target);
setPort(Number(monitor.config?.port ?? 443));
setTimeoutSeconds(Number(monitor.config?.timeout_seconds ?? 5));
setIntervalSeconds(monitor.interval_seconds);
setAlertEnabled(true);
setFailureThreshold(3);
setError(null);
}
function resetForm() {
setEditingMonitorId(null);
setCheckType("ping");
setName("");
setHost("");
setPort(443);
setTimeoutSeconds(5);
setIntervalSeconds(60);
setFailureThreshold(3);
setAlertEnabled(true);
}
async function deleteMonitor(monitorId: number) {
setDeletingId(monitorId);
setError(null);
try {
await api.deleteMonitor(token, monitorId);
await onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not delete network check");
} finally {
setDeletingId(null);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Network Checks</h1>
<p className="mt-2 text-sm text-slate-400">ICMP ping checks and TCP port availability checks.</p>
</div>
<Button variant="ghost" onClick={onChanged}>
<RefreshCw size={16} />
Refresh
</Button>
</div>
<section className="grid gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={handleSubmit}>
<div className="flex items-center gap-2">
{checkType === "tcp" ? <PlugZap size={18} className="text-pulse" /> : <Activity size={18} className="text-pulse" />}
<h2 className="text-base font-semibold">{editingMonitorId ? "Edit Network Check" : "Add Network Check"}</h2>
</div>
<div className="grid grid-cols-2 rounded-md border border-line bg-slate-950 p-1">
<button className={modeClass(checkType === "ping")} disabled={Boolean(editingMonitorId)} onClick={() => setCheckType("ping")} type="button">
Ping
</button>
<button className={modeClass(checkType === "tcp")} disabled={Boolean(editingMonitorId)} onClick={() => setCheckType("tcp")} type="button">
TCP
</button>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Name</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Host</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={host} onChange={(event) => setHost(event.target.value)} placeholder="router.local or 192.168.1.1" required />
</label>
<div className="grid gap-3 sm:grid-cols-3">
{checkType === "tcp" ? (
<label className="block space-y-2">
<span className="text-sm text-slate-300">Port</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={port} onChange={(event) => setPort(Number(event.target.value))} min={1} max={65535} type="number" />
</label>
) : null}
<label className="block space-y-2">
<span className="text-sm text-slate-300">Timeout</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={timeoutSeconds} onChange={(event) => setTimeoutSeconds(Number(event.target.value))} min={1} max={60} type="number" />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Interval</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={intervalSeconds} onChange={(event) => setIntervalSeconds(Number(event.target.value))} min={10} type="number" />
</label>
</div>
{!editingMonitorId ? (
<div className="flex items-center justify-between rounded-md border border-line bg-slate-950 px-3 py-2">
<span className="text-sm text-slate-300">Alert on repeated failures</span>
<input className="h-5 w-5 accent-teal-400" checked={alertEnabled} onChange={(event) => setAlertEnabled(event.target.checked)} type="checkbox" />
</div>
) : null}
{!editingMonitorId ? (
<label className="block space-y-2">
<span className="text-sm text-slate-300">Failure Threshold</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={failureThreshold} onChange={(event) => setFailureThreshold(Number(event.target.value))} min={1} max={20} type="number" />
</label>
) : null}
{error ? <div className="rounded-md border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
<div className="flex gap-2">
{editingMonitorId ? (
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
<X size={16} />
Cancel
</Button>
) : null}
<Button className="flex-1" disabled={submitting} type="submit">
<Plus size={16} />
{submitting ? "Saving..." : editingMonitorId ? "Save Check" : "Create Check"}
</Button>
</div>
</form>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="border-b border-line p-4">
<h2 className="text-base font-semibold">Configured Network Checks</h2>
</div>
<div className="divide-y divide-line">
{networkChecks.length ? (
networkChecks.map((monitor) => (
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_90px_110px_170px] md:items-center">
<div>
<div className="font-medium">{monitor.name}</div>
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
</div>
<span className="text-sm uppercase text-slate-400">{monitor.monitor_type}</span>
<Status status={monitor.status} />
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">{monitor.last_checked_at ? new Date(monitor.last_checked_at).toLocaleTimeString() : "Not checked"}</div>
<Button aria-label={`Edit ${monitor.name}`} className="h-8 w-8 px-0" onClick={() => startEdit(monitor)} title="Edit check" type="button" variant="ghost">
<Edit3 size={15} />
</Button>
<Button aria-label={`Delete ${monitor.name}`} className="h-8 w-8 px-0" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete check" type="button" variant="ghost">
<Trash2 size={15} />
</Button>
</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">No network checks yet.</div>
)}
</div>
</div>
</section>
</div>
);
}
function modeClass(active: boolean) {
return `h-8 rounded-md text-sm transition disabled:opacity-70 ${active ? "bg-slate-800 text-white" : "text-slate-400 hover:bg-slate-900 hover:text-white"}`;
}
function Status({ status }: { status: string }) {
const classes =
status === "up"
? "border-teal-500/40 bg-teal-950/40 text-teal-200"
: status === "down"
? "border-red-500/40 bg-red-950/40 text-red-200"
: status === "warning"
? "border-amber-500/40 bg-amber-950/40 text-amber-200"
: "border-slate-600 bg-slate-900 text-slate-300";
return <span className={`inline-flex h-7 w-24 items-center justify-center rounded-md border text-xs font-medium ${classes}`}>{status}</span>;
}