Add alert rule editing UI
This commit is contained in:
@@ -76,7 +76,7 @@ Issue source docs:
|
|||||||
- `docs/progress.md`
|
- `docs/progress.md`
|
||||||
- `docs/roadmap.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
|
## Guardrails
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -41,13 +41,22 @@ Implemented notification slice:
|
|||||||
- Worker sends incident open and recovery notifications.
|
- Worker sends incident open and recovery notifications.
|
||||||
- Notification state/history is stored in incident details to avoid duplicate sends.
|
- 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
|
## Known Gaps
|
||||||
|
|
||||||
- Credential vault UI and real credential encryption workflows are not complete.
|
- Credential vault UI and real credential encryption workflows are not complete.
|
||||||
- Audit logging tables exist, but events are not consistently written yet.
|
- Audit logging tables exist, but events are not consistently written yet.
|
||||||
- User management UI is not implemented.
|
- User management UI is not implemented.
|
||||||
- Role management is basic and needs full admin flows.
|
- 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.
|
- 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.
|
- 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.
|
- Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications.
|
||||||
@@ -59,16 +68,16 @@ Implemented notification slice:
|
|||||||
|
|
||||||
## Recommended Next Work
|
## Recommended Next Work
|
||||||
|
|
||||||
1. Add alert rule editing UI and richer alert conditions.
|
1. Add SNMP credential profiles and guided SNMP device discovery.
|
||||||
2. 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 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 SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
|
4. Add notification policy/routing controls.
|
||||||
5. Add notification policy/routing controls.
|
5. Add email/SMTP notification channel.
|
||||||
6. Add email/SMTP notification channel.
|
6. Add audit event writes for auth, monitor, credential, notification, and incident actions.
|
||||||
7. Add audit event writes for auth, monitor, credential, notification, and incident actions.
|
7. Build credential vault UI with masked secret handling.
|
||||||
8. Build credential vault UI with masked secret handling.
|
8. Add user administration UI.
|
||||||
9. Add user administration UI.
|
9. Add graphs for website response time and monitor status history.
|
||||||
10. 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.
|
11. Add backend and worker tests for the website-monitor and notification flows.
|
||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AlertRule,
|
||||||
|
AlertRuleUpdate,
|
||||||
Asset,
|
Asset,
|
||||||
CheckResult,
|
CheckResult,
|
||||||
Incident,
|
Incident,
|
||||||
@@ -85,6 +87,12 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
monitorResults: (token: string, monitorId: number, limit = 1) =>
|
monitorResults: (token: string, monitorId: number, limit = 1) =>
|
||||||
request<CheckResult[]>(`/monitors/${monitorId}/results?limit=${limit}`, token),
|
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),
|
incidents: (token: string) => request<Incident[]>("/incidents", token),
|
||||||
acknowledgeIncident: (token: string, incidentId: number) =>
|
acknowledgeIncident: (token: string, incidentId: number) =>
|
||||||
request<Incident>(`/incidents/${incidentId}/acknowledge`, token, {
|
request<Incident>(`/incidents/${incidentId}/acknowledge`, token, {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function App() {
|
|||||||
<NetworkChecksPage token={auth.token} monitors={monitors} onChanged={refreshData} />
|
<NetworkChecksPage token={auth.token} monitors={monitors} onChanged={refreshData} />
|
||||||
) : null}
|
) : null}
|
||||||
{page === "alerts" ? (
|
{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}
|
) : null}
|
||||||
{page === "discovery" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : 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}
|
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
|
||||||
|
|||||||
@@ -1,22 +1,52 @@
|
|||||||
import { useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { AlertTriangle, BellOff, CheckCheck, RefreshCw } from "lucide-react";
|
import { AlertTriangle, BellOff, CheckCheck, Pencil, RefreshCw, Save, ShieldAlert, X } from "lucide-react";
|
||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import type { Incident } from "../types/api";
|
import type { AlertRule, Incident, Monitor } from "../types/api";
|
||||||
|
|
||||||
interface AlertsPageProps {
|
interface AlertsPageProps {
|
||||||
token: string;
|
token: string;
|
||||||
|
monitors: Monitor[];
|
||||||
incidents: Incident[];
|
incidents: Incident[];
|
||||||
selectedIncidentId?: number | null;
|
selectedIncidentId?: number | null;
|
||||||
onChanged: () => Promise<void>;
|
onChanged: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }: AlertsPageProps) {
|
export function AlertsPage({ token, monitors, incidents, selectedIncidentId, onChanged }: AlertsPageProps) {
|
||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
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") {
|
async function runAction(incidentId: number, action: "ack" | "silence") {
|
||||||
setBusyId(incidentId);
|
setBusyIncidentId(incidentId);
|
||||||
try {
|
try {
|
||||||
if (action === "ack") {
|
if (action === "ack") {
|
||||||
await api.acknowledgeIncident(token, incidentId);
|
await api.acknowledgeIncident(token, incidentId);
|
||||||
@@ -25,7 +55,78 @@ export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }:
|
|||||||
}
|
}
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} finally {
|
} 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 className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold">Alerts</h1>
|
<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>
|
</div>
|
||||||
<Button variant="ghost" onClick={onChanged}>
|
<Button variant="ghost" onClick={refreshAll}>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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="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 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>
|
<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}
|
{incident.silenced_until ? <div className="text-xs text-slate-500">Silenced</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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} />
|
<CheckCheck size={16} />
|
||||||
Ack
|
Ack
|
||||||
</Button>
|
</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} />
|
<BellOff size={16} />
|
||||||
Silence
|
Silence
|
||||||
</Button>
|
</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" }) {
|
function Badge({ value, tone }: { value: string; tone: "critical" | "warning" | "ok" | "neutral" }) {
|
||||||
const classes = {
|
const classes = {
|
||||||
critical: "border-red-500/40 bg-red-950/40 text-red-200",
|
critical: "border-red-500/40 bg-red-950/40 text-red-200",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useState } from "react";
|
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 { api } from "../api/client";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
@@ -205,20 +205,22 @@ export function NetworkChecksPage({ token, monitors, onChanged }: NetworkChecksP
|
|||||||
<div className="divide-y divide-line">
|
<div className="divide-y divide-line">
|
||||||
{networkChecks.length ? (
|
{networkChecks.length ? (
|
||||||
networkChecks.map((monitor) => (
|
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>
|
||||||
<div className="font-medium">{monitor.name}</div>
|
<div className="font-medium">{monitor.name}</div>
|
||||||
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
|
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm uppercase text-slate-400">{monitor.monitor_type}</span>
|
<span className="text-sm uppercase text-slate-400">{monitor.monitor_type}</span>
|
||||||
<Status status={monitor.status} />
|
<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>
|
<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">
|
<Button aria-label={`Edit ${monitor.name}`} className="h-8 px-3" onClick={() => startEdit(monitor)} title="Edit check" type="button" variant="ghost">
|
||||||
<Edit3 size={15} />
|
<Pencil className="text-slate-100" size={15} />
|
||||||
|
Edit
|
||||||
</Button>
|
</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">
|
<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 size={15} />
|
<Trash2 className="text-red-200" size={15} />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
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 { api } from "../api/client";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
@@ -177,7 +177,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
|
|||||||
<div className="divide-y divide-line">
|
<div className="divide-y divide-line">
|
||||||
{channels.length ? (
|
{channels.length ? (
|
||||||
channels.map((channel) => (
|
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>
|
||||||
<div className="font-medium">{channel.name}</div>
|
<div className="font-medium">{channel.name}</div>
|
||||||
<div className="text-sm text-slate-400">{String(channel.settings.username || "OrbitalWard")}</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>
|
||||||
<div className="text-sm text-slate-300">{channel.channel_type}</div>
|
<div className="text-sm text-slate-300">{channel.channel_type}</div>
|
||||||
<Status enabled={channel.is_enabled} />
|
<Status enabled={channel.is_enabled} />
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||||
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => testChannel(channel.id)} title="Test channel" type="button" variant="ghost">
|
<Button className="h-8 px-3" disabled={busyId === channel.id} onClick={() => testChannel(channel.id)} title="Test channel" type="button" variant="ghost">
|
||||||
<Send size={15} />
|
<Send className="text-slate-100" size={15} />
|
||||||
|
Test
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => startEdit(channel)} title="Edit channel" type="button" variant="ghost">
|
<Button className="h-8 px-3" disabled={busyId === channel.id} onClick={() => startEdit(channel)} title="Edit channel" type="button" variant="ghost">
|
||||||
<Edit3 size={15} />
|
<Pencil className="text-slate-100" size={15} />
|
||||||
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => deleteChannel(channel.id)} title="Delete channel" type="button" variant="ghost">
|
<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 size={15} />
|
<Trash2 className="text-red-200" size={15} />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
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 { api } from "../api/client";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
@@ -233,7 +233,7 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
|
|||||||
<div className="divide-y divide-line">
|
<div className="divide-y divide-line">
|
||||||
{websites.length ? (
|
{websites.length ? (
|
||||||
websites.map((monitor) => (
|
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>
|
||||||
<div className="font-medium">{monitor.name}</div>
|
<div className="font-medium">{monitor.name}</div>
|
||||||
<div className="truncate text-sm text-slate-400">{monitor.target}</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}
|
{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>
|
</div>
|
||||||
<Status status={monitor.status} />
|
<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>
|
<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">
|
<Button aria-label={`Edit ${monitor.name}`} className="h-8 px-3" onClick={() => startEdit(monitor)} title="Edit monitor" type="button" variant="ghost">
|
||||||
<Edit3 size={15} />
|
<Pencil className="text-slate-100" size={15} />
|
||||||
|
Edit
|
||||||
</Button>
|
</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">
|
<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 size={15} />
|
<Trash2 className="text-red-200" size={15} />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ export interface Incident {
|
|||||||
details: Record<string, unknown>;
|
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 {
|
export interface NotificationChannel {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -144,6 +144,19 @@ class Scheduler:
|
|||||||
)
|
)
|
||||||
threshold_met = len(recent_statuses) >= rule.failure_threshold and all(status != "up" for status in recent_statuses)
|
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 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(
|
incident = Incident(
|
||||||
asset_id=monitor.asset_id,
|
asset_id=monitor.asset_id,
|
||||||
monitor_id=monitor.id,
|
monitor_id=monitor.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user