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 = '
';
- 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 = '';
- }
-}
-
-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 =
- '' +
- '';
- 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) + '
Click for help
';
- tile.style.cursor = "pointer";
- tile.addEventListener("click", function() { openSupportModal(); });
- return tile;
- }
-
- tile.innerHTML = '?
' + 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 = 'Scan with Zeus app on your phone
';
- }
- html += '' + escHtml(cred.label) + '
' + qrBlock + '
';
- }
- 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:
' +
- '' +
- 'Log into your router\'s admin panel (usually http://192.168.1.1) ' +
- 'Find the Port Forwarding section ' +
- 'Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + ' ' +
- 'Save your router settings ' +
- ' ' +
- '💡 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 + '
' +
- '' +
- 'Log into your router\'s admin panel ' +
- 'Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + ' ' +
- 'Save your router settings ' +
- ' '
- );
- }
-
- troubleshootHtml = '' + troubleParts.join('
') + '';
- }
-
- html += '' +
- '
Port Status
' +
- '
' +
- '' +
- 'Port Protocol Description Status ' +
- ' ' +
- '' + portTableRows + ' ' +
- '
' +
- 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:
' +
- '
' +
- 'Go to njal.la and log into your account ' +
- 'Find your domain and check the Dynamic DNS record ' +
- 'Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + ' ' +
- 'If you set up a DDNS curl command during onboarding, verify it\'s running correctly ' +
- ' ' +
- '
';
- } 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:
' +
- '
' +
- 'Go to njal.la and log into your account ' +
- 'Find the domain you purchased for this service ' +
- 'Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + ' ' +
- 'Copy the DDNS curl command from Njal.la\'s dashboard ' +
- 'You can re-enter it in the Feature Manager to update your configuration ' +
- ' ' +
- '
';
- } else {
- domainBadge = '' + escHtml(data.domain) + ' ';
- }
- } else {
- domainBadge = 'Not configured ';
- domainStatusHtml = '' +
- '
⚠️ No domain has been configured for this service yet. ' +
- '
To get this service working:
' +
- '
' +
- 'Purchase a subdomain at njal.la (if you haven\'t already) ' +
- 'Go to the Feature Manager in the sidebar ' +
- 'Find this service and configure your domain through the setup wizard ' +
- ' ' +
- '
';
- }
-
- 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" ?
- '
' +
- '➕ Add New User ' +
- '🔑 Change Password ' +
- '
' : "") +
- '
';
- } 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 + ' ' +
- '' + escHtml(addonBtnLabel) + ' ' +
- '
' +
- '
';
- }
-
- $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 += '' +
- '➕ Add New User ' +
- '🔑 Change Password ' +
- '
';
- }
- $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 =
- 'Username ' +
- '
' +
- 'Password ' +
- '
' +
- 'Make admin
' +
- '' +
- '← Back ' +
- 'Create User ' +
- '
' +
- '
';
-
- 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 =
- 'Username (localpart only, e.g. alice ) ' +
- '
' +
- 'New Password ' +
- '
' +
- '' +
- '← Back ' +
- 'Change Password ' +
- '
' +
- '
';
-
- 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 files (LND, Sparrow, Bisq) are protected by default . Support staff cannot access your private keys unless you explicitly grant access.
',
- '
',
- '
What happens:
',
- 'A restricted sovran-support user is created with limited access ',
- 'Our SSH key is added only to that restricted account ',
- 'Wallet files are locked via access controls — not visible to support ',
- 'You control if and when wallet access is granted (time-limited) ',
- 'All session events are logged for your audit ',
- ' ',
- '
Enable Support Access ',
- '
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 = [
- '',
- '',
- '
You have granted support temporary access to wallet files' + (unlockUntil ? ' until ' + escHtml(unlockUntil) + ' ' : '') + '.
',
- '
Re-lock Wallet Now ',
- '
',
- ].join("");
- } else {
- var pathList = protectedPaths.length
- ? '' + protectedPaths.map(function(p){ return '' + escHtml(p) + ' '; }).join("") + ' '
- : '';
- walletSection = [
- '',
- '',
- '
Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.
',
- pathList,
- '
',
- '',
- '1 hour ',
- '30 minutes ',
- '2 hours ',
- ' ',
- 'Grant Wallet Access ',
- '
',
- '
',
- ].join("");
- }
- } else {
- walletSection = [
- '',
- '',
- '
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,
- '
End Support Session ',
- '
This will remove the SSH key and revoke all wallet access immediately.
',
- '
View Audit Log ',
- '
',
- '
',
- ].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 + '
Done ';
- 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 = 'Nostr Public Key (npub1...):
';
- }
-
- var externalIp = _cachedExternalIp || "your external IP";
-
- $domainSetupBody.innerHTML =
- '' +
- '
Before continuing:
' +
- '
' +
- 'Create an account at https://njal.la ' +
- '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. ' +
- 'In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address: ' +
- '' + escHtml(externalIp) + ' ' +
- 'Njal.la will give you a curl command like: ' +
- 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto" ' +
- 'Enter the subdomain and paste that curl command below ' +
- ' ' +
- '
' +
- 'Subdomain (e.g. myservice.example.com):
' +
- '' +
- npubField +
- 'Cancel Save & Enable
';
-
- 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
- ? 'I Understand — Continue '
- : '';
-
- // 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 +
- '' +
- 'Port(s) Protocol Purpose Status ' +
- '' + rows + ' ' +
- '
' +
- "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.
' +
- '' +
- 'Dismiss ' +
- 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.
' +
- '' +
- 'Port(s) Protocol Purpose ' +
- '' + rows + ' ' +
- '
' +
- 'ℹ Search "how to set up port forwarding on [your router model] " for step-by-step instructions.
' +
- '' +
- 'Dismiss ' +
- 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 = ' ';
-
- // 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 = '';
-
- 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 = 'Nostr Public Key (npub1...):
';
+ }
+
+ var externalIp = _cachedExternalIp || "your external IP";
+
+ $domainSetupBody.innerHTML =
+ '' +
+ '
Before continuing:
' +
+ '
' +
+ 'Create an account at https://njal.la ' +
+ '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. ' +
+ 'In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address: ' +
+ '' + escHtml(externalIp) + ' ' +
+ 'Njal.la will give you a curl command like: ' +
+ 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto" ' +
+ 'Enter the subdomain and paste that curl command below ' +
+ ' ' +
+ '
' +
+ 'Subdomain (e.g. myservice.example.com):
' +
+ '' +
+ npubField +
+ 'Cancel Save & Enable
';
+
+ 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
+ ? 'I Understand — Continue '
+ : '';
+
+ // 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 +
+ '' +
+ 'Port(s) Protocol Purpose Status ' +
+ '' + rows + ' ' +
+ '
' +
+ "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.
' +
+ '' +
+ 'Dismiss ' +
+ 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.
' +
+ '' +
+ 'Port(s) Protocol Purpose ' +
+ '' + rows + ' ' +
+ '
' +
+ 'ℹ Search "how to set up port forwarding on [your router model] " for step-by-step instructions.
' +
+ '' +
+ 'Dismiss ' +
+ 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 = ' ';
+
+ // 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 = '';
+
+ 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 = 'Scan with Zeus app on your phone
';
+ }
+ html += '' + escHtml(cred.label) + '
' + qrBlock + '
';
+ }
+ 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:
' +
+ '' +
+ 'Log into your router\'s admin panel (usually http://192.168.1.1) ' +
+ 'Find the Port Forwarding section ' +
+ 'Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + ' ' +
+ 'Save your router settings ' +
+ ' ' +
+ '💡 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 + '
' +
+ '' +
+ 'Log into your router\'s admin panel ' +
+ 'Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + ' ' +
+ 'Save your router settings ' +
+ ' '
+ );
+ }
+
+ troubleshootHtml = '' + troubleParts.join('
') + '';
+ }
+
+ html += '' +
+ '
Port Status
' +
+ '
' +
+ '' +
+ 'Port Protocol Description Status ' +
+ ' ' +
+ '' + portTableRows + ' ' +
+ '
' +
+ 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:
' +
+ '
' +
+ 'Go to njal.la and log into your account ' +
+ 'Find your domain and check the Dynamic DNS record ' +
+ 'Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + ' ' +
+ 'If you set up a DDNS curl command during onboarding, verify it\'s running correctly ' +
+ ' ' +
+ '
';
+ } 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:
' +
+ '
' +
+ 'Go to njal.la and log into your account ' +
+ 'Find the domain you purchased for this service ' +
+ 'Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + ' ' +
+ 'Copy the DDNS curl command from Njal.la\'s dashboard ' +
+ 'You can re-enter it in the Feature Manager to update your configuration ' +
+ ' ' +
+ '
';
+ } else {
+ domainBadge = '' + escHtml(data.domain) + ' ';
+ }
+ } else {
+ domainBadge = 'Not configured ';
+ domainStatusHtml = '' +
+ '
⚠️ No domain has been configured for this service yet. ' +
+ '
To get this service working:
' +
+ '
' +
+ 'Purchase a subdomain at njal.la (if you haven\'t already) ' +
+ 'Go to the Feature Manager in the sidebar ' +
+ 'Find this service and configure your domain through the setup wizard ' +
+ ' ' +
+ '
';
+ }
+
+ 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" ?
+ '
' +
+ '➕ Add New User ' +
+ '🔑 Change Password ' +
+ '
' : "") +
+ '
';
+ } 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 + ' ' +
+ '' + escHtml(addonBtnLabel) + ' ' +
+ '
' +
+ '
';
+ }
+
+ $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 += '' +
+ '➕ Add New User ' +
+ '🔑 Change Password ' +
+ '
';
+ }
+ $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 =
+ 'Username ' +
+ '
' +
+ 'Password ' +
+ '
' +
+ 'Make admin
' +
+ '' +
+ '← Back ' +
+ 'Create User ' +
+ '
' +
+ '
';
+
+ 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 =
+ 'Username (localpart only, e.g. alice ) ' +
+ '
' +
+ 'New Password ' +
+ '
' +
+ '' +
+ '← Back ' +
+ 'Change Password ' +
+ '
' +
+ '
';
+
+ 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 files (LND, Sparrow, Bisq) are protected by default . Support staff cannot access your private keys unless you explicitly grant access.
',
+ '
',
+ '
What happens:
',
+ 'A restricted sovran-support user is created with limited access ',
+ 'Our SSH key is added only to that restricted account ',
+ 'Wallet files are locked via access controls — not visible to support ',
+ 'You control if and when wallet access is granted (time-limited) ',
+ 'All session events are logged for your audit ',
+ ' ',
+ '
Enable Support Access ',
+ '
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 = [
+ '',
+ '',
+ '
You have granted support temporary access to wallet files' + (unlockUntil ? ' until ' + escHtml(unlockUntil) + ' ' : '') + '.
',
+ '
Re-lock Wallet Now ',
+ '
',
+ ].join("");
+ } else {
+ var pathList = protectedPaths.length
+ ? '' + protectedPaths.map(function(p){ return '' + escHtml(p) + ' '; }).join("") + ' '
+ : '';
+ walletSection = [
+ '',
+ '',
+ '
Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.
',
+ pathList,
+ '
',
+ '',
+ '1 hour ',
+ '30 minutes ',
+ '2 hours ',
+ ' ',
+ 'Grant Wallet Access ',
+ '
',
+ '
',
+ ].join("");
+ }
+ } else {
+ walletSection = [
+ '',
+ '',
+ '
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,
+ '
End Support Session ',
+ '
This will remove the SSH key and revoke all wallet access immediately.
',
+ '
View Audit Log ',
+ '
',
+ '
',
+ ].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 + '
Done ';
+ 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 = '
';
+ 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 = '';
+ }
+}
+
+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 =
+ '' +
+ '';
+ 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) + '
Click for help
';
+ tile.style.cursor = "pointer";
+ tile.addEventListener("click", function() { openSupportModal(); });
+ return tile;
+ }
+
+ tile.innerHTML = '?
' + 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 @@
-
+
+
+
+
+
+
+
+
+
+