feat(notifications): replace polling with WebSocket push

Eliminates the 5-second setInterval polling loop for notifications in
EditorLayout. A persistent /ws/notifications WebSocket connection is
opened on mount; the backend pushes each alert the instant dispatchAlert
fires, with 5s auto-reconnect on close.

Key changes:
- NotificationService: injectable broadcaster callback (setBroadcaster)
- DatabaseService.addNotificationHistory: returns full NotificationHistory
  record (with id/is_read) instead of void
- index.ts: notificationSubscribers Set + /ws/notifications upgrade handler
  (JWT-verified, placed before remote proxy path)
- EditorLayout: polling removed, WS connect/reconnect replaces it
This commit is contained in:
SaelixCode
2026-03-20 20:47:51 -04:00
parent 23a22598ab
commit a5ac3e4981
5 changed files with 91 additions and 8 deletions
+23
View File
@@ -418,6 +418,17 @@ const wss = new WebSocket.Server({ noServer: true });
let terminalWs: WebSocket | null = null;
// Notification push — set of authenticated browser clients subscribed to real-time alerts
const notificationSubscribers = new Set<WebSocket>();
NotificationService.getInstance().setBroadcaster((notification) => {
const msg = JSON.stringify({ type: 'notification', payload: notification });
for (const ws of notificationSubscribers) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(msg);
}
}
});
// Handle WebSocket upgrade with JWT authentication
server.on('upgrade', async (req, socket, head) => {
// Parse cookies from the upgrade request
@@ -450,6 +461,18 @@ 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') {
const notifWss = new WebSocket.Server({ noServer: true });
notifWss.handleUpgrade(req, socket, head, (ws) => {
notifWss.close();
notificationSubscribers.add(ws);
ws.on('close', () => notificationSubscribers.delete(ws));
ws.on('error', () => { notificationSubscribers.delete(ws); ws.terminate(); });
});
return;
}
// Resolve node context from query param
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
+10 -2
View File
@@ -310,9 +310,9 @@ export class DatabaseService {
}));
}
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): void {
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
stmt.run(notification.level, notification.message, notification.timestamp);
const result = stmt.run(notification.level, notification.message, notification.timestamp);
this.db.exec(`
DELETE FROM notification_history
@@ -320,6 +320,14 @@ export class DatabaseService {
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
)
`);
return {
id: result.lastInsertRowid as number,
level: notification.level,
message: notification.message,
timestamp: notification.timestamp,
is_read: false,
};
}
public markAllNotificationsRead(): void {
+14 -3
View File
@@ -1,8 +1,9 @@
import { DatabaseService } from './DatabaseService';
import { DatabaseService, NotificationHistory } from './DatabaseService';
export class NotificationService {
private static instance: NotificationService;
private dbService: DatabaseService;
private broadcaster: ((notification: NotificationHistory) => void) | null = null;
private constructor() {
this.dbService = DatabaseService.getInstance();
@@ -15,14 +16,24 @@ export class NotificationService {
return NotificationService.instance;
}
/** Wire up the WebSocket push function after the WS server is initialised. */
public setBroadcaster(fn: (notification: NotificationHistory) => void): void {
this.broadcaster = fn;
}
public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string) {
// 1. Log to history
this.dbService.addNotificationHistory({
// 1. Log to history and get the full inserted record (with id)
const notification = this.dbService.addNotificationHistory({
level,
message,
timestamp: Date.now()
});
// 2. Push to connected browser clients via WebSocket
if (this.broadcaster) {
this.broadcaster(notification);
}
// 2. Fetch enabled agents
const agents = this.dbService.getEnabledAgents();
if (agents.length === 0) {