Merge pull request #54 from AnsoCode/fix/alerts-notifications-overhaul

feat(notifications): aggregate alerts from all connected nodes in the notification panel
This commit is contained in:
Anso
2026-03-21 17:50:30 -04:00
committed by GitHub
4 changed files with 207 additions and 29 deletions
+1
View File
@@ -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=<remoteId>` 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.
+9 -7
View File
@@ -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');
+171 -22
View File
@@ -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';
@@ -35,6 +35,7 @@ import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
interface ContainerInfo {
Id: string;
@@ -48,6 +49,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 +70,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<Node[]>([]);
nodesRef.current = nodes;
// Tracks cleanup functions for per-remote-node notification WebSocket connections.
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
const [files, setFiles] = useState<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [content, setContent] = useState<string>('');
@@ -113,7 +130,7 @@ export default function EditorLayout() {
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
// Notifications & Settings state
const [notifications, setNotifications] = useState<any[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
@@ -185,10 +202,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 +228,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<Notification, 'nodeId' | 'nodeName'>),
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 +272,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<typeof setTimeout> | 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<Notification, 'nodeId' | 'nodeName'>, 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 +371,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<Notification, 'nodeId' | 'nodeName'>[];
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<Notification, 'nodeId' | 'nodeName'>[];
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 +415,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 +1197,17 @@ export default function EditorLayout() {
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
{notifications.map((notif) => (
<div key={`${notif.nodeId}-${notif.id}`} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
<div className="flex items-center gap-2 mb-1 pr-6">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
{nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && (
<Badge variant="outline" className="text-[10px] font-normal">
{notif.nodeName}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
@@ -1072,7 +1221,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);
}}
>
<X className="w-3 h-3" />
+26
View File
@@ -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<Response> {
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<string, string> | undefined),
},
...rest,
});
if (response.status === 401) {
window.dispatchEvent(new Event('sencho-unauthorized'));
throw new Error('Unauthorized');
}
return response;
}
export { API_BASE };