From 1690f0d3c6b80f0825d71d32f4c6bdd3f07a1290 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 21 Mar 2026 17:38:55 -0400 Subject: [PATCH 1/2] feat(notifications): aggregate alerts from all nodes in the notification panel Remote-node alerts now appear in the local notification bell alongside local ones, each tagged with the originating node name. - backend: reorder WS upgrade handler so /ws/notifications?nodeId= falls through to the existing proxy path instead of short-circuiting - frontend/api.ts: add fetchForNode() helper for explicit node-targeted requests without touching the localStorage active-node key - frontend/EditorLayout: fetch notification history from all registered nodes in parallel on mount and on node-list changes; open a per-remote WebSocket connection for real-time push; route mark-read / delete / clear actions back to the originating node; show node-name badge on remote alerts --- CHANGELOG.md | 1 + backend/src/index.ts | 16 +- frontend/src/components/EditorLayout.tsx | 194 ++++++++++++++++++++--- frontend/src/lib/api.ts | 26 +++ 4 files changed, 207 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b2c273..ab78bc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** Cross-node notification aggregation — the notification bell now surfaces alerts from all connected remote nodes, not just the local instance. On mount and whenever the node list changes, `EditorLayout` fetches notification history from every registered node in parallel (using `fetchForNode` with targeted `x-node-id` headers). Each remote node also gets a dedicated real-time WebSocket connection (`/ws/notifications?nodeId=`) so alerts push instantly as they fire. Remote-sourced notifications display a node-name badge for quick identification. Mark-as-read, delete, and clear-all actions are routed to the correct node. The backend WS upgrade handler was updated to allow `/ws/notifications?nodeId=` to fall through to the existing proxy path (bare `/ws/notifications` with no nodeId continues to connect locally as before). - **Fixed:** Remote node host console and container exec WebSocket connections now succeed — the gateway exchanges the long-lived `node_proxy` api_token for a short-lived `console_session` JWT (60 s TTL) via a new `POST /api/system/console-token` endpoint before forwarding the WS upgrade to the remote. Previously the remote's `isProxyToken` guard correctly blocked `node_proxy` tokens from interactive terminals, which also blocked legitimate user-initiated console sessions routed through the gateway. - **Fixed:** `StackAlertSheet` now fetches notification agent status from the active node on open and displays a contextual banner: green checkmark with active channel names when agents are enabled, amber warning with a link to Settings → Notifications when none are configured, and a blue info callout on remote nodes explaining that alerts are evaluated and dispatched by that remote Sencho instance. - **Fixed:** `SettingsModal` Notifications tab is no longer hidden when a remote node is active — users can now configure Discord/Slack/Webhook channels directly on any remote node. The section header shows the remote node name and a "Remote" badge with a tooltip explaining that channels are saved on the remote instance. diff --git a/backend/src/index.ts b/backend/src/index.ts index 81e47998..ad887ca4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -495,8 +495,15 @@ server.on('upgrade', async (req, socket, head) => { const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; - // Notification push channel - always local, never proxied to remote nodes - if (pathname === '/ws/notifications') { + // Resolve node context from query param + const nodeIdParam = parsedUrl.searchParams.get('nodeId'); + const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + const node = NodeRegistry.getInstance().getNode(nodeId); + + // Notification push channel - local only when no remote nodeId is specified. + // When a nodeId pointing to a remote node is provided, fall through to the + // proxy block below so the browser subscribes to that remote node's push stream. + if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) { const notifWss = new WebSocket.Server({ noServer: true }); notifWss.handleUpgrade(req, socket, head, (ws) => { notifWss.close(); @@ -507,11 +514,6 @@ server.on('upgrade', async (req, socket, head) => { return; } - // Resolve node context from query param - const nodeIdParam = parsedUrl.searchParams.get('nodeId'); - const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); - const node = NodeRegistry.getInstance().getNode(nodeId); - // Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance if (node && node.type === 'remote' && node.api_url && node.api_token) { const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 32ee7ecc..0ae84481 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -18,7 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; -import { apiFetch } from '@/lib/api'; +import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from 'sonner'; import { Label } from './ui/label'; import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; @@ -34,7 +34,7 @@ import { StackAlertSheet } from './StackAlertSheet'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import { GlobalObservabilityView } from './GlobalObservabilityView'; -import { useNodes } from '@/context/NodeContext'; +import { useNodes, Node } from '@/context/NodeContext'; interface ContainerInfo { Id: string; @@ -48,6 +48,16 @@ interface StackStatus { [key: string]: 'running' | 'exited' | 'unknown'; } +interface Notification { + id: number; + level: 'info' | 'warning' | 'error'; + message: string; + timestamp: number; + is_read: number; // 0 | 1 (SQLite boolean) + nodeId: number; + nodeName: string; +} + const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -59,6 +69,12 @@ const formatBytes = (bytes: number) => { export default function EditorLayout() { const { logout } = useAuth(); const { nodes, activeNode, setActiveNode } = useNodes(); + // Stable ref so notification callbacks always read the latest nodes list + // without needing nodes in their dependency arrays (which would cause loops). + const nodesRef = useRef([]); + nodesRef.current = nodes; + // Tracks cleanup functions for per-remote-node notification WebSocket connections. + const remoteNotifWsRef = useRef void>>(new Map()); const [files, setFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); const [content, setContent] = useState(''); @@ -113,7 +129,7 @@ export default function EditorLayout() { const [stackUpdates, setStackUpdates] = useState>({}); // Notifications & Settings state - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState([]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); @@ -185,10 +201,9 @@ export default function EditorLayout() { } }; - // Notification WS push - load history once on mount, then receive live updates + // Notification WS push - subscribe to local real-time alerts. + // Initial history load is handled by the [nodes] effect below. useEffect(() => { - fetchNotifications(); - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsBase = `${wsProtocol}//${window.location.host}`; let ws: WebSocket | null = null; @@ -212,9 +227,15 @@ export default function EditorLayout() { ws.onmessage = (event) => { try { - const msg = JSON.parse(event.data); + const msg = JSON.parse(event.data as string); if (msg.type === 'notification' && msg.payload) { - setNotifications(prev => [msg.payload, ...prev]); + const localNode = nodesRef.current.find(n => n.type === 'local'); + const tagged: Notification = { + ...(msg.payload as Omit), + nodeId: localNode?.id ?? -1, + nodeName: localNode?.name ?? 'Local', + }; + setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp)); } } catch (e) { console.error('[WS notifications] parse error', e); @@ -250,6 +271,87 @@ export default function EditorLayout() { }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Re-fetch all notifications when the nodes list changes (e.g. remote node added/removed). + // nodesRef ensures fetchNotifications always reads the latest nodes at call time. + useEffect(() => { + fetchNotifications(); + }, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps + + // Open / close per-remote-node notification WebSocket connections as the nodes list changes. + // Uses remoteNotifWsRef to avoid tearing down existing connections on unrelated node updates. + useEffect(() => { + const remoteNodes = nodes.filter(n => n.type === 'remote'); + const currentIds = new Set(remoteNotifWsRef.current.keys()); + const newIds = new Set(remoteNodes.map(n => n.id)); + + // Close connections for nodes that are no longer registered as remote + for (const id of currentIds) { + if (!newIds.has(id)) { + remoteNotifWsRef.current.get(id)?.(); + remoteNotifWsRef.current.delete(id); + } + } + + // Open connections for newly-added remote nodes + for (const rn of remoteNodes) { + if (remoteNotifWsRef.current.has(rn.id)) continue; + + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let active = true; + let retryCount = 0; + + const connect = () => { + if (!active) return; + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/notifications?nodeId=${rn.id}`); + + ws.onopen = () => { if (!active) { ws?.close(); } else { retryCount = 0; } }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string); + if (msg.type === 'notification' && msg.payload) { + // Read node name from ref so it stays fresh even if the node was renamed + const current = nodesRef.current.find(n => n.id === rn.id); + setNotifications(prev => + [{ ...msg.payload as Omit, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev] + .sort((a, b) => b.timestamp - a.timestamp) + ); + } + } catch (e) { + console.error(`[WS notifications:${rn.name}] parse error`, e); + } + }; + + ws.onclose = () => { + if (!active) return; + const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); + retryCount++; + reconnectTimer = setTimeout(connect, delay); + }; + + ws.onerror = (e) => console.warn(`[WS notifications:${rn.name}] error`, e); + }; + + connect(); + + remoteNotifWsRef.current.set(rn.id, () => { + active = false; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws && ws.readyState === WebSocket.OPEN) ws.close(); + }); + } + }, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps + + // Cleanup all remote notification WebSocket connections on unmount + useEffect(() => { + return () => { + for (const cleanup of remoteNotifWsRef.current.values()) cleanup(); + remoteNotifWsRef.current.clear(); + }; + }, []); + // Re-fetch stacks whenever the active node changes (or becomes available on mount). // Also clears any stale editor/container state that belonged to the previous node. useEffect(() => { @@ -268,12 +370,36 @@ export default function EditorLayout() { const fetchNotifications = async () => { try { - const res = await apiFetch('/notifications'); - if (res.ok) { - const data = await res.json(); - setNotifications(data); + const currentNodes = nodesRef.current; + const localNode = currentNodes.find(n => n.type === 'local'); + const remoteNodes = currentNodes.filter(n => n.type === 'remote'); + + const [localResult, ...remoteResults] = await Promise.allSettled([ + apiFetch('/notifications', { localOnly: true }), + ...remoteNodes.map(n => fetchForNode('/notifications', n.id)), + ]); + + const all: Notification[] = []; + + if (localResult.status === 'fulfilled' && localResult.value.ok) { + const data = await localResult.value.json() as Omit[]; + data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' })); } - } catch (e) { } + + for (let i = 0; i < remoteNodes.length; i++) { + const result = remoteResults[i]; + if (result?.status === 'fulfilled' && result.value.ok) { + const data = await result.value.json() as Omit[]; + const rn = remoteNodes[i]; + data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name })); + } + } + + all.sort((a, b) => b.timestamp - a.timestamp); + setNotifications(all); + } catch (e) { + console.error('[Notifications] fetch error:', e); + } }; const fetchImageUpdates = async () => { @@ -288,22 +414,39 @@ export default function EditorLayout() { const markAllRead = async () => { try { - await apiFetch('/notifications/read', { method: 'POST' }); - fetchNotifications(); + const localNode = nodesRef.current.find(n => n.type === 'local'); + const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read).map(n => n.nodeId))]; + await Promise.allSettled(unreadNodeIds.map(nodeId => + nodeId === localNode?.id + ? apiFetch('/notifications/read', { method: 'POST', localOnly: true }) + : fetchForNode('/notifications/read', nodeId, { method: 'POST' }) + )); + setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 }))); } catch (e) { } }; - const deleteNotification = async (id: number) => { + const deleteNotification = async (notif: Notification) => { try { - await apiFetch(`/notifications/${id}`, { method: 'DELETE' }); - fetchNotifications(); + const localNode = nodesRef.current.find(n => n.type === 'local'); + if (notif.nodeId === localNode?.id) { + await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true }); + } else { + await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' }); + } + setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId))); } catch (e) { } }; const clearAllNotifications = async () => { try { - await apiFetch('/notifications', { method: 'DELETE' }); - fetchNotifications(); + const localNode = nodesRef.current.find(n => n.type === 'local'); + const uniqueNodeIds = [...new Set(notifications.map(n => n.nodeId))]; + await Promise.allSettled(uniqueNodeIds.map(nodeId => + nodeId === localNode?.id + ? apiFetch('/notifications', { method: 'DELETE', localOnly: true }) + : fetchForNode('/notifications', nodeId, { method: 'DELETE' }) + )); + setNotifications([]); } catch (e) { } }; @@ -1053,12 +1196,17 @@ export default function EditorLayout() {
No notifications
) : (
- {notifications.map((notif: any) => ( -
+ {notifications.map((notif) => ( +
{notif.level} + {nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && ( + + {notif.nodeName} + + )} {new Date(notif.timestamp).toLocaleString()} @@ -1072,7 +1220,7 @@ export default function EditorLayout() { className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => { e.stopPropagation(); - deleteNotification(notif.id); + deleteNotification(notif); }} > diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index da854367..7fbd64b4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,4 +47,30 @@ export async function apiFetch( return response; } +/** Fetch against a specific node by ID without touching the localStorage active-node key. + * Used by the notification panel to target individual remote nodes explicitly. */ +export async function fetchForNode( + endpoint: string, + nodeId: number, + options: RequestInit = {} +): Promise { + const { headers: extraHeaders, ...rest } = options; + const response = await fetch(`${API_BASE}${endpoint}`, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'x-node-id': String(nodeId), + ...(extraHeaders as Record | undefined), + }, + ...rest, + }); + + if (response.status === 401) { + window.dispatchEvent(new Event('sencho-unauthorized')); + throw new Error('Unauthorized'); + } + + return response; +} + export { API_BASE }; From 94d6c8fc0f8f3afce0e95d80f6a75a3884cca2af Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 21 Mar 2026 17:48:31 -0400 Subject: [PATCH 2/2] fix(ts): use type-only import for Node to satisfy verbatimModuleSyntax --- frontend/src/components/EditorLayout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 0ae84481..f9ef2149 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -34,7 +34,8 @@ import { StackAlertSheet } from './StackAlertSheet'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import { GlobalObservabilityView } from './GlobalObservabilityView'; -import { useNodes, Node } from '@/context/NodeContext'; +import { useNodes } from '@/context/NodeContext'; +import type { Node } from '@/context/NodeContext'; interface ContainerInfo { Id: string;