diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index 5edc70a..15f138e 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -2,6 +2,7 @@ import { startNotificationClient, subscribeNotifications, subscribeClientEvents, + subscribeNotificationsCleared, subscribeReady, subscribeStatus, markAllNotificationsRead, @@ -338,6 +339,18 @@ function clearTable() { applyTableView(); } +function removeEntriesForClient(clientId) { + if (!clientId) return; + let removed = false; + for (const [uid, entry] of tableState.entries) { + if (entry.item?.clientId === clientId) { + tableState.entries.delete(uid); + removed = true; + } + } + if (removed) applyTableView(); +} + // ── Client ID cell (collapsed by default, click to expand) ────────────────── const CLIENT_ID_SHORT_LEN = 8; @@ -718,6 +731,7 @@ function connect() { subscribeNotifications((item) => addNotification(item, true)); subscribeClientEvents((item) => addClientEvent(item)); + subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId)); } const CLIENT_EVENT_BADGE = { diff --git a/Overlord-Server/public/assets/notify-client.js b/Overlord-Server/public/assets/notify-client.js index 20791e3..ad2c59f 100644 --- a/Overlord-Server/public/assets/notify-client.js +++ b/Overlord-Server/public/assets/notify-client.js @@ -34,6 +34,7 @@ let notificationHandlers = new Set(); let statusHandlers = new Set(); let unreadHandlers = new Set(); let clientEventHandlers = new Set(); +let notificationsClearedHandlers = new Set(); let lastHistory = []; function emitStatus(status) { @@ -69,6 +70,17 @@ function emitClientEvent(item) { } } +function emitNotificationsCleared(clientId) { + if (Array.isArray(lastHistory) && lastHistory.length) { + lastHistory = lastHistory.filter((item) => item.clientId !== clientId); + } + for (const handler of notificationsClearedHandlers) { + try { + handler(clientId); + } catch {} + } +} + function shouldNotify() { return localStorage.getItem(STORAGE_KEY) === "1"; } @@ -319,6 +331,10 @@ function handleMessage(payload) { emitClientEvent(payload); showClientEventDesktopNotification(payload); } + if (payload.type === "notifications_cleared" && payload.clientId) { + console.log("[notifications] cleared for", payload.clientId); + emitNotificationsCleared(payload.clientId); + } } let msgpackLoadPromise = null; @@ -439,6 +455,11 @@ export function subscribeClientEvents(handler) { return () => clientEventHandlers.delete(handler); } +export function subscribeNotificationsCleared(handler) { + notificationsClearedHandlers.add(handler); + return () => notificationsClearedHandlers.delete(handler); +} + export function subscribeUnread(handler) { unreadHandlers.add(handler); try { diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 2ef48da..e9d092d 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -1945,6 +1945,12 @@ export function clearNotifications() { console.log("[db] cleared all notifications and screenshots"); } +export function deleteNotificationsForClient(clientId: string): number { + db.run(`DELETE FROM notification_screenshots WHERE client_id = ?`, clientId); + const result = db.run(`DELETE FROM notifications WHERE client_id = ?`, clientId); + return (result as any)?.changes || 0; +} + export function getClientEnrollmentStatus(id: string): string | null { const row = db .query<{ enrollment_status: string }>( diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index e1447e0..fb576a2 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -512,6 +512,7 @@ async function startServer() { CORS_HEADERS, pendingScripts, pendingCommandReplies, + broadcastNotificationsCleared: notificationPluginHandlers.broadcastNotificationsCleared, }, wsUpgrade: { isAuthorizedAgentRequest: isAuthorizedAgent, diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 0f6a81c..b694b09 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -25,6 +25,7 @@ import { updateGroup, deleteGroup, setClientGroup, + deleteNotificationsForClient, } from "../../db"; import { metrics } from "../../metrics"; import { encodeMessage } from "../../protocol"; @@ -58,6 +59,7 @@ type ClientRouteDeps = { CORS_HEADERS: Record; pendingScripts: Map; pendingCommandReplies: Map; + broadcastNotificationsCleared: (clientId: string) => void; }; export async function handleClientRoutes( @@ -496,15 +498,18 @@ export async function handleClientRoutes( const muted = !!body?.muted; let updated = 0; + let cleared = 0; for (const cid of clientIds) { if (!canUserAccessClient(user.userId, user.role, cid)) continue; if (setClientNotificationsMuted(cid, muted)) updated++; + cleared += deleteNotificationsForClient(cid); + deps.broadcastNotificationsCleared(cid); } notifyDashboardViewers(); const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_notifications_muted:${muted}:${updated}/${clientIds.length}`, success: true }); - return Response.json({ ok: true, updated, total: clientIds.length, muted }, { headers: deps.CORS_HEADERS }); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_notifications_muted:${muted}:${updated}/${clientIds.length} (cleared ${cleared})`, success: true }); + return Response.json({ ok: true, updated, total: clientIds.length, muted, cleared }, { headers: deps.CORS_HEADERS }); } const muteMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/notifications-muted$/); @@ -534,6 +539,8 @@ export async function handleClientRoutes( if (!updated) { return Response.json({ error: "Client not found" }, { status: 404 }); } + const cleared = deleteNotificationsForClient(targetId); + deps.broadcastNotificationsCleared(targetId); notifyDashboardViewers(); const ip = server.requestIP(req)?.address || "unknown"; @@ -543,11 +550,11 @@ export async function handleClientRoutes( ip, action: AuditAction.COMMAND, targetClientId: targetId, - details: muted ? "mute_notifications" : "unmute_notifications", + details: `${muted ? "mute_notifications" : "unmute_notifications"} (cleared ${cleared})`, success: true, }); - return Response.json({ ok: true, muted }, { headers: deps.CORS_HEADERS }); + return Response.json({ ok: true, muted, cleared }, { headers: deps.CORS_HEADERS }); } const bookmarkMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/bookmark$/); diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index 7541bd8..8ea828c 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -362,6 +362,25 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { } }, + broadcastNotificationsCleared(clientId: string) { + for (const key of Array.from(deps.notificationRate.keys())) { + if (key === clientId || key.startsWith(`${clientId}:`)) { + deps.notificationRate.delete(key); + } + } + const item = { type: "notifications_cleared", clientId, ts: Date.now() }; + for (const session of sessionManager.getAllNotificationSessions().values()) { + const sRole = session.userRole ?? session.viewer.data.userRole ?? ""; + const sUserId = session.userId ?? session.viewer.data.userId; + if (sRole !== "admin") { + if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, clientId)) { + continue; + } + } + safeSendViewer(session.viewer, item); + } + }, + broadcastClientLifecycleEvent( event: "client_online" | "client_offline" | "client_purgatory", info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },