2026-03-05 00:04:26 -06:00
|
|
|
import { state } from "./state.js";
|
|
|
|
|
import { digestData } from "./utils.js";
|
|
|
|
|
|
|
|
|
|
const POLL_INTERVAL_MS = 5000;
|
2026-03-17 14:30:43 +01:00
|
|
|
const FALLBACK_POLL_MS = 30000;
|
2026-03-06 20:50:02 -06:00
|
|
|
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
2026-03-05 00:04:26 -06:00
|
|
|
let pollTimer = null;
|
|
|
|
|
let render = () => {};
|
2026-03-17 14:30:43 +01:00
|
|
|
let dashboardWs = null;
|
|
|
|
|
let dashboardWsConnected = false;
|
|
|
|
|
let wsReconnectTimer = null;
|
2026-04-13 20:02:58 -05:00
|
|
|
const manuallyDisconnecting = new Set();
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-03-22 23:21:27 -05:00
|
|
|
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");
|
2026-05-09 20:47:44 -05:00
|
|
|
const card = grid?.querySelector(`[data-client-row][data-id="${selectorId}"]`);
|
2026-03-22 23:21:27 -05:00
|
|
|
if (!grid || !card) return;
|
|
|
|
|
|
2026-05-09 20:47:44 -05:00
|
|
|
const parent = card.parentNode || grid;
|
|
|
|
|
|
2026-03-22 23:21:27 -05:00
|
|
|
if (msg.event === "client_online") {
|
|
|
|
|
card.dataset.online = "true";
|
2026-05-09 20:47:44 -05:00
|
|
|
card.classList.remove("card-offline", "cv-offline");
|
|
|
|
|
parent.prepend(card);
|
2026-03-22 23:21:27 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.event === "client_offline") {
|
|
|
|
|
card.dataset.online = "false";
|
2026-05-09 20:47:44 -05:00
|
|
|
card.classList.add("card-offline", "cv-offline");
|
|
|
|
|
parent.appendChild(card);
|
2026-03-22 23:21:27 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export function registerRenderer(fn) {
|
|
|
|
|
render = fn;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadWithOptions(options = {}) {
|
2026-03-17 00:42:57 -05:00
|
|
|
const { force = false, reorder = false } = options;
|
2026-03-05 00:04:26 -06:00
|
|
|
if (state.isLoading) {
|
|
|
|
|
state.pendingForce = state.pendingForce || force;
|
2026-03-22 22:24:11 -05:00
|
|
|
state.pendingReorder = state.pendingReorder || reorder;
|
2026-03-05 00:04:26 -06:00
|
|
|
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",
|
2026-03-17 00:11:25 -05:00
|
|
|
country: state.filterCountry || "all",
|
2026-04-14 19:38:27 -05:00
|
|
|
group: state.filterGroup || "all",
|
2026-03-05 00:04:26 -06:00
|
|
|
});
|
|
|
|
|
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;
|
2026-03-17 00:42:57 -05:00
|
|
|
render(data, { reorder });
|
2026-04-19 00:20:35 -05:00
|
|
|
const pag = document.getElementById("pagination");
|
|
|
|
|
if (pag) pag.style.visibility = "";
|
2026-03-05 00:04:26 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error("load clients", err);
|
|
|
|
|
} finally {
|
|
|
|
|
state.isLoading = false;
|
2026-03-22 22:24:11 -05:00
|
|
|
if (state.pendingForce || state.pendingReorder) {
|
2026-03-05 00:04:26 -06:00
|
|
|
const shouldForce = state.pendingForce;
|
2026-03-22 22:24:11 -05:00
|
|
|
const shouldReorder = state.pendingReorder;
|
2026-03-05 00:04:26 -06:00
|
|
|
state.pendingForce = false;
|
2026-03-22 22:24:11 -05:00
|
|
|
state.pendingReorder = false;
|
|
|
|
|
loadWithOptions({ force: shouldForce, reorder: shouldReorder });
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 20:50:02 -06:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 14:30:43 +01:00
|
|
|
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") {
|
2026-03-22 22:24:11 -05:00
|
|
|
loadWithOptions({ force: false, reorder: true });
|
2026-03-22 23:21:27 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg && msg.type === "client_event") {
|
2026-04-13 20:02:58 -05:00
|
|
|
if (msg.event === "client_offline" && manuallyDisconnecting.has(msg.clientId)) {
|
|
|
|
|
manuallyDisconnecting.delete(msg.clientId);
|
|
|
|
|
loadWithOptions({ force: true });
|
|
|
|
|
} else {
|
|
|
|
|
moveClientCardImmediately(msg);
|
|
|
|
|
loadWithOptions({ force: false, reorder: true });
|
|
|
|
|
}
|
2026-03-17 14:30:43 +01:00
|
|
|
}
|
|
|
|
|
} 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(() => {
|
2026-03-22 22:24:11 -05:00
|
|
|
loadWithOptions({ force: false, reorder: true });
|
2026-03-17 14:30:43 +01:00
|
|
|
}, interval);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
|
2026-03-17 14:30:43 +01:00
|
|
|
connectDashboardWs();
|
|
|
|
|
const effectiveInterval = dashboardWsConnected
|
|
|
|
|
? FALLBACK_POLL_MS
|
|
|
|
|
: getConfiguredPollIntervalMs(intervalMs);
|
2026-03-05 00:04:26 -06:00
|
|
|
clearInterval(pollTimer);
|
|
|
|
|
pollTimer = setInterval(() => {
|
2026-03-22 22:24:11 -05:00
|
|
|
loadWithOptions({ force: false, reorder: true });
|
2026-03-06 20:50:02 -06:00
|
|
|
}, effectiveInterval);
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 20:02:58 -05:00
|
|
|
export function markManualDisconnect(clientId) {
|
|
|
|
|
manuallyDisconnecting.add(clientId);
|
|
|
|
|
setTimeout(() => manuallyDisconnecting.delete(clientId), 10000);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 23:27:57 -06:00
|
|
|
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" };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|