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>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-02 23:24:17 +00:00
committed by GitHub
parent 971b0797df
commit b9c8c20347
6 changed files with 1228 additions and 8 deletions

View File

@@ -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")

View File

@@ -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 = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1…" value="' + escHtml(currentNpub) + '" /></div>';
}
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro"><p>Before continuing, you need:</p><ol><li>A subdomain purchased on njal.la</li><li>A Dynamic DNS record for it</li></ol></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain:</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="relay.mydomain.com" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la DDNS URL:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="https://njal.la/update/?h=..." /><p class="domain-field-hint"> Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Enable</button></div>';
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 = '<div class="section-header">Feature Manager</div><hr class="section-divider" />';
// 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 = '<div class="feature-subcategory-header">' + escHtml(subcatLabel) + '</div>';
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 = '<div class="feature-conflict-warning">⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '</div>';
}
var domainHtml = "";
if (feat.needs_domain) {
if (feat.domain_configured) {
domainHtml = '<div class="feature-domain-badge configured">🌐 Domain: Configured</div>';
} else {
domainHtml = '<div class="feature-domain-badge not-configured">🌐 Domain: Not configured</div>';
}
}
var statusText = feat.enabled ? "Enabled" : "Disabled";
card.innerHTML =
'<div class="feature-card-top">' +
'<div class="feature-card-info">' +
'<div class="feature-card-name">' + escHtml(feat.name) + '</div>' +
'<div class="feature-card-desc">' + escHtml(feat.description) + '</div>' +
'</div>' +
'<label class="feature-toggle' + (feat.enabled ? " active" : "") + '" title="Toggle ' + escHtml(feat.name) + '">' +
'<input type="checkbox" class="feature-toggle-input"' + (feat.enabled ? " checked" : "") + ' />' +
'<span class="feature-toggle-slider"></span>' +
'</label>' +
'</div>' +
domainHtml +
conflictHtml +
'<div class="feature-card-status">Status: ' + escHtml(statusText) + '</div>';
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,7 +985,6 @@ async function init() {
}
var badge = document.getElementById("role-badge");
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
} catch (_) {}
await refreshServices();
loadNetwork();
@@ -514,6 +992,17 @@ async function init() {
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);

View File

@@ -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;
}
}

View File

@@ -80,6 +80,72 @@
</div>
</div>
<!-- Domain Setup Modal -->
<div class="modal-overlay" id="domain-setup-modal" role="dialog" aria-modal="true" aria-labelledby="domain-setup-title">
<div class="creds-dialog">
<div class="creds-header">
<span class="creds-title" id="domain-setup-title">🌐 Domain Setup</span>
<button class="creds-close-btn" id="domain-setup-close-btn" title="Close"></button>
</div>
<div class="creds-body" id="domain-setup-body"></div>
</div>
</div>
<!-- SSL Email Modal -->
<div class="modal-overlay" id="ssl-email-modal" role="dialog" aria-modal="true" aria-labelledby="ssl-email-title">
<div class="creds-dialog domain-narrow-dialog">
<div class="creds-header">
<span class="creds-title" id="ssl-email-title">📧 SSL Certificate Email</span>
<button class="creds-close-btn" id="ssl-email-close-btn" title="Close"></button>
</div>
<div class="creds-body">
<p class="support-desc">Let's Encrypt needs an email address for SSL certificate notifications.</p>
<div class="domain-field-group">
<label class="domain-field-label" for="ssl-email-input">Email:</label>
<input class="domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" />
</div>
<div class="domain-field-actions">
<button class="btn btn-close-modal" id="ssl-email-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="ssl-email-save-btn">Save</button>
</div>
</div>
</div>
</div>
<!-- Feature Confirm Modal -->
<div class="modal-overlay" id="feature-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="feature-confirm-title">
<div class="creds-dialog domain-narrow-dialog">
<div class="creds-header">
<span class="creds-title" id="feature-confirm-title">Confirm Action</span>
<button class="creds-close-btn" id="feature-confirm-close-btn" title="Close"></button>
</div>
<div class="creds-body">
<p class="support-desc" id="feature-confirm-message"></p>
<div class="domain-field-actions">
<button class="btn btn-close-modal" id="feature-confirm-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="feature-confirm-ok-btn">Continue</button>
</div>
</div>
</div>
</div>
<!-- Rebuild Modal -->
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
<div class="modal-dialog">
<div class="modal-header">
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
<div class="modal-spinner" id="rebuild-spinner"></div>
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
</div>
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
</div>
</div>
</div>
<!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay">
<div class="reboot-card">

View File

@@ -27,6 +27,7 @@
self.nixosModules.Sovran_SystemsOS
/etc/nixos/role-state.nix
/etc/nixos/custom.nix
/etc/nixos/hub-overrides.nix
];
};

View File

@@ -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 ];
};
}