From 606d113f347e06096cc556f57bd3d76c2c470b35 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 12 Feb 2026 13:28:01 +0100 Subject: [PATCH] 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. --- frontend/src/App.jsx | 21 +++++- frontend/src/pages/AlertsPage.jsx | 30 +++------ frontend/src/pages/DashboardPage.jsx | 9 +-- frontend/src/state.jsx | 98 +++++++++++++++++++++++++++- frontend/src/styles.css | 80 +++++++++++++++++++++++ 5 files changed, 205 insertions(+), 33 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1487b01..c2fde6c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,7 +17,7 @@ function Protected({ 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" : ""}`; return ( @@ -89,7 +89,24 @@ function Layout({ children }) { -
{children}
+
+ {children} +
+ {alertToasts.map((toast) => ( +
+
+ {toast.severity === "alert" ? "New Alert" : "New Warning"} + +
+
{toast.title}
+
{toast.target}
+
{toast.message}
+
+ ))} +
+
); } diff --git a/frontend/src/pages/AlertsPage.jsx b/frontend/src/pages/AlertsPage.jsx index b684606..dc55b6e 100644 --- a/frontend/src/pages/AlertsPage.jsx +++ b/frontend/src/pages/AlertsPage.jsx @@ -75,9 +75,8 @@ function buildAlertSuggestions(item) { } export function AlertsPage() { - const { tokens, refresh, me } = useAuth(); + const { tokens, refresh, me, alertStatus } = 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(""); @@ -92,12 +91,8 @@ export function AlertsPage() { const loadAll = async () => { try { setError(""); - const [targetRows, statusPayload] = await Promise.all([ - apiFetch("/targets", {}, tokens, refresh), - apiFetch("/alerts/status", {}, tokens, refresh), - ]); + const targetRows = await apiFetch("/targets", {}, tokens, refresh); setTargets(targetRows); - setStatus(statusPayload); if (canManageAlerts) { const defs = await apiFetch("/alerts/definitions", {}, tokens, refresh); @@ -114,15 +109,6 @@ export function AlertsPage() { 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] @@ -230,11 +216,11 @@ export function AlertsPage() {
- {status.warning_count || 0} + {alertStatus.warning_count || 0} Warnings
- {status.alert_count || 0} + {alertStatus.alert_count || 0} Alerts
@@ -242,9 +228,9 @@ export function AlertsPage() {

Warnings

- {status.warnings?.length ? ( + {alertStatus.warnings?.length ? (
- {status.warnings.map((item) => { + {alertStatus.warnings.map((item) => { const isOpen = expandedKey === item.alert_key; const suggestions = buildAlertSuggestions(item); return ( @@ -292,9 +278,9 @@ export function AlertsPage() {

Alerts

- {status.alerts?.length ? ( + {alertStatus.alerts?.length ? (
- {status.alerts.map((item) => { + {alertStatus.alerts.map((item) => { const isOpen = expandedKey === item.alert_key; const suggestions = buildAlertSuggestions(item); return ( diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index f52f520..310a3fc 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -4,9 +4,8 @@ import { apiFetch } from "../api"; import { useAuth } from "../state"; export function DashboardPage() { - const { tokens, refresh } = useAuth(); + const { tokens, refresh, alertStatus } = 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(""); @@ -15,13 +14,9 @@ export function DashboardPage() { let active = true; (async () => { try { - const [targetRows, alerts] = await Promise.all([ - apiFetch("/targets", {}, tokens, refresh), - apiFetch("/alerts/status", {}, tokens, refresh), - ]); + const targetRows = await apiFetch("/targets", {}, tokens, refresh); if (active) { setTargets(targetRows); - setAlertStatus(alerts); } } catch (e) { if (active) setError(String(e.message || e)); diff --git a/frontend/src/state.jsx b/frontend/src/state.jsx index 3af912c..4279b60 100644 --- a/frontend/src/state.jsx +++ b/frontend/src/state.jsx @@ -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"; const AuthCtx = createContext(null); @@ -27,6 +27,10 @@ export function AuthProvider({ children }) { const [tokens, setTokens] = useState(initial?.tokens || null); const [me, setMe] = useState(initial?.me || null); 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) => { 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 mode = nextMode === "easy" ? "easy" : "dba"; setUiModeState(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 {children}; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 811ab5e..d08140b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -979,6 +979,75 @@ td { 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 { display: flex; gap: 8px; @@ -1101,6 +1170,17 @@ select:-webkit-autofill { } } +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .query { max-width: 400px; white-space: nowrap;