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;