fix notifications + improve them, improve normal UI stuff further + fix readme.

This commit is contained in:
KingKDot
2026-05-08 22:36:03 -05:00
parent 9ad085c922
commit 0df68b2778
6 changed files with 302 additions and 65 deletions
+245 -26
View File
@@ -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 `<td class="py-2 pr-4 whitespace-nowrap text-slate-500">-</td>`;
}
if (id.length <= CLIENT_ID_SHORT_LEN) {
return `<td class="py-2 pr-4 whitespace-nowrap font-mono text-xs text-slate-300">${escapeHtml(id)}</td>`;
}
const safeFull = escapeHtml(id);
const safeShort = escapeHtml(id.slice(0, CLIENT_ID_SHORT_LEN));
return `<td class="py-2 pr-4 whitespace-nowrap"><button type="button" class="client-id-toggle inline-flex items-center gap-1 font-mono text-xs px-2 py-0.5 rounded border border-slate-700 bg-slate-900/80 hover:bg-slate-800 hover:border-slate-600 text-slate-300" data-full="${safeFull}" data-short="${safeShort}" title="${safeFull} (click to expand)"><span class="client-id-text">${safeShort}<span class="text-slate-500">…</span></span><i class="fa-solid fa-chevron-right text-[10px] text-slate-500"></i></button></td>`;
}
// ── Notification row factory ─────────────────────────────────────────────────
function createNotificationRowElement(item, isLive) {
const isClipboard = item.category === "clipboard";
const sourceBadge = isClipboard
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-violet-900/60 text-violet-300 border border-violet-700/50"><i class="fa-solid fa-clipboard text-xs"></i> clipboard</span>`
@@ -207,7 +364,7 @@ function renderRow(item, prepend = true) {
row.className = "border-t border-slate-800/60";
row.innerHTML = `
<td class="py-2 pr-4 whitespace-nowrap text-slate-400">${formatTime(item.ts)}</td>
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.clientId || "-")}</td>
${clientIdCellHtml(item.clientId)}
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.user || "-")}</td>
<td class="py-2 pr-4 max-w-xl truncate" title="${escapeHtml(item.title || "")}">${escapeHtml(item.title || "-")}</td>
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.process || "-")}</td>
@@ -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: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-900/60 text-amber-300 border border-amber-700/50"><i class="fa-solid fa-hourglass-half text-xs"></i> purgatory</span>`,
};
function renderClientEventRow(item) {
if (!listEl) return;
function createClientEventRowElement(item) {
const badge = CLIENT_EVENT_BADGE[item.event] ||
`<span class="text-xs text-slate-400">${escapeHtml(item.event)}</span>`;
const row = document.createElement("tr");
row.className = "border-t border-slate-800/60";
row.innerHTML = `
<td class="py-2 pr-4 whitespace-nowrap text-slate-400">${formatTime(item.ts)}</td>
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.clientId || "-")}</td>
${clientIdCellHtml(item.clientId)}
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.user || "-")}</td>
<td class="py-2 pr-4 max-w-xl truncate italic text-slate-400">client event</td>
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.os || "-")}</td>
@@ -591,10 +741,7 @@ function renderClientEventRow(item) {
<td class="py-2 pr-4 whitespace-nowrap">${badge}</td>
<td class="py-2 pr-4"></td>
`;
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 || "")}<span class="text-slate-500">…</span>`;
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();
+35 -9
View File
@@ -355,25 +355,51 @@
id="notification-panel"
class="bg-slate-900/60 border border-slate-800 rounded-xl p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center justify-between flex-wrap gap-3 mb-3">
<div>
<h2 class="text-lg font-semibold">Notifications</h2>
<p id="notification-scope-hint" class="text-xs text-slate-500 mt-0.5">
Showing notifications for your accessible clients.
</p>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs"></i>
<input
id="notification-search"
type="search"
placeholder="Search notifications..."
class="w-64 sm:w-72 rounded-lg bg-slate-950 border border-slate-800 text-slate-100 pl-8 pr-3 py-2 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
<span id="notification-result-hint" class="text-xs text-slate-500"></span>
</div>
</div>
<div class="overflow-x-auto max-h-96">
<table class="min-w-full text-sm">
<thead class="text-slate-400 sticky top-0 bg-slate-900">
<thead id="notification-thead" class="text-slate-400 sticky top-0 bg-slate-900">
<tr class="text-left">
<th class="py-2 pr-4">Time</th>
<th class="py-2 pr-4">Client</th>
<th class="py-2 pr-4">User</th>
<th class="py-2 pr-4">Title / Content</th>
<th class="py-2 pr-4">Process</th>
<th class="py-2 pr-4">Keyword</th>
<th class="py-2 pr-4">Source</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="ts">
<span class="inline-flex items-center gap-1">Time<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="clientId">
<span class="inline-flex items-center gap-1">Client<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="user">
<span class="inline-flex items-center gap-1">User<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="title">
<span class="inline-flex items-center gap-1">Title / Content<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="process">
<span class="inline-flex items-center gap-1">Process<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="keyword">
<span class="inline-flex items-center gap-1">Keyword<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="source">
<span class="inline-flex items-center gap-1">Source<i class="sort-icon fa-solid fa-sort text-xs text-slate-600"></i></span>
</th>
<th class="py-2 pr-4">Preview</th>
</tr>
</thead>
+10 -4
View File
@@ -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 ────────────────────────────────────────────────────── */
+5 -7
View File
@@ -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`);
}
}
+1 -2
View File
@@ -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();
+6 -17
View File
@@ -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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
@@ -131,27 +134,13 @@ On a derivative distro (e.g. Kali), replace the codename expansion with the matc
Install Docker:
```bash
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
Make sure the daemon is running:
```bash
sudo systemctl start docker
```
Optional — run Docker without sudo:
```bash
sudo usermod -aG docker $USER
newgrp docker
```
Verify:
```bash
docker --version
docker compose version
sudo systemctl status docker
```
**2. Grab the compose file**