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.
+
-
+
- | Time |
- Client |
- User |
- Title / Content |
- Process |
- Keyword |
- Source |
+
+ Time
+ |
+
+ Client
+ |
+
+ User
+ |
+
+ Title / Content
+ |
+
+ Process
+ |
+
+ Keyword
+ |
+
+ Source
+ |
Preview |
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 <