/* Sovran_SystemsOS Hub — Vanilla JS Frontend */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts const CATEGORY_ORDER = [ "infrastructure", "bitcoin-base", "bitcoin-apps", "communication", "apps", "nostr", ]; const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); // ── State ───────────────────────────────────────────────────────── let _servicesCache = []; let _categoryLabels = {}; let _updateLog = ""; let _updatePollTimer = null; let _updateLogOffset = 0; let _serverWasDown = false; let _updateFinished = false; // ── DOM refs ────────────────────────────────────────────────────── const $tilesArea = document.getElementById("tiles-area"); const $updateBtn = document.getElementById("btn-update"); const $updateBadge = document.getElementById("update-badge"); const $refreshBtn = document.getElementById("btn-refresh"); const $internalIp = document.getElementById("ip-internal"); const $externalIp = document.getElementById("ip-external"); const $modal = document.getElementById("update-modal"); const $modalSpinner = document.getElementById("modal-spinner"); const $modalStatus = document.getElementById("modal-status"); const $modalLog = document.getElementById("modal-log"); const $btnReboot = document.getElementById("btn-reboot"); const $btnSave = document.getElementById("btn-save-report"); const $btnCloseModal = document.getElementById("btn-close-modal"); const $rebootOverlay = document.getElementById("reboot-overlay"); const $credsModal = document.getElementById("creds-modal"); const $credsTitle = document.getElementById("creds-modal-title"); const $credsBody = document.getElementById("creds-body"); const $credsCloseBtn = document.getElementById("creds-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── function statusClass(status) { if (!status) return "unknown"; if (status === "active") return "active"; if (status === "inactive") return "inactive"; if (status === "failed") return "failed"; if (status === "disabled") return "disabled"; if (STATUS_LOADING_STATES.has(status)) return "loading"; return "unknown"; } function statusText(status, enabled) { if (!enabled) return "disabled"; if (!status || status === "unknown") return "unknown"; return status; } function escHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options = {}) { const res = await fetch(path, options); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } // ── Render: initial build ───────────────────────────────────────── function buildTiles(services, categoryLabels) { _servicesCache = services; const grouped = {}; for (const svc of services) { const cat = svc.category || "other"; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(svc); } $tilesArea.innerHTML = ""; const orderedKeys = [ ...CATEGORY_ORDER.filter(k => grouped[k]), ...Object.keys(grouped).filter(k => !CATEGORY_ORDER.includes(k)), ]; for (const catKey of orderedKeys) { const entries = grouped[catKey]; if (!entries || entries.length === 0) continue; const label = categoryLabels[catKey] || catKey; const section = document.createElement("div"); section.className = "category-section"; section.dataset.category = catKey; section.innerHTML = `
No services configured.
Loading…
'; $credsModal.classList.add("open"); try { const data = await apiFetch(`/api/credentials/${encodeURIComponent(unit)}`); if (!data.credentials || data.credentials.length === 0) { $credsBody.innerHTML = 'No connection info available yet.
'; return; } let html = ""; for (const cred of data.credentials) { const id = "cred-" + Math.random().toString(36).substring(2, 8); html += `Could not load credentials.
'; } } function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } // ── Update modal ────────────────────────────────────────────────── function openUpdateModal() { if (!$modal) return; _updateLog = ""; _updateLogOffset = 0; _serverWasDown = false; _updateFinished = false; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($btnReboot) { $btnReboot.style.display = "none"; } if ($btnSave) { $btnSave.style.display = "none"; } if ($btnCloseModal) { $btnCloseModal.disabled = true; } $modal.classList.add("open"); startUpdate(); } function closeUpdateModal() { if (!$modal) return; $modal.classList.remove("open"); stopUpdatePoll(); } function appendLog(text) { if (!text) return; _updateLog += text; if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } } function startUpdate() { fetch("/api/updates/run", { method: "POST" }) .then(response => { if (!response.ok) { return response.text().then(t => { throw new Error(t); }); } return response.json(); }) .then(data => { if (data.status === "already_running") { appendLog("[Update already in progress, attaching…]\n\n"); } if ($modalStatus) $modalStatus.textContent = "Updating…"; startUpdatePoll(); }) .catch(err => { appendLog(`[Error: failed to start update — ${err}]\n`); onUpdateDone(false); }); } function startUpdatePoll() { pollUpdateStatus(); _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); } function stopUpdatePoll() { if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } } async function pollUpdateStatus() { if (_updateFinished) return; try { const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } if (data.log) { appendLog(data.log); } _updateLogOffset = data.offset; if (data.running) { return; } _updateFinished = true; stopUpdatePoll(); if (data.result === "success") { onUpdateDone(true); } else { onUpdateDone(false); } } catch (err) { if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } } } function onUpdateDone(success) { if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($btnCloseModal) $btnCloseModal.disabled = false; if (success) { if ($modalStatus) $modalStatus.textContent = "✓ Update complete"; if ($btnReboot) $btnReboot.style.display = "inline-flex"; } else { if ($modalStatus) $modalStatus.textContent = "✗ Update failed"; if ($btnSave) $btnSave.style.display = "inline-flex"; if ($btnReboot) $btnReboot.style.display = "inline-flex"; } } function saveErrorReport() { const blob = new Blob([_updateLog], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ── Reboot with confirmation overlay ────────────────────────────── function doReboot() { if ($modal) $modal.classList.remove("open"); stopUpdatePoll(); if ($rebootOverlay) $rebootOverlay.classList.add("visible"); fetch("/api/reboot", { method: "POST" }).catch(() => {}); setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); } function waitForServerReboot() { fetch("/api/config", { cache: "no-store" }) .then(res => { if (res.ok) { window.location.reload(); } else { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); } }) .catch(() => { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); } // ── Event listeners ─────────────────────────────────────────────── if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices()); if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); if ($btnReboot) $btnReboot.addEventListener("click", doReboot); if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); if ($modal) { $modal.addEventListener("click", (e) => { if (e.target === $modal) closeUpdateModal(); }); } if ($credsModal) { $credsModal.addEventListener("click", (e) => { if (e.target === $credsModal) closeCredsModal(); }); } // ── Init ────────────────────────────────────────────────────────── async function init() { try { const cfg = await apiFetch("/api/config"); if (cfg.category_order) { for (const [key, label] of cfg.category_order) { _categoryLabels[key] = label; } } const badge = document.getElementById("role-badge"); if (badge && cfg.role_label) badge.textContent = cfg.role_label; } catch (_) {} await refreshServices(); loadNetwork(); checkUpdates(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); } document.addEventListener("DOMContentLoaded", init);