Improve alert handling and UI for Alerts and Dashboard pages

Added expandable alert details to the Alerts page, providing more insights into each warning or alert. Enhanced the Dashboard to display distinct counts for warnings and alerts, along with updated KPIs and improved styling for better visual hierarchy. These changes improve usability and clarity in monitoring alert statuses.
This commit is contained in:
2026-02-12 13:01:08 +01:00
parent 4035335901
commit d0e8154c21
3 changed files with 221 additions and 63 deletions

View File

@@ -19,12 +19,18 @@ function formatAlertValue(value) {
return Number(value).toFixed(2);
}
function formatTs(ts) {
if (!ts) return "-";
return new Date(ts).toLocaleString();
}
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 [expandedKey, setExpandedKey] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [testing, setTesting] = useState(false);
@@ -160,6 +166,10 @@ export function AlertsPage() {
}
};
const toggleExpanded = (key) => {
setExpandedKey((prev) => (prev === key ? "" : key));
};
if (loading) return <div className="card">Loading alerts...</div>;
return (
@@ -184,8 +194,16 @@ export function AlertsPage() {
<h3>Warnings</h3>
{status.warnings?.length ? (
<div className="alerts-list">
{status.warnings.map((item) => (
<article className="alert-item warning" key={item.alert_key}>
{status.warnings.map((item) => {
const isOpen = expandedKey === item.alert_key;
return (
<article
className={`alert-item warning ${isOpen ? "is-open" : ""}`}
key={item.alert_key}
onClick={() => toggleExpanded(item.alert_key)}
role="button"
tabIndex={0}
>
<div className="alert-item-head">
<span className="alert-badge warning">Warning</span>
<strong>{item.name}</strong>
@@ -193,8 +211,22 @@ export function AlertsPage() {
</div>
<p>{item.description}</p>
<p className="alert-message">{item.message}</p>
{isOpen && (
<div className="alert-details-grid">
<div><span>Source</span><strong>{item.source}</strong></div>
<div><span>Category</span><strong>{item.category}</strong></div>
<div><span>Current Value</span><strong>{formatAlertValue(item.value)}</strong></div>
<div><span>Comparison</span><strong>{item.comparison}</strong></div>
<div><span>Warning Threshold</span><strong>{formatAlertValue(item.warning_threshold)}</strong></div>
<div><span>Alert Threshold</span><strong>{formatAlertValue(item.alert_threshold)}</strong></div>
<div><span>Checked At</span><strong>{formatTs(item.checked_at)}</strong></div>
<div><span>Target ID</span><strong>{item.target_id}</strong></div>
{item.sql_text && <div className="alert-sql"><code>{item.sql_text}</code></div>}
</div>
)}
</article>
))}
);
})}
</div>
) : (
<p className="muted">No warning-level alerts right now.</p>
@@ -205,8 +237,16 @@ export function AlertsPage() {
<h3>Alerts</h3>
{status.alerts?.length ? (
<div className="alerts-list">
{status.alerts.map((item) => (
<article className="alert-item alert" key={item.alert_key}>
{status.alerts.map((item) => {
const isOpen = expandedKey === item.alert_key;
return (
<article
className={`alert-item alert ${isOpen ? "is-open" : ""}`}
key={item.alert_key}
onClick={() => toggleExpanded(item.alert_key)}
role="button"
tabIndex={0}
>
<div className="alert-item-head">
<span className="alert-badge alert">Alert</span>
<strong>{item.name}</strong>
@@ -214,8 +254,22 @@ export function AlertsPage() {
</div>
<p>{item.description}</p>
<p className="alert-message">{item.message}</p>
{isOpen && (
<div className="alert-details-grid">
<div><span>Source</span><strong>{item.source}</strong></div>
<div><span>Category</span><strong>{item.category}</strong></div>
<div><span>Current Value</span><strong>{formatAlertValue(item.value)}</strong></div>
<div><span>Comparison</span><strong>{item.comparison}</strong></div>
<div><span>Warning Threshold</span><strong>{formatAlertValue(item.warning_threshold)}</strong></div>
<div><span>Alert Threshold</span><strong>{formatAlertValue(item.alert_threshold)}</strong></div>
<div><span>Checked At</span><strong>{formatTs(item.checked_at)}</strong></div>
<div><span>Target ID</span><strong>{item.target_id}</strong></div>
{item.sql_text && <div className="alert-sql"><code>{item.sql_text}</code></div>}
</div>
)}
</article>
))}
);
})}
</div>
) : (
<p className="muted">No critical alerts right now.</p>
@@ -225,9 +279,14 @@ export function AlertsPage() {
{canManageAlerts && (
<>
<section className="card">
<details className="card collapsible">
<summary className="collapse-head">
<div>
<h3>Create Custom Alert</h3>
<p className="muted">Admins and operators can add SQL-based checks with warning and alert thresholds.</p>
<p>Admins and operators can add SQL-based checks with warning and alert thresholds.</p>
</div>
<span className="collapse-chevron" aria-hidden="true">v</span>
</summary>
<form className="alert-form grid two" onSubmit={createDefinition}>
<div className="field">
<label>Name</label>
@@ -246,7 +305,7 @@ export function AlertsPage() {
<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="gte">greater than or equal (&gt;=)</option>
<option value="gt">greater than (&gt;)</option>
<option value="lte">less than or equal (&lt;=)</option>
<option value="lt">less than (&lt;)</option>
@@ -284,10 +343,16 @@ export function AlertsPage() {
</div>
{testResult && <div className="test-connection-result">{testResult}</div>}
</form>
</section>
</details>
<section className="card">
<details className="card collapsible">
<summary className="collapse-head">
<div>
<h3>Custom Alert Definitions</h3>
<p>All saved SQL-based alert rules.</p>
</div>
<span className="collapse-chevron" aria-hidden="true">v</span>
</summary>
{definitions.length ? (
<table>
<thead>
@@ -325,7 +390,7 @@ export function AlertsPage() {
) : (
<p className="muted">No custom alerts created yet.</p>
)}
</section>
</details>
</>
)}
</div>

View File

@@ -6,6 +6,7 @@ import { useAuth } from "../state";
export function DashboardPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -14,8 +15,14 @@ export function DashboardPage() {
let active = true;
(async () => {
try {
const data = await apiFetch("/targets", {}, tokens, refresh);
if (active) setTargets(data);
const [targetRows, alerts] = await Promise.all([
apiFetch("/targets", {}, tokens, refresh),
apiFetch("/alerts/status", {}, tokens, refresh),
]);
if (active) {
setTargets(targetRows);
setAlertStatus(alerts);
}
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
@@ -30,8 +37,16 @@ export function DashboardPage() {
if (loading) return <div className="card">Loading dashboard...</div>;
if (error) return <div className="card error">{error}</div>;
const alerts = targets.filter((t) => !t.host || !t.dbname).length;
const okCount = Math.max(0, targets.length - alerts);
const targetSeverities = new Map();
for (const item of alertStatus.warnings || []) {
if (!targetSeverities.has(item.target_id)) targetSeverities.set(item.target_id, "warning");
}
for (const item of alertStatus.alerts || []) {
targetSeverities.set(item.target_id, "alert");
}
const affectedTargetCount = targetSeverities.size;
const okCount = Math.max(0, targets.length - affectedTargetCount);
const filteredTargets = targets.filter((t) => {
const q = search.trim().toLowerCase();
if (!q) return true;
@@ -46,7 +61,7 @@ export function DashboardPage() {
<div className="dashboard-page">
<h2>Dashboard Overview</h2>
<p className="dashboard-subtitle">Real-time snapshot of monitored PostgreSQL targets.</p>
<div className="grid three dashboard-kpis">
<div className="dashboard-kpis-grid">
<div className="card stat kpi-card">
<div className="kpi-orb blue" />
<strong>{targets.length}</strong>
@@ -57,9 +72,14 @@ export function DashboardPage() {
<strong>{okCount}</strong>
<span className="kpi-label">Status OK</span>
</div>
<div className="card stat kpi-card alert">
<div className="card stat kpi-card warning">
<div className="kpi-orb amber" />
<strong>{alerts}</strong>
<strong>{alertStatus.warning_count || 0}</strong>
<span className="kpi-label">Warnings</span>
</div>
<div className="card stat kpi-card alert">
<div className="kpi-orb red" />
<strong>{alertStatus.alert_count || 0}</strong>
<span className="kpi-label">Alerts</span>
</div>
</div>
@@ -81,21 +101,21 @@ export function DashboardPage() {
<div className="dashboard-target-list">
{filteredTargets.map((t) => {
const hasAlert = !t.host || !t.dbname;
const severity = targetSeverities.get(t.id) || "ok";
return (
<article className="dashboard-target-card" key={t.id}>
<div className="target-main">
<div className="target-title-row">
<h4>{t.name}</h4>
<span className={`status-chip ${hasAlert ? "alert" : "ok"}`}>
{hasAlert ? "Alert" : "OK"}
<span className={`status-chip ${severity}`}>
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
</span>
</div>
<p><strong>Host:</strong> {t.host}:{t.port}</p>
<p><strong>DB:</strong> {t.dbname}</p>
</div>
<div className="target-actions">
<Link className="details-btn" to={`/targets/${t.id}`}>Details <span aria-hidden="true"></span></Link>
<Link className="details-btn" to={`/targets/${t.id}`}>Details <span aria-hidden="true">{"->"}</span></Link>
</div>
</article>
);

View File

@@ -264,14 +264,21 @@ button {
font-size: 14px;
}
.dashboard-kpis .kpi-card {
.dashboard-kpis-grid .kpi-card {
border-left: 4px solid #2f7eca;
position: relative;
overflow: hidden;
box-shadow: 0 12px 30px #0416344d;
}
.dashboard-kpis .kpi-card::after {
.dashboard-kpis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.dashboard-kpis-grid .kpi-card::after {
content: "";
position: absolute;
right: -30px;
@@ -282,12 +289,16 @@ button {
background: radial-gradient(circle, #5bc2ff33, transparent 70%);
}
.dashboard-kpis .kpi-card.ok {
.dashboard-kpis-grid .kpi-card.ok {
border-left-color: #2fa86f;
}
.dashboard-kpis .kpi-card.alert {
border-left-color: #d47a2a;
.dashboard-kpis-grid .kpi-card.warning {
border-left-color: #f39c1f;
}
.dashboard-kpis-grid .kpi-card.alert {
border-left-color: #ff5f6d;
}
.dashboard-targets-head {
@@ -341,6 +352,10 @@ button {
background: #f2a43a;
}
.kpi-orb.red {
background: #ff5570;
}
.kpi-label {
color: #b9d2ed;
font-weight: 500;
@@ -412,9 +427,15 @@ button {
}
.status-chip.alert {
color: #fde68a;
background: #3a2c12;
border-color: #9b7b2f;
color: #fecaca;
background: #401724;
border-color: #bd4761;
}
.status-chip.warning {
color: #ffe4af;
background: #3e2b11;
border-color: #c78824;
}
.details-btn {
@@ -789,13 +810,15 @@ td {
}
.alerts-kpi.warning {
border-color: #9a6426;
background: linear-gradient(180deg, #342713, #251b0f);
border-color: #f39c1f;
background: linear-gradient(180deg, #4e300f, #32200d);
box-shadow: 0 10px 24px #c6771238, inset 0 1px 0 #ffd28030;
}
.alerts-kpi.alert {
border-color: #a53a46;
background: linear-gradient(180deg, #381520, #2b1018);
border-color: #ff5f6d;
background: linear-gradient(180deg, #5a1627, #3b111c);
box-shadow: 0 10px 24px #bf2f4f3f, inset 0 1px 0 #ff9aad2e;
}
.alerts-list {
@@ -814,13 +837,21 @@ td {
}
.alert-item.warning {
border-color: #8a6d34;
background: linear-gradient(180deg, #2f2516, #261f15);
border-color: #d38f2a;
background: linear-gradient(180deg, #4a3012, #34220f);
}
.alert-item.alert {
border-color: #9f3e4a;
background: linear-gradient(180deg, #361822, #2d131b);
border-color: #d8526a;
background: linear-gradient(180deg, #52202e, #38151f);
}
.alert-item:hover {
transform: translateY(-1px);
}
.alert-item.is-open {
box-shadow: 0 14px 28px #07163166;
}
.alert-item-head {
@@ -855,15 +886,54 @@ td {
}
.alert-badge.warning {
color: #f9d8a8;
background: #3a2c16;
border-color: #a1742f;
color: #ffe8bf;
background: #5d370f;
border-color: #e79f3a;
}
.alert-badge.alert {
color: #fecaca;
background: #3a1620;
border-color: #ad4552;
color: #ffe0e4;
background: #5e1929;
border-color: #f06a81;
}
.alert-details-grid {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #ffffff20;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.alert-details-grid div {
display: grid;
gap: 2px;
}
.alert-details-grid span {
font-size: 11px;
color: #b7cbe6;
}
.alert-details-grid strong {
font-size: 13px;
color: #edf4ff;
}
.alert-sql {
grid-column: 1 / -1;
margin-top: 4px;
padding: 8px;
border-radius: 8px;
background: #081a33a6;
border: 1px solid #376097;
}
.alert-sql code {
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
}
.alert-form .field-full {
@@ -1161,6 +1231,9 @@ select:-webkit-autofill {
.grid.three {
grid-template-columns: 1fr;
}
.dashboard-kpis-grid {
grid-template-columns: 1fr;
}
.main {
height: auto;
overflow: visible;