diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 79dbf4e..b37132d 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -378,8 +378,6 @@ def _file_hash(filename: str) -> str: except FileNotFoundError: return "0" -_APP_JS_HASH = _file_hash("app.js") -_STYLE_CSS_HASH = _file_hash("style.css") _ONBOARDING_JS_HASH = _file_hash("onboarding.js") # ── Update check helpers ────────────────────────────────────────── @@ -1137,8 +1135,6 @@ def _verify_support_removed() -> bool: async def index(request: Request): return templates.TemplateResponse("index.html", { "request": request, - "app_js_hash": _APP_JS_HASH, - "style_css_hash": _STYLE_CSS_HASH, }) @@ -1147,7 +1143,6 @@ async def onboarding(request: Request): return templates.TemplateResponse("onboarding.html", { "request": request, "onboarding_js_hash": _ONBOARDING_JS_HASH, - "style_css_hash": _STYLE_CSS_HASH, }) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js deleted file mode 100644 index 3f19269..0000000 --- a/app/sovran_systemsos_web/static/app.js +++ /dev/null @@ -1,1929 +0,0 @@ -/* 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, svc.icon); - }); - - 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, icon) { - if (!$credsModal) return; - if ($credsTitle) $credsTitle.textContent = name; - if ($credsBody) $credsBody.innerHTML = '

Loading…

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

' + escHtml(data.description) + '

' + - '
'; - } - - // Section B: Status - // When a feature override is present, use the feature's enabled state so the - // modal matches what the dashboard tile shows (feature toggle is authoritative). - var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled; - var effectiveHealth = data.feature && !data.feature.enabled - ? "disabled" - : (data.health || data.status); - var sc = statusClass(effectiveHealth); - var st = statusText(effectiveHealth, effectiveEnabled); - 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 && !data.feature) { - html += '
' + - '

This service is not enabled in your configuration.

' + - '
'; - } - - // Section F: Addon Feature toggle - if (data.feature) { - var feat = data.feature; - // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state - if (!_featuresData) { - _featuresData = { features: [feat], ssl_email_configured: false }; - } else { - var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); - if (fidx >= 0) { _featuresData.features[fidx] = feat; } - else { _featuresData.features.push(feat); } - } - var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; - var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; - var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; - var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; - - // Section title: use a more specific label for mutually-exclusive Bitcoin node features - var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core") - ? "\u20BF Bitcoin Node Selection" - : "\uD83D\uDD27 Addon Feature"; - - // Description: prefer the feature's own description over a generic fallback - var addonDesc = feat.description - ? feat.description - : "This is an optional addon feature. You can enable or disable it at any time."; - - // Conflicts warning: list mutually-exclusive feature names when present - var conflictsHtml = ""; - if (feat.conflicts_with && feat.conflicts_with.length > 0) { - var conflictNames = feat.conflicts_with.map(function(cid) { - if (_featuresData && Array.isArray(_featuresData.features)) { - var cf = _featuresData.features.find(function(f) { return f.id === cid; }); - if (cf) return cf.name; - } - return cid; - }); - conflictsHtml = '
\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
'; - } - - html += '
' + - '
' + addonSectionTitle + '
' + - '

' + escHtml(addonDesc) + '

' + - conflictsHtml + - '
' + - '' + addonStatusLabel + '' + - '' + - '
' + - '
'; - } - - $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, icon); }); - if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); - } - - if (data.feature) { - var addonBtn = document.getElementById("svc-detail-addon-btn"); - if (addonBtn) { - var addonFeat = data.feature; - addonBtn.addEventListener("click", function() { - closeCredsModal(); - handleFeatureToggle(addonFeat, !addonFeat.enabled); - }); - } - } - } 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, icon) { - if (!$credsBody) return; - $credsBody.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
'; - - document.getElementById("matrix-create-back-btn").addEventListener("click", function() { - openServiceDetailModal(unit, name, icon); - }); - - 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, icon) { - if (!$credsBody) return; - $credsBody.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
'; - - document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { - openServiceDetailModal(unit, name, icon); - }); - - 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 = '
'; - } - - var externalIp = _cachedExternalIp || "your external IP"; - - $domainSetupBody.innerHTML = - '
' + - '

Before continuing:

' + - '
    ' + - '
  1. Create an account at https://njal.la
  2. ' + - '
  3. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
  4. ' + - '
  5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
    ' + - '' + escHtml(externalIp) + '
  6. ' + - '
  7. Njal.la will give you a curl command like:
    ' + - 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
  8. ' + - '
  9. Enter the subdomain and paste that curl command below
  10. ' + - '
' + - '
' + - '
' + - '

ℹ Paste the full curl command 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) { - var confirmMsg; - if (feat.id === "bip110") { - confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; - } else if (feat.id === "bitcoin-core") { - confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; - } else { - confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?"; - } - openFeatureConfirm(confirmMsg, 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; - // Feature Manager is now integrated into tile modals; sidebar rendering removed. - } 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); \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/css/base.css b/app/sovran_systemsos_web/static/css/base.css new file mode 100644 index 0000000..68234bc --- /dev/null +++ b/app/sovran_systemsos_web/static/css/base.css @@ -0,0 +1,137 @@ +/* Sovran_SystemsOS Hub — Web UI Stylesheet + Dark theme matching the Adwaita dark aesthetic + v6 — Status-only tiles (no controls) */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-color: #1e1e2e; + --surface-color: #2a2a3c; + --card-color: #313244; + --border-color: #45475a; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-dim: #6c7086; + --accent-color: #89b4fa; + --green: #2ec27e; + --yellow: #e5a50a; + --red: #e01b24; + --grey: #888888; + --radius-card: 18px; + --radius-btn: 8px; + --shadow-card: 0 2px 8px rgba(0,0,0,0.4); + --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); +} + +html, body { + height: 100%; +} + +body { + font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Login page ──────────────────────────────────────────────────── */ + +.login-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.login-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +.login-header { + text-align: center; + margin-bottom: 32px; +} + +.login-logo { + height: 64px; + margin-bottom: 16px; +} + +.login-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background-color: var(--card-color); + color: var(--text-primary); + font-size: 0.92rem; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent-color); +} + +.btn-login { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-top: 8px; +} + +.btn-login:hover { + opacity: 0.88; +} + +.login-error { + background-color: rgba(224, 27, 36, 0.12); + border: 1px solid var(--red); + color: #f87171; + padding: 10px 14px; + border-radius: 8px; + font-size: 0.85rem; + display: none; +} + +.login-error.visible { + display: block; +} diff --git a/app/sovran_systemsos_web/static/css/buttons.css b/app/sovran_systemsos_web/static/css/buttons.css new file mode 100644 index 0000000..8f1ebc5 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/buttons.css @@ -0,0 +1,86 @@ +/* ── Buttons ────────────────────────────────────────────────────── */ + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; +} + +button:disabled { + opacity: 0.45; + cursor: default; +} + +.btn { + padding: 7px 16px; + border-radius: var(--radius-btn); + font-size: 0.88rem; + font-weight: 600; +} + +.btn-primary { + background-color: var(--accent-color); + color: #1e1e2e; +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.88; +} + +/* Update System button: BLUE by default */ +.btn-update { + background-color: #89b4fa; + color: #1e1e2e; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-update:hover:not(:disabled) { + opacity: 0.88; +} + +/* Update System button: GREEN when updates are available */ +.btn-update.has-updates { + background-color: #2ec27e; + color: #fff; +} + +.btn-update.has-updates:hover:not(:disabled) { + background-color: #27ae6e; +} + +.update-badge { + display: none; + width: 10px; + height: 10px; + background-color: var(--yellow); + border-radius: 50%; + animation: pulse-badge 1.4s ease-in-out infinite; +} + +.update-badge.visible { + display: inline-block; +} + +@keyframes pulse-badge { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.35); } +} + +.btn-icon { + background: none; + color: var(--text-secondary); + padding: 6px; + border-radius: 50%; + font-size: 1.1rem; + line-height: 1; +} + +.btn-icon:hover:not(:disabled) { + background-color: var(--border-color); + color: var(--text-primary); +} diff --git a/app/sovran_systemsos_web/static/css/domain-setup.css b/app/sovran_systemsos_web/static/css/domain-setup.css new file mode 100644 index 0000000..08293ca --- /dev/null +++ b/app/sovran_systemsos_web/static/css/domain-setup.css @@ -0,0 +1,173 @@ +/* ── Domain setup modal ──────────────────────────────────────────── */ + +domain-narrow-dialog { + max-width: 500px; +} + +domain-field-group { + margin-bottom: 14px; +} + +domain-field-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +domain-field-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +domain-field-input:focus { + outline: none; + border-color: var(--accent-color); +} + +domain-field-actions { + display: flex; + gap: 10px; + margin-top: 18px; + justify-content: flex-end; +} + +/* ── Port Requirements modal ─────────────────────────────────────── */ + +.domain-narrow-dialog { + max-width: 500px; +} + +.domain-setup-intro { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 16px; +} + +.domain-setup-intro ol { + padding-left: 20px; + margin-top: 8px; +} + +.domain-setup-intro li { + margin-bottom: 6px; +} + +.domain-field-group { + margin-bottom: 14px; +} + +.domain-field-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.domain-field-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.domain-field-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.domain-field-hint { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 4px; + font-style: italic; +} + +.domain-field-actions { + display: flex; + gap: 10px; + margin-top: 18px; + justify-content: flex-end; +} + +.port-req-intro { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 10px; +} + +.port-req-hint { + font-size: 0.82rem; + color: var(--text-dim); + line-height: 1.6; + margin-top: 10px; + margin-bottom: 10px; +} + +.port-req-internal-ip { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 700; + color: var(--accent-color); +} + +.port-req-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin-top: 8px; + margin-bottom: 8px; +} + +.port-req-table th { + text-align: left; + color: var(--text-dim); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); +} + +.port-req-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + color: var(--text-primary); +} + +.port-req-table tr:last-child td { + border-bottom: none; +} + +.port-req-port { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 600; + color: var(--accent-color); +} + +.port-req-proto { + text-transform: uppercase; + color: var(--text-secondary); +} + +.port-req-desc { + color: var(--text-secondary); +} + +.port-req-status { + font-weight: 600; +} diff --git a/app/sovran_systemsos_web/static/css/features.css b/app/sovran_systemsos_web/static/css/features.css new file mode 100644 index 0000000..ac343e4 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/features.css @@ -0,0 +1,143 @@ +/* ── Feature Manager styles ──────────────────────────────────────── */ + +.feature-manager-section { + margin-bottom: 32px; +} + +.feature-subcategory { + margin-bottom: 16px; +} + +.feature-subcategory-header { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 8px; + padding-left: 4px; +} + +.feature-cards-wrap { + display: flex; + flex-direction: column; + gap: 10px; +} + +.feature-card { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 14px 16px; +} + +.feature-card-top { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; +} + +.feature-card-info { + flex: 1; + min-width: 0; +} + +.feature-card-name { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.feature-card-desc { + font-size: 0.78rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.feature-card-status { + font-size: 0.72rem; + color: var(--text-dim); + margin-top: 6px; +} + +.feature-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; + cursor: pointer; +} + +.feature-toggle-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.feature-toggle-slider { + position: absolute; + inset: 0; + background-color: var(--border-color); + border-radius: 24px; + transition: background-color 0.2s; +} + +.feature-toggle-slider::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background-color: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.feature-toggle.active .feature-toggle-slider { + background-color: var(--green); +} + +.feature-toggle.active .feature-toggle-slider::before { + transform: translateX(20px); +} + +.feature-domain-badge { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + font-size: 0.78rem; +} + +.feature-domain-icon { + flex-shrink: 0; +} + +.feature-domain-label { + color: var(--text-secondary); +} + +.feature-domain-label--checking { + color: var(--text-dim); + font-style: italic; +} + +.feature-domain-label--ok { + color: var(--green); + font-weight: 600; +} + +.feature-domain-label--warn { + color: var(--yellow); + font-weight: 600; +} + +.feature-domain-label--error { + color: var(--red); + font-weight: 600; +} diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css new file mode 100644 index 0000000..ac294ff --- /dev/null +++ b/app/sovran_systemsos_web/static/css/header.css @@ -0,0 +1,72 @@ +/* ── Header bar ─────────────────────────────────────────────────── */ + +.header-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 16px 24px; + display: flex; + align-items: center; + gap: 16px; + position: sticky; + top: 0; + z-index: 100; + justify-content: flex-end; +} + +.header-bar .title { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + position: absolute; + left: 0; + right: 0; + text-align: center; + pointer-events: none; + white-space: nowrap; +} + +.header-logo { + height: 108px; + width: auto; + vertical-align: middle; + margin-right: 10px; +} + +.role-badge { + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.72rem; + font-weight: 700; + padding: 3px 10px; + border-radius: 20px; + letter-spacing: 0.03em; +} + +/* ── IP bar ─────────────────────────────────────────────────────── */ + +.ip-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 8px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.ip-bar .ip-label { + color: var(--text-dim); + margin-right: 6px; +} + +.ip-bar .ip-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + color: var(--accent-color); + font-weight: 600; +} + +.ip-separator { + color: var(--border-color); +} diff --git a/app/sovran_systemsos_web/static/css/layout.css b/app/sovran_systemsos_web/static/css/layout.css new file mode 100644 index 0000000..1edb4e5 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/layout.css @@ -0,0 +1,130 @@ +/* ── Main content ───────────────────────────────────────────────── */ + +.main-content { + display: flex; + align-items: flex-start; + flex: 1; + overflow: hidden; + max-width: 1400px; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +/* ── Sidebar ────────────────────────────────────────────────────── */ + +.sidebar { + width: 270px; + flex-shrink: 0; + height: 100%; + overflow-y: auto; + border-right: 1px solid var(--border-color); + background-color: var(--surface-color); + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Sidebar: Tech Support button ───────────────────────────────── */ + +.sidebar-support-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background-color: var(--card-color); + border: 2px dashed var(--accent-color); + border-radius: 12px; + padding: 12px 14px; + color: var(--text-primary); + cursor: pointer; + transition: border-style 0.15s, border-color 0.15s, background-color 0.15s; + text-align: left; +} + +.sidebar-support-btn:hover { + border-style: solid; + border-color: #a8c8ff; + background-color: #35354a; +} + +.sidebar-support-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.sidebar-support-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-support-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.sidebar-support-hint { + font-size: 0.72rem; + color: var(--accent-color); + font-weight: 600; +} + +.sidebar-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 16px 0; +} + +/* ── Tiles area ─────────────────────────────────────────────────── */ + +#tiles-area { + flex: 1; + height: 100%; + overflow-y: auto; + padding: 24px 20px 48px; + min-width: 0; +} + +/* ── Category sections ──────────────────────────────────────────── */ + +.category-section { + margin-bottom: 32px; +} + +.section-header { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 4px; + padding-left: 4px; +} + +.section-divider { + border: none; + border-top: 1px solid var(--border-color); + margin-bottom: 16px; +} + +.tiles-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +/* ── Empty state ────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--text-dim); +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 8px; +} diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css new file mode 100644 index 0000000..f98e956 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -0,0 +1,421 @@ +/* ── Update modal ────────────────────────────────────────────────── */ + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(0,0,0,0.65); + z-index: 200; + align-items: center; + justify-content: center; +} + +.modal-overlay.open { + display: flex; +} + +.modal-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 900px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); +} + +.modal-header { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + gap: 12px; +} + +.modal-title { + font-size: 1rem; + font-weight: 700; + flex: 1; +} + +.modal-status { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.modal-spinner { + width: 18px; + height: 18px; + border: 2.5px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.75s linear infinite; + display: none; +} + +.modal-spinner.spinning { + display: block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.modal-log { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + line-height: 1.6; + color: var(--text-primary); + background-color: #12121c; + white-space: pre-wrap; + word-break: break-all; + min-height: 200px; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 12px 20px; + border-top: 1px solid var(--border-color); +} + +/* Reboot = GREEN */ +.modal-footer .btn-reboot, +button.btn-reboot { + background-color: #2ec27e; + color: #fff; +} + +.modal-footer .btn-reboot:hover:not(:disabled), +button.btn-reboot:hover:not(:disabled) { + background-color: #27ae6e; +} + +.btn-save { + background-color: var(--yellow); + color: #1e1e2e; +} + +.btn-save:hover:not(:disabled) { + background-color: #c98d08; +} + +.btn-close-modal { + background-color: var(--border-color); + color: var(--text-primary); +} + +.btn-close-modal:hover:not(:disabled) { + background-color: #5a5c72; +} + +/* ── Credentials info modal ──────────────────────────────────────── */ + +.creds-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 700px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); + animation: creds-fade-in 0.2s ease-out; +} + +@keyframes creds-fade-in { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.creds-header { + display: flex; + align-items: center; + padding: 20px 28px; + border-bottom: 1px solid var(--border-color); +} + +.creds-title { + font-size: 1.15rem; + font-weight: 700; + flex: 1; +} + +.creds-close-btn { + background: none; + color: var(--text-secondary); + font-size: 1.3rem; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + border: none; +} + +.creds-close-btn:hover { + background-color: var(--border-color); + color: var(--text-primary); +} + +.creds-body { + padding: 24px 28px; + overflow-y: auto; +} + +.creds-loading { + color: var(--text-dim); + text-align: center; + padding: 24px 0; +} + +.creds-row { + margin-bottom: 20px; +} + +.creds-row:last-child { + margin-bottom: 0; +} + +.creds-label { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.creds-value-wrap { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.creds-value { + flex: 1; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.92rem; + color: var(--text-primary); + background-color: #12121c; + padding: 12px 16px; + border-radius: 8px; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.6; + border: 1px solid var(--border-color); +} + +.creds-copy-btn { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.78rem; + font-weight: 600; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + border: none; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + margin-top: 10px; +} + +.creds-copy-btn:hover { + background-color: #5a5c72; +} + +.creds-copy-btn.copied { + background-color: var(--green); + color: #fff; +} + +.creds-empty { + color: var(--text-dim); + text-align: center; + padding: 24px 0; + font-size: 0.88rem; +} + +/* ── Credential links ────────────────────────────────────────────── */ + +.creds-link { + color: #b8f0c0; + text-decoration: none; + word-break: break-all; +} + +.creds-link:hover { + text-decoration: underline; + color: #defce6; +} + +/* ── Matrix action buttons ───────────────────────────────────────── */ + +.matrix-actions-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 18px 0 14px; +} + +.matrix-actions-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.matrix-action-btn { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 18px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; + min-width: 140px; +} + +.matrix-action-btn:hover { + background-color: #a8c8ff; +} + +.matrix-form-group { + margin-bottom: 14px; +} + +.matrix-form-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.matrix-form-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.matrix-form-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.matrix-form-checkbox-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.matrix-form-checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-color); +} + +.matrix-form-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} + +.matrix-form-submit { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; +} + +.matrix-form-submit:hover:not(:disabled) { + background-color: #a8c8ff; +} + +.matrix-form-submit:disabled { + opacity: 0.6; + cursor: default; +} + +.matrix-form-back { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.88rem; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; +} + +.matrix-form-back:hover { + background-color: #5a5c72; +} + +.matrix-form-result { + margin-top: 14px; + padding: 12px 16px; + border-radius: 8px; + font-size: 0.88rem; + line-height: 1.5; + display: none; +} + +.matrix-form-result.success { + background-color: rgba(74, 222, 128, 0.12); + border: 1px solid var(--green); + color: var(--green); + display: block; +} + +.matrix-form-result.error { + background-color: rgba(239, 68, 68, 0.12); + border: 1px solid #ef4444; + color: #f87171; + display: block; +} + +/* ── QR code in credentials modal ────────────────────────────────── */ + +.creds-qr-wrap { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + margin-bottom: 10px; +} + +.creds-qr-img { + width: 240px; + height: 240px; + border-radius: 12px; + border: 4px solid #fff; + background-color: #fff; + image-rendering: pixelated; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +.creds-qr-hint { + margin-top: 10px; + font-size: 0.82rem; + color: var(--text-secondary); + font-style: italic; +} diff --git a/app/sovran_systemsos_web/static/css/onboarding.css b/app/sovran_systemsos_web/static/css/onboarding.css new file mode 100644 index 0000000..f5da9d6 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/onboarding.css @@ -0,0 +1,144 @@ +/* ── Reboot overlay ─────────────────────────────────────────────── */ + +.reboot-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(15, 15, 25, 0.92); + z-index: 999; + align-items: center; + justify-content: center; +} + +.reboot-overlay.visible { + display: flex; +} + +.reboot-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 56px; + text-align: center; + max-width: 480px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); + animation: reboot-fade-in 0.4s ease-out; +} + +@keyframes reboot-fade-in { + from { opacity: 0; transform: scale(0.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.reboot-icon { + font-size: 3rem; + color: var(--accent-color); + margin-bottom: 16px; + animation: reboot-spin 2s linear infinite; + display: inline-block; +} + +@keyframes reboot-spin { + to { transform: rotate(360deg); } +} + +.reboot-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 12px; +} + +.reboot-message { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.reboot-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.reboot-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--accent-color); + animation: reboot-bounce 1.4s ease-in-out infinite; +} + +.reboot-dot:nth-child(2) { animation-delay: 0.2s; } +.reboot-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes reboot-bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +.reboot-submessage { + font-size: 0.82rem; + color: var(--text-dim); + font-style: italic; +} + +/* ── Responsive ─────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + body { + overflow: auto; + } + .main-content { + flex-direction: column; + overflow: visible; + } + .sidebar { + width: 100%; + height: auto; + border-right: none; + border-bottom: 1px solid var(--border-color); + padding: 14px 12px; + } + #tiles-area { + height: auto; + overflow-y: visible; + padding: 16px 12px 40px; + } +} + +@media (max-width: 600px) { + .header-bar { + padding: 10px 14px; + gap: 10px; + } + .header-bar .title { + font-size: 0.95rem; + } + .ip-bar { + gap: 16px; + flex-wrap: wrap; + padding: 8px 14px; + } + .tiles-grid { + justify-content: center; + } + .service-tile { + width: 140px; + min-height: 130px; + } + .reboot-card { + padding: 36px 28px; + margin: 0 16px; + } + .creds-dialog { + margin: 0 12px; + } + .creds-qr-img { + width: 200px; + height: 200px; + } +} diff --git a/app/sovran_systemsos_web/static/css/support.css b/app/sovran_systemsos_web/static/css/support.css new file mode 100644 index 0000000..d47d068 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/support.css @@ -0,0 +1,362 @@ +/* ── Tech Support modal ──────────────────────────────────────────── */ + +.support-section { + text-align: center; +} + +.support-icon-big { + font-size: 3rem; + margin-bottom: 12px; +} + +.support-active-icon { + animation: pulse-badge 2s ease-in-out infinite; +} + +.support-heading { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.support-active-heading { + color: var(--green); +} + +.support-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 16px; + text-align: left; +} + +.support-active-note { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.support-info-box { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 16px; + text-align: left; +} + +.support-active-box { + border-color: var(--green); +} + +.support-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.support-info-label { + font-size: 0.82rem; + color: var(--text-dim); + font-weight: 600; +} + +.support-info-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.88rem; + color: var(--accent-color); + font-weight: 600; +} + +.support-info-hint { + font-size: 0.72rem; + color: var(--text-dim); + margin-top: 6px; + font-style: italic; +} + +.support-steps { + text-align: left; + margin-bottom: 16px; + padding: 14px 18px; + background-color: var(--card-color); + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.support-steps-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.support-steps ol { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.support-steps code { + background-color: rgba(137, 180, 250, 0.12); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--accent-color); +} + +.support-btn-enable { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 10px; +} + +.support-btn-enable:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-disable { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--red); + color: #fff; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 10px; +} + +.support-btn-disable:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-done { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-top: 16px; +} + +.support-btn-done:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-auditlog { + width: 100%; + padding: 10px; + border-radius: var(--radius-btn); + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.85rem; + font-weight: 600; + margin-top: 8px; +} + +.support-btn-auditlog:hover:not(:disabled) { + background-color: #5a5c72; +} + +.support-fine-print { + font-size: 0.72rem; + color: var(--text-dim); + font-style: italic; + margin-bottom: 8px; +} + +.support-verify-box { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 16px 0; + padding: 12px; + background-color: var(--card-color); + border-radius: 8px; +} + +.support-verify-label { + font-size: 0.82rem; + color: var(--text-dim); + font-weight: 600; +} + +.support-verify-value { + font-size: 0.88rem; + font-weight: 700; +} + +.support-verify-value.verified-gone { + color: var(--green); +} + +.support-verify-value.verify-warning { + color: var(--yellow); +} + +/* ── Wallet protection ───────────────────────────────────────────── */ + +.support-wallet-box { + text-align: left; + padding: 14px 18px; + border-radius: 10px; + margin-bottom: 16px; + border: 1px solid var(--border-color); +} + +.support-wallet-protected { + background-color: rgba(46, 194, 126, 0.06); + border-color: rgba(46, 194, 126, 0.3); +} + +.support-wallet-unlocked { + background-color: rgba(229, 165, 10, 0.06); + border-color: rgba(229, 165, 10, 0.3); +} + +.support-wallet-warning { + background-color: rgba(224, 27, 36, 0.06); + border-color: rgba(224, 27, 36, 0.3); +} + +.support-wallet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.support-wallet-icon { + font-size: 1.2rem; +} + +.support-wallet-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.support-wallet-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 8px; +} + +.support-wallet-paths { + list-style: none; + padding: 0; + margin: 8px 0; +} + +.support-wallet-paths li { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + color: var(--text-dim); + padding: 2px 0; +} + +.support-wallet-unlock-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.support-unlock-select { + background-color: var(--card-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + font-size: 0.82rem; +} + +.support-btn-wallet-unlock { + padding: 8px 16px; + border-radius: var(--radius-btn); + background-color: var(--yellow); + color: #1e1e2e; + font-size: 0.82rem; + font-weight: 700; +} + +.support-btn-wallet-unlock:hover:not(:disabled) { + background-color: #c98d08; +} + +.support-btn-wallet-lock { + padding: 8px 16px; + border-radius: var(--radius-btn); + background-color: var(--green); + color: #fff; + font-size: 0.82rem; + font-weight: 700; + margin-top: 8px; +} + +.support-btn-wallet-lock:hover:not(:disabled) { + background-color: #27ae6e; +} + +/* ── Audit log ───────────────────────────────────────────────────── */ + +.support-audit-container { + margin-top: 12px; + border-top: 1px solid var(--border-color); + padding-top: 12px; +} + +.support-audit-log { + max-height: 200px; + overflow-y: auto; + background-color: #12121c; + border-radius: 8px; + padding: 10px 14px; +} + +.support-audit-entry { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.72rem; + color: var(--text-secondary); + padding: 3px 0; + border-bottom: 1px solid rgba(69, 71, 90, 0.3); +} + +.support-audit-entry:last-child { + border-bottom: none; +} + +.support-audit-empty { + font-size: 0.82rem; + color: var(--text-dim); + text-align: center; + padding: 12px; +} + +/* ── Tech Support tile ───────────────────────────────────────────── */ + +.support-tile { + border-color: var(--accent-color); + border-width: 2px; + border-style: dashed; +} + +.support-tile:hover { + border-color: #a8c8ff; + border-style: solid; +} diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css new file mode 100644 index 0000000..df7ba58 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -0,0 +1,279 @@ +/* ── Service tile card (status-only) ─────────────────────────────── */ + +.service-tile { + width: 160px; + min-height: 130px; + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 12px 18px; + gap: 0; + transition: box-shadow 0.2s, border-color 0.2s; + position: relative; + cursor: pointer; +} + +.service-tile:hover { + box-shadow: var(--shadow-hover); + border-color: #6c7086; +} + +.service-tile.disabled { + opacity: 0.45; +} + +.tile-icon { + width: 48px; + height: 48px; + object-fit: contain; + margin-bottom: 10px; +} + +.tile-icon-fallback { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--border-color); + border-radius: 12px; + color: var(--text-dim); + font-size: 1.5rem; + margin-bottom: 10px; +} + +.tile-name { + font-size: 0.88rem; + font-weight: 600; + text-align: center; + color: var(--text-primary); + line-height: 1.3; + max-width: 140px; + word-break: break-word; + hyphens: auto; + min-height: 1.3em; + display: flex; + align-items: center; + justify-content: center; +} + +.tile-status { + font-size: 0.75rem; + margin-top: 8px; + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--grey); +} + +.status-dot.active { background-color: var(--green); } +.status-dot.inactive { background-color: var(--red); } +.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } +.status-dot.failed { background-color: var(--red); } +.status-dot.disabled { background-color: var(--grey); } +.status-dot.needs-attention { background-color: var(--yellow); } + +/* ── Service detail modal sections ───────────────────────────────── */ + +.svc-detail-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.svc-detail-section-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 10px; +} + +.svc-detail-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.svc-detail-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +/* ── Service detail: Domain ──────────────────────────────────────── */ + +.svc-detail-domain-value { + font-size: 0.9rem; + color: var(--text-primary); + font-weight: 600; +} + +.tile-domain-label--ok { + color: var(--green); + font-weight: 600; +} + +.tile-domain-label--warn { + color: var(--yellow); + font-weight: 600; +} + +.tile-domain-label--error { + color: var(--red); + font-weight: 600; +} + +/* ── Service detail: Port table ──────────────────────────────────── */ + +.svc-detail-port-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin-top: 8px; +} + +.svc-detail-port-table th { + text-align: left; + color: var(--text-dim); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-port-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + color: var(--text-primary); +} + +.svc-detail-port-table tr:last-child td { + border-bottom: none; +} + +.svc-detail-port-table-port { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 600; + color: var(--accent-color); +} + +.svc-detail-port-table-proto { + text-transform: uppercase; + color: var(--text-secondary); +} + +.svc-detail-port-table-desc { + color: var(--text-secondary); +} + +.svc-detail-port-table-status { + font-weight: 600; +} + +.port-status-listening { color: var(--green); } +.port-status-open { color: var(--yellow); } +.port-status-closed { color: var(--red); } +.port-status-unknown { color: var(--text-dim); } + +/* ── Service detail: Troubleshoot box ────────────────────────────── */ + +.svc-detail-troubleshoot { + margin-top: 12px; + padding: 14px 16px; + background-color: rgba(229, 165, 10, 0.08); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 10px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.svc-detail-troubleshoot strong { + color: var(--yellow); +} + +.svc-detail-troubleshoot ol { + margin-top: 8px; + padding-left: 20px; +} + +.svc-detail-troubleshoot li { + margin-bottom: 4px; +} + +.svc-detail-troubleshoot code { + background-color: rgba(137, 180, 250, 0.12); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--accent-color); +} + +.svc-detail-troubleshoot a { + color: var(--accent-color); + text-decoration: none; +} + +.svc-detail-troubleshoot a:hover { + text-decoration: underline; +} + +/* ── Service detail: Addon feature toggle ────────────────────────── */ + +.svc-detail-addon-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 12px; +} + +.svc-detail-addon-status { + font-size: 0.88rem; + font-weight: 700; +} + +.addon-status--on { + color: var(--green); +} + +.addon-status--off { + color: var(--text-dim); +} + +.feature-conflict-warning { + margin-top: 8px; + margin-bottom: 8px; + padding: 10px 14px; + background-color: rgba(229, 165, 10, 0.1); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 8px; + font-size: 0.82rem; + color: var(--yellow); + font-weight: 600; +} diff --git a/app/sovran_systemsos_web/static/js/constants.js b/app/sovran_systemsos_web/static/js/constants.js new file mode 100644 index 0000000..a13e52a --- /dev/null +++ b/app/sovran_systemsos_web/static/js/constants.js @@ -0,0 +1,31 @@ +/* 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", +]); diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js new file mode 100644 index 0000000..b120492 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/events.js @@ -0,0 +1,80 @@ +"use strict"; + +// ── 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); diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js new file mode 100644 index 0000000..4c92cfb --- /dev/null +++ b/app/sovran_systemsos_web/static/js/features.js @@ -0,0 +1,581 @@ +"use strict"; + +// ── 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 = '
'; + } + + var externalIp = _cachedExternalIp || "your external IP"; + + $domainSetupBody.innerHTML = + '
' + + '

Before continuing:

' + + '
    ' + + '
  1. Create an account at https://njal.la
  2. ' + + '
  3. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
  4. ' + + '
  5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
    ' + + '' + escHtml(externalIp) + '
  6. ' + + '
  7. Njal.la will give you a curl command like:
    ' + + 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
  8. ' + + '
  9. Enter the subdomain and paste that curl command below
  10. ' + + '
' + + '
' + + '
' + + '

ℹ Paste the full curl command 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) { + var confirmMsg; + if (feat.id === "bip110") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; + } else if (feat.id === "bitcoin-core") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; + } else { + confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?"; + } + openFeatureConfirm(confirmMsg, 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; + // Feature Manager is now integrated into tile modals; sidebar rendering removed. + } 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; +} diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js new file mode 100644 index 0000000..b0b8e99 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -0,0 +1,58 @@ +"use strict"; + +// ── 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(); +} diff --git a/app/sovran_systemsos_web/static/js/rebuild.js b/app/sovran_systemsos_web/static/js/rebuild.js new file mode 100644 index 0000000..a377d3c --- /dev/null +++ b/app/sovran_systemsos_web/static/js/rebuild.js @@ -0,0 +1,84 @@ +"use strict"; + +// ── 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); +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js new file mode 100644 index 0000000..6b0e928 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -0,0 +1,478 @@ +"use strict"; + +// ── 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, icon) { + if (!$credsModal) return; + if ($credsTitle) $credsTitle.textContent = name; + if ($credsBody) $credsBody.innerHTML = '

Loading…

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

' + escHtml(data.description) + '

' + + '
'; + } + + // Section B: Status + // When a feature override is present, use the feature's enabled state so the + // modal matches what the dashboard tile shows (feature toggle is authoritative). + var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled; + var effectiveHealth = data.feature && !data.feature.enabled + ? "disabled" + : (data.health || data.status); + var sc = statusClass(effectiveHealth); + var st = statusText(effectiveHealth, effectiveEnabled); + 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 && !data.feature) { + html += '
' + + '

This service is not enabled in your configuration.

' + + '
'; + } + + // Section F: Addon Feature toggle + if (data.feature) { + var feat = data.feature; + // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state + if (!_featuresData) { + _featuresData = { features: [feat], ssl_email_configured: false }; + } else { + var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); + if (fidx >= 0) { _featuresData.features[fidx] = feat; } + else { _featuresData.features.push(feat); } + } + var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; + var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; + var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; + var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; + + // Section title: use a more specific label for mutually-exclusive Bitcoin node features + var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core") + ? "\u20BF Bitcoin Node Selection" + : "\uD83D\uDD27 Addon Feature"; + + // Description: prefer the feature's own description over a generic fallback + var addonDesc = feat.description + ? feat.description + : "This is an optional addon feature. You can enable or disable it at any time."; + + // Conflicts warning: list mutually-exclusive feature names when present + var conflictsHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (_featuresData && Array.isArray(_featuresData.features)) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf) return cf.name; + } + return cid; + }); + conflictsHtml = '
\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
'; + } + + html += '
' + + '
' + addonSectionTitle + '
' + + '

' + escHtml(addonDesc) + '

' + + conflictsHtml + + '
' + + '' + addonStatusLabel + '' + + '' + + '
' + + '
'; + } + + $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, icon); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); + } + + if (data.feature) { + var addonBtn = document.getElementById("svc-detail-addon-btn"); + if (addonBtn) { + var addonFeat = data.feature; + addonBtn.addEventListener("click", function() { + closeCredsModal(); + handleFeatureToggle(addonFeat, !addonFeat.enabled); + }); + } + } + } 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, icon) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + document.getElementById("matrix-create-back-btn").addEventListener("click", function() { + openServiceDetailModal(unit, name, icon); + }); + + 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, icon) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { + openServiceDetailModal(unit, name, icon); + }); + + 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"); } diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js new file mode 100644 index 0000000..8113571 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/state.js @@ -0,0 +1,94 @@ +"use strict"; + +// ── 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) diff --git a/app/sovran_systemsos_web/static/js/support.js b/app/sovran_systemsos_web/static/js/support.js new file mode 100644 index 0000000..039f5bf --- /dev/null +++ b/app/sovran_systemsos_web/static/js/support.js @@ -0,0 +1,261 @@ +"use strict"; + +// ── 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(); +} diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js new file mode 100644 index 0000000..b792b60 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -0,0 +1,151 @@ +"use strict"; + +// ── 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, svc.icon); + }); + + 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 (_) {} +} diff --git a/app/sovran_systemsos_web/static/js/update.js b/app/sovran_systemsos_web/static/js/update.js new file mode 100644 index 0000000..040ec44 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/update.js @@ -0,0 +1,120 @@ +"use strict"; + +// ── 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); }); +} diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css deleted file mode 100644 index cc26a73..0000000 --- a/app/sovran_systemsos_web/static/style.css +++ /dev/null @@ -1,1679 +0,0 @@ -/* Sovran_SystemsOS Hub — Web UI Stylesheet - Dark theme matching the Adwaita dark aesthetic - v6 — Status-only tiles (no controls) */ - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg-color: #1e1e2e; - --surface-color: #2a2a3c; - --card-color: #313244; - --border-color: #45475a; - --text-primary: #cdd6f4; - --text-secondary: #a6adc8; - --text-dim: #6c7086; - --accent-color: #89b4fa; - --green: #2ec27e; - --yellow: #e5a50a; - --red: #e01b24; - --grey: #888888; - --radius-card: 18px; - --radius-btn: 8px; - --shadow-card: 0 2px 8px rgba(0,0,0,0.4); - --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); -} - -html, body { - height: 100%; -} - -body { - font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; - background-color: var(--bg-color); - color: var(--text-primary); - line-height: 1.5; - min-height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ── Header bar ─────────────────────────────────────────────────── */ - -.header-bar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: 16px 24px; - display: flex; - align-items: center; - gap: 16px; - position: sticky; - top: 0; - z-index: 100; - justify-content: flex-end; -} - -.header-bar .title { - font-size: 1.15rem; - font-weight: 700; - color: var(--text-primary); - position: absolute; - left: 0; - right: 0; - text-align: center; - pointer-events: none; - white-space: nowrap; -} - -.header-logo { - height: 108px; - width: auto; - vertical-align: middle; - margin-right: 10px; -} - -.role-badge { - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.72rem; - font-weight: 700; - padding: 3px 10px; - border-radius: 20px; - letter-spacing: 0.03em; -} - -/* ── Buttons ────────────────────────────────────────────────────── */ - -button { - font-family: inherit; - cursor: pointer; - border: none; - outline: none; - transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; -} - -button:disabled { - opacity: 0.45; - cursor: default; -} - -.btn { - padding: 7px 16px; - border-radius: var(--radius-btn); - font-size: 0.88rem; - font-weight: 600; -} - -.btn-primary { - background-color: var(--accent-color); - color: #1e1e2e; -} - -.btn-primary:hover:not(:disabled) { - opacity: 0.88; -} - -/* Update System button: BLUE by default */ -.btn-update { - background-color: #89b4fa; - color: #1e1e2e; - position: relative; - display: flex; - align-items: center; - gap: 8px; -} - -.btn-update:hover:not(:disabled) { - opacity: 0.88; -} - -/* Update System button: GREEN when updates are available */ -.btn-update.has-updates { - background-color: #2ec27e; - color: #fff; -} - -.btn-update.has-updates:hover:not(:disabled) { - background-color: #27ae6e; -} - -.update-badge { - display: none; - width: 10px; - height: 10px; - background-color: var(--yellow); - border-radius: 50%; - animation: pulse-badge 1.4s ease-in-out infinite; -} - -.update-badge.visible { - display: inline-block; -} - -@keyframes pulse-badge { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.35); } -} - -.btn-icon { - background: none; - color: var(--text-secondary); - padding: 6px; - border-radius: 50%; - font-size: 1.1rem; - line-height: 1; -} - -.btn-icon:hover:not(:disabled) { - background-color: var(--border-color); - color: var(--text-primary); -} - -/* ── IP bar ─────────────────────────────────────────────────────── */ - -.ip-bar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: 8px 24px; - display: flex; - align-items: center; - justify-content: center; - gap: 32px; - font-size: 0.82rem; - color: var(--text-secondary); -} - -.ip-bar .ip-label { - color: var(--text-dim); - margin-right: 6px; -} - -.ip-bar .ip-value { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - color: var(--accent-color); - font-weight: 600; -} - -.ip-separator { - color: var(--border-color); -} - -/* ── Main content ───────────────────────────────────────────────── */ - -.main-content { - display: flex; - align-items: flex-start; - flex: 1; - overflow: hidden; - max-width: 1400px; - width: 100%; - margin-left: auto; - margin-right: auto; -} - -/* ── Sidebar ────────────────────────────────────────────────────── */ - -.sidebar { - width: 270px; - flex-shrink: 0; - height: 100%; - overflow-y: auto; - border-right: 1px solid var(--border-color); - background-color: var(--surface-color); - padding: 20px 14px; - display: flex; - flex-direction: column; - gap: 0; -} - -/* ── Sidebar: Tech Support button ───────────────────────────────── */ - -.sidebar-support-btn { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - background-color: var(--card-color); - border: 2px dashed var(--accent-color); - border-radius: 12px; - padding: 12px 14px; - color: var(--text-primary); - cursor: pointer; - transition: border-style 0.15s, border-color 0.15s, background-color 0.15s; - text-align: left; -} - -.sidebar-support-btn:hover { - border-style: solid; - border-color: #a8c8ff; - background-color: #35354a; -} - -.sidebar-support-icon { - font-size: 1.5rem; - flex-shrink: 0; -} - -.sidebar-support-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.sidebar-support-title { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-primary); -} - -.sidebar-support-hint { - font-size: 0.72rem; - color: var(--accent-color); - font-weight: 600; -} - -.sidebar-divider { - border: none; - border-top: 1px solid var(--border-color); - margin: 16px 0; -} - -/* ── Tiles area ─────────────────────────────────────────────────── */ - -#tiles-area { - flex: 1; - height: 100%; - overflow-y: auto; - padding: 24px 20px 48px; - min-width: 0; -} - -/* ── Category sections ──────────────────────────────────────────── */ - -.category-section { - margin-bottom: 32px; -} - -.section-header { - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-secondary); - margin-bottom: 4px; - padding-left: 4px; -} - -.section-divider { - border: none; - border-top: 1px solid var(--border-color); - margin-bottom: 16px; -} - -.tiles-grid { - display: flex; - flex-wrap: wrap; - gap: 14px; -} - -/* ── Service tile card (status-only) ─────────────────────────────── */ - -.service-tile { - width: 160px; - min-height: 130px; - background-color: var(--card-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 20px 12px 18px; - gap: 0; - transition: box-shadow 0.2s, border-color 0.2s; - position: relative; - cursor: pointer; -} - -.service-tile:hover { - box-shadow: var(--shadow-hover); - border-color: #6c7086; -} - -.service-tile.disabled { - opacity: 0.45; -} - -.tile-icon { - width: 48px; - height: 48px; - object-fit: contain; - margin-bottom: 10px; -} - -.tile-icon-fallback { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background-color: var(--border-color); - border-radius: 12px; - color: var(--text-dim); - font-size: 1.5rem; - margin-bottom: 10px; -} - -.tile-name { - font-size: 0.88rem; - font-weight: 600; - text-align: center; - color: var(--text-primary); - line-height: 1.3; - max-width: 140px; - word-break: break-word; - hyphens: auto; - min-height: 1.3em; - display: flex; - align-items: center; - justify-content: center; -} - -.tile-status { - font-size: 0.75rem; - margin-top: 8px; - display: flex; - align-items: center; - gap: 5px; - color: var(--text-secondary); -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - background-color: var(--grey); -} - -.status-dot.active { background-color: var(--green); } -.status-dot.inactive { background-color: var(--red); } -.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } -.status-dot.failed { background-color: var(--red); } -.status-dot.disabled { background-color: var(--grey); } -.status-dot.needs-attention { background-color: var(--yellow); } - -/* ── Service detail modal sections ───────────────────────────────── */ - -.svc-detail-section { - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 1px solid var(--border-color); -} - -.svc-detail-section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.svc-detail-section-title { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - margin-bottom: 10px; -} - -.svc-detail-desc { - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 1.6; -} - -.svc-detail-status { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.9rem; - font-weight: 600; - color: var(--text-primary); -} - -/* ── Service detail: Domain ──────────────────────────────────────── */ - -.svc-detail-domain-value { - font-size: 0.9rem; - color: var(--text-primary); - font-weight: 600; -} - -.tile-domain-label--ok { - color: var(--green); - font-weight: 600; -} - -.tile-domain-label--warn { - color: var(--yellow); - font-weight: 600; -} - -.tile-domain-label--error { - color: var(--red); - font-weight: 600; -} - -/* ── Service detail: Port table ──────────────────────────────────── */ - -.svc-detail-port-table { - width: 100%; - border-collapse: collapse; - font-size: 0.82rem; - margin-top: 8px; -} - -.svc-detail-port-table th { - text-align: left; - color: var(--text-dim); - font-weight: 600; - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 6px 10px; - border-bottom: 1px solid var(--border-color); -} - -.svc-detail-port-table td { - padding: 8px 10px; - border-bottom: 1px solid rgba(69, 71, 90, 0.4); - color: var(--text-primary); -} - -.svc-detail-port-table tr:last-child td { - border-bottom: none; -} - -.svc-detail-port-table-port { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-weight: 600; - color: var(--accent-color); -} - -.svc-detail-port-table-proto { - text-transform: uppercase; - color: var(--text-secondary); -} - -.svc-detail-port-table-desc { - color: var(--text-secondary); -} - -.svc-detail-port-table-status { - font-weight: 600; -} - -.port-status-listening { color: var(--green); } -.port-status-open { color: var(--yellow); } -.port-status-closed { color: var(--red); } -.port-status-unknown { color: var(--text-dim); } - -/* ── Service detail: Troubleshoot box ────────────────────────────── */ - -.svc-detail-troubleshoot { - margin-top: 12px; - padding: 14px 16px; - background-color: rgba(229, 165, 10, 0.08); - border: 1px solid rgba(229, 165, 10, 0.3); - border-radius: 10px; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.6; -} - -.svc-detail-troubleshoot strong { - color: var(--yellow); -} - -.svc-detail-troubleshoot ol { - margin-top: 8px; - padding-left: 20px; -} - -.svc-detail-troubleshoot li { - margin-bottom: 4px; -} - -.svc-detail-troubleshoot code { - background-color: rgba(137, 180, 250, 0.12); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.82rem; - color: var(--accent-color); -} - -.svc-detail-troubleshoot a { - color: var(--accent-color); - text-decoration: none; -} - -.svc-detail-troubleshoot a:hover { - text-decoration: underline; -} - -/* ── Service detail: Addon feature toggle ────────────────────────── */ - -.svc-detail-addon-row { - display: flex; - align-items: center; - gap: 14px; - margin-top: 12px; -} - -.svc-detail-addon-status { - font-size: 0.88rem; - font-weight: 700; -} - -.addon-status--on { - color: var(--green); -} - -.addon-status--off { - color: var(--text-dim); -} - -.feature-conflict-warning { - margin-top: 8px; - margin-bottom: 8px; - padding: 10px 14px; - background-color: rgba(229, 165, 10, 0.1); - border: 1px solid rgba(229, 165, 10, 0.3); - border-radius: 8px; - font-size: 0.82rem; - color: var(--yellow); - font-weight: 600; -} - -/* ── Update modal ────────────────────────────────────────────────── */ - -.modal-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(0,0,0,0.65); - z-index: 200; - align-items: center; - justify-content: center; -} - -.modal-overlay.open { - display: flex; -} - -.modal-dialog { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 16px; - width: 90vw; - max-width: 900px; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0,0,0,0.7); -} - -.modal-header { - display: flex; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border-color); - gap: 12px; -} - -.modal-title { - font-size: 1rem; - font-weight: 700; - flex: 1; -} - -.modal-status { - font-size: 0.85rem; - color: var(--text-secondary); -} - -.modal-spinner { - width: 18px; - height: 18px; - border: 2.5px solid var(--border-color); - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 0.75s linear infinite; - display: none; -} - -.modal-spinner.spinning { - display: block; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.modal-log { - flex: 1; - overflow-y: auto; - padding: 12px 16px; - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - line-height: 1.6; - color: var(--text-primary); - background-color: #12121c; - white-space: pre-wrap; - word-break: break-all; - min-height: 200px; -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - padding: 12px 20px; - border-top: 1px solid var(--border-color); -} - -/* Reboot = GREEN */ -.modal-footer .btn-reboot, -button.btn-reboot { - background-color: #2ec27e; - color: #fff; -} - -.modal-footer .btn-reboot:hover:not(:disabled), -button.btn-reboot:hover:not(:disabled) { - background-color: #27ae6e; -} - -.btn-save { - background-color: var(--yellow); - color: #1e1e2e; -} - -.btn-save:hover:not(:disabled) { - background-color: #c98d08; -} - -.btn-close-modal { - background-color: var(--border-color); - color: var(--text-primary); -} - -.btn-close-modal:hover:not(:disabled) { - background-color: #5a5c72; -} - -/* ── Credentials info modal ──────────────────────────────────────── */ - -.creds-dialog { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 16px; - width: 90vw; - max-width: 700px; - max-height: 85vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0,0,0,0.7); - animation: creds-fade-in 0.2s ease-out; -} - -@keyframes creds-fade-in { - from { opacity: 0; transform: scale(0.95) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.creds-header { - display: flex; - align-items: center; - padding: 20px 28px; - border-bottom: 1px solid var(--border-color); -} - -.creds-title { - font-size: 1.15rem; - font-weight: 700; - flex: 1; -} - -.creds-close-btn { - background: none; - color: var(--text-secondary); - font-size: 1.3rem; - padding: 4px 8px; - border-radius: 6px; - cursor: pointer; - border: none; -} - -.creds-close-btn:hover { - background-color: var(--border-color); - color: var(--text-primary); -} - -.creds-body { - padding: 24px 28px; - overflow-y: auto; -} - -.creds-loading { - color: var(--text-dim); - text-align: center; - padding: 24px 0; -} - -.creds-row { - margin-bottom: 20px; -} - -.creds-row:last-child { - margin-bottom: 0; -} - -.creds-label { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - margin-bottom: 6px; -} - -.creds-value-wrap { - display: flex; - align-items: flex-start; - gap: 10px; -} - -.creds-value { - flex: 1; - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.92rem; - color: var(--text-primary); - background-color: #12121c; - padding: 12px 16px; - border-radius: 8px; - word-break: break-all; - white-space: pre-wrap; - line-height: 1.6; - border: 1px solid var(--border-color); -} - -.creds-copy-btn { - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.78rem; - font-weight: 600; - padding: 8px 14px; - border-radius: 6px; - cursor: pointer; - border: none; - white-space: nowrap; - flex-shrink: 0; - align-self: flex-start; - margin-top: 10px; -} - -.creds-copy-btn:hover { - background-color: #5a5c72; -} - -.creds-copy-btn.copied { - background-color: var(--green); - color: #fff; -} - -.creds-empty { - color: var(--text-dim); - text-align: center; - padding: 24px 0; - font-size: 0.88rem; -} - -/* ── Credential links ────────────────────────────────────────────── */ - -.creds-link { - color: #b8f0c0; - text-decoration: none; - word-break: break-all; -} - -.creds-link:hover { - text-decoration: underline; - color: #defce6; -} - -/* ── Matrix action buttons ───────────────────────────────────────── */ - -.matrix-actions-divider { - border: none; - border-top: 1px solid var(--border-color); - margin: 18px 0 14px; -} - -.matrix-actions-row { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.matrix-action-btn { - background-color: var(--accent-color); - color: #0f0f19; - font-size: 0.88rem; - font-weight: 700; - padding: 10px 18px; - border-radius: 8px; - border: none; - cursor: pointer; - flex: 1; - min-width: 140px; -} - -.matrix-action-btn:hover { - background-color: #a8c8ff; -} - -.matrix-form-group { - margin-bottom: 14px; -} - -.matrix-form-label { - display: block; - font-size: 0.82rem; - color: var(--text-secondary); - margin-bottom: 6px; - font-weight: 600; -} - -.matrix-form-input { - width: 100%; - background-color: #12121c; - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.9rem; - box-sizing: border-box; -} - -.matrix-form-input:focus { - outline: none; - border-color: var(--accent-color); -} - -.matrix-form-checkbox-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 14px; -} - -.matrix-form-checkbox-row input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: var(--accent-color); -} - -.matrix-form-actions { - display: flex; - gap: 10px; - margin-top: 18px; -} - -.matrix-form-submit { - background-color: var(--accent-color); - color: #0f0f19; - font-size: 0.88rem; - font-weight: 700; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; - flex: 1; -} - -.matrix-form-submit:hover:not(:disabled) { - background-color: #a8c8ff; -} - -.matrix-form-submit:disabled { - opacity: 0.6; - cursor: default; -} - -.matrix-form-back { - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.88rem; - font-weight: 600; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; -} - -.matrix-form-back:hover { - background-color: #5a5c72; -} - -.matrix-form-result { - margin-top: 14px; - padding: 12px 16px; - border-radius: 8px; - font-size: 0.88rem; - line-height: 1.5; - display: none; -} - -.matrix-form-result.success { - background-color: rgba(74, 222, 128, 0.12); - border: 1px solid var(--green); - color: var(--green); - display: block; -} - -.matrix-form-result.error { - background-color: rgba(239, 68, 68, 0.12); - border: 1px solid #ef4444; - color: #f87171; - display: block; -} - -/* ── QR code in credentials modal ────────────────────────────────── */ - -.creds-qr-wrap { - display: flex; - flex-direction: column; - align-items: center; - padding: 20px 0; - margin-bottom: 10px; -} - -.creds-qr-img { - width: 240px; - height: 240px; - border-radius: 12px; - border: 4px solid #fff; - background-color: #fff; - image-rendering: pixelated; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); -} - -.creds-qr-hint { - margin-top: 10px; - font-size: 0.82rem; - color: var(--text-secondary); - font-style: italic; -} - -/* ── Reboot overlay ─────────────────────────────────────────────── */ - -.reboot-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(15, 15, 25, 0.92); - z-index: 999; - align-items: center; - justify-content: center; -} - -.reboot-overlay.visible { - display: flex; -} - -.reboot-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 56px; - text-align: center; - max-width: 480px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); - animation: reboot-fade-in 0.4s ease-out; -} - -@keyframes reboot-fade-in { - from { opacity: 0; transform: scale(0.92) translateY(12px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.reboot-icon { - font-size: 3rem; - color: var(--accent-color); - margin-bottom: 16px; - animation: reboot-spin 2s linear infinite; - display: inline-block; -} - -@keyframes reboot-spin { - to { transform: rotate(360deg); } -} - -.reboot-title { - font-size: 1.35rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 12px; -} - -.reboot-message { - font-size: 0.92rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 24px; -} - -.reboot-dots { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-bottom: 16px; -} - -.reboot-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background-color: var(--accent-color); - animation: reboot-bounce 1.4s ease-in-out infinite; -} - -.reboot-dot:nth-child(2) { animation-delay: 0.2s; } -.reboot-dot:nth-child(3) { animation-delay: 0.4s; } - -@keyframes reboot-bounce { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1.2); } -} - -.reboot-submessage { - font-size: 0.82rem; - color: var(--text-dim); - font-style: italic; -} - -/* ── Empty state ────────────────────────────────────────────────── */ - -.empty-state { - text-align: center; - padding: 64px 24px; - color: var(--text-dim); -} - -.empty-state p { - font-size: 1rem; - margin-bottom: 8px; -} - -/* ── Tech Support modal ──────────────────────────────────────────── */ - -.support-section { - text-align: center; -} - -.support-icon-big { - font-size: 3rem; - margin-bottom: 12px; -} - -.support-active-icon { - animation: pulse-badge 2s ease-in-out infinite; -} - -.support-heading { - font-size: 1.15rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 8px; -} - -.support-active-heading { - color: var(--green); -} - -.support-desc { - font-size: 0.88rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 16px; - text-align: left; -} - -.support-active-note { - font-size: 0.88rem; - color: var(--text-secondary); - margin-bottom: 16px; -} - -.support-info-box { - background-color: var(--card-color); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 14px 18px; - margin-bottom: 16px; - text-align: left; -} - -.support-active-box { - border-color: var(--green); -} - -.support-info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 0; -} - -.support-info-label { - font-size: 0.82rem; - color: var(--text-dim); - font-weight: 600; -} - -.support-info-value { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.88rem; - color: var(--accent-color); - font-weight: 600; -} - -.support-info-hint { - font-size: 0.72rem; - color: var(--text-dim); - margin-top: 6px; - font-style: italic; -} - -.support-steps { - text-align: left; - margin-bottom: 16px; - padding: 14px 18px; - background-color: var(--card-color); - border-radius: 10px; - border: 1px solid var(--border-color); -} - -.support-steps-title { - font-size: 0.82rem; - font-weight: 700; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 8px; -} - -.support-steps ol { - padding-left: 20px; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.7; -} - -.support-steps code { - background-color: rgba(137, 180, 250, 0.12); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.82rem; - color: var(--accent-color); -} - -.support-btn-enable { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.95rem; - font-weight: 700; - margin-bottom: 10px; -} - -.support-btn-enable:hover:not(:disabled) { - opacity: 0.88; -} - -.support-btn-disable { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--red); - color: #fff; - font-size: 0.95rem; - font-weight: 700; - margin-bottom: 10px; -} - -.support-btn-disable:hover:not(:disabled) { - opacity: 0.88; -} - -.support-btn-done { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.95rem; - font-weight: 700; - margin-top: 16px; -} - -.support-btn-done:hover:not(:disabled) { - opacity: 0.88; -} - -.support-btn-auditlog { - width: 100%; - padding: 10px; - border-radius: var(--radius-btn); - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.85rem; - font-weight: 600; - margin-top: 8px; -} - -.support-btn-auditlog:hover:not(:disabled) { - background-color: #5a5c72; -} - -.support-fine-print { - font-size: 0.72rem; - color: var(--text-dim); - font-style: italic; - margin-bottom: 8px; -} - -.support-verify-box { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - margin: 16px 0; - padding: 12px; - background-color: var(--card-color); - border-radius: 8px; -} - -.support-verify-label { - font-size: 0.82rem; - color: var(--text-dim); - font-weight: 600; -} - -.support-verify-value { - font-size: 0.88rem; - font-weight: 700; -} - -.support-verify-value.verified-gone { - color: var(--green); -} - -.support-verify-value.verify-warning { - color: var(--yellow); -} - -/* ── Wallet protection ───────────────────────────────────────────── */ - -.support-wallet-box { - text-align: left; - padding: 14px 18px; - border-radius: 10px; - margin-bottom: 16px; - border: 1px solid var(--border-color); -} - -.support-wallet-protected { - background-color: rgba(46, 194, 126, 0.06); - border-color: rgba(46, 194, 126, 0.3); -} - -.support-wallet-unlocked { - background-color: rgba(229, 165, 10, 0.06); - border-color: rgba(229, 165, 10, 0.3); -} - -.support-wallet-warning { - background-color: rgba(224, 27, 36, 0.06); - border-color: rgba(224, 27, 36, 0.3); -} - -.support-wallet-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.support-wallet-icon { - font-size: 1.2rem; -} - -.support-wallet-title { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-primary); -} - -.support-wallet-desc { - font-size: 0.82rem; - color: var(--text-secondary); - line-height: 1.5; - margin-bottom: 8px; -} - -.support-wallet-paths { - list-style: none; - padding: 0; - margin: 8px 0; -} - -.support-wallet-paths li { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - color: var(--text-dim); - padding: 2px 0; -} - -.support-wallet-unlock-row { - display: flex; - align-items: center; - gap: 10px; - margin-top: 10px; -} - -.support-unlock-select { - background-color: var(--card-color); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 10px; - font-size: 0.82rem; -} - -.support-btn-wallet-unlock { - padding: 8px 16px; - border-radius: var(--radius-btn); - background-color: var(--yellow); - color: #1e1e2e; - font-size: 0.82rem; - font-weight: 700; -} - -.support-btn-wallet-unlock:hover:not(:disabled) { - background-color: #c98d08; -} - -.support-btn-wallet-lock { - padding: 8px 16px; - border-radius: var(--radius-btn); - background-color: var(--green); - color: #fff; - font-size: 0.82rem; - font-weight: 700; - margin-top: 8px; -} - -.support-btn-wallet-lock:hover:not(:disabled) { - background-color: #27ae6e; -} - -/* ── Audit log ───────────────────────────────────────────────────── */ - -.support-audit-container { - margin-top: 12px; - border-top: 1px solid var(--border-color); - padding-top: 12px; -} - -.support-audit-log { - max-height: 200px; - overflow-y: auto; - background-color: #12121c; - border-radius: 8px; - padding: 10px 14px; -} - -.support-audit-entry { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.72rem; - color: var(--text-secondary); - padding: 3px 0; - border-bottom: 1px solid rgba(69, 71, 90, 0.3); -} - -.support-audit-entry:last-child { - border-bottom: none; -} - -.support-audit-empty { - font-size: 0.82rem; - color: var(--text-dim); - text-align: center; - padding: 12px; -} - -/* ── Domain setup modal ──────────────────────────────────────────── */ - -domain-narrow-dialog { - max-width: 500px; -} - -domain-field-group { - margin-bottom: 14px; -} - -domain-field-label { - display: block; - font-size: 0.82rem; - color: var(--text-secondary); - margin-bottom: 6px; - font-weight: 600; -} - -domain-field-input { - width: 100%; - background-color: #12121c; - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.9rem; - box-sizing: border-box; -} - -domain-field-input:focus { - outline: none; - border-color: var(--accent-color); -} - -domain-field-actions { - display: flex; - gap: 10px; - margin-top: 18px; - justify-content: flex-end; -} - -/* ── Responsive ─────────────────────────────────────────────────── */ - -@media (max-width: 768px) { - body { - overflow: auto; - } - .main-content { - flex-direction: column; - overflow: visible; - } - .sidebar { - width: 100%; - height: auto; - border-right: none; - border-bottom: 1px solid var(--border-color); - padding: 14px 12px; - } - #tiles-area { - height: auto; - overflow-y: visible; - padding: 16px 12px 40px; - } -} - -@media (max-width: 600px) { - .header-bar { - padding: 10px 14px; - gap: 10px; - } - .header-bar .title { - font-size: 0.95rem; - } - .ip-bar { - gap: 16px; - flex-wrap: wrap; - padding: 8px 14px; - } - .tiles-grid { - justify-content: center; - } - .service-tile { - width: 140px; - min-height: 130px; - } - .reboot-card { - padding: 36px 28px; - margin: 0 16px; - } - .creds-dialog { - margin: 0 12px; - } - .creds-qr-img { - width: 200px; - height: 200px; - } -} - -/* ── Tech Support tile ───────────────────────────────────────────── */ - -.support-tile { - border-color: var(--accent-color); - border-width: 2px; - border-style: dashed; -} - -.support-tile:hover { - border-color: #a8c8ff; - border-style: solid; -} - -/* ── Login page ──────────────────────────────────────────────────── */ - -.login-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - padding: 24px; -} - -.login-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 40px; - width: 100%; - max-width: 400px; - box-shadow: 0 8px 32px rgba(0,0,0,0.5); -} - -.login-header { - text-align: center; - margin-bottom: 32px; -} - -.login-logo { - height: 64px; - margin-bottom: 16px; -} - -.login-title { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); -} - -.login-form { - display: flex; - flex-direction: column; - gap: 16px; -} - -.form-group label { - display: block; - font-size: 0.82rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 6px; -} - -.form-group input { - width: 100%; - padding: 10px 14px; - border: 1px solid var(--border-color); - border-radius: var(--radius-btn); - background-color: var(--card-color); - color: var(--text-primary); - font-size: 0.92rem; -} - -.form-group input:focus { - outline: none; - border-color: var(--accent-color); -} - -.btn-login { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.95rem; - font-weight: 700; - margin-top: 8px; -} - -.btn-login:hover { - opacity: 0.88; -} - -.login-error { - background-color: rgba(224, 27, 36, 0.12); - border: 1px solid var(--red); - color: #f87171; - padding: 10px 14px; - border-radius: 8px; - font-size: 0.85rem; - display: none; -} - -.login-error.visible { - display: block; -} diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 69fded9..11a111d 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,16 @@ Sovran_SystemsOS Hub - + + + + + + + + + + @@ -182,6 +191,15 @@ - + + + + + + + + + + \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index 756c67b..8c46788 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -4,7 +4,16 @@ Sovran_SystemsOS — First-Boot Setup - + + + + + + + + + +