Files
NexaPG/frontend/src/pages/AlertsPage.jsx
nessi 4035335901 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.
2026-02-12 12:50:11 +01:00

334 lines
12 KiB
JavaScript

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>
);
}