diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 6db8a1e..ddcd132 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,14 +1,14 @@ /* Sovran_SystemsOS Hub β Vanilla JS Frontend - v8 β Status-only dashboard + Tech Support + Feature Manager */ + v7 β Status-only dashboard + Tech Support + Feature Manager */ "use strict"; -var POLL_INTERVAL_SERVICES = 5000; -var POLL_INTERVAL_UPDATES = 1800000; -var UPDATE_POLL_INTERVAL = 2000; -var REBOOT_CHECK_INTERVAL = 5000; -var SUPPORT_TIMER_INTERVAL = 1000; +const POLL_INTERVAL_SERVICES = 5000; +const POLL_INTERVAL_UPDATES = 1800000; +const UPDATE_POLL_INTERVAL = 2000; +const REBOOT_CHECK_INTERVAL = 5000; +const SUPPORT_TIMER_INTERVAL = 1000; -var CATEGORY_ORDER = [ +const CATEGORY_ORDER = [ "infrastructure", "bitcoin-base", "bitcoin-apps", @@ -19,104 +19,97 @@ var CATEGORY_ORDER = [ "feature-manager", ]; -var FEATURE_SUBCATEGORY_LABELS = { +const FEATURE_SUBCATEGORY_LABELS = { "infrastructure": "π§ Infrastructure", "bitcoin": "βΏ Bitcoin", "communication": "π¬ Communication", "nostr": "π‘ Nostr", }; -var FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; +const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; -var FEATURE_UNIT_MAP = { - "rdp": "gnome-remote-desktop.service", - "haven": "haven-relay.service", - "element-calling": "livekit.service", - "mempool": "mempool-frontend.service", -}; - -var STATUS_LOADING_STATES = new Set([ +const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -var _servicesCache = []; -var _categoryLabels = {}; -var _updateLog = ""; -var _updatePollTimer = null; -var _updateLogOffset = 0; -var _serverWasDown = false; -var _updateFinished = false; -var _supportTimerInt = null; -var _supportEnabledAt = null; -var _cachedExternalIp = null; +let _servicesCache = []; +let _categoryLabels = {}; +let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; +let _serverWasDown = false; +let _updateFinished = false; +let _supportTimerInt = null; +let _supportEnabledAt = null; +let _cachedExternalIp = null; // Feature Manager state -var _featuresData = null; -var _rebuildLog = ""; -var _rebuildLogOffset = 0; -var _rebuildPollTimer = null; -var _rebuildFinished = false; -var _rebuildServerDown = false; -var _pendingToggle = null; +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm // ββ DOM refs ββββββββββββββββββββββββββββββββββββββββββββββββββββββ -var $tilesArea = document.getElementById("tiles-area"); -var $updateBtn = document.getElementById("btn-update"); -var $updateBadge = document.getElementById("update-badge"); -var $refreshBtn = document.getElementById("btn-refresh"); -var $internalIp = document.getElementById("ip-internal"); -var $externalIp = document.getElementById("ip-external"); +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"); -var $modal = document.getElementById("update-modal"); -var $modalSpinner = document.getElementById("modal-spinner"); -var $modalStatus = document.getElementById("modal-status"); -var $modalLog = document.getElementById("modal-log"); -var $btnReboot = document.getElementById("btn-reboot"); -var $btnSave = document.getElementById("btn-save-report"); -var $btnCloseModal = document.getElementById("btn-close-modal"); +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"); -var $rebootOverlay = document.getElementById("reboot-overlay"); +const $rebootOverlay = document.getElementById("reboot-overlay"); -var $credsModal = document.getElementById("creds-modal"); -var $credsTitle = document.getElementById("creds-modal-title"); -var $credsBody = document.getElementById("creds-body"); -var $credsCloseBtn = document.getElementById("creds-close-btn"); +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"); -var $supportModal = document.getElementById("support-modal"); -var $supportBody = document.getElementById("support-body"); -var $supportCloseBtn = document.getElementById("support-close-btn"); +const $supportModal = document.getElementById("support-modal"); +const $supportBody = document.getElementById("support-body"); +const $supportCloseBtn = document.getElementById("support-close-btn"); // Feature Manager β rebuild modal -var $rebuildModal = document.getElementById("rebuild-modal"); -var $rebuildSpinner = document.getElementById("rebuild-spinner"); -var $rebuildStatus = document.getElementById("rebuild-status"); -var $rebuildLogEl = document.getElementById("rebuild-log"); -var $rebuildReboot = document.getElementById("rebuild-reboot-btn"); -var $rebuildSave = document.getElementById("rebuild-save-report"); -var $rebuildClose = document.getElementById("rebuild-close-btn"); +const $rebuildModal = document.getElementById("rebuild-modal"); +const $rebuildSpinner = document.getElementById("rebuild-spinner"); +const $rebuildStatus = document.getElementById("rebuild-status"); +const $rebuildLog = document.getElementById("rebuild-log"); +const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +const $rebuildSave = document.getElementById("rebuild-save-report"); +const $rebuildClose = document.getElementById("rebuild-close-btn"); // Feature Manager β domain setup modal -var $domainSetupModal = document.getElementById("domain-setup-modal"); -var $domainSetupTitle = document.getElementById("domain-setup-title"); -var $domainSetupBody = document.getElementById("domain-setup-body"); -var $domainSetupClose = document.getElementById("domain-setup-close-btn"); +const $domainSetupModal = document.getElementById("domain-setup-modal"); +const $domainSetupTitle = document.getElementById("domain-setup-title"); +const $domainSetupBody = document.getElementById("domain-setup-body"); +const $domainSetupClose = document.getElementById("domain-setup-close-btn"); // Feature Manager β SSL email modal -var $sslEmailModal = document.getElementById("ssl-email-modal"); -var $sslEmailInput = document.getElementById("ssl-email-input"); -var $sslEmailSave = document.getElementById("ssl-email-save-btn"); -var $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); -var $sslEmailClose = document.getElementById("ssl-email-close-btn"); +const $sslEmailModal = document.getElementById("ssl-email-modal"); +const $sslEmailInput = document.getElementById("ssl-email-input"); +const $sslEmailSave = document.getElementById("ssl-email-save-btn"); +const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +const $sslEmailClose = document.getElementById("ssl-email-close-btn"); // Feature Manager β confirm modal -var $featureConfirmModal = document.getElementById("feature-confirm-modal"); -var $featureConfirmMsg = document.getElementById("feature-confirm-message"); -var $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); -var $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); -var $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); +const $featureConfirmModal = document.getElementById("feature-confirm-modal"); +const $featureConfirmMsg = document.getElementById("feature-confirm-message"); +const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ @@ -147,9 +140,9 @@ function linkify(str) { } function formatDuration(seconds) { - var h = Math.floor(seconds / 3600); - var m = Math.floor((seconds % 3600) / 60); - var s = Math.floor(seconds % 60); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); if (h > 0) return h + "h " + m + "m " + s + "s"; if (m > 0) return m + "m " + s + "s"; return s + "s"; @@ -158,7 +151,7 @@ function formatDuration(seconds) { // ββ Fetch wrappers ββββββββββββββββββββββββββββββββββββββββββββββββ async function apiFetch(path, options) { - var res = await fetch(path, options || {}); + const res = await fetch(path, options || {}); if (!res.ok) throw new Error(res.status + " " + res.statusText); return res.json(); } @@ -287,7 +280,7 @@ async function checkUpdates() { } catch (_) {} } -// ββ Credentials info modal ββββββββββοΏ½οΏ½βββββββββββββββββββββββββββββ +// ββ Credentials info modal ββββββββββββββββββββββββββββββββββββββββ async function openCredsModal(unit, name) { if (!$credsModal) return; @@ -375,4 +368,641 @@ async function enableSupport() { if (btn) { btn.disabled = true; btn.textContent = "Enablingβ¦"; } try { await apiFetch("/api/support/enable", { method: "POST" }); - var status = await apiFetch("/api/support/status"); \ No newline at end of file + var status = await apiFetch("/api/support/status"); + _supportEnabledAt = status.enabled_at; + renderSupportActive(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } + alert("Failed to enable support access. Please try again."); + } +} + +async function disableSupport() { + var btn = document.getElementById("btn-support-disable"); + if (btn) { btn.disabled = true; btn.textContent = "Removing keyβ¦"; } + try { + var result = await apiFetch("/api/support/disable", { method: "POST" }); + renderSupportRemoved(result.verified); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } + alert("Failed to disable support access. Please try again."); + } +} + +function startSupportTimer() { + stopSupportTimer(); + updateSupportTimer(); + _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); +} + +function stopSupportTimer() { + if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } +} + +function updateSupportTimer() { + var el = document.getElementById("support-timer"); + if (!el || !_supportEnabledAt) return; + var elapsed = (Date.now() / 1000) - _supportEnabledAt; + el.textContent = formatDuration(Math.max(0, elapsed)); +} + +function closeSupportModal() { + if ($supportModal) $supportModal.classList.remove("open"); + stopSupportTimer(); +} + +// ββ 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(function(response) { + if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); + return response.json(); + }) + .then(function(data) { + if (data.status === "already_running") appendLog("[Update already in progress, attachingβ¦]\n\n"); + if ($modalStatus) $modalStatus.textContent = "Updatingβ¦"; + startUpdatePoll(); + }) + .catch(function(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 { + var 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() { + var blob = new Blob([_updateLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ + +function doReboot() { + if ($modal) $modal.classList.remove("open"); + stopUpdatePoll(); + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + fetch("/api/reboot", { method: "POST" }).catch(function() {}); + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); +} + +function waitForServerReboot() { + fetch("/api/config", { cache: "no-store" }) + .then(function(res) { + if (res.ok) window.location.reload(); + else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }) + .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); +} + +// ββ Rebuild modal βββββββββββββββββββββββββββββββββββββββββββββββββ + +function openRebuildModal() { + if (!$rebuildModal) return; + _rebuildLog = ""; + _rebuildLogOffset = 0; + _rebuildServerDown = false; + _rebuildFinished = false; + if ($rebuildLog) $rebuildLog.textContent = ""; + if ($rebuildStatus) $rebuildStatus.textContent = "Rebuildingβ¦"; + if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); + if ($rebuildReboot) $rebuildReboot.style.display = "none"; + if ($rebuildSave) $rebuildSave.style.display = "none"; + if ($rebuildClose) $rebuildClose.disabled = true; + $rebuildModal.classList.add("open"); + startRebuildPoll(); +} + +function closeRebuildModal() { + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopRebuildPoll(); +} + +function appendRebuildLog(text) { + if (!text) return; + _rebuildLog += text; + if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } +} + +function startRebuildPoll() { + pollRebuildStatus(); + _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); +} + +function stopRebuildPoll() { + if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } +} + +async function pollRebuildStatus() { + if (_rebuildFinished) return; + try { + var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); + if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuildingβ¦"; } + if (data.log) appendRebuildLog(data.log); + _rebuildLogOffset = data.offset; + if (data.running) return; + _rebuildFinished = true; + stopRebuildPoll(); + onRebuildDone(data.result === "success"); + } catch (err) { + if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting β waiting for it to come backβ¦]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restartingβ¦"; } + } +} + +function onRebuildDone(success) { + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (success) { + if ($rebuildStatus) $rebuildStatus.textContent = "β Rebuild complete"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + // Refresh feature states + loadFeatureManager(); + } else { + if ($rebuildStatus) $rebuildStatus.textContent = "β Rebuild failed"; + if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + } +} + +function saveRebuildErrorReport() { + var blob = new Blob([_rebuildLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ββ Feature confirm modal βββββββββββββββββββββββββββββββββββββββββ + +function openFeatureConfirm(message, onConfirm) { + if (!$featureConfirmModal) return; + if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; + $featureConfirmModal.classList.add("open"); + // Replace ok handler + var newOk = $featureConfirmOk.cloneNode(true); + $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); + newOk.addEventListener("click", function() { + closeFeatureConfirm(); + onConfirm(); + }); +} + +function closeFeatureConfirm() { + if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); +} + +// ββ SSL Email modal βββββββββββββββββββββββββββββββββββββββββββββββ + +function openSslEmailModal(onSaved) { + if (!$sslEmailModal) return; + if ($sslEmailInput) $sslEmailInput.value = ""; + $sslEmailModal.classList.add("open"); + // Replace save handler + var newSave = $sslEmailSave.cloneNode(true); + $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); + newSave.addEventListener("click", async function() { + var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; + if (!email) { alert("Please enter an email address."); return; } + newSave.disabled = true; + newSave.textContent = "Savingβ¦"; + try { + await apiFetch("/api/domains/set-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }), + }); + closeSslEmailModal(); + onSaved(); + } catch (err) { + newSave.disabled = false; + newSave.textContent = "Save"; + alert("Failed to save email. Please try again."); + } + }); +} + +function closeSslEmailModal() { + if ($sslEmailModal) $sslEmailModal.classList.remove("open"); +} + +// ββ Domain Setup modal ββββββββββββββββββββββββββββββββββββββββββββ + +function openDomainSetupModal(feat, onSaved) { + if (!$domainSetupModal) return; + if ($domainSetupTitle) $domainSetupTitle.textContent = "π Domain Setup β " + feat.name; + + var npubField = ""; + if (feat.id === "haven") { + var currentNpub = ""; + if (feat.extra_fields && feat.extra_fields.length > 0) { + for (var i = 0; i < feat.extra_fields.length; i++) { + if (feat.extra_fields[i].id === "nostr_npub") { + currentNpub = feat.extra_fields[i].current_value || ""; + break; + } + } + } + npubField = '
'; + } + + $domainSetupBody.innerHTML = + 'Before continuing, you need:
βΉ Paste the curl URL from your Njal.la dashboard\'s Dynamic record