fix notifications + improve them, improve normal UI stuff further + fix readme.
This commit is contained in:
@@ -16,6 +16,9 @@ const wsStatus = document.getElementById("ws-status");
|
|||||||
const listEl = document.getElementById("notification-list");
|
const listEl = document.getElementById("notification-list");
|
||||||
const emptyState = document.getElementById("empty-state");
|
const emptyState = document.getElementById("empty-state");
|
||||||
const notificationScopeHint = document.getElementById("notification-scope-hint");
|
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
|
// Keyword section
|
||||||
const keywordSection = document.getElementById("keyword-section");
|
const keywordSection = document.getElementById("keyword-section");
|
||||||
@@ -196,9 +199,163 @@ function wireToggle(inputEl, btnEl) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification table row ────────────────────────────────────────────────────
|
// ── Unified table state (notifications + client events) ──────────────────────
|
||||||
function renderRow(item, prepend = true) {
|
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;
|
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 isClipboard = item.category === "clipboard";
|
||||||
const sourceBadge = isClipboard
|
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>`
|
? `<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.className = "border-t border-slate-800/60";
|
||||||
row.innerHTML = `
|
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 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 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 max-w-xl truncate" title="${escapeHtml(item.title || "")}">${escapeHtml(item.title || "-")}</td>
|
||||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.process || "-")}</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");
|
const preview = row.querySelector(".preview-slot");
|
||||||
if (preview) {
|
if (preview) {
|
||||||
const notificationId = item?.id || "";
|
const notificationId = item?.id || "";
|
||||||
if (!notificationId) {
|
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
|
||||||
|
if (skipFetch) {
|
||||||
preview.textContent = "-";
|
preview.textContent = "-";
|
||||||
preview.className = "text-slate-500";
|
preview.className = "text-slate-500";
|
||||||
} else {
|
} else {
|
||||||
@@ -280,16 +438,7 @@ function renderRow(item, prepend = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prepend) {
|
return row;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyword section ───────────────────────────────────────────────────────────
|
// ── Keyword section ───────────────────────────────────────────────────────────
|
||||||
@@ -559,14 +708,16 @@ function connect() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
subscribeReady((history) => {
|
subscribeReady((history) => {
|
||||||
if (listEl) listEl.innerHTML = "";
|
clearTable();
|
||||||
history.reverse().forEach((item) => renderRow(item, false));
|
for (const item of history) {
|
||||||
if (emptyState) emptyState.classList.toggle("hidden", history.length > 0);
|
upsertEntry(item, "notification", false);
|
||||||
|
}
|
||||||
|
applyTableView();
|
||||||
markAllNotificationsRead();
|
markAllNotificationsRead();
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribeNotifications((item) => renderRow(item, true));
|
subscribeNotifications((item) => addNotification(item, true));
|
||||||
subscribeClientEvents((item) => renderClientEventRow(item));
|
subscribeClientEvents((item) => addClientEvent(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIENT_EVENT_BADGE = {
|
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>`,
|
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) {
|
function createClientEventRowElement(item) {
|
||||||
if (!listEl) return;
|
|
||||||
const badge = CLIENT_EVENT_BADGE[item.event] ||
|
const badge = CLIENT_EVENT_BADGE[item.event] ||
|
||||||
`<span class="text-xs text-slate-400">${escapeHtml(item.event)}</span>`;
|
`<span class="text-xs text-slate-400">${escapeHtml(item.event)}</span>`;
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
row.className = "border-t border-slate-800/60";
|
row.className = "border-t border-slate-800/60";
|
||||||
row.innerHTML = `
|
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 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 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 max-w-xl truncate italic text-slate-400">client event</td>
|
||||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.os || "-")}</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 whitespace-nowrap">${badge}</td>
|
||||||
<td class="py-2 pr-4"></td>
|
<td class="py-2 pr-4"></td>
|
||||||
`;
|
`;
|
||||||
listEl.prepend(row);
|
return row;
|
||||||
const rows = listEl.querySelectorAll("tr");
|
|
||||||
if (rows.length > MAX_ROWS) rows[rows.length - 1].remove();
|
|
||||||
if (emptyState) emptyState.classList.add("hidden");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Preview modal ─────────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────────────────
|
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||||
initWebhookTemplateEditor();
|
initWebhookTemplateEditor();
|
||||||
wireKeywordSave();
|
wireKeywordSave();
|
||||||
@@ -711,5 +928,7 @@ wireEventNotifSave();
|
|||||||
wireEventChannelsSave();
|
wireEventChannelsSave();
|
||||||
updateDesktopPermissionUi();
|
updateDesktopPermissionUi();
|
||||||
wireDesktopPermissionBtn();
|
wireDesktopPermissionBtn();
|
||||||
|
wireTableControls();
|
||||||
|
wireClientIdToggle();
|
||||||
initRoleUi();
|
initRoleUi();
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
@@ -355,25 +355,51 @@
|
|||||||
id="notification-panel"
|
id="notification-panel"
|
||||||
class="bg-slate-900/60 border border-slate-800 rounded-xl p-4"
|
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>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||||
<p id="notification-scope-hint" class="text-xs text-slate-500 mt-0.5">
|
<p id="notification-scope-hint" class="text-xs text-slate-500 mt-0.5">
|
||||||
Showing notifications for your accessible clients.
|
Showing notifications for your accessible clients.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<div class="overflow-x-auto max-h-96">
|
<div class="overflow-x-auto max-h-96">
|
||||||
<table class="min-w-full text-sm">
|
<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">
|
<tr class="text-left">
|
||||||
<th class="py-2 pr-4">Time</th>
|
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="ts">
|
||||||
<th class="py-2 pr-4">Client</th>
|
<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 class="py-2 pr-4">User</th>
|
</th>
|
||||||
<th class="py-2 pr-4">Title / Content</th>
|
<th class="py-2 pr-4 select-none cursor-pointer hover:text-slate-200" data-sort-key="clientId">
|
||||||
<th class="py-2 pr-4">Process</th>
|
<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 class="py-2 pr-4">Keyword</th>
|
</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="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>
|
<th class="py-2 pr-4">Preview</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -137,10 +137,16 @@ for (const mode of ["powershell", "shell", "python"]) {
|
|||||||
/* ── Ace Editor ──────────────────────────────────────────────────── */
|
/* ── Ace Editor ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
console.log("Copying Ace Editor ...");
|
console.log("Copying Ace Editor ...");
|
||||||
copyFile(
|
const aceSrc = path.join(NM, "ace-builds", "src-min-noconflict");
|
||||||
path.join(NM, "ace-builds", "src-min-noconflict", "ace.js"),
|
const aceDst = path.join(VENDOR, "ace-builds");
|
||||||
path.join(VENDOR, "ace-builds", "ace.js"),
|
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 ────────────────────────────────────────────────────── */
|
/* ── Chart.js ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
export type NotificationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
clientId: 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() {
|
export function pruneOldNotifications() {
|
||||||
const cutoff = Date.now() - NOTIFICATION_RETENTION_MS;
|
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 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);
|
const result = db.run(`DELETE FROM notifications WHERE ts < ?`, cutoff);
|
||||||
if (result.changes > 0) {
|
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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
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 { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
|
||||||
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
|
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
|
||||||
import { ClientInfo, ClientRole } from "./types";
|
import { ClientInfo, ClientRole } from "./types";
|
||||||
@@ -732,7 +732,6 @@ async function startServer() {
|
|||||||
|
|
||||||
|
|
||||||
markAllClientsOffline();
|
markAllClientsOffline();
|
||||||
clearNotificationScreenshots();
|
|
||||||
loadOrGenerateVapidKeys();
|
loadOrGenerateVapidKeys();
|
||||||
|
|
||||||
deleteExpiredBuilds();
|
deleteExpiredBuilds();
|
||||||
|
|||||||
@@ -109,17 +109,20 @@ Official docs: https://docs.docker.com/engine/install/debian/
|
|||||||
Set up Docker's apt repository:
|
Set up Docker's apt repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Add Docker's official GPG key:
|
||||||
sudo apt update
|
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 install -m 0755 -d /etc/apt/keyrings
|
||||||
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
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
|
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
|
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
|
||||||
Types: deb
|
Types: deb
|
||||||
URIs: https://download.docker.com/linux/debian
|
URIs: https://download.docker.com/linux/debian
|
||||||
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
|
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||||
Components: stable
|
Components: stable
|
||||||
|
Architectures: $(dpkg --print-architecture)
|
||||||
Signed-By: /etc/apt/keyrings/docker.asc
|
Signed-By: /etc/apt/keyrings/docker.asc
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -131,27 +134,13 @@ On a derivative distro (e.g. Kali), replace the codename expansion with the matc
|
|||||||
Install Docker:
|
Install Docker:
|
||||||
|
|
||||||
```bash
|
```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:
|
Make sure the daemon is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl start docker
|
sudo systemctl status docker
|
||||||
```
|
|
||||||
|
|
||||||
Optional — run Docker without sudo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
newgrp docker
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Grab the compose file**
|
**2. Grab the compose file**
|
||||||
|
|||||||
Reference in New Issue
Block a user