Files
overlord/Overlord-Server/public/assets/data.js
T

396 lines
11 KiB
JavaScript

import { state } from "./state.js";
import { digestData } from "./utils.js";
const POLL_INTERVAL_MS = 5000;
const FALLBACK_POLL_MS = 30000;
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
const REALTIME_MIN_LOAD_MS = 1500;
const REALTIME_CHURN_WINDOW_MS = 5000;
let pollTimer = null;
let render = () => {};
let lastClientData = null;
const pageCache = new Map();
const PAGE_CACHE_LIMIT = 8;
let dashboardWs = null;
let dashboardWsConnected = false;
let wsReconnectTimer = null;
let realtimeLoadTimer = null;
let lastRealtimeLoadAt = 0;
let lastRealtimeEventAt = 0;
let realtimeEventCount = 0;
let realtimeForce = false;
let realtimeReorder = false;
const manuallyDisconnecting = new Set();
function stopDashboardRealtime() {
clearInterval(pollTimer);
pollTimer = null;
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
clearTimeout(realtimeLoadTimer);
realtimeLoadTimer = null;
if (dashboardWs) {
try {
dashboardWs.onopen = null;
dashboardWs.onmessage = null;
dashboardWs.onerror = null;
dashboardWs.onclose = null;
dashboardWs.close(1000, "page hidden");
} catch {}
}
dashboardWs = null;
dashboardWsConnected = false;
}
function noteRealtimeEvent() {
const now = Date.now();
if (now - lastRealtimeEventAt > REALTIME_CHURN_WINDOW_MS) {
realtimeEventCount = 0;
}
lastRealtimeEventAt = now;
realtimeEventCount += 1;
}
function isRealtimeChurnActive() {
return Date.now() - lastRealtimeEventAt < REALTIME_CHURN_WINDOW_MS && realtimeEventCount > 5;
}
function scheduleRealtimeLoad(options = {}) {
noteRealtimeEvent();
realtimeForce = realtimeForce || !!options.force;
realtimeReorder = realtimeReorder || options.reorder !== false;
if (realtimeLoadTimer) return;
const elapsed = Date.now() - lastRealtimeLoadAt;
const delay = Math.max(0, REALTIME_MIN_LOAD_MS - elapsed);
realtimeLoadTimer = setTimeout(() => {
realtimeLoadTimer = null;
lastRealtimeLoadAt = Date.now();
const force = realtimeForce;
const reorder = realtimeReorder;
realtimeForce = false;
realtimeReorder = false;
loadWithOptions({ force, reorder });
}, delay);
}
function bindDashboardPagehideCleanup() {
window.removeEventListener("pagehide", stopDashboardRealtime);
window.addEventListener("pagehide", stopDashboardRealtime);
}
function moveClientCardImmediately(msg) {
const clientId = typeof msg?.clientId === "string" ? msg.clientId : "";
if (!clientId) return;
const selectorId =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(clientId)
: clientId;
const grid = document.getElementById("grid");
const card = grid?.querySelector(`[data-client-row][data-id="${selectorId}"]`);
if (!grid || !card) return;
const parent = card.parentNode || grid;
if (msg.event === "client_online") {
card.dataset.online = "true";
card.classList.remove("card-offline", "cv-offline");
parent.prepend(card);
return;
}
if (msg.event === "client_offline") {
card.dataset.online = "false";
card.classList.add("card-offline", "cv-offline");
parent.appendChild(card);
}
}
export function registerRenderer(fn) {
render = fn;
}
function clientQueryParams(page = state.page) {
return new URLSearchParams({
page: String(page),
pageSize: String(state.pageSize),
q: state.searchTerm,
sort: state.sort,
status: state.filterStatus || "all",
os: state.filterOs || "all",
country: state.filterCountry || "all",
group: state.filterGroup || "all",
webcam: state.filterWebcam || "all",
});
}
function rememberPage(key, data) {
pageCache.set(key, data);
while (pageCache.size > PAGE_CACHE_LIMIT) {
pageCache.delete(pageCache.keys().next().value);
}
}
async function prefetchClientPage(page) {
if (page < 1) return;
const params = clientQueryParams(page);
const key = params.toString();
if (pageCache.has(key)) return;
try {
const res = await fetch(`/api/clients?${key}`);
if (!res.ok) return;
rememberPage(key, await res.json());
} catch {}
}
function prefetchAdjacentPages(data) {
if (isRealtimeChurnActive()) return;
const totalPages = Math.max(1, Math.ceil((Number(data.total) || 0) / (Number(data.pageSize) || state.pageSize || 1)));
const current = Number(data.page) || state.page;
if (current < totalPages) prefetchClientPage(current + 1);
if (current > 1) prefetchClientPage(current - 1);
}
export async function loadWithOptions(options = {}) {
const { force = false, reorder = false } = options;
if (state.isLoading) {
state.pendingForce = state.pendingForce || force;
state.pendingReorder = state.pendingReorder || reorder;
return;
}
state.isLoading = true;
try {
const params = clientQueryParams();
const cacheKey = params.toString();
const cached = pageCache.get(cacheKey);
if (force && cached) {
lastClientData = cached;
render(cached, { reorder, fromCache: true });
}
const res = await fetch(`/api/clients?${cacheKey}`);
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
lastClientData = data;
rememberPage(cacheKey, data);
updateOsFilter(data.items);
const digest = digestData(data, state);
if (!force && digest === state.lastDigest) {
return;
}
state.lastDigest = digest;
render(data, { reorder });
prefetchAdjacentPages(data);
const pag = document.getElementById("pagination");
if (pag) pag.style.visibility = "";
} catch (err) {
console.error("load clients", err);
} finally {
state.isLoading = false;
if (state.pendingForce || state.pendingReorder) {
const shouldForce = state.pendingForce;
const shouldReorder = state.pendingReorder;
state.pendingForce = false;
state.pendingReorder = false;
loadWithOptions({ force: shouldForce, reorder: shouldReorder });
}
}
}
export function renderCachedClients(options = {}) {
if (!lastClientData) return false;
render(lastClientData, { ...options, fromCache: true });
return true;
}
function updateOsFilter(items) {
const osSelect = document.getElementById("filter-os");
if (!osSelect) return;
const osList = new Set();
items.forEach((item) => {
if (item.os) osList.add(item.os);
});
const currentValue = osSelect.value;
const osArray = Array.from(osList).sort();
osSelect.innerHTML = "";
const allOption = document.createElement("option");
allOption.value = "all";
allOption.textContent = "All OS";
osSelect.appendChild(allOption);
const windowsOption = document.createElement("option");
windowsOption.value = "windows";
windowsOption.textContent = "Windows";
osSelect.appendChild(windowsOption);
osArray.forEach((os) => {
const option = document.createElement("option");
option.value = os;
option.textContent = os;
osSelect.appendChild(option);
});
if (currentValue === "windows" || (currentValue !== "all" && osList.has(currentValue))) {
osSelect.value = currentValue;
}
}
function getConfiguredPollIntervalMs(defaultMs) {
const savedSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY));
if (!Number.isFinite(savedSeconds)) return defaultMs;
const boundedSeconds = Math.min(120, Math.max(3, savedSeconds));
return boundedSeconds * 1000;
}
function connectDashboardWs() {
if (dashboardWs) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/dashboard/ws`;
try {
dashboardWs = new WebSocket(wsUrl);
} catch {
scheduleDashboardReconnect();
return;
}
dashboardWs.onopen = () => {
console.log("[dashboard] ws connected");
dashboardWsConnected = true;
adjustPollingForWs();
};
dashboardWs.onmessage = (event) => {
try {
const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
if (msg && msg.type === "clients_changed") {
scheduleRealtimeLoad({ force: false, reorder: true });
return;
}
if (msg && msg.type === "client_event") {
if (msg.event === "client_offline" && manuallyDisconnecting.has(msg.clientId)) {
manuallyDisconnecting.delete(msg.clientId);
scheduleRealtimeLoad({ force: true, reorder: true });
} else {
if (!isRealtimeChurnActive()) moveClientCardImmediately(msg);
scheduleRealtimeLoad({ force: false, reorder: true });
}
}
} catch {}
};
dashboardWs.onclose = () => {
console.warn("[dashboard] ws closed");
dashboardWs = null;
dashboardWsConnected = false;
adjustPollingForWs();
scheduleDashboardReconnect();
};
dashboardWs.onerror = () => {
console.warn("[dashboard] ws error");
};
}
function scheduleDashboardReconnect() {
if (wsReconnectTimer) return;
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
connectDashboardWs();
}, 3000);
}
function adjustPollingForWs() {
clearInterval(pollTimer);
const interval = dashboardWsConnected
? FALLBACK_POLL_MS
: getConfiguredPollIntervalMs(POLL_INTERVAL_MS);
pollTimer = setInterval(() => {
loadWithOptions({ force: false, reorder: true });
}, interval);
}
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
bindDashboardPagehideCleanup();
connectDashboardWs();
const effectiveInterval = dashboardWsConnected
? FALLBACK_POLL_MS
: getConfiguredPollIntervalMs(intervalMs);
clearInterval(pollTimer);
pollTimer = setInterval(() => {
loadWithOptions({ force: false, reorder: true });
}, effectiveInterval);
}
export function markManualDisconnect(clientId) {
manuallyDisconnecting.add(clientId);
setTimeout(() => manuallyDisconnecting.delete(clientId), 10000);
}
export async function sendCommand(clientId, action) {
try {
const res = await fetch(`/api/clients/${clientId}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) throw new Error(`command failed ${res.status}`);
return true;
} catch (err) {
console.error(err);
return false;
}
}
export async function fetchVoiceCapabilities(clientId) {
try {
const res = await fetch(`/api/clients/${clientId}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "voice_capabilities" }),
});
const data = await res.json().catch(() => ({}));
return {
ok: Boolean(data?.ok),
capabilities: data?.capabilities || null,
error: data?.error || (res.ok ? "" : `voice capabilities failed ${res.status}`),
};
} catch (err) {
console.error(err);
return { ok: false, capabilities: null, error: "Voice capability probe failed" };
}
}
export async function requestPreview(clientId) {
const ok = await sendCommand(clientId, "screenshot");
if (ok) {
setTimeout(() => loadWithOptions({ force: true }), 250);
setTimeout(() => loadWithOptions({ force: true }), 800);
}
}
export async function requestThumbnail(clientId) {
try {
const res = await fetch(`/api/clients/${clientId}/thumbnail`, {
method: "POST",
});
if (res.ok) {
const data = await res.json();
if (data.ok) {
await loadWithOptions({ force: true });
setTimeout(() => loadWithOptions({ force: true }), 250);
setTimeout(() => loadWithOptions({ force: true }), 800);
return true;
}
}
return false;
} catch (err) {
console.error("Failed to request thumbnail:", err);
return false;
}
}