Add alert toasts and optimize alert status handling

Introduced a toast notification system to display new alerts and warnings. Updated the handling of alert status by centralizing it in the auth context and removing redundant API calls from individual pages. Improved styling for better user experience with alert notifications.
This commit is contained in:
2026-02-12 13:28:01 +01:00
parent 2c727c361e
commit 606d113f34
5 changed files with 205 additions and 33 deletions

View File

@@ -17,7 +17,7 @@ function Protected({ children }) {
} }
function Layout({ children }) { function Layout({ children }) {
const { me, logout, uiMode, setUiMode } = useAuth(); const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast } = useAuth();
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`; const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
return ( return (
@@ -89,7 +89,24 @@ function Layout({ children }) {
<button className="logout-btn" onClick={logout}>Logout</button> <button className="logout-btn" onClick={logout}>Logout</button>
</div> </div>
</aside> </aside>
<main className="main">{children}</main> <main className="main">
{children}
<div className="toast-stack" aria-live="polite" aria-atomic="true">
{alertToasts.map((toast) => (
<div key={toast.id} className={`alert-toast ${toast.severity || "warning"}`}>
<div className="alert-toast-head">
<strong>{toast.severity === "alert" ? "New Alert" : "New Warning"}</strong>
<button type="button" className="toast-close" onClick={() => dismissAlertToast(toast.id)}>
x
</button>
</div>
<div className="alert-toast-title">{toast.title}</div>
<div className="alert-toast-target">{toast.target}</div>
<div className="alert-toast-message">{toast.message}</div>
</div>
))}
</div>
</main>
</div> </div>
); );
} }

View File

@@ -75,9 +75,8 @@ function buildAlertSuggestions(item) {
} }
export function AlertsPage() { export function AlertsPage() {
const { tokens, refresh, me } = useAuth(); const { tokens, refresh, me, alertStatus } = useAuth();
const [targets, setTargets] = useState([]); const [targets, setTargets] = useState([]);
const [status, setStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [definitions, setDefinitions] = useState([]); const [definitions, setDefinitions] = useState([]);
const [form, setForm] = useState(initialForm); const [form, setForm] = useState(initialForm);
const [expandedKey, setExpandedKey] = useState(""); const [expandedKey, setExpandedKey] = useState("");
@@ -92,12 +91,8 @@ export function AlertsPage() {
const loadAll = async () => { const loadAll = async () => {
try { try {
setError(""); setError("");
const [targetRows, statusPayload] = await Promise.all([ const targetRows = await apiFetch("/targets", {}, tokens, refresh);
apiFetch("/targets", {}, tokens, refresh),
apiFetch("/alerts/status", {}, tokens, refresh),
]);
setTargets(targetRows); setTargets(targetRows);
setStatus(statusPayload);
if (canManageAlerts) { if (canManageAlerts) {
const defs = await apiFetch("/alerts/definitions", {}, tokens, refresh); const defs = await apiFetch("/alerts/definitions", {}, tokens, refresh);
@@ -114,15 +109,6 @@ export function AlertsPage() {
loadAll(); loadAll();
}, [canManageAlerts]); }, [canManageAlerts]);
useEffect(() => {
const timer = setInterval(() => {
apiFetch("/alerts/status", {}, tokens, refresh)
.then(setStatus)
.catch(() => {});
}, 20000);
return () => clearInterval(timer);
}, [tokens, refresh]);
const targetOptions = useMemo( const targetOptions = useMemo(
() => [{ id: "", name: "All targets" }, ...targets.map((t) => ({ id: String(t.id), name: `${t.name} (${t.host}:${t.port})` }))], () => [{ id: "", name: "All targets" }, ...targets.map((t) => ({ id: String(t.id), name: `${t.name} (${t.host}:${t.port})` }))],
[targets] [targets]
@@ -230,11 +216,11 @@ export function AlertsPage() {
<div className="grid two alerts-kpis"> <div className="grid two alerts-kpis">
<div className="card alerts-kpi warning"> <div className="card alerts-kpi warning">
<strong>{status.warning_count || 0}</strong> <strong>{alertStatus.warning_count || 0}</strong>
<span>Warnings</span> <span>Warnings</span>
</div> </div>
<div className="card alerts-kpi alert"> <div className="card alerts-kpi alert">
<strong>{status.alert_count || 0}</strong> <strong>{alertStatus.alert_count || 0}</strong>
<span>Alerts</span> <span>Alerts</span>
</div> </div>
</div> </div>
@@ -242,9 +228,9 @@ export function AlertsPage() {
<div className="grid two"> <div className="grid two">
<section className="card"> <section className="card">
<h3>Warnings</h3> <h3>Warnings</h3>
{status.warnings?.length ? ( {alertStatus.warnings?.length ? (
<div className="alerts-list"> <div className="alerts-list">
{status.warnings.map((item) => { {alertStatus.warnings.map((item) => {
const isOpen = expandedKey === item.alert_key; const isOpen = expandedKey === item.alert_key;
const suggestions = buildAlertSuggestions(item); const suggestions = buildAlertSuggestions(item);
return ( return (
@@ -292,9 +278,9 @@ export function AlertsPage() {
<section className="card"> <section className="card">
<h3>Alerts</h3> <h3>Alerts</h3>
{status.alerts?.length ? ( {alertStatus.alerts?.length ? (
<div className="alerts-list"> <div className="alerts-list">
{status.alerts.map((item) => { {alertStatus.alerts.map((item) => {
const isOpen = expandedKey === item.alert_key; const isOpen = expandedKey === item.alert_key;
const suggestions = buildAlertSuggestions(item); const suggestions = buildAlertSuggestions(item);
return ( return (

View File

@@ -4,9 +4,8 @@ import { apiFetch } from "../api";
import { useAuth } from "../state"; import { useAuth } from "../state";
export function DashboardPage() { export function DashboardPage() {
const { tokens, refresh } = useAuth(); const { tokens, refresh, alertStatus } = useAuth();
const [targets, setTargets] = useState([]); const [targets, setTargets] = useState([]);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -15,13 +14,9 @@ export function DashboardPage() {
let active = true; let active = true;
(async () => { (async () => {
try { try {
const [targetRows, alerts] = await Promise.all([ const targetRows = await apiFetch("/targets", {}, tokens, refresh);
apiFetch("/targets", {}, tokens, refresh),
apiFetch("/alerts/status", {}, tokens, refresh),
]);
if (active) { if (active) {
setTargets(targetRows); setTargets(targetRows);
setAlertStatus(alerts);
} }
} catch (e) { } catch (e) {
if (active) setError(String(e.message || e)); if (active) setError(String(e.message || e));

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useMemo, useState } from "react"; import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { API_URL } from "./api"; import { API_URL } from "./api";
const AuthCtx = createContext(null); const AuthCtx = createContext(null);
@@ -27,6 +27,10 @@ export function AuthProvider({ children }) {
const [tokens, setTokens] = useState(initial?.tokens || null); const [tokens, setTokens] = useState(initial?.tokens || null);
const [me, setMe] = useState(initial?.me || null); const [me, setMe] = useState(initial?.me || null);
const [uiMode, setUiModeState] = useState(loadUiMode); const [uiMode, setUiModeState] = useState(loadUiMode);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [alertToasts, setAlertToasts] = useState([]);
const knownAlertKeysRef = useRef(new Set());
const hasAlertSnapshotRef = useRef(false);
const persist = (nextTokens, nextMe) => { const persist = (nextTokens, nextMe) => {
if (nextTokens && nextMe) { if (nextTokens && nextMe) {
@@ -90,13 +94,103 @@ export function AuthProvider({ children }) {
} }
}; };
const dismissAlertToast = (toastId) => {
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
};
useEffect(() => {
if (!tokens?.accessToken) {
setAlertStatus({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
setAlertToasts([]);
knownAlertKeysRef.current = new Set();
hasAlertSnapshotRef.current = false;
return;
}
let mounted = true;
const pushToastsForNewItems = (items) => {
if (!items.length) return;
const createdAt = Date.now();
const nextToasts = items.slice(0, 4).map((item, idx) => ({
id: `${createdAt}-${idx}-${item.alert_key}`,
severity: item.severity,
title: item.name,
target: item.target_name,
message: item.message,
}));
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
for (const toast of nextToasts) {
setTimeout(() => {
if (!mounted) return;
dismissAlertToast(toast.id);
}, 8000);
}
};
const loadAlertStatus = async () => {
const request = async (accessToken) =>
fetch(`${API_URL}/alerts/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
let res = await request(tokens.accessToken);
if (res.status === 401 && tokens.refreshToken) {
const refreshed = await refresh();
if (refreshed?.accessToken) {
res = await request(refreshed.accessToken);
}
}
if (!res.ok) return;
const payload = await res.json();
if (!mounted) return;
setAlertStatus(payload);
const currentItems = [...(payload.warnings || []), ...(payload.alerts || [])];
const currentKeys = new Set(currentItems.map((item) => item.alert_key));
if (!hasAlertSnapshotRef.current) {
knownAlertKeysRef.current = currentKeys;
hasAlertSnapshotRef.current = true;
return;
}
const newItems = currentItems.filter((item) => !knownAlertKeysRef.current.has(item.alert_key));
knownAlertKeysRef.current = currentKeys;
pushToastsForNewItems(newItems);
};
loadAlertStatus().catch(() => {});
const timer = setInterval(() => {
loadAlertStatus().catch(() => {});
}, 8000);
return () => {
mounted = false;
clearInterval(timer);
};
}, [tokens?.accessToken, tokens?.refreshToken]);
const setUiMode = (nextMode) => { const setUiMode = (nextMode) => {
const mode = nextMode === "easy" ? "easy" : "dba"; const mode = nextMode === "easy" ? "easy" : "dba";
setUiModeState(mode); setUiModeState(mode);
localStorage.setItem(UI_MODE_KEY, mode); localStorage.setItem(UI_MODE_KEY, mode);
}; };
const value = useMemo(() => ({ tokens, me, login, logout, refresh, uiMode, setUiMode }), [tokens, me, uiMode]); const value = useMemo(
() => ({
tokens,
me,
login,
logout,
refresh,
uiMode,
setUiMode,
alertStatus,
alertToasts,
dismissAlertToast,
}),
[tokens, me, uiMode, alertStatus, alertToasts]
);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>; return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
} }

View File

@@ -979,6 +979,75 @@ td {
gap: 8px; gap: 8px;
} }
.toast-stack {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 50;
display: grid;
gap: 10px;
width: min(360px, calc(100vw - 36px));
pointer-events: none;
}
.alert-toast {
pointer-events: auto;
border-radius: 12px;
border: 1px solid #3b669b;
background: linear-gradient(180deg, #11305d, #0d2448);
box-shadow: 0 16px 34px #03132782;
padding: 10px 12px;
animation: toastIn 0.22s ease;
}
.alert-toast.warning {
border-color: #db9125;
background: linear-gradient(180deg, #4a2d0f, #35210d);
}
.alert-toast.alert {
border-color: #e4556d;
background: linear-gradient(180deg, #57202f, #3b1520);
}
.alert-toast-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 2px;
}
.alert-toast-head strong {
font-size: 12px;
letter-spacing: 0.02em;
}
.toast-close {
border: 1px solid #5d80b1;
background: #0f274b;
border-radius: 8px;
font-size: 11px;
line-height: 1;
padding: 4px 7px;
}
.alert-toast-title {
font-weight: 700;
margin-bottom: 2px;
}
.alert-toast-target {
color: #b9cde8;
font-size: 12px;
margin-bottom: 4px;
}
.alert-toast-message {
color: #dceafe;
font-size: 12px;
}
.range-picker { .range-picker {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -1101,6 +1170,17 @@ select:-webkit-autofill {
} }
} }
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.query { .query {
max-width: 400px; max-width: 400px;
white-space: nowrap; white-space: nowrap;