253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
import { FormEvent, useState } from "react";
|
|
import { Activity, Pencil, 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_250px] 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 flex-wrap items-center justify-start gap-2 md:justify-end">
|
|
<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 px-3" onClick={() => startEdit(monitor)} title="Edit check" type="button" variant="ghost">
|
|
<Pencil className="text-slate-100" size={15} />
|
|
Edit
|
|
</Button>
|
|
<Button aria-label={`Delete ${monitor.name}`} className="h-8 px-3 text-red-100" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete check" type="button" variant="ghost">
|
|
<Trash2 className="text-red-200" size={15} />
|
|
Delete
|
|
</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>;
|
|
}
|