- {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;