diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3120df6..01c38b5 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -72,6 +72,15 @@ function statusText(status, enabled) { return status; } +function escHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options = {}) { @@ -107,4 +116,428 @@ function buildTiles(services, categoryLabels) { const section = document.createElement("div"); section.className = "category-section"; - section.dataset.category \ No newline at end of file + section.dataset.category = catKey; + + section.innerHTML = ` +
${escHtml(label)}
+
+
+ `; + + const grid = section.querySelector(".tiles-grid"); + for (const svc of entries) { + grid.appendChild(buildTile(svc)); + } + + $tilesArea.appendChild(section); + } + + if ($tilesArea.children.length === 0) { + $tilesArea.innerHTML = `

No services configured.

`; + } +} + +function buildTile(svc) { + const sc = statusClass(svc.status); + const st = statusText(svc.status, svc.enabled); + const dis = !svc.enabled; + const isOn = svc.status === "active"; + const hasCreds = svc.has_credentials; + + const tile = document.createElement("div"); + tile.className = "service-tile" + (dis ? " disabled" : ""); + tile.dataset.unit = svc.unit; + if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; + + // Info button (only if service has credentials) + const infoBtn = hasCreds + ? `` + : ""; + + tile.innerHTML = ` + ${infoBtn} + ${escHtml(svc.name)} + +
${escHtml(svc.name)}
+
+ + ${escHtml(st)} +
+
+
+ + +
+ `; + + // Info button click handler + const infoBtnEl = tile.querySelector(".tile-info-btn"); + if (infoBtnEl) { + infoBtnEl.addEventListener("click", (e) => { + e.stopPropagation(); + openCredsModal(svc.unit, svc.name); + }); + } + + const chk = tile.querySelector(".tile-toggle"); + if (!dis) { + chk.addEventListener("change", async (e) => { + const action = e.target.checked ? "start" : "stop"; + chk.disabled = true; + try { + await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" }); + } catch (_) {} + setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); + }); + } + + const restartBtn = tile.querySelector(".tile-restart-btn"); + if (!dis) { + restartBtn.addEventListener("click", async () => { + restartBtn.disabled = true; + try { + await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" }); + } catch (_) {} + setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); + }); + } + + return tile; +} + +// ── Render: live update (no DOM rebuild) ────────────────────────── + +function updateTiles(services) { + _servicesCache = services; + + for (const svc of services) { + const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`); + if (!tile) continue; + + const sc = statusClass(svc.status); + const st = statusText(svc.status, svc.enabled); + + const dot = tile.querySelector(".status-dot"); + const text = tile.querySelector(".status-text"); + const chk = tile.querySelector(".tile-toggle"); + + if (dot) { dot.className = `status-dot ${sc}`; } + if (text) { text.textContent = st; } + if (chk && !chk.disabled) { + chk.checked = svc.status === "active"; + } + } +} + +// ── Service polling ─────────────────────────────────────────────── + +let _firstLoad = true; + +async function refreshServices() { + try { + const services = await apiFetch("/api/services"); + if (_firstLoad) { + buildTiles(services, _categoryLabels); + _firstLoad = false; + } else { + updateTiles(services); + } + } catch (err) { + console.warn("Failed to fetch services:", err); + } +} + +// ── Network IPs ─────────────────────────────────────────────────── + +async function loadNetwork() { + try { + const data = await apiFetch("/api/network"); + if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; + if ($externalIp) $externalIp.textContent = data.external_ip || "—"; + } catch (_) { + if ($internalIp) $internalIp.textContent = "—"; + if ($externalIp) $externalIp.textContent = "—"; + } +} + +// ── Update check ────────────────────────────────────────────────── + +async function checkUpdates() { + try { + const data = await apiFetch("/api/updates/check"); + const hasUpdates = !!data.available; + if ($updateBadge) { + $updateBadge.classList.toggle("visible", hasUpdates); + } + if ($updateBtn) { + $updateBtn.classList.toggle("has-updates", hasUpdates); + } + } catch (_) {} +} + +// ── Credentials info modal ──────────────────────────────────────── + +async function openCredsModal(unit, name) { + if (!$credsModal) return; + + if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; + if ($credsBody) $credsBody.innerHTML = '

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 += ` +
+
${escHtml(cred.label)}
+
+
${escHtml(cred.value)}
+ +
+
+ `; + } + $credsBody.innerHTML = html; + + // Attach copy handlers + $credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => { + btn.addEventListener("click", () => { + const target = document.getElementById(btn.dataset.target); + if (target) { + navigator.clipboard.writeText(target.textContent).then(() => { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { + btn.textContent = "Copy"; + btn.classList.remove("copied"); + }, 1500); + }).catch(() => {}); + } + }); + }); + + } catch (err) { + $credsBody.innerHTML = '

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); \ No newline at end of file