fix(alerts): overhaul alerts & notifications system for local and remote nodes

- StackAlertSheet: fetch agent status on open and show contextual banner
  (amber warning when no channels configured, green when active, blue info
  callout on remote nodes explaining remote-instance evaluation semantics)
- StackAlertSheet: surface actual server error message on addAlert failure
  instead of generic string; add console logging for all failure paths
- SettingsModal: expose Notifications tab for remote nodes so agents can be
  configured directly on any Sencho instance; section header shows node name
  and Remote badge with tooltip when proxying to a remote node
- SettingsModal: re-fetch agents/settings when active node changes
- SettingsModal: add hidden DialogTitle/DialogDescription to satisfy Radix
  UI accessibility requirements (eliminates console errors)
- backend POST /api/alerts: add Zod validation schema; rejects unknown
  metric/operator values, negative thresholds, and missing fields with 400
- EditorLayout WS: upgrade reconnect from flat 5s retry to exponential
  backoff (1s→2s→4s→8s→16s→30s max); onerror now logs the event; cleanup
  only closes OPEN sockets — CONNECTING sockets are closed in onopen after
  isMounted check, eliminating "closed before established" StrictMode error
This commit is contained in:
SaelixCode
2026-03-21 01:19:29 -04:00
parent bfef6a7979
commit e190f3ad8a
5 changed files with 213 additions and 30 deletions
+28 -7
View File
@@ -194,10 +194,22 @@ export default function EditorLayout() {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let isMounted = true;
let retryCount = 0;
const MAX_RETRY_DELAY_MS = 30000;
const connect = () => {
if (!isMounted) return;
ws = new WebSocket(`${wsBase}/ws/notifications`);
ws.onopen = () => {
if (!isMounted) {
// Component unmounted while the handshake was in-flight (React StrictMode double-mount)
ws?.close();
return;
}
retryCount = 0; // Reset backoff on successful connect
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
@@ -209,14 +221,18 @@ export default function EditorLayout() {
}
};
ws.onclose = () => {
if (isMounted) {
reconnectTimer = setTimeout(connect, 5000);
}
ws.onclose = (event) => {
if (!isMounted) return;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
retryCount++;
console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms (attempt ${retryCount})`);
reconnectTimer = setTimeout(connect, delay);
};
ws.onerror = () => {
ws?.close();
ws.onerror = (event) => {
// onerror always fires before onclose - log it and let onclose handle reconnect
console.warn('[WS notifications] error event', event);
};
};
@@ -225,7 +241,12 @@ export default function EditorLayout() {
return () => {
isMounted = false;
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
// Only close an already-open connection. If still CONNECTING, let onopen
// detect isMounted=false and close then — avoids the browser warning
// "WebSocket is closed before the connection is established".
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps