Add screenshot page, add polling screenshots, better screenshot memory cleanup, better visualization/no jitter when screenshots are shown now.
This commit is contained in:
@@ -4,14 +4,12 @@ import { digestData } from "./utils.js";
|
|||||||
const POLL_INTERVAL_MS = 5000;
|
const POLL_INTERVAL_MS = 5000;
|
||||||
const FALLBACK_POLL_MS = 30000;
|
const FALLBACK_POLL_MS = 30000;
|
||||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||||
const THUMBNAIL_COOLDOWN_MS = 60000;
|
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let render = () => {};
|
let render = () => {};
|
||||||
let dashboardWs = null;
|
let dashboardWs = null;
|
||||||
let dashboardWsConnected = false;
|
let dashboardWsConnected = false;
|
||||||
let wsReconnectTimer = null;
|
let wsReconnectTimer = null;
|
||||||
const manuallyDisconnecting = new Set();
|
const manuallyDisconnecting = new Set();
|
||||||
const thumbnailRequestedAt = new Map();
|
|
||||||
|
|
||||||
function moveClientCardImmediately(msg) {
|
function moveClientCardImmediately(msg) {
|
||||||
const clientId = typeof msg?.clientId === "string" ? msg.clientId : "";
|
const clientId = typeof msg?.clientId === "string" ? msg.clientId : "";
|
||||||
@@ -77,8 +75,6 @@ export async function loadWithOptions(options = {}) {
|
|||||||
render(data, { reorder });
|
render(data, { reorder });
|
||||||
const pag = document.getElementById("pagination");
|
const pag = document.getElementById("pagination");
|
||||||
if (pag) pag.style.visibility = "";
|
if (pag) pag.style.visibility = "";
|
||||||
|
|
||||||
requestThumbnailsForClients(data.items);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("load clients", err);
|
console.error("load clients", err);
|
||||||
} finally {
|
} 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) {
|
function updateOsFilter(items) {
|
||||||
const osSelect = document.getElementById("filter-os");
|
const osSelect = document.getElementById("filter-os");
|
||||||
if (!osSelect) return;
|
if (!osSelect) return;
|
||||||
@@ -183,7 +166,6 @@ function connectDashboardWs() {
|
|||||||
console.warn("[dashboard] ws closed");
|
console.warn("[dashboard] ws closed");
|
||||||
dashboardWs = null;
|
dashboardWs = null;
|
||||||
dashboardWsConnected = false;
|
dashboardWsConnected = false;
|
||||||
thumbnailRequestedAt.clear();
|
|
||||||
adjustPollingForWs();
|
adjustPollingForWs();
|
||||||
scheduleDashboardReconnect();
|
scheduleDashboardReconnect();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2335,6 +2335,16 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
.cv-thumb-img { object-fit: cover; cursor: pointer; }
|
.cv-thumb-img { object-fit: cover; cursor: pointer; }
|
||||||
.cv-thumb { position: relative; }
|
.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 {
|
.cv-faux {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
requestThumbnail,
|
requestThumbnail,
|
||||||
markManualDisconnect,
|
markManualDisconnect,
|
||||||
} from "./data.js";
|
} from "./data.js";
|
||||||
|
import { ThumbnailLoader } from "./thumbnail-loader.js";
|
||||||
|
|
||||||
const grid = document.getElementById("grid");
|
const grid = document.getElementById("grid");
|
||||||
const totalPill = document.getElementById("total-pill");
|
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));
|
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() {
|
function initializeRenderer() {
|
||||||
const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
|
const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
|
||||||
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
|
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
|
||||||
@@ -666,6 +721,7 @@ function initializeRenderer() {
|
|||||||
});
|
});
|
||||||
rendererSetLayout = rSetLayout;
|
rendererSetLayout = rSetLayout;
|
||||||
registerRenderer(renderMerge);
|
registerRenderer(renderMerge);
|
||||||
|
setupDashboardThumbnailLoader();
|
||||||
refreshGroupFilter();
|
refreshGroupFilter();
|
||||||
loadWithOptions();
|
loadWithOptions();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
import { mountNav } from "./nav/template.js";
|
import { mountNav } from "./nav/template.js";
|
||||||
import { createAdaptiveNavController } from "./nav/layout.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 { loadPluginNavItems } from "./nav/plugins-loader.js";
|
||||||
import { init as initCommandPalette } from "./command-palette.js";
|
import { init as initCommandPalette } from "./command-palette.js";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ if (host) {
|
|||||||
const activeMap = {
|
const activeMap = {
|
||||||
"/": "nav-clients",
|
"/": "nav-clients",
|
||||||
"/metrics": "metrics-link",
|
"/metrics": "metrics-link",
|
||||||
|
"/screenshots": "screenshots-link",
|
||||||
"/logs": "logs-link",
|
"/logs": "logs-link",
|
||||||
"/scripts": "scripts-link",
|
"/scripts": "scripts-link",
|
||||||
"/socks5-manager": "socks5-link",
|
"/socks5-manager": "socks5-link",
|
||||||
@@ -134,6 +135,7 @@ if (host) {
|
|||||||
}
|
}
|
||||||
const user = await res.json();
|
const user = await res.json();
|
||||||
applyUserRoleUI(user, refs);
|
applyUserRoleUI(user, refs);
|
||||||
|
applyThumbnailWallVisibility(user);
|
||||||
|
|
||||||
if (user.role === "admin" || user.role === "operator") {
|
if (user.role === "admin" || user.role === "operator") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -75,3 +75,14 @@ export function applyUserRoleUI(user, refs) {
|
|||||||
unhide("file-share-link");
|
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 {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const NAV_GROUPS = [
|
|||||||
iconColor: "text-emerald-400",
|
iconColor: "text-emerald-400",
|
||||||
children: [
|
children: [
|
||||||
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" },
|
{ 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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,10 +71,32 @@ function fauxDesktopHtml(client, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function thumbHtml(client, { width, height, small = false } = {}) {
|
function thumbHtml(client, { width, height, small = false } = {}) {
|
||||||
if (client.thumbnail) {
|
const hasThumb = !!client.hasThumbnail;
|
||||||
return `<img class="thumb-img cv-thumb-img" alt="preview" src="${escapeHtml(client.thumbnail)}" style="width:${width}px;height:${height}px;${client.online ? "" : "opacity:0.35"}">`;
|
const version = Number(client.thumbnailVersion) || 0;
|
||||||
}
|
const opacityStyle = client.online ? "" : "opacity:0.35";
|
||||||
return `<div class="cv-thumb" style="width:${width}px;height:${height}px;${client.online ? "" : "opacity:0.35"}">${fauxDesktopHtml(client, { small })}</div>`;
|
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 `
|
||||||
|
<div class="cv-thumb cv-thumb-host"
|
||||||
|
data-thumb-host
|
||||||
|
data-thumb-client="${idAttr}"
|
||||||
|
data-thumb-version="${version}"
|
||||||
|
data-thumb-online="${client.online ? "1" : "0"}"
|
||||||
|
style="width:${width}px;height:${height}px;${opacityStyle}">
|
||||||
|
${fauxDesktopHtml(client, { small })}
|
||||||
|
<img class="thumb-img cv-thumb-img cv-thumb-overlay"
|
||||||
|
data-thumb-img
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
${initialSrc ? `src="${initialSrc}" data-thumb-url="${initialSrc}"` : ""}
|
||||||
|
style="${initialDisplay}"
|
||||||
|
onload="this.style.display='block'"
|
||||||
|
onerror="this.style.display='none';this.removeAttribute('src');this.removeAttribute('data-thumb-url')">
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDot(client) {
|
function statusDot(client) {
|
||||||
@@ -408,7 +430,7 @@ export function createRenderer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cardDigest(c) {
|
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 = {}) {
|
function renderMerge(data, options = {}) {
|
||||||
|
|||||||
@@ -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 = `<i class="fa-solid fa-camera"></i><span>${client.online ? "Waiting for screenshot…" : "Offline"}</span>`;
|
||||||
|
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 = `
|
||||||
|
<span class="wall-dot ${client.online ? "on" : "off"}"></span>
|
||||||
|
<span class="wall-host"></span>
|
||||||
|
`;
|
||||||
|
tile.appendChild(top);
|
||||||
|
|
||||||
|
const bottom = document.createElement("div");
|
||||||
|
bottom.className = "wall-overlay-bottom";
|
||||||
|
bottom.innerHTML = `
|
||||||
|
<span class="wall-user-os"><span class="wall-user"></span> <span class="wall-os"></span></span>
|
||||||
|
<span class="wall-age"></span>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<button class="wall-pill" data-act="prev" ${currentPage <= 1 ? "disabled" : ""}>
|
||||||
|
<i class="fa-solid fa-chevron-left"></i><span>Prev</span>
|
||||||
|
</button>
|
||||||
|
<span class="text-slate-400 text-sm">Page ${currentPage} of ${totalPages}</span>
|
||||||
|
<button class="wall-pill" data-act="next" ${currentPage >= totalPages ? "disabled" : ""}>
|
||||||
|
<span>Next</span><i class="fa-solid fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
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();
|
||||||
@@ -1606,6 +1606,59 @@ function initBuildRateLimitHandlers() {
|
|||||||
if (brlForm) brlForm.addEventListener("submit", saveBuildRateLimitSettings);
|
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() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
await loadCurrentUser();
|
await loadCurrentUser();
|
||||||
@@ -1634,16 +1687,20 @@ async function init() {
|
|||||||
await loadChatSettings();
|
await loadChatSettings();
|
||||||
await loadBannedIps();
|
await loadBannedIps();
|
||||||
|
|
||||||
// Registration + Build Rate Limit (admin only)
|
// Registration + Build Rate Limit + Thumbnails (admin only)
|
||||||
if (isAdmin(currentUser?.role)) {
|
if (isAdmin(currentUser?.role)) {
|
||||||
const regSection = document.getElementById("registration-section");
|
const regSection = document.getElementById("registration-section");
|
||||||
const brlSection = document.getElementById("build-rate-limit-section");
|
const brlSection = document.getElementById("build-rate-limit-section");
|
||||||
|
const thumbSection = document.getElementById("thumbnails-section");
|
||||||
if (regSection) regSection.classList.remove("hidden");
|
if (regSection) regSection.classList.remove("hidden");
|
||||||
if (brlSection) brlSection.classList.remove("hidden");
|
if (brlSection) brlSection.classList.remove("hidden");
|
||||||
|
if (thumbSection) thumbSection.classList.remove("hidden");
|
||||||
await loadRegistrationSettings();
|
await loadRegistrationSettings();
|
||||||
await loadBuildRateLimitSettings();
|
await loadBuildRateLimitSettings();
|
||||||
|
await loadThumbnailSettings();
|
||||||
initRegistrationHandlers();
|
initRegistrationHandlers();
|
||||||
initBuildRateLimitHandlers();
|
initBuildRateLimitHandlers();
|
||||||
|
initThumbnailHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
passwordForm.addEventListener("submit", updatePassword);
|
passwordForm.addEventListener("submit", updatePassword);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ export function digestData(data, { page, pageSize, searchTerm, sort }) {
|
|||||||
user: c.user,
|
user: c.user,
|
||||||
customTag: c.customTag,
|
customTag: c.customTag,
|
||||||
customTagNote: c.customTagNote,
|
customTagNote: c.customTagNote,
|
||||||
thumbnail: c.thumbnail,
|
|
||||||
version: c.version,
|
version: c.version,
|
||||||
country: c.country,
|
country: c.country,
|
||||||
arch: c.arch,
|
arch: c.arch,
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<script src="/assets/nav-prelude.js"></script>
|
||||||
|
<link rel="modulepreload" href="/assets/nav.js" />
|
||||||
|
<title>Screenshot Wall - Overlord</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||||
|
<link rel="stylesheet" href="/vendor/inter/600.css" />
|
||||||
|
<link rel="stylesheet" href="/vendor/inter/700.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||||
|
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||||
|
<link rel="stylesheet" href="/vendor/flag-icons/css/flag-icons.min.css" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/custom.css" />
|
||||||
|
<style>
|
||||||
|
.wall-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--wall-tile-w, 320px), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.wall-tile {
|
||||||
|
position: relative;
|
||||||
|
background: rgb(15 23 42 / 0.7);
|
||||||
|
border: 1px solid rgb(51 65 85 / 0.6);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
|
||||||
|
}
|
||||||
|
.wall-tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgb(56 189 248 / 0.6);
|
||||||
|
box-shadow: 0 10px 30px -10px rgb(56 189 248 / 0.35);
|
||||||
|
}
|
||||||
|
.wall-tile.is-offline {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.wall-tile.is-offline:hover {
|
||||||
|
transform: none;
|
||||||
|
border-color: rgb(51 65 85 / 0.6);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.wall-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgb(30 41 59) 0%, rgb(15 23 42) 100%);
|
||||||
|
}
|
||||||
|
.wall-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(100 116 139);
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.wall-empty i { font-size: 32px; opacity: 0.5; }
|
||||||
|
.wall-overlay-top {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: linear-gradient(to bottom, rgb(0 0 0 / 0.7), transparent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.wall-overlay-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: linear-gradient(to top, rgb(0 0 0 / 0.78), transparent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.wall-host {
|
||||||
|
color: rgb(226 232 240);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.wall-user {
|
||||||
|
color: rgb(148 163 184);
|
||||||
|
}
|
||||||
|
.wall-os {
|
||||||
|
color: rgb(148 163 184);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.wall-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wall-dot.on { background: rgb(34 197 94); box-shadow: 0 0 8px rgb(34 197 94 / 0.6); }
|
||||||
|
.wall-dot.off { background: rgb(100 116 139); }
|
||||||
|
.wall-age {
|
||||||
|
color: rgb(148 163 184);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.wall-age.fresh { color: rgb(74 222 128); }
|
||||||
|
.wall-age.stale { color: rgb(251 191 36); }
|
||||||
|
.wall-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(56 189 248);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
}
|
||||||
|
.wall-tile.is-loading .wall-loading { opacity: 1; animation: wallPulse 1s ease-in-out infinite; }
|
||||||
|
@keyframes wallPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
.wall-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.wall-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgb(30 41 59 / 0.7);
|
||||||
|
border: 1px solid rgb(51 65 85 / 0.6);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: rgb(203 213 225);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.wall-pill select,
|
||||||
|
.wall-pill input[type="search"] {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgb(226 232 240);
|
||||||
|
outline: none;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wall-pill input[type="search"]::placeholder { color: rgb(100 116 139); }
|
||||||
|
.wall-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<header id="top-nav"></header>
|
||||||
|
<main class="px-5 py-6">
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Screenshot Wall</h1>
|
||||||
|
<p class="text-slate-400 text-sm">Live thumbnails across every online agent — click a tile to open remote desktop.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wall-controls">
|
||||||
|
<label class="wall-pill">
|
||||||
|
<i class="fa-solid fa-magnifying-glass text-slate-500"></i>
|
||||||
|
<input id="wall-search" type="search" placeholder="Filter host / user / id" />
|
||||||
|
</label>
|
||||||
|
<label class="wall-pill">
|
||||||
|
<span class="text-slate-400">Refresh</span>
|
||||||
|
<select id="wall-interval">
|
||||||
|
<option value="2000">2s</option>
|
||||||
|
<option value="3000" selected>3s</option>
|
||||||
|
<option value="5000">5s</option>
|
||||||
|
<option value="10000">10s</option>
|
||||||
|
<option value="0">Manual</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wall-pill">
|
||||||
|
<span class="text-slate-400">Tile size</span>
|
||||||
|
<select id="wall-size">
|
||||||
|
<option value="220">Small</option>
|
||||||
|
<option value="320" selected>Medium</option>
|
||||||
|
<option value="440">Large</option>
|
||||||
|
<option value="600">XL</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wall-pill wall-toggle">
|
||||||
|
<input id="wall-show-offline" type="checkbox" class="accent-sky-400" />
|
||||||
|
<span>Show offline</span>
|
||||||
|
</label>
|
||||||
|
<button id="wall-refresh-now" class="wall-pill hover:bg-slate-700/70 transition-colors" title="Refresh all tiles now">
|
||||||
|
<i class="fa-solid fa-rotate"></i><span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
<span class="wall-pill" id="wall-stats">0 online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wall-grid" class="wall-grid"></div>
|
||||||
|
<div id="wall-empty" class="hidden text-center text-slate-500 py-16">
|
||||||
|
<i class="fa-solid fa-display text-4xl mb-3 opacity-40"></i>
|
||||||
|
<p>No matching agents.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
|
<script defer src="/assets/toast.js"></script>
|
||||||
|
<script defer src="/assets/ripple.js"></script>
|
||||||
|
<script type="module" src="/assets/screenshots.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
|
|
||||||
<span class="settings-nav-group-label" data-admin-only>Admin</span>
|
<span class="settings-nav-group-label" data-admin-only>Admin</span>
|
||||||
<a data-target="section-security" class="settings-nav-link" data-admin-only style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
<a data-target="section-security" class="settings-nav-link" data-admin-only style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
|
||||||
|
<a data-target="thumbnails-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||||
<a data-target="section-tls" class="settings-nav-link" data-admin-only style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
<a data-target="section-tls" class="settings-nav-link" data-admin-only style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
|
||||||
<a data-target="chat-settings-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
<a data-target="chat-settings-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||||
<a data-target="registration-section" class="settings-nav-link" data-admin-only style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
<a data-target="registration-section" class="settings-nav-link" data-admin-only style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||||
@@ -821,6 +822,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Screenshot Thumbnails (admin only) -->
|
||||||
|
<section id="thumbnails-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||||
|
<i class="fa-solid fa-images text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Screenshot Thumbnails</h2>
|
||||||
|
<p class="text-sm text-slate-400">Admin-only. Control automatic agent screenshot polling.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-slate-400 bg-slate-800/40 border border-slate-700/60 rounded-lg p-3">
|
||||||
|
<p>Each visible card or tile triggers one screenshot request to its agent on a refresh interval. With many concurrent users or thousands of agents, this can add up. Turn either off to reduce load — manual screenshot commands still work either way.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="thumbnails-form" class="space-y-4">
|
||||||
|
<label class="flex items-start gap-3 p-3 rounded-lg border border-slate-700/60 bg-slate-900/40 hover:bg-slate-900/70 cursor-pointer transition-colors">
|
||||||
|
<input id="thumb-dashboard-enabled" type="checkbox" class="mt-1 h-4 w-4 accent-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-slate-100">Dashboard live thumbnails</div>
|
||||||
|
<div class="text-xs text-slate-400">Auto-refresh the thumbnail on each visible client card on the Clients dashboard (~15s per visible tile).</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-3 rounded-lg border border-slate-700/60 bg-slate-900/40 hover:bg-slate-900/70 cursor-pointer transition-colors">
|
||||||
|
<input id="thumb-wall-enabled" type="checkbox" class="mt-1 h-4 w-4 accent-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-slate-100">Screenshot Wall page</div>
|
||||||
|
<div class="text-xs text-slate-400">Enable the <code class="text-sky-300">/screenshots</code> page. When off, the page returns 403 and the nav link is hidden.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-700 hover:bg-sky-600 transition-colors text-white text-sm font-medium">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p id="thumbnails-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Build Rate Limit (admin only) -->
|
<!-- Build Rate Limit (admin only) -->
|
||||||
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export interface Config {
|
|||||||
buildSigning: {
|
buildSigning: {
|
||||||
banlist: string[];
|
banlist: string[];
|
||||||
};
|
};
|
||||||
|
thumbnails: {
|
||||||
|
dashboardEnabled: boolean;
|
||||||
|
wallEnabled: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: Config = {
|
const DEFAULT_CONFIG: Config = {
|
||||||
@@ -160,6 +164,10 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
buildSigning: {
|
buildSigning: {
|
||||||
banlist: [],
|
banlist: [],
|
||||||
},
|
},
|
||||||
|
thumbnails: {
|
||||||
|
dashboardEnabled: true,
|
||||||
|
wallEnabled: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type SaveSecrets = {
|
type SaveSecrets = {
|
||||||
@@ -558,6 +566,16 @@ export function loadConfig(): Config {
|
|||||||
? fileConfig.buildSigning.banlist.filter((v: unknown): v is string => typeof v === "string" && v.length > 0)
|
? fileConfig.buildSigning.banlist.filter((v: unknown): v is string => typeof v === "string" && v.length > 0)
|
||||||
: [...DEFAULT_CONFIG.buildSigning.banlist],
|
: [...DEFAULT_CONFIG.buildSigning.banlist],
|
||||||
},
|
},
|
||||||
|
thumbnails: {
|
||||||
|
dashboardEnabled:
|
||||||
|
fileConfig.thumbnails?.dashboardEnabled !== undefined
|
||||||
|
? Boolean(fileConfig.thumbnails.dashboardEnabled)
|
||||||
|
: DEFAULT_CONFIG.thumbnails.dashboardEnabled,
|
||||||
|
wallEnabled:
|
||||||
|
fileConfig.thumbnails?.wallEnabled !== undefined
|
||||||
|
? Boolean(fileConfig.thumbnails.wallEnabled)
|
||||||
|
: DEFAULT_CONFIG.thumbnails.wallEnabled,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (saveChanged) {
|
if (saveChanged) {
|
||||||
@@ -858,6 +876,33 @@ export async function updateBuildRateLimitConfig(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateThumbnailsConfig(
|
||||||
|
updates: Partial<Config["thumbnails"]>,
|
||||||
|
): Promise<Config["thumbnails"]> {
|
||||||
|
const current = getConfig();
|
||||||
|
|
||||||
|
const next: Config["thumbnails"] = {
|
||||||
|
dashboardEnabled:
|
||||||
|
updates.dashboardEnabled !== undefined
|
||||||
|
? Boolean(updates.dashboardEnabled)
|
||||||
|
: current.thumbnails.dashboardEnabled,
|
||||||
|
wallEnabled:
|
||||||
|
updates.wallEnabled !== undefined
|
||||||
|
? Boolean(updates.wallEnabled)
|
||||||
|
: current.thumbnails.wallEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
configCache = {
|
||||||
|
...current,
|
||||||
|
thumbnails: next,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileConfig = readFileConfigForUpdate();
|
||||||
|
fileConfig.thumbnails = next;
|
||||||
|
await writePersistentFileConfig(fileConfig);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function getBuildSigningSecrets(): BuildSigningSecrets | null {
|
export function getBuildSigningSecrets(): BuildSigningSecrets | null {
|
||||||
const dataDir = ensureDataDir();
|
const dataDir = ensureDataDir();
|
||||||
const savePath = resolve(dataDir, "save.json");
|
const savePath = resolve(dataDir, "save.json");
|
||||||
@@ -926,6 +971,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
|||||||
publicKey: buildSigningSecrets?.publicKey || "",
|
publicKey: buildSigningSecrets?.publicKey || "",
|
||||||
banlist: config.buildSigning.banlist,
|
banlist: config.buildSigning.banlist,
|
||||||
},
|
},
|
||||||
|
thumbnails: config.thumbnails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,6 +1045,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
|
|||||||
applied.push("buildRateLimit");
|
applied.push("buildRateLimit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.thumbnails && typeof data.thumbnails === "object") {
|
||||||
|
await updateThumbnailsConfig(data.thumbnails);
|
||||||
|
applied.push("thumbnails");
|
||||||
|
}
|
||||||
|
|
||||||
if (data.auth && typeof data.auth === "object") {
|
if (data.auth && typeof data.auth === "object") {
|
||||||
const dataDir = ensureDataDir();
|
const dataDir = ensureDataDir();
|
||||||
const savePath = resolve(dataDir, "save.json");
|
const savePath = resolve(dataDir, "save.json");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Database from "bun:sqlite";
|
import Database from "bun:sqlite";
|
||||||
import type { Statement } from "bun:sqlite";
|
import type { Statement } from "bun:sqlite";
|
||||||
import { ClientInfo, ListFilters, ListResult, ClientRole } from "./types";
|
import { ClientInfo, ListFilters, ListResult, ClientRole } from "./types";
|
||||||
import { getThumbnail } from "./thumbnails";
|
import { hasThumbnail, getThumbnailVersion } from "./thumbnails";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
|
|
||||||
@@ -1186,7 +1186,8 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
groupName: c.groupName || null,
|
groupName: c.groupName || null,
|
||||||
groupColor: c.groupColor || null,
|
groupColor: c.groupColor || null,
|
||||||
notificationsMuted: c.notificationsMuted === 1,
|
notificationsMuted: c.notificationsMuted === 1,
|
||||||
thumbnail: getThumbnail(c.id),
|
hasThumbnail: hasThumbnail(c.id),
|
||||||
|
thumbnailVersion: getThumbnailVersion(c.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
|
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
listUserClientRuleIdsByAccess,
|
listUserClientRuleIdsByAccess,
|
||||||
} from "../../users";
|
} from "../../users";
|
||||||
import { notifyDashboardViewers } from "../../sessions/sessionManager";
|
import { notifyDashboardViewers } from "../../sessions/sessionManager";
|
||||||
|
import { clearThumbnail } from "../../thumbnails";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -252,7 +253,7 @@ export async function handleClientRoutes(
|
|||||||
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
}
|
}
|
||||||
const { generateThumbnail, markThumbnailRequested } = await import("../../thumbnails");
|
const { generateThumbnail, markThumbnailRequested, getThumbnailVersion } = await import("../../thumbnails");
|
||||||
markThumbnailRequested(clientId);
|
markThumbnailRequested(clientId);
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
if (target?.online) {
|
if (target?.online) {
|
||||||
@@ -268,7 +269,40 @@ export async function handleClientRoutes(
|
|||||||
metrics.recordCommand("screenshot");
|
metrics.recordCommand("screenshot");
|
||||||
}
|
}
|
||||||
const success = await generateThumbnail(clientId);
|
const success = await generateThumbnail(clientId);
|
||||||
return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS });
|
const version = getThumbnailVersion(clientId);
|
||||||
|
return Response.json(
|
||||||
|
{ ok: true, updated: success, version },
|
||||||
|
{ headers: deps.CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && thumbnailMatch) {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const clientId = thumbnailMatch[1];
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
const { getThumbnailRecord } = await import("../../thumbnails");
|
||||||
|
const record = getThumbnailRecord(clientId);
|
||||||
|
if (!record) {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
const etag = `"${clientId}-${record.version}"`;
|
||||||
|
if (req.headers.get("if-none-match") === etag) {
|
||||||
|
return new Response(null, { status: 304, headers: { ETag: etag } });
|
||||||
|
}
|
||||||
|
return new Response(record.bytes as unknown as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": record.contentType,
|
||||||
|
"Content-Length": String(record.bytes.byteLength),
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": "private, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "DELETE" && url.pathname === "/api/clients/offline") {
|
if (req.method === "DELETE" && url.pathname === "/api/clients/offline") {
|
||||||
@@ -338,6 +372,7 @@ export async function handleClientRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteClientRow(targetId);
|
deleteClientRow(targetId);
|
||||||
|
clearThumbnail(targetId);
|
||||||
notifyDashboardViewers();
|
notifyDashboardViewers();
|
||||||
|
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
@@ -771,6 +806,7 @@ export async function handleClientRoutes(
|
|||||||
metrics.recordCommand("uninstall");
|
metrics.recordCommand("uninstall");
|
||||||
clientManager.deleteClient(targetId);
|
clientManager.deleteClient(targetId);
|
||||||
deleteClientRow(targetId);
|
deleteClientRow(targetId);
|
||||||
|
clearThumbnail(targetId);
|
||||||
logAudit({
|
logAudit({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig } from "../../config";
|
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
|
||||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
|
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission } from "../../rbac";
|
||||||
@@ -693,5 +693,40 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Thumbnail toggles (GET: any user, PUT: admin only) ─────────────
|
||||||
|
if (url.pathname === "/api/settings/thumbnails") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
return Response.json({ thumbnails: getConfig().thumbnails }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try { body = await req.json(); } catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateThumbnailsConfig({
|
||||||
|
dashboardEnabled: body?.dashboardEnabled !== undefined ? Boolean(body.dashboardEnabled) : undefined,
|
||||||
|
wallEnabled: body?.wallEnabled !== undefined ? Boolean(body.wallEnabled) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: deps.requestIP?.(req)?.address || "unknown",
|
||||||
|
action: AuditAction.COMMAND,
|
||||||
|
details: `Updated thumbnail settings (dashboard=${updated.dashboardEnabled}, wall=${updated.wallEnabled})`,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true, thumbnails: updated }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
|
import { getConfig } from "../../config";
|
||||||
import { requirePermission, type Permission } from "../../rbac";
|
import { requirePermission, type Permission } from "../../rbac";
|
||||||
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
|
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ type QueryClientPageDef = {
|
|||||||
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
||||||
const STATIC_PAGES: StaticPageDef[] = [
|
const STATIC_PAGES: StaticPageDef[] = [
|
||||||
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
||||||
|
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
|
||||||
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
||||||
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
||||||
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
|
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
|
||||||
@@ -162,6 +164,10 @@ export async function handlePageRoutes(
|
|||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return serveLoginOrUnauthorized(deps);
|
if (!user) return serveLoginOrUnauthorized(deps);
|
||||||
|
|
||||||
|
if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) {
|
||||||
|
return new Response("Screenshot wall is disabled by the administrator", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
if (page.checkPasswordChange) {
|
if (page.checkPasswordChange) {
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||||
if (maybeChange) return maybeChange;
|
if (maybeChange) return maybeChange;
|
||||||
|
|||||||
@@ -21,17 +21,50 @@ const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
|
|||||||
256 * 1024,
|
256 * 1024,
|
||||||
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
|
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
|
||||||
);
|
);
|
||||||
|
const THUMBNAIL_CACHE_MAX = Math.max(
|
||||||
|
64,
|
||||||
|
Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000),
|
||||||
|
);
|
||||||
|
|
||||||
const thumbnails = new Map<string, string>();
|
type ThumbnailRecord = {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
contentType: string;
|
||||||
|
version: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbnails = new Map<string, ThumbnailRecord>();
|
||||||
const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
|
const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
|
||||||
const thumbnailRequests = new Map<string, number>();
|
const thumbnailRequests = new Map<string, number>();
|
||||||
|
|
||||||
export function setThumbnail(id: string, dataUrl: string) {
|
function touchThumbnailLRU(id: string) {
|
||||||
thumbnails.set(id, dataUrl);
|
const existing = thumbnails.get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
thumbnails.delete(id);
|
||||||
|
thumbnails.set(id, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThumbnail(id: string) {
|
function evictThumbnailsIfFull() {
|
||||||
return thumbnails.get(id) ?? null;
|
while (thumbnails.size > THUMBNAIL_CACHE_MAX) {
|
||||||
|
const oldestKey = thumbnails.keys().next().value;
|
||||||
|
if (oldestKey === undefined) break;
|
||||||
|
thumbnails.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasThumbnail(id: string): boolean {
|
||||||
|
return thumbnails.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThumbnailRecord(id: string): ThumbnailRecord | null {
|
||||||
|
const rec = thumbnails.get(id);
|
||||||
|
if (!rec) return null;
|
||||||
|
touchThumbnailLRU(id);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThumbnailVersion(id: string): number {
|
||||||
|
return thumbnails.get(id)?.version ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearThumbnail(id: string) {
|
export function clearThumbnail(id: string) {
|
||||||
@@ -48,7 +81,7 @@ export function setLatestFrame(id: string, bytes: Uint8Array, format: string) {
|
|||||||
latestFrames.set(id, { bytes, format, capturedAt: Date.now() });
|
latestFrames.set(id, { bytes, format, capturedAt: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildThumbnailDataUrl(bytes: Uint8Array, format: string): Promise<string | null> {
|
async function buildThumbnailBytes(bytes: Uint8Array, format: string): Promise<Uint8Array | null> {
|
||||||
if (!bytes || bytes.byteLength === 0) {
|
if (!bytes || bytes.byteLength === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -71,7 +104,7 @@ async function buildThumbnailDataUrl(bytes: Uint8Array, format: string): Promise
|
|||||||
.webp({ quality: THUMBNAIL_QUALITY })
|
.webp({ quality: THUMBNAIL_QUALITY })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
return `data:image/webp;base64,${output.toString("base64")}`;
|
return new Uint8Array(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateThumbnail(id: string): Promise<boolean> {
|
export async function generateThumbnail(id: string): Promise<boolean> {
|
||||||
@@ -81,11 +114,20 @@ export async function generateThumbnail(id: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dataUrl = await buildThumbnailDataUrl(frameData.bytes, frameData.format);
|
const out = await buildThumbnailBytes(frameData.bytes, frameData.format);
|
||||||
if (!dataUrl) {
|
if (!out) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
thumbnails.set(id, dataUrl);
|
const prior = thumbnails.get(id);
|
||||||
|
const now = Date.now();
|
||||||
|
if (prior) thumbnails.delete(id);
|
||||||
|
thumbnails.set(id, {
|
||||||
|
bytes: out,
|
||||||
|
contentType: "image/webp",
|
||||||
|
version: (prior?.version ?? 0) + 1,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
evictThumbnailsIfFull();
|
||||||
latestFrames.delete(id);
|
latestFrames.delete(id);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export type ListFilters = {
|
|||||||
|
|
||||||
export type ListItem = Omit<ClientInfo, 'ws'> & {
|
export type ListItem = Omit<ClientInfo, 'ws'> & {
|
||||||
online: boolean;
|
online: boolean;
|
||||||
thumbnail: string | null;
|
hasThumbnail: boolean;
|
||||||
|
thumbnailVersion: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListResult = {
|
export type ListResult = {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ async function getGeoip() {
|
|||||||
import { ClientInfo } from "./types";
|
import { ClientInfo } from "./types";
|
||||||
import {
|
import {
|
||||||
consumeThumbnailRequest,
|
consumeThumbnailRequest,
|
||||||
getThumbnail,
|
hasThumbnail,
|
||||||
generateThumbnail,
|
generateThumbnail,
|
||||||
setLatestFrame,
|
setLatestFrame,
|
||||||
} from "./thumbnails";
|
} from "./thumbnails";
|
||||||
@@ -297,8 +297,7 @@ export function handleFrame(info: ClientInfo, payload: any) {
|
|||||||
if (safeFormat) {
|
if (safeFormat) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const thumbnailRequested = consumeThumbnailRequest(info.id);
|
const thumbnailRequested = consumeThumbnailRequest(info.id);
|
||||||
const hasThumbnail = Boolean(getThumbnail(info.id));
|
if (thumbnailRequested || !hasThumbnail(info.id)) {
|
||||||
if (thumbnailRequested || !hasThumbnail) {
|
|
||||||
setLatestFrame(info.id, bytes, safeFormat);
|
setLatestFrame(info.id, bytes, safeFormat);
|
||||||
void generateThumbnail(info.id);
|
void generateThumbnail(info.id);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user