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

@@ -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,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);