From b9c8c20347d1b4d985a2ecc6c913ef2d1ec9a710 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:24:17 +0000 Subject: [PATCH] feat: add Feature Manager to Sovran Hub dashboard - flake.nix: import /etc/nixos/hub-overrides.nix alongside custom.nix - sovran-hub.nix: add hub-overrides-init service (seeds file if missing), sovran-hub-rebuild service (nixos-rebuild switch only), and feature_manager=true in generated config.json - server.py: add FEATURE_REGISTRY with 6 features (rdp, haven, element-calling, mempool, bip110, bitcoin-core); add hub-overrides.nix read/write helpers; add /api/features, /api/features/toggle, /api/rebuild/status, /api/domains/set, /api/domains/set-email, /api/domains/status endpoints; update /api/config to expose feature_manager - index.html: add domain setup modal, SSL email modal, feature confirm modal, and rebuild modal HTML - app.js: add Feature Manager rendering with sub-category layout, feature toggle cards with sliding toggles, domain setup flow, SSL email collection, conflict confirmation, rebuild polling - style.css: add Feature Manager styles (feature cards, toggle switch, domain badge, conflict warning, domain input fields)" Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 389 +++++++++++++- app/sovran_systemsos_web/static/app.js | 503 +++++++++++++++++- app/sovran_systemsos_web/static/style.css | 217 ++++++++ app/sovran_systemsos_web/templates/index.html | 66 +++ flake.nix | 1 + modules/core/sovran-hub.nix | 60 +++ 6 files changed, 1228 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5b79a79..f4de31c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -13,10 +13,11 @@ import subprocess import urllib.request from fastapi import FastAPI, HTTPException -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request +from pydantic import BaseModel from .config import load_config from . import systemctl as sysctl @@ -31,6 +32,15 @@ UPDATE_LOG = "/var/log/sovran-hub-update.log" UPDATE_STATUS = "/var/log/sovran-hub-update.status" UPDATE_UNIT = "sovran-hub-update.service" +REBUILD_LOG = "/var/log/sovran-hub-rebuild.log" +REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status" +REBUILD_UNIT = "sovran-hub-rebuild.service" + +HUB_OVERRIDES_NIX = "/etc/nixos/hub-overrides.nix" +DOMAINS_DIR = "/var/lib/domains" +NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub" +NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh" + INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" @@ -55,6 +65,85 @@ CATEGORY_ORDER = [ ("apps", "Self-Hosted Apps"), ("nostr", "Nostr"), ("support", "Support"), + ("feature-manager", "Feature Manager"), +] + +FEATURE_REGISTRY = [ + { + "id": "rdp", + "name": "Remote Desktop (RDP)", + "description": "Access your desktop remotely via RDP client", + "category": "infrastructure", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + }, + { + "id": "haven", + "name": "Haven NOSTR Relay", + "description": "Run your own private Nostr relay", + "category": "nostr", + "needs_domain": True, + "domain_name": "haven", + "needs_ddns": True, + "extra_fields": [ + { + "id": "nostr_npub", + "label": "Nostr Public Key (npub1...)", + "type": "text", + "required": True, + "current_value": "", + }, + ], + "conflicts_with": [], + }, + { + "id": "element-calling", + "name": "Element Video & Audio Calling", + "description": "Add video/audio calling to Matrix via LiveKit", + "category": "communication", + "needs_domain": True, + "domain_name": "element-calling", + "needs_ddns": True, + "extra_fields": [], + "conflicts_with": [], + "requires": ["matrix_domain"], + }, + { + "id": "mempool", + "name": "Mempool Explorer", + "description": "Bitcoin mempool visualization and explorer", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + }, + { + "id": "bip110", + "name": "BIP-110 (Bitcoin Better Money)", + "description": "Bitcoin Knots with BIP-110 consensus changes", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": ["bitcoin-core"], + }, + { + "id": "bitcoin-core", + "name": "Bitcoin Core", + "description": "Use Bitcoin Core instead of Bitcoin Knots", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": ["bip110"], + }, ] ROLE_LABELS = { @@ -303,6 +392,77 @@ def _resolve_credential(cred: dict) -> dict | None: return result +# ── Rebuild helpers (file-based, no systemctl) ─────────────────── + +def _read_rebuild_status() -> str: + """Read the rebuild status file. Returns RUNNING, SUCCESS, FAILED, or IDLE.""" + try: + with open(REBUILD_STATUS, "r") as f: + return f.read().strip() + except FileNotFoundError: + return "IDLE" + + +def _read_rebuild_log(offset: int = 0) -> tuple[str, int]: + """Read the rebuild log file from the given byte offset.""" + try: + with open(REBUILD_LOG, "rb") as f: + f.seek(0, 2) + size = f.tell() + if offset > size: + offset = 0 + f.seek(offset) + chunk = f.read() + return chunk.decode(errors="replace"), offset + len(chunk) + except FileNotFoundError: + return "", 0 + + +# ── hub-overrides.nix helpers ───────────────────────────────────── + +def _read_hub_overrides() -> tuple[dict, str | None]: + """Parse hub-overrides.nix. Returns (features_dict, nostr_npub_or_none).""" + features: dict[str, bool] = {} + nostr_npub = None + try: + with open(HUB_OVERRIDES_NIX, "r") as f: + content = f.read() + for m in re.finditer( + r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(true|false)\s*;', + content, + ): + features[m.group(1)] = m.group(2) == "true" + m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*"([^"]*)"', content) + if m2: + nostr_npub = m2.group(1) + except FileNotFoundError: + pass + return features, nostr_npub + + +def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: + """Write a complete hub-overrides.nix from the given state.""" + lines = [] + for feat_id, enabled in features.items(): + val = "true" if enabled else "false" + lines.append(f" sovran_systemsOS.features.{feat_id} = {val};") + if nostr_npub: + lines.append(f' sovran_systemsOS.nostr_npub = "{nostr_npub}";') + body = "\n".join(lines) + "\n" if lines else "" + content = ( + "# Auto-generated by Sovran Hub — do not edit manually\n" + "{ ... }:\n" + "{\n" + + body + + "}\n" + ) + nix_dir = os.path.dirname(HUB_OVERRIDES_NIX) + if nix_dir: + os.makedirs(nix_dir, exist_ok=True) + with open(HUB_OVERRIDES_NIX, "w") as f: + f.write(content) + + # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: @@ -422,6 +582,7 @@ async def api_config(): "role": role, "role_label": ROLE_LABELS.get(role, role), "category_order": CATEGORY_ORDER, + "feature_manager": cfg.get("feature_manager", False), } @@ -611,6 +772,232 @@ async def api_support_disable(): return {"ok": True, "verified": verified, "message": "Support access removed and verified"} +# ── Feature Manager endpoints ───────────────────────────────────── + +@app.get("/api/features") +async def api_features(): + """Return all toggleable features with current state and domain requirements.""" + loop = asyncio.get_event_loop() + overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + + ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail") + ssl_email_configured = os.path.exists(ssl_email_path) + + features = [] + for feat in FEATURE_REGISTRY: + feat_id = feat["id"] + enabled = overrides.get(feat_id, False) + + domain_name = feat.get("domain_name") + domain_configured = True + if domain_name: + domain_configured = os.path.exists(os.path.join(DOMAINS_DIR, domain_name)) + + extra_fields = [] + for ef in feat.get("extra_fields", []): + ef_copy = dict(ef) + if ef["id"] == "nostr_npub": + ef_copy["current_value"] = nostr_npub or "" + extra_fields.append(ef_copy) + + entry: dict = { + "id": feat_id, + "name": feat["name"], + "description": feat["description"], + "category": feat["category"], + "enabled": enabled, + "needs_domain": feat.get("needs_domain", False), + "domain_configured": domain_configured, + "domain_name": domain_name, + "needs_ddns": feat.get("needs_ddns", False), + "extra_fields": extra_fields, + "conflicts_with": feat.get("conflicts_with", []), + } + if "requires" in feat: + entry["requires"] = feat["requires"] + features.append(entry) + + return {"features": features, "ssl_email_configured": ssl_email_configured} + + +class FeatureToggleRequest(BaseModel): + feature: str + enabled: bool + extra: dict = {} + + +@app.post("/api/features/toggle") +async def api_features_toggle(req: FeatureToggleRequest): + """Enable or disable a feature and trigger a system rebuild.""" + feat_meta = next((f for f in FEATURE_REGISTRY if f["id"] == req.feature), None) + if not feat_meta: + raise HTTPException(status_code=404, detail="Feature not found") + + loop = asyncio.get_event_loop() + features, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + + if req.enabled: + # Element-calling requires matrix domain + if req.feature == "element-calling": + if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")): + raise HTTPException( + status_code=400, + detail=( + "Element Calling requires a Matrix domain to be configured. " + "Please run `sovran-setup-domains` first or configure the Matrix domain." + ), + ) + + # Domain requirement check + if feat_meta.get("needs_domain") and feat_meta.get("domain_name"): + domain_path = os.path.join(DOMAINS_DIR, feat_meta["domain_name"]) + if not os.path.exists(domain_path): + return JSONResponse( + status_code=400, + content={ + "error": "domain_required", + "domain_name": feat_meta["domain_name"], + }, + ) + + # Haven requires nostr_npub + if req.feature == "haven": + npub = (req.extra or {}).get("nostr_npub", "").strip() + if npub: + nostr_npub = npub + elif not nostr_npub: + raise HTTPException(status_code=400, detail="nostr_npub is required for Haven") + + # Auto-disable conflicting features + for conflict_id in feat_meta.get("conflicts_with", []): + features[conflict_id] = False + + features[req.feature] = True + else: + features[req.feature] = False + + # Persist any extra fields (nostr_npub) + new_npub = (req.extra or {}).get("nostr_npub", "").strip() + if new_npub: + nostr_npub = new_npub + try: + os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True) + with open(NOSTR_NPUB_FILE, "w") as f: + f.write(nostr_npub) + except OSError: + pass + + await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub) + + # Start the rebuild service + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "rebuilding"} + + +@app.get("/api/rebuild/status") +async def api_rebuild_status(offset: int = 0): + """Poll endpoint for rebuild progress.""" + loop = asyncio.get_event_loop() + status = await loop.run_in_executor(None, _read_rebuild_status) + new_log, new_offset = await loop.run_in_executor(None, _read_rebuild_log, offset) + running = status == "RUNNING" + result = "pending" if running else status.lower() + return { + "running": running, + "result": result, + "log": new_log, + "offset": new_offset, + } + + +# ── Domain endpoints ────────────────────────────────────────────── + +class DomainSetRequest(BaseModel): + domain_name: str + domain: str + ddns_url: str = "" + + +@app.post("/api/domains/set") +async def api_domains_set(req: DomainSetRequest): + """Save a domain and optionally register a DDNS URL.""" + os.makedirs(DOMAINS_DIR, exist_ok=True) + domain_path = os.path.join(DOMAINS_DIR, req.domain_name) + with open(domain_path, "w") as f: + f.write(req.domain.strip()) + + if req.ddns_url: + ddns_url = req.ddns_url.strip() + # Strip leading "curl " if present + if ddns_url.lower().startswith("curl "): + ddns_url = ddns_url[5:].strip() + # Strip surrounding quotes + if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]: + ddns_url = ddns_url[1:-1] + # Replace trailing &auto with &a=${IP} + if ddns_url.endswith("&auto"): + ddns_url = ddns_url[:-5] + "&a=${IP}" + # Append curl line to njalla.sh + njalla_dir = os.path.dirname(NJALLA_SCRIPT) + if njalla_dir: + os.makedirs(njalla_dir, exist_ok=True) + with open(NJALLA_SCRIPT, "a") as f: + f.write(f'curl "{ddns_url}"\n') + try: + os.chmod(NJALLA_SCRIPT, 0o755) + except OSError: + pass + # Run njalla.sh immediately to update DNS + try: + subprocess.run([NJALLA_SCRIPT], timeout=30, check=False) + except Exception: + pass + + return {"ok": True} + + +class DomainSetEmailRequest(BaseModel): + email: str + + +@app.post("/api/domains/set-email") +async def api_domains_set_email(req: DomainSetEmailRequest): + """Save the SSL certificate email address.""" + os.makedirs(DOMAINS_DIR, exist_ok=True) + with open(os.path.join(DOMAINS_DIR, "sslemail"), "w") as f: + f.write(req.email.strip()) + return {"ok": True} + + +@app.get("/api/domains/status") +async def api_domains_status(): + """Return the value of each known domain file (or null if missing).""" + known = [ + "matrix", "haven", "element-calling", "sslemail", + "vaultwarden", "btcpayserver", "nextcloud", "wordpress", + ] + domains: dict[str, str | None] = {} + for name in known: + path = os.path.join(DOMAINS_DIR, name) + try: + with open(path, "r") as f: + domains[name] = f.read().strip() + except FileNotFoundError: + domains[name] = None + return {"domains": domains} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3f39249..179c0ed 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,5 +1,5 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v7 — Status-only dashboard + Tech Support */ + v7 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; @@ -16,8 +16,18 @@ const CATEGORY_ORDER = [ "apps", "nostr", "support", + "feature-manager", ]; +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", ]); @@ -35,6 +45,15 @@ let _supportTimerInt = null; let _supportEnabledAt = null; let _cachedExternalIp = null; +// Feature Manager state +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm + // ── DOM refs ────────────────────────────────────────────────────── const $tilesArea = document.getElementById("tiles-area"); @@ -63,6 +82,35 @@ 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"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } @@ -480,6 +528,417 @@ function waitForServerReboot() { .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); } +// ── Rebuild modal ───────────────────────────────────────────────── + +function openRebuildModal() { + if (!$rebuildModal) return; + _rebuildLog = ""; + _rebuildLogOffset = 0; + _rebuildServerDown = false; + _rebuildFinished = false; + if ($rebuildLog) $rebuildLog.textContent = ""; + if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; + if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); + if ($rebuildReboot) $rebuildReboot.style.display = "none"; + if ($rebuildSave) $rebuildSave.style.display = "none"; + if ($rebuildClose) $rebuildClose.disabled = true; + $rebuildModal.classList.add("open"); + startRebuildPoll(); +} + +function closeRebuildModal() { + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopRebuildPoll(); +} + +function appendRebuildLog(text) { + if (!text) return; + _rebuildLog += text; + if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } +} + +function startRebuildPoll() { + pollRebuildStatus(); + _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); +} + +function stopRebuildPoll() { + if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } +} + +async function pollRebuildStatus() { + if (_rebuildFinished) return; + try { + var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); + if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } + if (data.log) appendRebuildLog(data.log); + _rebuildLogOffset = data.offset; + if (data.running) return; + _rebuildFinished = true; + stopRebuildPoll(); + onRebuildDone(data.result === "success"); + } catch (err) { + if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } + } +} + +function onRebuildDone(success) { + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (success) { + if ($rebuildStatus) $rebuildStatus.textContent = "✓ Rebuild complete"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + // Refresh feature states + loadFeatureManager(); + } else { + if ($rebuildStatus) $rebuildStatus.textContent = "✗ Rebuild failed"; + if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + } +} + +function saveRebuildErrorReport() { + var blob = new Blob([_rebuildLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Feature confirm modal ───────────────────────────────────────── + +function openFeatureConfirm(message, onConfirm) { + if (!$featureConfirmModal) return; + if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; + $featureConfirmModal.classList.add("open"); + // Replace ok handler + var newOk = $featureConfirmOk.cloneNode(true); + $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); + newOk.addEventListener("click", function() { + closeFeatureConfirm(); + onConfirm(); + }); +} + +function closeFeatureConfirm() { + if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); +} + +// ── SSL Email modal ─────────────────────────────────────────────── + +function openSslEmailModal(onSaved) { + if (!$sslEmailModal) return; + if ($sslEmailInput) $sslEmailInput.value = ""; + $sslEmailModal.classList.add("open"); + // Replace save handler + var newSave = $sslEmailSave.cloneNode(true); + $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); + newSave.addEventListener("click", async function() { + var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; + if (!email) { alert("Please enter an email address."); return; } + newSave.disabled = true; + newSave.textContent = "Saving…"; + try { + await apiFetch("/api/domains/set-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }), + }); + closeSslEmailModal(); + onSaved(); + } catch (err) { + newSave.disabled = false; + newSave.textContent = "Save"; + alert("Failed to save email. Please try again."); + } + }); +} + +function closeSslEmailModal() { + if ($sslEmailModal) $sslEmailModal.classList.remove("open"); +} + +// ── Domain Setup modal ──────────────────────────────────────────── + +function openDomainSetupModal(feat, onSaved) { + if (!$domainSetupModal) return; + if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + + var npubField = ""; + if (feat.id === "haven") { + var currentNpub = ""; + if (feat.extra_fields && feat.extra_fields.length > 0) { + for (var i = 0; i < feat.extra_fields.length; i++) { + if (feat.extra_fields[i].id === "nostr_npub") { + currentNpub = feat.extra_fields[i].current_value || ""; + break; + } + } + } + npubField = '
'; + } + + $domainSetupBody.innerHTML = + '

Before continuing, you need:

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

ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

' + + npubField + + '
'; + + document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); + + document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { + var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; + var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; + var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; + subdomain = subdomain.trim(); + ddnsUrl = ddnsUrl.trim(); + npub = npub.trim(); + + if (!subdomain) { alert("Please enter a subdomain."); return; } + if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } + + var saveBtn = document.getElementById("domain-setup-save-btn"); + saveBtn.disabled = true; + saveBtn.textContent = "Saving…"; + + try { + await apiFetch("/api/domains/set", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain_name: feat.domain_name, + domain: subdomain, + ddns_url: ddnsUrl, + }), + }); + closeDomainSetupModal(); + onSaved(npub); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = "Save & Enable"; + alert("Failed to save domain. Please try again."); + } + }); + + $domainSetupModal.classList.add("open"); +} + +function closeDomainSetupModal() { + if ($domainSetupModal) $domainSetupModal.classList.remove("open"); +} + +// ── Feature toggle logic ────────────────────────────────────────── + +async function performFeatureToggle(featId, enabled, extra) { + try { + var res = await fetch("/api/features/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), + }); + var body = await res.json(); + if (!res.ok) { + if (body && body.error === "domain_required") { + alert("Domain not configured for this feature. Please configure it first."); + } else { + alert("Error: " + (body.detail || body.error || "Unknown error")); + } + loadFeatureManager(); + return; + } + openRebuildModal(); + } catch (err) { + alert("Failed to toggle feature: " + err); + loadFeatureManager(); + } +} + +function handleFeatureToggle(feat, newEnabled) { + if (!newEnabled) { + // Disable: ask confirmation + openFeatureConfirm( + "This will disable " + feat.name + ". The system will rebuild. Continue?", + function() { performFeatureToggle(feat.id, false, {}); } + ); + return; + } + + // Enabling + var conflictNames = []; + if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { + feat.conflicts_with.forEach(function(cid) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf && cf.enabled) conflictNames.push(cf.name); + }); + } + + function proceedAfterConflictCheck() { + // Check SSL email first + if (!_featuresData || !_featuresData.ssl_email_configured) { + if (feat.needs_domain) { + openSslEmailModal(function() { + // After ssl email saved, check domain + checkDomainAndEnable(feat, {}); + }); + return; + } + } + if (feat.needs_domain && !feat.domain_configured) { + checkDomainAndEnable(feat, {}); + return; + } + if (feat.id === "haven") { + var npub = ""; + if (feat.extra_fields) { + var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); + if (ef) npub = ef.current_value || ""; + } + if (!npub) { + // Need to collect npub via domain modal + openDomainSetupModal(feat, function(collectedNpub) { + performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); + }); + return; + } + } + performFeatureToggle(feat.id, true, {}); + } + + if (conflictNames.length > 0) { + openFeatureConfirm( + "This will disable " + conflictNames.join(", ") + ". Continue?", + proceedAfterConflictCheck + ); + } else { + proceedAfterConflictCheck(); + } +} + +function checkDomainAndEnable(feat, extra) { + openDomainSetupModal(feat, function(collectedNpub) { + var extraData = {}; + if (collectedNpub) extraData.nostr_npub = collectedNpub; + performFeatureToggle(feat.id, true, extraData); + }); +} + +// ── Feature Manager rendering ───────────────────────────────────── + +async function loadFeatureManager() { + try { + var data = await apiFetch("/api/features"); + _featuresData = data; + renderFeatureManager(data); + } catch (err) { + console.warn("Failed to load features:", err); + } +} + +function renderFeatureManager(data) { + // Remove old feature manager section if it exists + var old = $tilesArea.querySelector(".feature-manager-section"); + if (old) old.parentNode.removeChild(old); + + var section = document.createElement("div"); + section.className = "category-section feature-manager-section"; + section.dataset.category = "feature-manager"; + section.innerHTML = '
Feature Manager

'; + + // Group by sub-category + var grouped = {}; + for (var i = 0; i < data.features.length; i++) { + var f = data.features[i]; + var cat = f.category || "other"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(f); + } + + var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); + Object.keys(grouped).forEach(function(k) { + if (orderedCats.indexOf(k) === -1) orderedCats.push(k); + }); + + for (var j = 0; j < orderedCats.length; j++) { + var catKey = orderedCats[j]; + var feats = grouped[catKey]; + if (!feats || feats.length === 0) continue; + + var subcat = document.createElement("div"); + subcat.className = "feature-subcategory"; + var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; + subcat.innerHTML = '
' + escHtml(subcatLabel) + '
'; + + var cardsWrap = document.createElement("div"); + cardsWrap.className = "feature-cards-wrap"; + + for (var k = 0; k < feats.length; k++) { + cardsWrap.appendChild(buildFeatureCard(feats[k])); + } + subcat.appendChild(cardsWrap); + section.appendChild(subcat); + } + + $tilesArea.appendChild(section); +} + +function buildFeatureCard(feat) { + var card = document.createElement("div"); + card.className = "feature-card"; + + var conflictHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (!_featuresData) return cid; + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + return cf ? cf.name : cid; + }); + conflictHtml = '
⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
'; + } + + var domainHtml = ""; + if (feat.needs_domain) { + if (feat.domain_configured) { + domainHtml = '
🌐 Domain: Configured
'; + } else { + domainHtml = '
🌐 Domain: Not configured
'; + } + } + + var statusText = feat.enabled ? "Enabled" : "Disabled"; + + card.innerHTML = + '
' + + '
' + + '
' + escHtml(feat.name) + '
' + + '
' + escHtml(feat.description) + '
' + + '
' + + '' + + '
' + + domainHtml + + conflictHtml + + '
Status: ' + escHtml(statusText) + '
'; + + var toggle = card.querySelector(".feature-toggle-input"); + var toggleLabel = card.querySelector(".feature-toggle"); + toggle.addEventListener("change", function() { + var newEnabled = toggle.checked; + // Revert visually until confirmed + toggle.checked = feat.enabled; + if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } + handleFeatureToggle(feat, newEnabled); + }); + + return card; +} + // ── Event listeners ─────────────────────────────────────────────── if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); @@ -490,6 +949,26 @@ 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(); }); @@ -506,14 +985,24 @@ async function init() { } var badge = document.getElementById("role-badge"); if (badge && cfg.role_label) badge.textContent = cfg.role_label; - } catch (_) {} - await refreshServices(); - loadNetwork(); - checkUpdates(); + await refreshServices(); + loadNetwork(); + checkUpdates(); - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + 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/style.css b/app/sovran_systemsos_web/static/style.css index cbfa0e1..068ec04 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -960,3 +960,220 @@ button.btn-reboot:hover:not(:disabled) { } } + +/* ── Feature Manager ─────────────────────────────────────────────── */ + +.feature-manager-section { + margin-bottom: 32px; +} + +.feature-subcategory { + margin-bottom: 24px; +} + +.feature-subcategory-header { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 10px; + padding-left: 2px; +} + +.feature-cards-wrap { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; +} + +.feature-card { + background-color: var(--card-color); + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); +} + +.feature-card:last-child { + border-bottom: none; +} + +.feature-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.feature-card-info { + flex: 1; + min-width: 0; +} + +.feature-card-name { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.feature-card-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.feature-card-status { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 6px; +} + +/* ── Feature toggle switch ───────────────────────────────────────── */ + +.feature-toggle { + position: relative; + display: inline-flex; + align-items: center; + width: 44px; + height: 24px; + flex-shrink: 0; + cursor: pointer; + margin-top: 2px; +} + +.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: var(--text-secondary); + border-radius: 50%; + transition: transform 0.2s, background-color 0.2s; +} + +.feature-toggle.active .feature-toggle-slider { + background-color: var(--green); +} + +.feature-toggle.active .feature-toggle-slider::before { + transform: translateX(20px); + background-color: #fff; +} + +/* ── Feature domain badge ────────────────────────────────────────── */ + +.feature-domain-badge { + font-size: 0.75rem; + font-weight: 600; + margin-top: 6px; + padding: 2px 0; +} + +.feature-domain-badge.configured { + color: var(--green); +} + +.feature-domain-badge.not-configured { + color: var(--yellow); +} + +/* ── Feature conflict warning ────────────────────────────────────── */ + +.feature-conflict-warning { + font-size: 0.75rem; + color: var(--yellow); + margin-top: 4px; +} + +/* ── Domain setup modal inputs ───────────────────────────────────── */ + +.domain-narrow-dialog { + max-width: 520px; +} + +.domain-setup-intro { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 18px; + line-height: 1.6; +} + +.domain-setup-intro ol { + padding-left: 20px; + margin-top: 6px; +} + +.domain-field-group { + margin-bottom: 14px; +} + +.domain-field-label { + display: block; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.domain-field-input { + width: 100%; + background-color: #12121c; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.92rem; + padding: 10px 14px; + outline: none; + transition: border-color 0.15s; +} + +.domain-field-input:focus { + border-color: var(--accent-color); +} + +.domain-field-hint { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 5px; + font-style: italic; +} + +.domain-field-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +@media (max-width: 600px) { + .feature-cards-wrap { + border-radius: 10px; + } + .feature-card { + padding: 12px 14px; + } + .domain-narrow-dialog { + margin: 0 12px; + } +} diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 1b7455b..f0e9bd0 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -80,6 +80,72 @@ + + + + + + + + + + + +
diff --git a/flake.nix b/flake.nix index 89222a7..eb5b029 100755 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,7 @@ self.nixosModules.Sovran_SystemsOS /etc/nixos/role-state.nix /etc/nixos/custom.nix + /etc/nixos/hub-overrides.nix ]; }; diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index f8f7923..02faeaf 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -99,6 +99,7 @@ let command_method = "systemctl"; role = activeRole; services = monitoredServices; + feature_manager = true; }); # ── Update wrapper script ────────────────────────────────────── @@ -159,6 +160,39 @@ let exit "$RC" ''; + # ── Rebuild wrapper script ───────────────────────────────────── + rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" '' + set -uo pipefail + export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.coreutils ]}:$PATH" + + LOG="/var/log/sovran-hub-rebuild.log" + STATUS="/var/log/sovran-hub-rebuild.status" + + echo "RUNNING" > "$STATUS" + : > "$LOG" + exec > >(tee -a "$LOG") 2>&1 + + echo "══════════════════════════════════════════════════" + echo " Sovran_SystemsOS Rebuild — $(date)" + echo "══════════════════════════════════════════════════" + echo "" + echo "── Rebuilding system configuration ──────────────" + if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then + echo "" + echo "══════════════════════════════════════════════════" + echo " ✓ Rebuild completed successfully" + echo "══════════════════════════════════════════════════" + echo "SUCCESS" > "$STATUS" + else + echo "" + echo "══════════════════════════════════════════════════" + echo " ✗ Rebuild failed — see errors above" + echo "══════════════════════════════════════════════════" + echo "FAILED" > "$STATUS" + exit 1 + fi + ''; + sovran-hub-web = pkgs.python3Packages.buildPythonApplication { pname = "sovran-systemsos-hub-web"; version = "1.0.0"; @@ -241,6 +275,32 @@ in }; }; + systemd.services.sovran-hub-rebuild = { + description = "Sovran_SystemsOS System Rebuild"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${rebuild-script}"; + }; + }; + + systemd.services.hub-overrides-init = { + description = "Initialize hub-overrides.nix if missing"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + unitConfig.ConditionPathExists = "!/etc/nixos/hub-overrides.nix"; + script = '' + cat > /etc/nixos/hub-overrides.nix <<'EOF' +# Auto-generated by Sovran Hub — do not edit manually +{ ... }: +{ +} +EOF + ''; + }; + networking.firewall.allowedTCPPorts = [ 8937 ]; }; } \ No newline at end of file