From 0df68b2778fa89252e8413aee6af689dd7241a6b Mon Sep 17 00:00:00 2001 From: KingKDot Date: Fri, 8 May 2026 22:36:03 -0500 Subject: [PATCH] fix notifications + improve them, improve normal UI stuff further + fix readme. --- .../public/assets/notifications.js | 271 ++++++++++++++++-- Overlord-Server/public/notifications.html | 44 ++- Overlord-Server/scripts/vendor.ts | 14 +- Overlord-Server/src/db.ts | 12 +- Overlord-Server/src/main-server.ts | 3 +- README.md | 23 +- 6 files changed, 302 insertions(+), 65 deletions(-) diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index 8738531..5edc70a 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -16,6 +16,9 @@ const wsStatus = document.getElementById("ws-status"); const listEl = document.getElementById("notification-list"); const emptyState = document.getElementById("empty-state"); const notificationScopeHint = document.getElementById("notification-scope-hint"); +const tableHead = document.getElementById("notification-thead"); +const searchInput = document.getElementById("notification-search"); +const resultHint = document.getElementById("notification-result-hint"); // Keyword section const keywordSection = document.getElementById("keyword-section"); @@ -196,9 +199,163 @@ function wireToggle(inputEl, btnEl) { }); } -// ── Notification table row ──────────────────────────────────────────────────── -function renderRow(item, prepend = true) { +// ── Unified table state (notifications + client events) ────────────────────── +const tableState = { + entries: new Map(), + search: "", + sortBy: "ts", + sortDir: "desc", +}; + +let clientEventCounter = 0; + +function getEntryUid(item, kind) { + if (kind === "event") { + return `event:${item.clientId || ""}:${item.event || ""}:${item.ts || 0}:${++clientEventCounter}`; + } + return `notification:${item.id || `${item.clientId || ""}:${item.ts || ""}:${item.title || ""}`}`; +} + +function getSortValue(entry, key) { + const item = entry.item; + if (key === "ts") return Number(item.ts) || 0; + if (key === "source") { + if (entry.kind === "event") return `event:${item.event || ""}`; + return `notif:${item.category || ""}`; + } + const value = item[key]; + return value == null ? "" : String(value).toLowerCase(); +} + +function compareEntries(a, b) { + const key = tableState.sortBy; + const av = getSortValue(a, key); + const bv = getSortValue(b, key); + let cmp; + if (typeof av === "number" && typeof bv === "number") { + cmp = av - bv; + } else { + cmp = String(av).localeCompare(String(bv)); + } + if (cmp === 0) { + cmp = (Number(a.item.ts) || 0) - (Number(b.item.ts) || 0); + } + return tableState.sortDir === "asc" ? cmp : -cmp; +} + +function entryMatchesSearch(entry, query) { + if (!query) return true; + const item = entry.item; + const haystack = [ + item.clientId, + item.user, + item.host, + item.title, + item.process, + item.processPath, + item.keyword, + entry.kind === "event" ? item.event : item.category, + item.os, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(query); +} + +function applyTableView() { if (!listEl) return; + const q = tableState.search.trim().toLowerCase(); + const all = Array.from(tableState.entries.values()); + const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all; + filtered.sort(compareEntries); + + listEl.innerHTML = ""; + for (const entry of filtered) { + listEl.appendChild(entry.element); + } + + if (emptyState) { + if (all.length === 0) { + emptyState.textContent = "No notifications yet."; + emptyState.classList.remove("hidden"); + } else if (filtered.length === 0) { + emptyState.textContent = "No notifications match the current search."; + emptyState.classList.remove("hidden"); + } else { + emptyState.classList.add("hidden"); + } + } + + if (resultHint) { + if (q) { + resultHint.textContent = `${filtered.length} of ${all.length}`; + } else { + resultHint.textContent = all.length > 0 ? `${all.length} total` : ""; + } + } +} + +function trimEntries() { + if (tableState.entries.size <= MAX_ROWS) return; + const ordered = Array.from(tableState.entries.entries()) + .sort((a, b) => (Number(b[1].item.ts) || 0) - (Number(a[1].item.ts) || 0)); + const keep = new Set(ordered.slice(0, MAX_ROWS).map(([uid]) => uid)); + for (const uid of tableState.entries.keys()) { + if (!keep.has(uid)) tableState.entries.delete(uid); + } +} + +function upsertEntry(item, kind, isLive) { + const uid = getEntryUid(item, kind); + const existing = tableState.entries.get(uid); + if (existing) { + existing.item = { ...existing.item, ...item }; + return existing; + } + const element = + kind === "event" + ? createClientEventRowElement(item) + : createNotificationRowElement(item, isLive); + const entry = { item, kind, element, isLive }; + tableState.entries.set(uid, entry); + trimEntries(); + return entry; +} + +function addNotification(item, isLive) { + upsertEntry(item, "notification", isLive); + applyTableView(); +} + +function addClientEvent(item) { + upsertEntry(item, "event", true); + applyTableView(); +} + +function clearTable() { + tableState.entries.clear(); + applyTableView(); +} + +// ── Client ID cell (collapsed by default, click to expand) ────────────────── +const CLIENT_ID_SHORT_LEN = 8; + +function clientIdCellHtml(clientId) { + const id = String(clientId || ""); + if (!id) { + return `-`; + } + if (id.length <= CLIENT_ID_SHORT_LEN) { + return `${escapeHtml(id)}`; + } + const safeFull = escapeHtml(id); + const safeShort = escapeHtml(id.slice(0, CLIENT_ID_SHORT_LEN)); + return ``; +} + +// ── Notification row factory ───────────────────────────────────────────────── +function createNotificationRowElement(item, isLive) { const isClipboard = item.category === "clipboard"; const sourceBadge = isClipboard ? ` clipboard` @@ -207,7 +364,7 @@ function renderRow(item, prepend = true) { row.className = "border-t border-slate-800/60"; row.innerHTML = ` ${formatTime(item.ts)} - ${escapeHtml(item.clientId || "-")} + ${clientIdCellHtml(item.clientId)} ${escapeHtml(item.user || "-")} ${escapeHtml(item.title || "-")} ${escapeHtml(item.process || "-")} @@ -219,7 +376,8 @@ function renderRow(item, prepend = true) { const preview = row.querySelector(".preview-slot"); if (preview) { const notificationId = item?.id || ""; - if (!notificationId) { + const skipFetch = !notificationId || (!isLive && !item?.screenshotId); + if (skipFetch) { preview.textContent = "-"; preview.className = "text-slate-500"; } else { @@ -280,16 +438,7 @@ function renderRow(item, prepend = true) { } } - if (prepend) { - listEl.prepend(row); - } else { - listEl.appendChild(row); - } - - const rows = listEl.querySelectorAll("tr"); - if (rows.length > MAX_ROWS) rows[rows.length - 1].remove(); - - if (emptyState) emptyState.classList.toggle("hidden", listEl.children.length > 0); + return row; } // ── Keyword section ─────────────────────────────────────────────────────────── @@ -559,14 +708,16 @@ function connect() { }); subscribeReady((history) => { - if (listEl) listEl.innerHTML = ""; - history.reverse().forEach((item) => renderRow(item, false)); - if (emptyState) emptyState.classList.toggle("hidden", history.length > 0); + clearTable(); + for (const item of history) { + upsertEntry(item, "notification", false); + } + applyTableView(); markAllNotificationsRead(); }); - subscribeNotifications((item) => renderRow(item, true)); - subscribeClientEvents((item) => renderClientEventRow(item)); + subscribeNotifications((item) => addNotification(item, true)); + subscribeClientEvents((item) => addClientEvent(item)); } const CLIENT_EVENT_BADGE = { @@ -575,15 +726,14 @@ const CLIENT_EVENT_BADGE = { client_purgatory: ` purgatory`, }; -function renderClientEventRow(item) { - if (!listEl) return; +function createClientEventRowElement(item) { const badge = CLIENT_EVENT_BADGE[item.event] || `${escapeHtml(item.event)}`; const row = document.createElement("tr"); row.className = "border-t border-slate-800/60"; row.innerHTML = ` ${formatTime(item.ts)} - ${escapeHtml(item.clientId || "-")} + ${clientIdCellHtml(item.clientId)} ${escapeHtml(item.user || "-")} client event ${escapeHtml(item.os || "-")} @@ -591,10 +741,7 @@ function renderClientEventRow(item) { ${badge} `; - listEl.prepend(row); - const rows = listEl.querySelectorAll("tr"); - if (rows.length > MAX_ROWS) rows[rows.length - 1].remove(); - if (emptyState) emptyState.classList.add("hidden"); + return row; } // ── Preview modal ───────────────────────────────────────────────────────────── @@ -700,6 +847,76 @@ function wireEventChannelsSave() { }); } +function updateSortIndicators() { + if (!tableHead) return; + const headers = tableHead.querySelectorAll("th[data-sort-key]"); + for (const th of headers) { + const icon = th.querySelector(".sort-icon"); + if (!icon) continue; + const key = th.getAttribute("data-sort-key"); + if (key === tableState.sortBy) { + icon.className = `sort-icon fa-solid ${tableState.sortDir === "asc" ? "fa-sort-up" : "fa-sort-down"} text-xs text-slate-300`; + th.classList.add("text-slate-200"); + } else { + icon.className = "sort-icon fa-solid fa-sort text-xs text-slate-600"; + th.classList.remove("text-slate-200"); + } + } +} + +function wireClientIdToggle() { + if (!listEl) return; + listEl.addEventListener("click", (event) => { + const btn = event.target.closest(".client-id-toggle"); + if (!btn) return; + const textEl = btn.querySelector(".client-id-text"); + const icon = btn.querySelector("i"); + if (!textEl) return; + const expanded = btn.classList.toggle("expanded"); + if (expanded) { + textEl.textContent = btn.dataset.full || ""; + if (icon) icon.className = "fa-solid fa-chevron-left text-[10px] text-slate-500"; + btn.title = `${btn.dataset.full} (click to collapse)`; + } else { + textEl.innerHTML = `${escapeHtml(btn.dataset.short || "")}…`; + if (icon) icon.className = "fa-solid fa-chevron-right text-[10px] text-slate-500"; + btn.title = `${btn.dataset.full} (click to expand)`; + } + }); +} + +function wireTableControls() { + if (tableHead) { + tableHead.addEventListener("click", (event) => { + const th = event.target.closest("th[data-sort-key]"); + if (!th) return; + const key = th.getAttribute("data-sort-key"); + if (!key) return; + if (tableState.sortBy === key) { + tableState.sortDir = tableState.sortDir === "asc" ? "desc" : "asc"; + } else { + tableState.sortBy = key; + tableState.sortDir = key === "ts" ? "desc" : "asc"; + } + updateSortIndicators(); + applyTableView(); + }); + } + + if (searchInput) { + let debounce = null; + searchInput.addEventListener("input", () => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + tableState.search = searchInput.value || ""; + applyTableView(); + }, 100); + }); + } + + updateSortIndicators(); +} + // ── Bootstrap ───────────────────────────────────────────────────────────────── initWebhookTemplateEditor(); wireKeywordSave(); @@ -711,5 +928,7 @@ wireEventNotifSave(); wireEventChannelsSave(); updateDesktopPermissionUi(); wireDesktopPermissionBtn(); +wireTableControls(); +wireClientIdToggle(); initRoleUi(); connect(); diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html index e128541..c5fe35b 100644 --- a/Overlord-Server/public/notifications.html +++ b/Overlord-Server/public/notifications.html @@ -355,25 +355,51 @@ id="notification-panel" class="bg-slate-900/60 border border-slate-800 rounded-xl p-4" > -
+

Notifications

Showing notifications for your accessible clients.

+
+
+ + +
+ +
- + - - - - - - - + + + + + + + diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts index 4df3e27..517b2e2 100644 --- a/Overlord-Server/scripts/vendor.ts +++ b/Overlord-Server/scripts/vendor.ts @@ -137,10 +137,16 @@ for (const mode of ["powershell", "shell", "python"]) { /* ── Ace Editor ──────────────────────────────────────────────────── */ console.log("Copying Ace Editor ..."); -copyFile( - path.join(NM, "ace-builds", "src-min-noconflict", "ace.js"), - path.join(VENDOR, "ace-builds", "ace.js"), -); +const aceSrc = path.join(NM, "ace-builds", "src-min-noconflict"); +const aceDst = path.join(VENDOR, "ace-builds"); +for (const file of [ + "ace.js", + "mode-json.js", + "worker-json.js", + "theme-tomorrow_night.js", +]) { + copyFile(path.join(aceSrc, file), path.join(aceDst, file)); +} /* ── Chart.js ────────────────────────────────────────────────────── */ diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 8fc2985..f052d41 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -1839,11 +1839,6 @@ export function getNotificationScreenshot(notificationId: string): NotificationS }; } -export function clearNotificationScreenshots() { - db.run(`DELETE FROM notification_screenshots`); - console.log("[db] cleared notification screenshots"); -} - export type NotificationRow = { id: string; clientId: string; @@ -1910,14 +1905,17 @@ export function getNotificationHistory(limit: number = 500): NotificationRow[] { })); } -const NOTIFICATION_RETENTION_MS = 3 * 24 * 60 * 60 * 1000; +const NOTIFICATION_RETENTION_DAYS = 3; +const NOTIFICATION_RETENTION_MS = NOTIFICATION_RETENTION_DAYS * 24 * 60 * 60 * 1000; export function pruneOldNotifications() { const cutoff = Date.now() - NOTIFICATION_RETENTION_MS; db.run(`DELETE FROM notification_screenshots WHERE notification_id IN (SELECT id FROM notifications WHERE ts < ?)`, cutoff); + db.run(`DELETE FROM notification_screenshots WHERE ts < ?`, cutoff); + db.run(`DELETE FROM notification_screenshots WHERE notification_id NOT IN (SELECT id FROM notifications)`); const result = db.run(`DELETE FROM notifications WHERE ts < ?`, cutoff); if (result.changes > 0) { - console.log(`[db] pruned ${result.changes} notifications older than 3 days`); + console.log(`[db] pruned ${result.changes} notifications older than ${NOTIFICATION_RETENTION_DAYS} days`); } } diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 7aef728..23cdb6f 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; import path from "path"; import fs from "fs/promises"; import { existsSync } from "node:fs"; -import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications } from "./db"; +import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications } from "./db"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { ClientInfo, ClientRole } from "./types"; @@ -732,7 +732,6 @@ async function startServer() { markAllClientsOffline(); - clearNotificationScreenshots(); loadOrGenerateVapidKeys(); deleteExpiredBuilds(); diff --git a/README.md b/README.md index f38b72f..c0b5acd 100644 --- a/README.md +++ b/README.md @@ -109,17 +109,20 @@ Official docs: https://docs.docker.com/engine/install/debian/ Set up Docker's apt repository: ```bash +# Add Docker's official GPG key: sudo apt update -sudo apt install -y ca-certificates curl +sudo apt install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc +# Add the repository to Apt sources: sudo tee /etc/apt/sources.list.d/docker.sources <
TimeClientUserTitle / ContentProcessKeywordSource + Time + + Client + + User + + Title / Content + + Process + + Keyword + + Source + Preview