/* Sovran_SystemsOS Hub β€” Vanilla JS Frontend v7 β€” Status-only dashboard + Tech Support + Feature Manager */ "use strict"; 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; const CATEGORY_ORDER = [ "infrastructure", "bitcoin-base", "bitcoin-apps", "communication", "apps", "nostr", ]; const FEATURE_SUBCATEGORY_LABELS = { "infrastructure": "πŸ”§ Infrastructure", "bitcoin": "β‚Ώ Bitcoin", "communication": "πŸ’¬ Communication", "nostr": "πŸ“‘ Nostr", }; const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "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; let _supportTimerInt = null; let _supportEnabledAt = null; let _cachedExternalIp = null; // Feature Manager state 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 let _rebuildFeatureName = ""; let _rebuildIsEnabling = true; // ── DOM refs ────────────────────────────────────────────────────── const $tilesArea = document.getElementById("tiles-area"); const $sidebarSupport = document.getElementById("sidebar-support"); const $sidebarFeatures = document.getElementById("sidebar-features"); 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"); const $supportModal = document.getElementById("support-modal"); const $supportBody = document.getElementById("support-body"); const $supportCloseBtn = document.getElementById("support-close-btn"); // Feature Manager β€” rebuild modal 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 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 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 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"); // Port Requirements modal const $portReqModal = document.getElementById("port-requirements-modal"); const $portReqBody = document.getElementById("port-req-body"); const $portReqClose = document.getElementById("port-req-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } 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,"'"); } function linkify(str) { return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '$1'); } function formatDuration(seconds) { 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"; } // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options) { const res = await fetch(path, options || {}); if (!res.ok) { let detail = res.status + " " + res.statusText; try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} throw new Error(detail); } return res.json(); } // ── Render: initial build ───────────────────────────────────────── function buildTiles(services, categoryLabels) { _servicesCache = services; var grouped = {}; for (var i = 0; i < services.length; i++) { var svc = services[i]; // Support tiles go to the sidebar, not the main grid if (svc.category === "support" || svc.type === "support") { renderSidebarSupport(svc); continue; } var cat = svc.category || "other"; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(svc); } $tilesArea.innerHTML = ""; var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; }); Object.keys(grouped).forEach(function(k) { if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k); }); for (var j = 0; j < orderedKeys.length; j++) { var catKey = orderedKeys[j]; var entries = grouped[catKey]; if (!entries || entries.length === 0) continue; var label = categoryLabels[catKey] || catKey; var section = document.createElement("div"); section.className = "category-section"; section.dataset.category = catKey; section.innerHTML = '
' + escHtml(label) + '

'; var grid = section.querySelector(".tiles-grid"); for (var k = 0; k < entries.length; k++) { grid.appendChild(buildTile(entries[k])); } $tilesArea.appendChild(section); } if ($tilesArea.children.length === 0) { $tilesArea.innerHTML = '

No services configured.

'; } } function renderSidebarSupport(svc) { $sidebarSupport.innerHTML = ""; var btn = document.createElement("button"); btn.className = "sidebar-support-btn"; btn.innerHTML = 'πŸ›Ÿ' + '' + '' + escHtml(svc.name || "Tech Support") + '' + 'Click for help' + ''; btn.addEventListener("click", function() { openSupportModal(); }); $sidebarSupport.appendChild(btn); var hr = document.createElement("hr"); hr.className = "sidebar-divider"; $sidebarSupport.appendChild(hr); } function buildTile(svc) { var isSupport = svc.type === "support"; var sc = statusClass(svc.status); var st = statusText(svc.status, svc.enabled); var dis = !svc.enabled; var hasCreds = svc.has_credentials && svc.enabled; var tile = document.createElement("div"); tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); tile.dataset.unit = svc.unit; tile.dataset.tileId = tileId(svc); if (dis) tile.title = svc.name + " is not enabled in custom.nix"; if (isSupport) { tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
Click for help
'; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { openSupportModal(); }); return tile; } var infoBtn = hasCreds ? '' : ""; // Port requirements badge var ports = svc.port_requirements || []; var portsHtml = ""; if (ports.length > 0) { portsHtml = '
πŸ”ŒPorts: ' + ports.length + ' required
'; } tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + st + '
' + portsHtml; var infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { infoBtnEl.addEventListener("click", function(e) { e.stopPropagation(); openCredsModal(svc.unit, svc.name); }); } var portsEl = tile.querySelector(".tile-ports"); if (portsEl) { portsEl.style.cursor = "pointer"; portsEl.addEventListener("click", function(e) { e.stopPropagation(); openPortRequirementsModal(svc.name, ports, null); }); // Async: fetch port status and update badge summary if (ports.length > 0) { fetch("/api/ports/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ports: ports }), }) .then(function(r) { return r.json(); }) .then(function(data) { var listeningCount = 0; (data.ports || []).forEach(function(p) { if (p.status === "listening") listeningCount++; }); var total = ports.length; var labelEl = portsEl.querySelector(".tile-ports-label"); if (labelEl) { labelEl.classList.remove("tile-ports-label--loading"); if (listeningCount === total) { labelEl.className = "tile-ports-label tile-ports-all-ready"; labelEl.textContent = "Ports: " + total + "/" + total + " ready βœ“"; } else if (listeningCount > 0) { labelEl.className = "tile-ports-label tile-ports-partial"; labelEl.textContent = "Ports: " + listeningCount + "/" + total + " ready"; } else { labelEl.className = "tile-ports-label tile-ports-none-ready"; labelEl.textContent = "Ports: " + total + " required"; } } }) .catch(function() { // Leave badge as-is on error }); } } return tile; } // ── Render: live update ─────────────────────────────────────────── function updateTiles(services) { _servicesCache = services; for (var i = 0; i < services.length; i++) { var svc = services[i]; if (svc.type === "support") continue; var id = CSS.escape(tileId(svc)); var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); if (!tile) continue; var sc = statusClass(svc.status); var st = statusText(svc.status, svc.enabled); var dot = tile.querySelector(".status-dot"); var text = tile.querySelector(".status-text"); if (dot) dot.className = "status-dot " + sc; if (text) text.textContent = st; } } // ── Service polling ─────────────────────────────────────────────── var _firstLoad = true; async function refreshServices() { try { var 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 { var data = await apiFetch("/api/network"); if ($internalIp) $internalIp.textContent = data.internal_ip || "β€”"; if ($externalIp) $externalIp.textContent = data.external_ip || "β€”"; _cachedExternalIp = data.external_ip || "unavailable"; } catch (_) { if ($internalIp) $internalIp.textContent = "β€”"; if ($externalIp) $externalIp.textContent = "β€”"; } } // ── Update check ────────────────────────────────────────────────── async function checkUpdates() { try { var data = await apiFetch("/api/updates/check"); var 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 { var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit)); if (!data.credentials || data.credentials.length === 0) { $credsBody.innerHTML = '

No connection info available yet.

'; return; } var html = ""; for (var i = 0; i < data.credentials.length; i++) { var cred = data.credentials[i]; var id = "cred-" + Math.random().toString(36).substring(2, 8); var displayValue = linkify(cred.value); var qrBlock = ""; if (cred.qrcode) { qrBlock = '
QR Code for ' + escHtml(cred.label) + '
Scan with Zeus app on your phone
'; } html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
'; } if (unit === "matrix-synapse.service") { html += '
' + '' + '' + '
'; } $credsBody.innerHTML = html; $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { btn.addEventListener("click", function() { var target = document.getElementById(btn.dataset.target); if (!target) return; var text = target.textContent; function onSuccess() { btn.textContent = "Copied!"; btn.classList.add("copied"); setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); } function fallbackCopy() { var ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); onSuccess(); } catch (e) {} document.body.removeChild(ta); } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); } else { fallbackCopy(); } }); }); if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn"); if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); } } catch (err) { $credsBody.innerHTML = '

Could not load credentials.

'; } } function openMatrixCreateUserModal(unit, name) { if (!$credsBody) return; $credsBody.innerHTML = '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; document.getElementById("matrix-create-back-btn").addEventListener("click", function() { openCredsModal(unit, name); }); document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("matrix-create-submit-btn"); var resultEl = document.getElementById("matrix-create-result"); var username = (document.getElementById("matrix-new-username").value || "").trim(); var password = document.getElementById("matrix-new-password").value || ""; var isAdmin = document.getElementById("matrix-new-admin").checked; if (!username || !password) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Username and password are required."; return; } submitBtn.disabled = true; submitBtn.textContent = "Creating…"; resultEl.className = "matrix-form-result"; resultEl.textContent = ""; try { var resp = await apiFetch("/api/matrix/create-user", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, password: password, admin: isAdmin }) }); resultEl.className = "matrix-form-result success"; resultEl.textContent = "βœ… User @" + escHtml(resp.username) + " created successfully."; submitBtn.textContent = "Create User"; submitBtn.disabled = false; } catch (err) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "❌ " + (err.message || "Failed to create user."); submitBtn.textContent = "Create User"; submitBtn.disabled = false; } }); } function openMatrixChangePasswordModal(unit, name) { if (!$credsBody) return; $credsBody.innerHTML = '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { openCredsModal(unit, name); }); document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("matrix-chpw-submit-btn"); var resultEl = document.getElementById("matrix-chpw-result"); var username = (document.getElementById("matrix-chpw-username").value || "").trim(); var newPassword = document.getElementById("matrix-chpw-password").value || ""; if (!username || !newPassword) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Username and new password are required."; return; } submitBtn.disabled = true; submitBtn.textContent = "Changing…"; resultEl.className = "matrix-form-result"; resultEl.textContent = ""; try { var resp = await apiFetch("/api/matrix/change-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, new_password: newPassword }) }); resultEl.className = "matrix-form-result success"; resultEl.textContent = "βœ… Password for @" + escHtml(resp.username) + " changed successfully."; submitBtn.textContent = "Change Password"; submitBtn.disabled = false; } catch (err) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "❌ " + (err.message || "Failed to change password."); submitBtn.textContent = "Change Password"; submitBtn.disabled = false; } }); } function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } // ── Tech Support modal ──────────────────────────────────────────── async function openSupportModal() { if (!$supportModal) return; $supportModal.classList.add("open"); $supportBody.innerHTML = '

Checking support status…

'; try { var status = await apiFetch("/api/support/status"); if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); } else { renderSupportInactive(); } } catch (err) { $supportBody.innerHTML = '

Could not check support status.

'; } } function renderSupportInactive() { stopSupportTimer(); var ip = _cachedExternalIp || "loading…"; $supportBody.innerHTML = '
πŸ›Ÿ

Need help from Sovran Systems?

This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

Your IP' + escHtml(ip) + '
This IP will be shared with Sovran Systems support
What happens:
  1. Our public SSH key is added to your machine
  2. We connect and help fix the issue
  3. You click "End Session" to remove our access

You can revoke access at any time

'; document.getElementById("btn-support-enable").addEventListener("click", enableSupport); } function renderSupportActive() { var ip = _cachedExternalIp || "loading…"; $supportBody.innerHTML = '
πŸ”“

Support Access is Active

Sovran Systems can currently connect to your machine via SSH.

Your IP' + escHtml(ip) + '
Duration…

This will remove the SSH key immediately

'; document.getElementById("btn-support-disable").addEventListener("click", disableSupport); startSupportTimer(); } function renderSupportRemoved(verified) { stopSupportTimer(); var icon = verified ? "βœ…" : "⚠️"; var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; var vclass = verified ? "verified-gone" : "verify-warning"; var vlabel = verified ? "βœ“ Removed β€” No access" : "⚠ Verify by rebooting"; $supportBody.innerHTML = '
' + icon + '

Support Session Ended

' + escHtml(msg) + '

SSH Key Status:' + vlabel + '
'; document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); } async function enableSupport() { var btn = document.getElementById("btn-support-enable"); if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } try { await apiFetch("/api/support/enable", { method: "POST" }); 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"); if ($rebuildModal) $rebuildModal.classList.remove("open"); stopUpdatePoll(); stopRebuildPoll(); 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 = ""; $rebuildLog.style.display = "none"; } var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; var label = _rebuildFeatureName || "feature"; if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…"; 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"); // Delay first poll slightly to let the rebuild service start and clear stale log setTimeout(startRebuildPoll, 1500); } function closeRebuildModal() { if ($rebuildModal) $rebuildModal.classList.remove("open"); stopRebuildPoll(); } function appendRebuildLog(text) { if (!text) return; _rebuildLog += text; // Log is collected silently for error reports β€” not displayed to user } 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; } 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; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } } } function onRebuildDone(success) { if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); if ($rebuildClose) $rebuildClose.disabled = false; if (success) { if ($rebuildStatus) $rebuildStatus.textContent = "βœ“ Done"; // Auto-reload the page after a short delay so tiles and toggles reflect the new state setTimeout(function() { window.location.reload(); }, 1200); } else { if ($rebuildStatus) $rebuildStatus.textContent = "βœ— Something went wrong"; 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:

  1. A subdomain purchased on njal.la
  2. A Dynamic DNS record for it
' + '
' + '

β„Ή Paste the curl URL from your Njal.la dashboard\'s Dynamic record

' + npubField + '
'; document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; subdomain = subdomain.trim(); ddnsUrl = ddnsUrl.trim(); npub = npub.trim(); if (!subdomain) { alert("Please enter a subdomain."); return; } if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } var saveBtn = document.getElementById("domain-setup-save-btn"); saveBtn.disabled = true; saveBtn.textContent = "Saving…"; try { await apiFetch("/api/domains/set", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain_name: feat.domain_name, domain: subdomain, ddns_url: ddnsUrl, }), }); closeDomainSetupModal(); onSaved(npub); } catch (err) { saveBtn.disabled = false; saveBtn.textContent = "Save & Enable"; alert("Failed to save domain. Please try again."); } }); $domainSetupModal.classList.add("open"); } function closeDomainSetupModal() { if ($domainSetupModal) $domainSetupModal.classList.remove("open"); } // ── Port Requirements modal ─────────────────────────────────────── function openPortRequirementsModal(featureName, ports, onContinue) { if (!$portReqModal || !$portReqBody) return; var continueBtn = onContinue ? '' : ''; // Show loading state while fetching port status $portReqBody.innerHTML = '

Checking port status for ' + escHtml(featureName) + '…

' + '

Detecting which ports are open on this machine…

'; $portReqModal.classList.add("open"); // Fetch live port status from local system commands (no external calls) fetch("/api/ports/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ports: ports }), }) .then(function(r) { return r.json(); }) .then(function(data) { var internalIp = (data.internal_ip && data.internal_ip !== "unavailable") ? data.internal_ip : null; var portStatuses = {}; (data.ports || []).forEach(function(p) { portStatuses[p.port + "/" + p.protocol] = p.status; }); var rows = ports.map(function(p) { var key = p.port + "/" + p.protocol; var status = portStatuses[key] || "unknown"; var statusHtml; if (status === "listening") { statusHtml = '🟒 Listening'; } else if (status === "firewall_open") { statusHtml = '🟑 Open (idle)'; } else if (status === "closed") { statusHtml = 'πŸ”΄ Closed'; } else { statusHtml = 'βšͺ Unknown'; } return '' + '' + escHtml(p.port) + '' + '' + escHtml(p.protocol) + '' + '' + escHtml(p.description) + '' + '' + statusHtml + '' + ''; }).join(""); var ipLine = internalIp ? '

Forward each port below to this machine\'s internal IP: ' + escHtml(internalIp) + '

' : "

Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.

"; $portReqBody.innerHTML = '

Port Forwarding Required

' + '

For ' + escHtml(featureName) + " to work with clients outside your local network, " + "you must configure port forwarding in your router's admin panel.

" + ipLine + '' + '' + '' + rows + '' + '
Port(s)ProtocolPurposeStatus
' + "

How to verify: Router-side forwarding cannot be checked from inside your network. " + "To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " + "or check your router's port forwarding page.

" + '

β„Ή Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

' + '
' + '' + continueBtn + '
'; document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { closePortRequirementsModal(); }); if (onContinue) { document.getElementById("port-req-continue-btn").addEventListener("click", function() { closePortRequirementsModal(); onContinue(); }); } }) .catch(function() { // Fallback: show static table without status column if fetch fails var rows = ports.map(function(p) { return '' + escHtml(p.port) + '' + '' + escHtml(p.protocol) + '' + '' + escHtml(p.description) + ''; }).join(""); $portReqBody.innerHTML = '

Port Forwarding Required

' + '

For ' + escHtml(featureName) + ' to work with clients outside your local network, ' + 'you must configure port forwarding in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.

' + '' + '' + '' + rows + '' + '
Port(s)ProtocolPurpose
' + '

β„Ή Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

' + '
' + '' + continueBtn + '
'; document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { closePortRequirementsModal(); }); if (onContinue) { document.getElementById("port-req-continue-btn").addEventListener("click", function() { closePortRequirementsModal(); onContinue(); }); } }); } function closePortRequirementsModal() { if ($portReqModal) $portReqModal.classList.remove("open"); } if ($portReqClose) { $portReqClose.addEventListener("click", closePortRequirementsModal); } // ── Feature toggle logic ────────────────────────────────────────── async function performFeatureToggle(featId, enabled, extra) { // Look up feature name for the rebuild modal _rebuildIsEnabling = enabled; _rebuildFeatureName = featId; if (_featuresData) { var found = _featuresData.features.find(function(f) { return f.id === featId; }); if (found) _rebuildFeatureName = found.name; } try { var res = await fetch("/api/features/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), }); var body = await res.json(); if (!res.ok) { if (body && body.error === "domain_required") { alert("Domain not configured for this feature. Please configure it first."); } else { alert("Error: " + (body.detail || body.error || "Unknown error")); } loadFeatureManager(); return; } openRebuildModal(); } catch (err) { alert("Failed to toggle feature: " + err); loadFeatureManager(); } } function handleFeatureToggle(feat, newEnabled) { if (!newEnabled) { // Disable: ask confirmation openFeatureConfirm( "This will disable " + feat.name + ". The system will rebuild. Continue?", function() { performFeatureToggle(feat.id, false, {}); } ); return; } // Enabling var conflictNames = []; if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { feat.conflicts_with.forEach(function(cid) { var cf = _featuresData.features.find(function(f) { return f.id === cid; }); if (cf && cf.enabled) conflictNames.push(cf.name); }); } function proceedAfterPortCheck() { // Check SSL email first if (!_featuresData || !_featuresData.ssl_email_configured) { if (feat.needs_domain) { openSslEmailModal(function() { // After ssl email saved, check domain checkDomainAndEnable(feat, {}); }); return; } } if (feat.needs_domain && !feat.domain_configured) { checkDomainAndEnable(feat, {}); return; } if (feat.id === "haven") { var npub = ""; if (feat.extra_fields) { var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); if (ef) npub = ef.current_value || ""; } if (!npub) { // Need to collect npub via domain modal openDomainSetupModal(feat, function(collectedNpub) { performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); }); return; } } performFeatureToggle(feat.id, true, {}); } function proceedAfterConflictCheck() { // Show port requirements notification if the feature has extra port needs var ports = feat.port_requirements || []; if (ports.length > 0) { openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); } else { proceedAfterPortCheck(); } } if (conflictNames.length > 0) { openFeatureConfirm( "This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck ); } else { proceedAfterConflictCheck(); } } function checkDomainAndEnable(feat, extra) { openDomainSetupModal(feat, function(collectedNpub) { var extraData = {}; if (collectedNpub) extraData.nostr_npub = collectedNpub; performFeatureToggle(feat.id, true, extraData); }); } // ── Feature Manager rendering ───────────────────────────────────── async function loadFeatureManager() { try { var data = await apiFetch("/api/features"); _featuresData = data; renderFeatureManager(data); } catch (err) { console.warn("Failed to load features:", err); } } function renderFeatureManager(data) { // Remove old feature manager section if it exists var old = $sidebarFeatures.querySelector(".feature-manager-section"); if (old) old.parentNode.removeChild(old); var section = document.createElement("div"); section.className = "category-section feature-manager-section"; section.dataset.category = "feature-manager"; section.innerHTML = '
Feature Manager

'; // Group by sub-category var grouped = {}; for (var i = 0; i < data.features.length; i++) { var f = data.features[i]; var cat = f.category || "other"; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(f); } var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); Object.keys(grouped).forEach(function(k) { if (orderedCats.indexOf(k) === -1) orderedCats.push(k); }); for (var j = 0; j < orderedCats.length; j++) { var catKey = orderedCats[j]; var feats = grouped[catKey]; if (!feats || feats.length === 0) continue; var subcat = document.createElement("div"); subcat.className = "feature-subcategory"; var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; subcat.innerHTML = '
' + escHtml(subcatLabel) + '
'; var cardsWrap = document.createElement("div"); cardsWrap.className = "feature-cards-wrap"; for (var k = 0; k < feats.length; k++) { cardsWrap.appendChild(buildFeatureCard(feats[k])); } subcat.appendChild(cardsWrap); section.appendChild(subcat); } $sidebarFeatures.appendChild(section); } function buildFeatureCard(feat) { var card = document.createElement("div"); card.className = "feature-card"; var conflictHtml = ""; if (feat.conflicts_with && feat.conflicts_with.length > 0) { var conflictNames = feat.conflicts_with.map(function(cid) { if (!_featuresData) return cid; var cf = _featuresData.features.find(function(f) { return f.id === cid; }); return cf ? cf.name : cid; }); conflictHtml = '
⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
'; } var domainHtml = ""; if (feat.needs_domain) { if (feat.domain_configured) { domainHtml = '
🌐 Domain: Configured
'; } else { domainHtml = '
🌐 Domain: Not configured
'; } } var statusText = feat.enabled ? "Enabled" : "Disabled"; card.innerHTML = '
' + '
' + '
' + escHtml(feat.name) + '
' + '
' + escHtml(feat.description) + '
' + '
' + '' + '
' + domainHtml + conflictHtml + '
Status: ' + escHtml(statusText) + '
'; var toggle = card.querySelector(".feature-toggle-input"); var toggleLabel = card.querySelector(".feature-toggle"); toggle.addEventListener("change", function() { var newEnabled = toggle.checked; // Revert visually until confirmed toggle.checked = feat.enabled; if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } handleFeatureToggle(feat, newEnabled); }); return card; } // ── Event listeners ─────────────────────────────────────────────── if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); if ($refreshBtn) $refreshBtn.addEventListener("click", function() { 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 ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); // Rebuild modal if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); // Domain setup modal if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal); if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); }); // SSL Email modal if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal); if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal); if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); }); // Feature confirm modal if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm); if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm); if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); }); if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); // ── Init ────────────────────────────────────────────────────────── async function init() { try { var cfg = await apiFetch("/api/config"); if (cfg.category_order) { for (var i = 0; i < cfg.category_order.length; i++) { _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; } } var badge = document.getElementById("role-badge"); if (badge && cfg.role_label) badge.textContent = cfg.role_label; await refreshServices(); loadNetwork(); checkUpdates(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); if (cfg.feature_manager) { loadFeatureManager(); } } catch (_) { await refreshServices(); loadNetwork(); checkUpdates(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); } } document.addEventListener("DOMContentLoaded", init);