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:
@@ -6,6 +6,8 @@ import type {
|
||||
NotificationChannel,
|
||||
NotificationChannelCreate,
|
||||
NotificationChannelUpdate,
|
||||
PingMonitorCreate,
|
||||
TcpMonitorCreate,
|
||||
User,
|
||||
WebsiteMonitorCreate,
|
||||
} from "../types/api";
|
||||
@@ -61,6 +63,16 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
createPingMonitor: (token: string, payload: PingMonitorCreate) =>
|
||||
request<Monitor>("/monitors/ping", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
createTcpMonitor: (token: string, payload: TcpMonitorCreate) =>
|
||||
request<Monitor>("/monitors/tcp", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateMonitor: (token: string, monitorId: number, payload: MonitorUpdate) =>
|
||||
request<Monitor>(`/monitors/${monitorId}`, token, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AlertsPage } from "../pages/AlertsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { ListPage } from "../pages/ListPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { NetworkChecksPage } from "../pages/NetworkChecksPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { WebsitesPage } from "../pages/WebsitesPage";
|
||||
import type { Asset, Incident, Monitor } from "../types/api";
|
||||
@@ -79,6 +80,9 @@ export function App() {
|
||||
{page === "websites" ? (
|
||||
<WebsitesPage token={auth.token} monitors={monitors} onCreated={refreshData} />
|
||||
) : null}
|
||||
{page === "network-checks" ? (
|
||||
<NetworkChecksPage token={auth.token} monitors={monitors} onChanged={refreshData} />
|
||||
) : null}
|
||||
{page === "alerts" ? (
|
||||
<AlertsPage token={auth.token} incidents={incidents} selectedIncidentId={selectedIncidentId} onChanged={refreshData} />
|
||||
) : null}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Network,
|
||||
PlugZap,
|
||||
Radar,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -20,6 +21,7 @@ const navigation = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: Gauge },
|
||||
{ id: "assets", label: "Assets", icon: Network },
|
||||
{ id: "websites", label: "Websites", icon: Globe },
|
||||
{ id: "network-checks", label: "Network Checks", icon: PlugZap },
|
||||
{ id: "alerts", label: "Alerts", icon: Bell },
|
||||
{ id: "discovery", label: "Discovery", icon: Radar },
|
||||
{ id: "graphs", label: "Graphs", icon: Activity },
|
||||
|
||||
@@ -11,7 +11,6 @@ interface DashboardPageProps {
|
||||
export function DashboardPage({ assets, monitors, incidents }: DashboardPageProps) {
|
||||
const attentionMonitors = monitors.filter((monitor) => monitor.status !== "up" && monitor.status !== "unknown").length;
|
||||
const activeIncidents = incidents.filter((incident) => incident.status === "open").length;
|
||||
const websites = monitors.filter((monitor) => monitor.monitor_type === "http");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -26,29 +25,30 @@ export function DashboardPage({ assets, monitors, incidents }: DashboardPageProp
|
||||
<StatusTile icon={CheckCircle2} label="Overall Status" value={activeIncidents ? "Attention" : "Healthy"} tone={activeIncidents ? "warn" : "ok"} />
|
||||
<StatusTile icon={AlertTriangle} label="Active Incidents" value={String(activeIncidents)} tone={activeIncidents ? "warn" : "ok"} />
|
||||
<StatusTile icon={Server} label="Assets" value={String(assets.length)} />
|
||||
<StatusTile icon={Globe2} label="Websites" value={String(websites.length)} />
|
||||
<StatusTile icon={Globe2} label="Monitors" value={String(monitors.length)} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="flex items-center justify-between border-b border-line p-4">
|
||||
<h2 className="text-base font-semibold">Website Monitors</h2>
|
||||
<h2 className="text-base font-semibold">Monitor Status</h2>
|
||||
<span className="text-sm text-slate-400">{attentionMonitors} need attention</span>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{websites.length ? (
|
||||
websites.map((monitor) => (
|
||||
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_120px_110px] md:items-center">
|
||||
{monitors.length ? (
|
||||
monitors.map((monitor) => (
|
||||
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_90px_120px_110px] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{monitor.name}</div>
|
||||
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
|
||||
</div>
|
||||
<div className="text-sm uppercase text-slate-400">{monitor.monitor_type}</div>
|
||||
<div className="text-sm text-slate-400">{monitor.interval_seconds}s interval</div>
|
||||
<StatusBadge status={monitor.status} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No website monitors yet.</div>
|
||||
<div className="p-6 text-sm text-slate-400">No monitors yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -94,3 +94,26 @@ export interface WebsiteMonitorCreate {
|
||||
alert_severity: string;
|
||||
failure_threshold: number;
|
||||
}
|
||||
|
||||
export interface PingMonitorCreate {
|
||||
name: string;
|
||||
host: string;
|
||||
timeout_seconds: number;
|
||||
interval_seconds: number;
|
||||
create_asset: boolean;
|
||||
alert_enabled: boolean;
|
||||
alert_severity: string;
|
||||
failure_threshold: number;
|
||||
}
|
||||
|
||||
export interface TcpMonitorCreate {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
timeout_seconds: number;
|
||||
interval_seconds: number;
|
||||
create_asset: boolean;
|
||||
alert_enabled: boolean;
|
||||
alert_severity: string;
|
||||
failure_threshold: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user