/* 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 _supportStatus = null; // last fetched /api/support/status payload let _walletUnlockTimerInt = 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"); // System status banner // (removed β€” health is now shown per-tile via the composite health field) // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } function statusClass(health) { if (!health) return "unknown"; if (health === "healthy") return "active"; if (health === "needs_attention") return "needs-attention"; if (health === "active") return "active"; // backwards compat if (health === "inactive") return "inactive"; if (health === "failed") return "failed"; if (health === "disabled") return "disabled"; if (STATUS_LOADING_STATES.has(health)) return "loading"; return "unknown"; } function statusText(health, enabled) { if (!enabled) return "Disabled"; if (health === "healthy") return "Active"; if (health === "needs_attention") return "Needs Attention"; if (health === "active") return "Active"; if (health === "inactive") return "Inactive"; if (health === "failed") return "Failed"; if (!health || health === "unknown") return "Unknown"; if (STATUS_LOADING_STATES.has(health)) return health; return health; } 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 = {}; var supportServices = []; 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") { supportServices.push(svc); continue; } var cat = svc.category || "other"; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(svc); } renderSidebarSupport(supportServices); $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(supportServices) { $sidebarSupport.innerHTML = ""; for (var i = 0; i < supportServices.length; i++) { var svc = supportServices[i]; 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); } if (supportServices.length > 0) { var hr = document.createElement("hr"); hr.className = "sidebar-divider"; $sidebarSupport.appendChild(hr); } } function buildTile(svc) { var isSupport = svc.type === "support"; var sc = statusClass(svc.health || svc.status); var st = statusText(svc.health || svc.status, svc.enabled); var dis = !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; } tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + st + '
'; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { openServiceDetailModal(svc.unit, svc.name); }); 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.health || svc.status); var st = statusText(svc.health || 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 (_) {} } // ── Service detail modal ────────────────────────────────────────── function _renderCredsHtml(credentials, unit) { var html = ""; for (var i = 0; i < credentials.length; i++) { var cred = 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 + '
'; } return html; } function _attachCopyHandlers(container) { container.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(); } }); }); } async function openServiceDetailModal(unit, name) { if (!$credsModal) return; if ($credsTitle) $credsTitle.textContent = name; if ($credsBody) $credsBody.innerHTML = '

Loading…

'; $credsModal.classList.add("open"); try { var data = await apiFetch("/api/service-detail/" + encodeURIComponent(unit)); var html = ""; // Section A: Description if (data.description) { html += '
' + '

' + escHtml(data.description) + '

' + '
'; } // Section B: Status var sc = statusClass(data.health || data.status); var st = statusText(data.health || data.status, data.enabled); html += '
' + '
Status
' + '
' + '' + '' + escHtml(st) + '' + '
' + '
'; // Section C: Ports (only if service has port_requirements) if (data.port_statuses && data.port_statuses.length > 0) { var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; }); var portTableRows = ""; data.port_statuses.forEach(function(p) { var statusIcon, statusClass2; if (p.status === "listening") { statusIcon = "βœ… Open"; statusClass2 = "port-status-listening"; } else if (p.status === "firewall_open") { statusIcon = "🟑 Firewall open"; statusClass2 = "port-status-open"; } else if (p.status === "closed") { statusIcon = "πŸ”΄ Closed"; statusClass2 = "port-status-closed"; } else { statusIcon = "β€” Unknown"; statusClass2 = "port-status-unknown"; } var desc = p.description; var portNum = parseInt(p.port, 10); if (portNum === 80 || portNum === 443) { desc += " (shared β€” all services)"; } portTableRows += '' + '' + escHtml(p.port) + '' + '' + escHtml(p.protocol) + '' + '' + escHtml(desc) + '' + '' + statusIcon + '' + ''; }); var troubleshootHtml = ""; if (anyPortClosed) { var sharedPorts = []; var specificPorts = []; data.port_statuses.forEach(function(p) { if (p.status === "closed") { var portNum = parseInt(p.port, 10); if (portNum === 80 || portNum === 443) { sharedPorts.push(p); } else { specificPorts.push(p); } } }); var troubleParts = []; if (sharedPorts.length > 0) { troubleParts.push( '⚠️ Ports 80 and 443 need to be forwarded on your router.' + '

These are shared system ports β€” you only need to set them up once and they cover all your domain-based services ' + '(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).

' + '

If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:

' + '
    ' + '
  1. Log into your router\'s admin panel (usually http://192.168.1.1)
  2. ' + '
  3. Find the Port Forwarding section
  4. ' + '
  5. Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "β€”") + '
  6. ' + '
  7. Save your router settings
  8. ' + '
' + '

πŸ’‘ Once these two ports are forwarded, you won\'t see this warning on any service again.

' ); } if (specificPorts.length > 0) { var portList = specificPorts.map(function(p) { return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') β€” ' + escHtml(p.description); }).join('
'); troubleParts.push( '⚠️ This service requires additional ports to be forwarded:' + '

' + portList + '

' + '
    ' + '
  1. Log into your router\'s admin panel
  2. ' + '
  3. Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "β€”") + '
  4. ' + '
  5. Save your router settings
  6. ' + '
' ); } troubleshootHtml = '
' + troubleParts.join('
') + '
'; } html += '
' + '
Port Status
' + '' + '' + '' + '' + '' + portTableRows + '' + '
PortProtocolDescriptionStatus
' + troubleshootHtml + '
'; } // Section D: Domain (only if service needs_domain) if (data.needs_domain) { var domainStatusHtml = ""; var ds = data.domain_status || {}; var domainBadge = ""; if (data.domain) { if (ds.status === "connected") { domainBadge = 'βœ“ ' + escHtml(data.domain) + ''; } else if (ds.status === "dns_mismatch") { domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch)'; domainStatusHtml = '
' + '⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.' + '

This usually means the DNS record needs to be updated:

' + '
    ' + '
  1. Go to njal.la and log into your account
  2. ' + '
  3. Find your domain and check the Dynamic DNS record
  4. ' + '
  5. Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "β€”") + '
  6. ' + '
  7. If you set up a DDNS curl command during onboarding, verify it\'s running correctly
  8. ' + '
' + '
'; } else if (ds.status === "unresolvable") { domainBadge = 'βœ— ' + escHtml(data.domain) + ' (DNS error)'; domainStatusHtml = '
' + '⚠️ This domain cannot be resolved. DNS is not configured yet.' + '

Let\'s get it set up:

' + '
    ' + '
  1. Go to njal.la and log into your account
  2. ' + '
  3. Find the domain you purchased for this service
  4. ' + '
  5. Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "β€”") + '
  6. ' + '
  7. Copy the DDNS curl command from Njal.la\'s dashboard
  8. ' + '
  9. You can re-enter it in the Feature Manager to update your configuration
  10. ' + '
' + '
'; } else { domainBadge = '' + escHtml(data.domain) + ''; } } else { domainBadge = 'Not configured'; domainStatusHtml = '
' + '⚠️ No domain has been configured for this service yet.' + '

To get this service working:

' + '
    ' + '
  1. Purchase a subdomain at njal.la (if you haven\'t already)
  2. ' + '
  3. Go to the Feature Manager in the sidebar
  4. ' + '
  5. Find this service and configure your domain through the setup wizard
  6. ' + '
' + '
'; } html += '
' + '
Domain
' + domainBadge + domainStatusHtml + '
'; } // Section E: Credentials & Links if (data.has_credentials && data.credentials && data.credentials.length > 0) { html += '
' + '
Credentials & Access
' + _renderCredsHtml(data.credentials, unit) + (unit === "matrix-synapse.service" ? '
' + '' + '' + '
' : "") + '
'; } else if (!data.enabled) { html += '
' + '

This service is not enabled in your configuration. You can enable it from the Feature Manager in the sidebar.

' + '
'; } $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); 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) { if ($credsBody) $credsBody.innerHTML = '

Could not load service details.

'; } } // ── 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 = _renderCredsHtml(data.credentials, unit); if (unit === "matrix-synapse.service") { html += '
' + '' + '' + '
'; } $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); 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() { openServiceDetailModal(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() { openServiceDetailModal(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"); _supportStatus = status; if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); } 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
', '
', '
', '
πŸ”’Wallet Protection
', '

Wallet files (LND, Sparrow, Bisq) are protected by default. Support staff cannot access your private keys unless you explicitly grant access.

', '
', '
What happens:
    ', '
  1. A restricted sovran-support user is created with limited access
  2. ', '
  3. Our SSH key is added only to that restricted account
  4. ', '
  5. Wallet files are locked via access controls β€” not visible to support
  6. ', '
  7. You control if and when wallet access is granted (time-limited)
  8. ', '
  9. All session events are logged for your audit
  10. ', '
', '', '

You can revoke access at any time. Wallet files are protected unless you unlock them.

', '
', ].join(""); document.getElementById("btn-support-enable").addEventListener("click", enableSupport); } function renderSupportActive(status) { var ip = _cachedExternalIp || "loading…"; var walletProtected = status && status.wallet_protected; var walletUnlocked = status && status.wallet_unlocked; var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : ""; var protectedPaths = (status && status.protected_paths && status.protected_paths.length) ? status.protected_paths : []; var walletSection; if (walletProtected) { if (walletUnlocked) { walletSection = [ '
', '
πŸ”“Wallet Access: UNLOCKED
', '

You have granted support temporary access to wallet files' + (unlockUntil ? ' until ' + escHtml(unlockUntil) + '' : '') + '.

', '', '
', ].join(""); } else { var pathList = protectedPaths.length ? '' : ''; walletSection = [ '
', '
πŸ”’Wallet Files: Protected
', '

Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.

', pathList, '
', '', '', '
', '
', ].join(""); } } else { walletSection = [ '
', '
⚠️Wallet Protection Unavailable
', '

The restricted support user could not be created. Support is running with root access β€” wallet files may be accessible. End the session if you are concerned.

', '
', ].join(""); } $supportBody.innerHTML = [ '
', '
πŸ”“
', '

Support Access is Active

', '

Sovran Systems can currently connect to your machine via SSH.

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

This will remove the SSH key and revoke all wallet access immediately.

', '', '
', '', ].join(""); document.getElementById("btn-support-disable").addEventListener("click", disableSupport); document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog); if (walletProtected && !walletUnlocked) { document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock); } if (walletProtected && walletUnlocked) { document.getElementById("btn-wallet-lock").addEventListener("click", walletLock); } startSupportTimer(); if (walletUnlocked && status.wallet_unlocked_until) { startWalletUnlockTimer(status.wallet_unlocked_until); } } function renderSupportRemoved(verified) { stopSupportTimer(); stopWalletUnlockTimer(); 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"); _supportStatus = status; _supportEnabledAt = status.enabled_at; renderSupportActive(status); } 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."); } } async function walletUnlock() { var btn = document.getElementById("btn-wallet-unlock"); var sel = document.getElementById("wallet-unlock-duration"); var duration = sel ? parseInt(sel.value, 10) : 3600; if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; } try { var result = await apiFetch("/api/support/wallet-unlock", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ duration: duration }), }); var status = await apiFetch("/api/support/status"); _supportStatus = status; renderSupportActive(status); } catch (err) { if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; } alert("Failed to unlock wallet access: " + (err.message || "Unknown error")); } } async function walletLock() { var btn = document.getElementById("btn-wallet-lock"); if (btn) { btn.disabled = true; btn.textContent = "Locking…"; } try { await apiFetch("/api/support/wallet-lock", { method: "POST" }); var status = await apiFetch("/api/support/status"); _supportStatus = status; renderSupportActive(status); } catch (err) { if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; } alert("Failed to re-lock wallet: " + (err.message || "Unknown error")); } } async function toggleAuditLog() { var container = document.getElementById("support-audit-container"); if (!container) return; if (container.style.display !== "none") { container.style.display = "none"; return; } container.style.display = "block"; container.innerHTML = '

Loading audit log…

'; try { var data = await apiFetch("/api/support/audit-log"); if (!data.entries || data.entries.length === 0) { container.innerHTML = '

No audit events recorded yet.

'; } else { container.innerHTML = '
' + data.entries.map(function(e) { return '
' + escHtml(e) + '
'; }).join("") + '
'; } } catch (err) { container.innerHTML = '

Could not load audit log.

'; } } 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 startWalletUnlockTimer(expiresAt) { stopWalletUnlockTimer(); _walletUnlockTimerInt = setInterval(function() { if (Date.now() / 1000 >= expiresAt) { stopWalletUnlockTimer(); // Refresh the support modal to show re-locked state apiFetch("/api/support/status").then(function(status) { _supportStatus = status; renderSupportActive(status); }).catch(function() {}); } }, 10000); } function stopWalletUnlockTimer() { if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; } } function closeSupportModal() { if ($supportModal) $supportModal.classList.remove("open"); stopSupportTimer(); stopWalletUnlockTimer(); } // ── 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); // After rendering, do a batch domain check for all features that have a configured domain _checkFeatureManagerDomains(data); } catch (err) { console.warn("Failed to load features:", err); } } function _checkFeatureManagerDomains(data) { // Collect all features with a configured domain var featsWithDomain = (data.features || []).filter(function(f) { return f.needs_domain && f.domain_configured; }); if (!featsWithDomain.length) return; // Get the actual domain values from /api/domains/status, then check them fetch("/api/domains/status") .then(function(r) { return r.json(); }) .then(function(statusData) { var domainFileMap = statusData.domains || {}; // Build list of domains to check and a map from domain value β†’ feature id var domainsToCheck = []; var domainToFeatIds = {}; featsWithDomain.forEach(function(feat) { var domainName = feat.domain_name; var domainVal = domainName ? domainFileMap[domainName] : null; if (domainVal) { domainsToCheck.push(domainVal); if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = []; domainToFeatIds[domainVal].push(feat.id); } else { // Domain file missing β€” update badge to warn _updateFeatureDomainBadge(feat.id, null, "unresolvable"); } }); if (!domainsToCheck.length) return; return fetch("/api/domains/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domains: domainsToCheck }), }) .then(function(r) { return r.json(); }) .then(function(checkData) { (checkData.domains || []).forEach(function(d) { var featIds = domainToFeatIds[d.domain] || []; featIds.forEach(function(featId) { _updateFeatureDomainBadge(featId, d.domain, d.status); }); }); }); }) .catch(function() {}); } function _updateFeatureDomainBadge(featId, domainVal, status) { var section = $sidebarFeatures.querySelector(".feature-manager-section"); if (!section) return; // Find the card β€” cards don't have a data-feat-id, so find via name match var badges = section.querySelectorAll(".feature-domain-badge.configured"); badges.forEach(function(badge) { var domainNameAttr = badge.getAttribute("data-domain-name"); // Match by domain_name attribute β€” we need to look up the feat's domain_name var feat = _featuresData && _featuresData.features ? _featuresData.features.find(function(f) { return f.id === featId; }) : null; if (!feat) return; if (domainNameAttr !== (feat.domain_name || "")) return; var lbl = badge.querySelector(".feature-domain-label"); if (!lbl) return; lbl.classList.remove("feature-domain-label--checking"); if (status === "connected") { lbl.className = "feature-domain-label feature-domain-label--ok"; lbl.textContent = (domainVal || "Domain") + " βœ“"; } else if (status === "dns_mismatch") { lbl.className = "feature-domain-label feature-domain-label--warn"; lbl.textContent = (domainVal || "Domain") + " (IP mismatch)"; } else if (status === "unresolvable") { lbl.className = "feature-domain-label feature-domain-label--error"; lbl.textContent = (domainVal || "Domain") + " (DNS error)"; } else { lbl.className = "feature-domain-label feature-domain-label--warn"; lbl.textContent = (domainVal || "Domain") + " (unknown)"; } }); } 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: Checking\u2026' + '
'; } 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() { // Check onboarding status first β€” redirect to wizard if not complete try { var onboardingStatus = await apiFetch("/api/onboarding/status"); if (!onboardingStatus.complete) { window.location.href = "/onboarding"; return; } } catch (_) { // If we can't reach the endpoint, continue to normal dashboard } 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);