mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user