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 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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,10 +71,32 @@ function fauxDesktopHtml(client, opts = {}) {
|
||||
}
|
||||
|
||||
function thumbHtml(client, { width, height, small = false } = {}) {
|
||||
if (client.thumbnail) {
|
||||
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"}">`;
|
||||
}
|
||||
return `<div class="cv-thumb" style="width:${width}px;height:${height}px;${client.online ? "" : "opacity:0.35"}">${fauxDesktopHtml(client, { small })}</div>`;
|
||||
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 `
|
||||
<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) {
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
@@ -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,
|
||||
customTag: c.customTag,
|
||||
customTagNote: c.customTagNote,
|
||||
thumbnail: c.thumbnail,
|
||||
version: c.version,
|
||||
country: c.country,
|
||||
arch: c.arch,
|
||||
|
||||
Reference in New Issue
Block a user