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

Before continuing, you need:

  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"); +} + +// ── Feature toggle logic ────────────────────────────────────────── + +async function performFeatureToggle(featId, enabled, extra) { + 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 proceedAfterConflictCheck() { + // 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, {}); + } + + 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 = $tilesArea.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); + } + + $tilesArea.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);