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
+1 -1
View File
@@ -76,7 +76,7 @@ Issue source docs:
- `docs/progress.md`
- `docs/roadmap.md`
Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, incident actions, and webhook notification channels. The next recommended implementation issue is alert rule editing UI, followed by guided SNMP discovery and monitor selection.
Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, and webhook notification channels. The next recommended implementation issue is SNMP credential profiles and guided SNMP discovery, followed by SNMP monitor selection.
## Guardrails
+20 -11
View File
@@ -41,13 +41,22 @@ Implemented notification slice:
- Worker sends incident open and recovery notifications.
- Notification state/history is stored in incident details to avoid duplicate sends.
Implemented alerting management slice:
- Alerts page lists alert rules separately from incidents.
- Alert rules can be enabled, disabled, and edited from the UI.
- Editable alert rule fields include friendly name, severity, failure threshold, and cooldown.
- HTTPS website alert rules expose TLS certificate expiry check and warning-day controls.
- Existing simple alert conditions are shown in friendly language instead of raw condition data.
- Worker honors alert rule cooldown before opening a new incident for a recently-triggered rule.
## Known Gaps
- Credential vault UI and real credential encryption workflows are not complete.
- Audit logging tables exist, but events are not consistently written yet.
- User management UI is not implemented.
- Role management is basic and needs full admin flows.
- Alert rule editing UI is not implemented.
- Richer alert condition editing is not implemented yet.
- Guided SNMP device discovery and friendly SNMP monitor selection are not implemented yet.
- SNMP credential profiles, interface status, traffic counters, errors, uptime, CPU, and memory checks are not implemented yet.
- Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications.
@@ -59,16 +68,16 @@ Implemented notification slice:
## Recommended Next Work
1. Add alert rule editing UI and richer alert conditions.
2. Add SNMP credential profiles and guided SNMP device discovery.
3. Add SNMP discovery selection UI to choose what to monitor and alert on.
4. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
5. Add notification policy/routing controls.
6. Add email/SMTP notification channel.
7. Add audit event writes for auth, monitor, credential, notification, and incident actions.
8. Build credential vault UI with masked secret handling.
9. Add user administration UI.
10. Add graphs for website response time and monitor status history.
1. Add SNMP credential profiles and guided SNMP device discovery.
2. Add SNMP discovery selection UI to choose what to monitor and alert on.
3. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
4. Add notification policy/routing controls.
5. Add email/SMTP notification channel.
6. Add audit event writes for auth, monitor, credential, notification, and incident actions.
7. Build credential vault UI with masked secret handling.
8. Add user administration UI.
9. Add graphs for website response time and monitor status history.
10. Add richer alert condition editing.
11. Add backend and worker tests for the website-monitor and notification flows.
## Operational Notes
+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;
+13
View File
@@ -144,6 +144,19 @@ class Scheduler:
)
threshold_met = len(recent_statuses) >= rule.failure_threshold and all(status != "up" for status in recent_statuses)
if threshold_met and open_incident is None:
if rule.cooldown_seconds > 0:
latest_incident = db.scalar(
select(Incident)
.where(
Incident.monitor_id == monitor.id,
Incident.alert_rule_id == rule.id,
)
.order_by(Incident.opened_at.desc())
.limit(1)
)
if latest_incident is not None and latest_incident.opened_at + timedelta(seconds=rule.cooldown_seconds) > now:
return
incident = Incident(
asset_id=monitor.asset_id,
monitor_id=monitor.id,