Add alert management functionality in backend and frontend

This commit introduces alert management capabilities, including creating, updating, listing, and removing custom SQL-based alerts in the backend. It adds the necessary database migrations, API endpoints, and frontend pages to manage alerts, enabling users to define thresholds and monitor system health effectively.
This commit is contained in:
2026-02-12 12:50:11 +01:00
parent d76a838bbb
commit 4035335901
11 changed files with 1236 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/DashboardPage";
import { TargetsPage } from "./pages/TargetsPage";
import { TargetDetailPage } from "./pages/TargetDetailPage";
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
import { AlertsPage } from "./pages/AlertsPage";
import { AdminUsersPage } from "./pages/AdminUsersPage";
function Protected({ children }) {
@@ -51,6 +52,14 @@ function Layout({ children }) {
</span>
<span className="nav-label">Query Insights</span>
</NavLink>
<NavLink to="/alerts" className={navClass}>
<span className="nav-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V10a6 6 0 0 0-12 0v4.2a2 2 0 0 1-.6 1.4L4 17h5m6 0a3 3 0 0 1-6 0" />
</svg>
</span>
<span className="nav-label">Alerts</span>
</NavLink>
{me?.role === "admin" && (
<NavLink to="/admin/users" className={navClass}>
<span className="nav-icon" aria-hidden="true">
@@ -99,6 +108,7 @@ export function App() {
<Route path="/targets" element={<TargetsPage />} />
<Route path="/targets/:id" element={<TargetDetailPage />} />
<Route path="/query-insights" element={<QueryInsightsPage />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
</Routes>
</Layout>

View File

@@ -0,0 +1,333 @@
import React, { useEffect, useMemo, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const initialForm = {
name: "",
description: "",
target_id: "",
sql_text: "SELECT count(*)::float FROM pg_stat_activity WHERE state = 'active'",
comparison: "gte",
warning_threshold: "",
alert_threshold: "",
enabled: true,
};
function formatAlertValue(value) {
if (value === null || value === undefined) return "-";
if (Number.isInteger(value)) return String(value);
return Number(value).toFixed(2);
}
export function AlertsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
const [status, setStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [definitions, setDefinitions] = useState([]);
const [form, setForm] = useState(initialForm);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
const [saving, setSaving] = useState(false);
const canManageAlerts = me?.role === "admin" || me?.role === "operator";
const loadAll = async () => {
try {
setError("");
const [targetRows, statusPayload] = await Promise.all([
apiFetch("/targets", {}, tokens, refresh),
apiFetch("/alerts/status", {}, tokens, refresh),
]);
setTargets(targetRows);
setStatus(statusPayload);
if (canManageAlerts) {
const defs = await apiFetch("/alerts/definitions", {}, tokens, refresh);
setDefinitions(defs);
}
} catch (e) {
setError(String(e.message || e));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAll();
}, [canManageAlerts]);
useEffect(() => {
const timer = setInterval(() => {
apiFetch("/alerts/status", {}, tokens, refresh)
.then(setStatus)
.catch(() => {});
}, 20000);
return () => clearInterval(timer);
}, [tokens, refresh]);
const targetOptions = useMemo(
() => [{ id: "", name: "All targets" }, ...targets.map((t) => ({ id: String(t.id), name: `${t.name} (${t.host}:${t.port})` }))],
[targets]
);
const createDefinition = async (e) => {
e.preventDefault();
setSaving(true);
setTestResult("");
try {
await apiFetch(
"/alerts/definitions",
{
method: "POST",
body: JSON.stringify({
name: form.name,
description: form.description || null,
target_id: form.target_id ? Number(form.target_id) : null,
sql_text: form.sql_text,
comparison: form.comparison,
warning_threshold: form.warning_threshold === "" ? null : Number(form.warning_threshold),
alert_threshold: Number(form.alert_threshold),
enabled: !!form.enabled,
}),
},
tokens,
refresh
);
setForm(initialForm);
await loadAll();
} catch (e) {
setError(String(e.message || e));
} finally {
setSaving(false);
}
};
const testDefinition = async () => {
if (!form.target_id) {
setTestResult("Select a specific target to test this SQL query.");
return;
}
setTesting(true);
setTestResult("");
try {
const res = await apiFetch(
"/alerts/definitions/test",
{
method: "POST",
body: JSON.stringify({
target_id: Number(form.target_id),
sql_text: form.sql_text,
}),
},
tokens,
refresh
);
if (res.ok) {
setTestResult(`Query test succeeded. Returned value: ${formatAlertValue(res.value)}`);
} else {
setTestResult(`Query test failed: ${res.error}`);
}
} catch (e) {
setTestResult(String(e.message || e));
} finally {
setTesting(false);
}
};
const removeDefinition = async (definitionId) => {
if (!confirm("Delete this custom alert definition?")) return;
try {
await apiFetch(`/alerts/definitions/${definitionId}`, { method: "DELETE" }, tokens, refresh);
await loadAll();
} catch (e) {
setError(String(e.message || e));
}
};
const toggleDefinition = async (definition) => {
try {
await apiFetch(
`/alerts/definitions/${definition.id}`,
{ method: "PUT", body: JSON.stringify({ enabled: !definition.enabled }) },
tokens,
refresh
);
await loadAll();
} catch (e) {
setError(String(e.message || e));
}
};
if (loading) return <div className="card">Loading alerts...</div>;
return (
<div className="alerts-page">
<h2>Alerts</h2>
<p className="alerts-subtitle">Warnings are early signals. Alerts are critical thresholds reached or exceeded.</p>
{error && <div className="card error">{error}</div>}
<div className="grid two alerts-kpis">
<div className="card alerts-kpi warning">
<strong>{status.warning_count || 0}</strong>
<span>Warnings</span>
</div>
<div className="card alerts-kpi alert">
<strong>{status.alert_count || 0}</strong>
<span>Alerts</span>
</div>
</div>
<div className="grid two">
<section className="card">
<h3>Warnings</h3>
{status.warnings?.length ? (
<div className="alerts-list">
{status.warnings.map((item) => (
<article className="alert-item warning" key={item.alert_key}>
<div className="alert-item-head">
<span className="alert-badge warning">Warning</span>
<strong>{item.name}</strong>
<small>{item.target_name}</small>
</div>
<p>{item.description}</p>
<p className="alert-message">{item.message}</p>
</article>
))}
</div>
) : (
<p className="muted">No warning-level alerts right now.</p>
)}
</section>
<section className="card">
<h3>Alerts</h3>
{status.alerts?.length ? (
<div className="alerts-list">
{status.alerts.map((item) => (
<article className="alert-item alert" key={item.alert_key}>
<div className="alert-item-head">
<span className="alert-badge alert">Alert</span>
<strong>{item.name}</strong>
<small>{item.target_name}</small>
</div>
<p>{item.description}</p>
<p className="alert-message">{item.message}</p>
</article>
))}
</div>
) : (
<p className="muted">No critical alerts right now.</p>
)}
</section>
</div>
{canManageAlerts && (
<>
<section className="card">
<h3>Create Custom Alert</h3>
<p className="muted">Admins and operators can add SQL-based checks with warning and alert thresholds.</p>
<form className="alert-form grid two" onSubmit={createDefinition}>
<div className="field">
<label>Name</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. High Active Sessions" required />
</div>
<div className="field">
<label>Target Scope</label>
<select value={form.target_id} onChange={(e) => setForm({ ...form, target_id: e.target.value })}>
{targetOptions.map((opt) => (
<option key={opt.id || "all"} value={opt.id}>
{opt.name}
</option>
))}
</select>
</div>
<div className="field">
<label>Comparison</label>
<select value={form.comparison} onChange={(e) => setForm({ ...form, comparison: e.target.value })}>
<option value="gte">greater than or equal (>=)</option>
<option value="gt">greater than (&gt;)</option>
<option value="lte">less than or equal (&lt;=)</option>
<option value="lt">less than (&lt;)</option>
</select>
</div>
<div className="field">
<label>Description</label>
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this check validate?" />
</div>
<div className="field">
<label>Warning Threshold (optional)</label>
<input type="number" step="any" value={form.warning_threshold} onChange={(e) => setForm({ ...form, warning_threshold: e.target.value })} placeholder="e.g. 20" />
</div>
<div className="field">
<label>Alert Threshold</label>
<input type="number" step="any" value={form.alert_threshold} onChange={(e) => setForm({ ...form, alert_threshold: e.target.value })} placeholder="e.g. 50" required />
</div>
<div className="field field-full">
<label>SQL Query (must return one numeric value)</label>
<textarea
rows={4}
value={form.sql_text}
onChange={(e) => setForm({ ...form, sql_text: e.target.value })}
placeholder="SELECT count(*)::float FROM pg_stat_activity WHERE state = 'active'"
required
/>
</div>
<div className="alert-form-actions field-full">
<button type="button" className="secondary-btn" onClick={testDefinition} disabled={testing}>
{testing ? "Testing..." : "Test query output"}
</button>
<button className="primary-btn" disabled={saving}>
{saving ? "Creating..." : "Create custom alert"}
</button>
</div>
{testResult && <div className="test-connection-result">{testResult}</div>}
</form>
</section>
<section className="card">
<h3>Custom Alert Definitions</h3>
{definitions.length ? (
<table>
<thead>
<tr>
<th>Name</th>
<th>Scope</th>
<th>Comparison</th>
<th>Warn</th>
<th>Alert</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{definitions.map((d) => (
<tr key={d.id}>
<td>{d.name}</td>
<td>{d.target_id ? targets.find((t) => t.id === d.target_id)?.name || `Target #${d.target_id}` : "All targets"}</td>
<td>{d.comparison}</td>
<td>{d.warning_threshold ?? "-"}</td>
<td>{d.alert_threshold}</td>
<td>{d.enabled ? "Enabled" : "Disabled"}</td>
<td>
<button type="button" className="secondary-btn small-btn" onClick={() => toggleDefinition(d)}>
{d.enabled ? "Disable" : "Enable"}
</button>{" "}
<button type="button" className="danger-btn" onClick={() => removeDefinition(d.id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">No custom alerts created yet.</p>
)}
</section>
</>
)}
</div>
);
}

View File

@@ -225,6 +225,7 @@ a {
input,
select,
textarea,
button {
background: #0f2750;
color: var(--text);
@@ -730,6 +731,11 @@ details[open] .collapse-chevron {
border-color: #be3f63;
}
.small-btn {
min-height: 30px;
padding: 4px 10px;
}
button {
cursor: pointer;
}
@@ -763,6 +769,121 @@ td {
border-color: #7f1d1d;
}
.muted {
color: #9eb8d6;
}
.alerts-subtitle {
margin-top: 2px;
color: #a6c0df;
}
.alerts-kpis .alerts-kpi {
display: grid;
gap: 2px;
}
.alerts-kpi strong {
font-size: 32px;
line-height: 1;
}
.alerts-kpi.warning {
border-color: #9a6426;
background: linear-gradient(180deg, #342713, #251b0f);
}
.alerts-kpi.alert {
border-color: #a53a46;
background: linear-gradient(180deg, #381520, #2b1018);
}
.alerts-list {
display: grid;
gap: 10px;
max-height: 520px;
overflow: auto;
padding-right: 3px;
}
.alert-item {
border-radius: 12px;
border: 1px solid #375d8f;
background: #10294f;
padding: 12px;
}
.alert-item.warning {
border-color: #8a6d34;
background: linear-gradient(180deg, #2f2516, #261f15);
}
.alert-item.alert {
border-color: #9f3e4a;
background: linear-gradient(180deg, #361822, #2d131b);
}
.alert-item-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.alert-item-head small {
color: #c3d5ef;
margin-left: auto;
}
.alert-item p {
margin: 4px 0;
color: #d3e5fb;
}
.alert-message {
font-size: 13px;
color: #b6cae8;
}
.alert-badge {
display: inline-block;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
border: 1px solid transparent;
}
.alert-badge.warning {
color: #f9d8a8;
background: #3a2c16;
border-color: #a1742f;
}
.alert-badge.alert {
color: #fecaca;
background: #3a1620;
border-color: #ad4552;
}
.alert-form .field-full {
grid-column: 1 / -1;
}
.alert-form textarea {
width: 100%;
min-height: 90px;
resize: vertical;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
}
.alert-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.range-picker {
display: flex;
gap: 8px;