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

290 lines
8.3 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 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 : "";
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(`article[data-id="${selectorId}"]`);
if (!grid || !card) return;
if (msg.event === "client_online") {
card.dataset.online = "true";
card.classList.remove("card-offline");
grid.prepend(card);
return;
}
if (msg.event === "client_offline") {
card.dataset.online = "false";
card.classList.add("card-offline");
grid.appendChild(card);
}
}
export function registerRenderer(fn) {
render = fn;
}
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 = new URLSearchParams({
page: String(state.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",
});
const res = await fetch(`/api/clients?${params.toString()}`);
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
updateOsFilter(data.items);
const digest = digestData(data, state);
if (!force && digest === state.lastDigest) {
return;
}
state.lastDigest = digest;
render(data, { reorder });
const pag = document.getElementById("pagination");
if (pag) pag.style.visibility = "";
requestThumbnailsForClients(data.items);
} 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 });
}
}
}
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;
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);
osArray.forEach((os) => {
const option = document.createElement("option");
option.value = os;
option.textContent = os;
osSelect.appendChild(option);
});
if (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") {
loadWithOptions({ force: false, reorder: true });
return;
}
if (msg && msg.type === "client_event") {
if (msg.event === "client_offline" && manuallyDisconnecting.has(msg.clientId)) {
manuallyDisconnecting.delete(msg.clientId);
loadWithOptions({ force: true });
} else {
moveClientCardImmediately(msg);
loadWithOptions({ force: false, reorder: true });
}
}
} catch {}
};
dashboardWs.onclose = () => {
console.warn("[dashboard] ws closed");
dashboardWs = null;
dashboardWsConnected = false;
thumbnailRequestedAt.clear();
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) {
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;
}
}