Add alert rule editing UI

This commit is contained in:
Keith Smith
2026-05-23 16:08:27 -06:00
parent a8a4eb84f6
commit 5c9f93692a
10 changed files with 340 additions and 47 deletions
+8
View File
@@ -1,4 +1,6 @@
import type {
AlertRule,
AlertRuleUpdate,
Asset,
CheckResult,
Incident,
@@ -85,6 +87,12 @@ export const api = {
}),
monitorResults: (token: string, monitorId: number, limit = 1) =>
request<CheckResult[]>(`/monitors/${monitorId}/results?limit=${limit}`, token),
alertRules: (token: string) => request<AlertRule[]>("/alerts/rules", token),
updateAlertRule: (token: string, ruleId: number, payload: AlertRuleUpdate) =>
request<AlertRule>(`/alerts/rules/${ruleId}`, token, {
method: "PATCH",
body: JSON.stringify(payload),
}),
incidents: (token: string) => request<Incident[]>("/incidents", token),
acknowledgeIncident: (token: string, incidentId: number) =>
request<Incident>(`/incidents/${incidentId}/acknowledge`, token, {
+1 -1
View File
@@ -84,7 +84,7 @@ export function App() {
<NetworkChecksPage token={auth.token} monitors={monitors} onChanged={refreshData} />
) : null}
{page === "alerts" ? (
<AlertsPage token={auth.token} incidents={incidents} selectedIncidentId={selectedIncidentId} onChanged={refreshData} />
<AlertsPage token={auth.token} monitors={monitors} incidents={incidents} selectedIncidentId={selectedIncidentId} onChanged={refreshData} />
) : null}
{page === "discovery" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : null}
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
+246 -11
View File
@@ -1,22 +1,52 @@
import { useState } from "react";
import { AlertTriangle, BellOff, CheckCheck, RefreshCw } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { AlertTriangle, BellOff, CheckCheck, Pencil, RefreshCw, Save, ShieldAlert, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
import type { Incident } from "../types/api";
import type { AlertRule, Incident, Monitor } from "../types/api";
interface AlertsPageProps {
token: string;
monitors: Monitor[];
incidents: Incident[];
selectedIncidentId?: number | null;
onChanged: () => Promise<void>;
}
export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }: AlertsPageProps) {
const [busyId, setBusyId] = useState<number | null>(null);
export function AlertsPage({ token, monitors, incidents, selectedIncidentId, onChanged }: AlertsPageProps) {
const [rules, setRules] = useState<AlertRule[]>([]);
const [busyIncidentId, setBusyIncidentId] = useState<number | null>(null);
const [busyRuleId, setBusyRuleId] = useState<number | null>(null);
const [editingRuleId, setEditingRuleId] = useState<number | null>(null);
const [ruleName, setRuleName] = useState("");
const [severity, setSeverity] = useState("warning");
const [failureThreshold, setFailureThreshold] = useState(3);
const [cooldownSeconds, setCooldownSeconds] = useState(300);
const [enabled, setEnabled] = useState(true);
const [tlsCheckEnabled, setTlsCheckEnabled] = useState(false);
const [tlsWarningDays, setTlsWarningDays] = useState(30);
const [savingRule, setSavingRule] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const monitorById = useMemo(() => new Map(monitors.map((monitor) => [monitor.id, monitor])), [monitors]);
const editingRule = rules.find((rule) => rule.id === editingRuleId) ?? null;
const editingMonitor = editingRule ? (monitorById.get(editingRule.monitor_id) ?? null) : null;
const editingHttpsMonitor = editingMonitor && isHttpsWebsiteMonitor(editingMonitor) ? editingMonitor : null;
async function refreshRules() {
setRules(await api.alertRules(token));
}
async function refreshAll() {
await Promise.all([onChanged(), refreshRules()]);
}
useEffect(() => {
refreshRules().catch(() => setRules([]));
}, [token]);
async function runAction(incidentId: number, action: "ack" | "silence") {
setBusyId(incidentId);
setBusyIncidentId(incidentId);
try {
if (action === "ack") {
await api.acknowledgeIncident(token, incidentId);
@@ -25,7 +55,78 @@ export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }:
}
await onChanged();
} finally {
setBusyId(null);
setBusyIncidentId(null);
}
}
function startEdit(rule: AlertRule) {
setEditingRuleId(rule.id);
setRuleName(rule.name);
setSeverity(rule.severity);
setFailureThreshold(rule.failure_threshold);
setCooldownSeconds(rule.cooldown_seconds);
setEnabled(rule.is_enabled);
const monitor = monitorById.get(rule.monitor_id);
setTlsCheckEnabled(Boolean(monitor?.config?.check_tls_expiry ?? false));
setTlsWarningDays(Number(monitor?.config?.tls_warning_days ?? 30));
setMessage(null);
}
function resetRuleForm() {
setEditingRuleId(null);
setRuleName("");
setSeverity("warning");
setFailureThreshold(3);
setCooldownSeconds(300);
setEnabled(true);
setTlsCheckEnabled(false);
setTlsWarningDays(30);
}
async function saveRule(event: FormEvent) {
event.preventDefault();
if (!editingRule) return;
setSavingRule(true);
setMessage(null);
try {
await Promise.all([
api.updateAlertRule(token, editingRule.id, {
name: ruleName,
severity,
failure_threshold: failureThreshold,
cooldown_seconds: cooldownSeconds,
is_enabled: enabled,
}),
editingHttpsMonitor
? api.updateMonitor(token, editingHttpsMonitor.id, {
config: {
...(editingHttpsMonitor.config ?? {}),
check_tls_expiry: tlsCheckEnabled,
tls_warning_days: tlsWarningDays,
},
})
: Promise.resolve(),
]);
resetRuleForm();
await refreshAll();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Could not save alert rule");
} finally {
setSavingRule(false);
}
}
async function toggleRule(rule: AlertRule) {
setBusyRuleId(rule.id);
setMessage(null);
try {
await api.updateAlertRule(token, rule.id, { is_enabled: !rule.is_enabled });
await refreshRules();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Could not update alert rule");
} finally {
setBusyRuleId(null);
}
}
@@ -34,14 +135,129 @@ export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }:
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Alerts</h1>
<p className="mt-2 text-sm text-slate-400">Open incidents, acknowledgements, silences, recoveries, and notification history.</p>
<p className="mt-2 text-sm text-slate-400">Alert rules, open incidents, acknowledgements, silences, recoveries, and notification history.</p>
</div>
<Button variant="ghost" onClick={onChanged}>
<Button variant="ghost" onClick={refreshAll}>
<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={saveRule}>
<div className="flex items-center gap-2">
<ShieldAlert size={18} className="text-pulse" />
<h2 className="text-base font-semibold">{editingRule ? "Edit Alert Rule" : "Alert Rule Settings"}</h2>
</div>
{editingRule ? (
<>
<div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">
<div className="font-medium">{editingMonitor?.name ?? `Monitor ${editingRule.monitor_id}`}</div>
<div className="mt-1 text-xs text-slate-500">{describeCondition(editingRule.condition, editingMonitor)}</div>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Rule 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={ruleName} onChange={(event) => setRuleName(event.target.value)} required />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Severity</span>
<select 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={severity} onChange={(event) => setSeverity(event.target.value)}>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<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>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Cooldown Seconds</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={cooldownSeconds} onChange={(event) => setCooldownSeconds(Number(event.target.value))} min={0} max={86400} type="number" />
</label>
</div>
<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">Enabled</span>
<input className="h-5 w-5 accent-teal-400" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} type="checkbox" />
</div>
{editingHttpsMonitor ? (
<div className="space-y-3 rounded-md border border-line bg-slate-950 p-3">
<div className="text-sm font-medium text-slate-300">TLS Certificate</div>
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
<div className="flex items-center justify-between rounded-md border border-line bg-[#0d131c] px-3 py-2">
<span className="text-sm text-slate-300">Expiry Check</span>
<input className="h-5 w-5 accent-teal-400" checked={tlsCheckEnabled} onChange={(event) => setTlsCheckEnabled(event.target.checked)} type="checkbox" />
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Warn Days</span>
<input className="h-10 w-full rounded-md border border-line bg-[#0d131c] px-3 text-sm outline-none ring-pulse/40 focus:ring-2" disabled={!tlsCheckEnabled} value={tlsWarningDays} onChange={(event) => setTlsWarningDays(Number(event.target.value))} min={1} max={365} type="number" />
</label>
</div>
</div>
) : null}
{message ? <div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">{message}</div> : null}
<div className="flex gap-2">
<Button className="flex-1" onClick={resetRuleForm} type="button" variant="ghost">
<X size={16} />
Cancel
</Button>
<Button className="flex-1" disabled={savingRule} type="submit">
<Save size={16} />
{savingRule ? "Saving..." : "Save Rule"}
</Button>
</div>
</>
) : (
<div className="rounded-md border border-line bg-slate-950 p-4 text-sm text-slate-400">Select a rule from the list to edit severity, threshold, cooldown, or enabled state.</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">Alert Rules</h2>
</div>
<div className="divide-y divide-line">
{rules.length ? (
rules.map((rule) => {
const monitor = monitorById.get(rule.monitor_id);
return (
<div key={rule.id} className={`grid gap-3 p-4 md:grid-cols-[1fr_100px_115px_165px] md:items-center ${editingRuleId === rule.id ? "bg-slate-800/60" : ""}`}>
<div>
<div className="font-medium">{rule.name}</div>
<div className="text-sm text-slate-400">{monitor?.name ?? `Monitor ${rule.monitor_id}`}</div>
<div className="text-xs text-slate-500">
{describeCondition(rule.condition, monitor)} after {rule.failure_threshold} failed {rule.failure_threshold === 1 ? "check" : "checks"}, cooldown {formatDuration(rule.cooldown_seconds)}
</div>
</div>
<Badge value={rule.severity} tone={rule.severity === "critical" ? "critical" : "warning"} />
<Badge value={rule.is_enabled ? "enabled" : "disabled"} tone={rule.is_enabled ? "ok" : "neutral"} />
<div className="flex gap-2 md:justify-end">
<Button className="h-8 px-3" disabled={busyRuleId === rule.id} onClick={() => toggleRule(rule)} title={rule.is_enabled ? "Disable rule" : "Enable rule"} type="button" variant="ghost">
{rule.is_enabled ? "Disable" : "Enable"}
</Button>
<Button aria-label={`Edit ${rule.name}`} className="h-8 px-3" onClick={() => startEdit(rule)} title="Edit rule" type="button" variant="ghost">
<Pencil className="text-slate-100" size={15} />
Edit
</Button>
</div>
</div>
);
})
) : (
<div className="p-6 text-sm text-slate-400">No alert rules yet.</div>
)}
</div>
</div>
</section>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="grid grid-cols-[1fr_100px_130px_220px] gap-3 border-b border-line px-4 py-3 text-xs uppercase text-slate-500 max-lg:hidden">
<div>Incident</div>
@@ -78,11 +294,11 @@ export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }:
{incident.silenced_until ? <div className="text-xs text-slate-500">Silenced</div> : null}
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!open || Boolean(incident.acknowledged_at) || busyId === incident.id} onClick={() => runAction(incident.id, "ack")} variant="ghost">
<Button disabled={!open || Boolean(incident.acknowledged_at) || busyIncidentId === incident.id} onClick={() => runAction(incident.id, "ack")} variant="ghost">
<CheckCheck size={16} />
Ack
</Button>
<Button disabled={!open || busyId === incident.id} onClick={() => runAction(incident.id, "silence")} variant="ghost">
<Button disabled={!open || busyIncidentId === incident.id} onClick={() => runAction(incident.id, "silence")} variant="ghost">
<BellOff size={16} />
Silence
</Button>
@@ -99,6 +315,25 @@ export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }:
);
}
function describeCondition(condition: Record<string, unknown>, monitor?: Monitor | null) {
if (condition.type === "status_not_up" && monitor?.monitor_type === "http" && monitor.config?.check_tls_expiry) {
return `Website failure or TLS certificate inside ${String(monitor.config.tls_warning_days ?? 30)}-day warning window`;
}
if (condition.type === "status_not_up") return "Monitor is not up";
return "Custom condition";
}
function isHttpsWebsiteMonitor(monitor: Monitor) {
return monitor.monitor_type === "http" && monitor.target.trim().toLowerCase().startsWith("https://");
}
function formatDuration(seconds: number) {
if (seconds === 0) return "off";
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
if (seconds % 60 === 0) return `${seconds / 60}m`;
return `${seconds}s`;
}
function Badge({ value, tone }: { value: string; tone: "critical" | "warning" | "ok" | "neutral" }) {
const classes = {
critical: "border-red-500/40 bg-red-950/40 text-red-200",
+9 -7
View File
@@ -1,5 +1,5 @@
import { FormEvent, useState } from "react";
import { Activity, Edit3, PlugZap, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { Activity, Pencil, PlugZap, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
@@ -205,20 +205,22 @@ export function NetworkChecksPage({ token, monitors, onChanged }: NetworkChecksP
<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 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 items-center justify-between gap-3">
<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 w-8 px-0" onClick={() => startEdit(monitor)} title="Edit check" type="button" variant="ghost">
<Edit3 size={15} />
<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 w-8 px-0" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete check" type="button" variant="ghost">
<Trash2 size={15} />
<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>
+12 -9
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react";
import { Bell, Edit3, RefreshCw, Send, Trash2, X } from "lucide-react";
import { Bell, Pencil, RefreshCw, Send, Trash2, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
@@ -177,7 +177,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
<div className="divide-y divide-line">
{channels.length ? (
channels.map((channel) => (
<div key={channel.id} className="grid gap-3 p-4 md:grid-cols-[1fr_140px_90px_150px] md:items-center">
<div key={channel.id} className="grid gap-3 p-4 md:grid-cols-[1fr_140px_90px_260px] md:items-center">
<div>
<div className="font-medium">{channel.name}</div>
<div className="text-sm text-slate-400">{String(channel.settings.username || "OrbitalWard")}</div>
@@ -185,15 +185,18 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
</div>
<div className="text-sm text-slate-300">{channel.channel_type}</div>
<Status enabled={channel.is_enabled} />
<div className="flex gap-2">
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => testChannel(channel.id)} title="Test channel" type="button" variant="ghost">
<Send size={15} />
<div className="flex flex-wrap gap-2 md:justify-end">
<Button className="h-8 px-3" disabled={busyId === channel.id} onClick={() => testChannel(channel.id)} title="Test channel" type="button" variant="ghost">
<Send className="text-slate-100" size={15} />
Test
</Button>
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => startEdit(channel)} title="Edit channel" type="button" variant="ghost">
<Edit3 size={15} />
<Button className="h-8 px-3" disabled={busyId === channel.id} onClick={() => startEdit(channel)} title="Edit channel" type="button" variant="ghost">
<Pencil className="text-slate-100" size={15} />
Edit
</Button>
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => deleteChannel(channel.id)} title="Delete channel" type="button" variant="ghost">
<Trash2 size={15} />
<Button className="h-8 px-3 text-red-100" disabled={busyId === channel.id} onClick={() => deleteChannel(channel.id)} title="Delete channel" type="button" variant="ghost">
<Trash2 className="text-red-200" size={15} />
Delete
</Button>
</div>
</div>
+9 -7
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react";
import { Edit3, Globe2, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { Globe2, Pencil, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
@@ -233,7 +233,7 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
<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_110px_170px] md:items-center">
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_110px_250px] md:items-center">
<div>
<div className="font-medium">{monitor.name}</div>
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
@@ -241,13 +241,15 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
{monitor.config?.check_tls_expiry ? <div className="text-xs text-slate-500">TLS warning at {String(monitor.config.tls_warning_days ?? 30)} days</div> : null}
</div>
<Status status={monitor.status} />
<div className="flex items-center justify-between gap-3">
<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 w-8 px-0" onClick={() => startEdit(monitor)} title="Edit monitor" type="button" variant="ghost">
<Edit3 size={15} />
<Button aria-label={`Edit ${monitor.name}`} className="h-8 px-3" onClick={() => startEdit(monitor)} title="Edit monitor" type="button" variant="ghost">
<Pencil className="text-slate-100" size={15} />
Edit
</Button>
<Button aria-label={`Delete ${monitor.name}`} className="h-8 w-8 px-0" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete monitor" type="button" variant="ghost">
<Trash2 size={15} />
<Button aria-label={`Delete ${monitor.name}`} className="h-8 px-3 text-red-100" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete monitor" type="button" variant="ghost">
<Trash2 className="text-red-200" size={15} />
Delete
</Button>
</div>
</div>
+21
View File
@@ -57,6 +57,27 @@ export interface Incident {
details: Record<string, unknown>;
}
export interface AlertRule {
id: number;
monitor_id: number;
name: string;
severity: string;
condition: Record<string, unknown>;
failure_threshold: number;
cooldown_seconds: number;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface AlertRuleUpdate {
name?: string;
severity?: string;
failure_threshold?: number;
cooldown_seconds?: number;
is_enabled?: boolean;
}
export interface NotificationChannel {
id: number;
name: string;