diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 099781c..b1abb4e 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -241,7 +241,6 @@ SERVICE_DOMAIN_MAP: dict[str, str] = { "phpfpm-wordpress.service": "wordpress", "haven-relay.service": "haven", "livekit.service": "element-calling", - "caddy.service": "matrix", # Caddy serves the main domain } # For features that share a unit, disambiguate by icon field @@ -1334,7 +1333,7 @@ async def api_service_detail(unit: str): } loop = asyncio.get_event_loop() - overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) # Find the service config entry entry = next((s for s in services if s.get("unit") == unit), None) @@ -1478,6 +1477,41 @@ async def api_service_detail(unit: str): else: health = status # loading states, etc. + # Build feature entry if this service is an addon feature + feature_entry: dict | None = None + if feat_id is not None: + feat_meta = next((f for f in FEATURE_REGISTRY if f["id"] == feat_id), None) + if feat_meta is not None: + domain_name_feat = feat_meta.get("domain_name") + domain_configured = True + if domain_name_feat: + domain_path_feat = os.path.join(DOMAINS_DIR, domain_name_feat) + try: + with open(domain_path_feat, "r") as f: + domain_configured = bool(f.read(256).strip()) + except OSError: + domain_configured = False + extra_fields = [] + for ef in feat_meta.get("extra_fields", []): + ef_copy = dict(ef) + if ef["id"] == "nostr_npub": + ef_copy["current_value"] = nostr_npub or "" + extra_fields.append(ef_copy) + feature_entry = { + "id": feat_id, + "name": feat_meta["name"], + "description": feat_meta["description"], + "category": feat_meta["category"], + "enabled": enabled, + "needs_domain": feat_meta.get("needs_domain", False), + "domain_configured": domain_configured, + "domain_name": domain_name_feat, + "needs_ddns": feat_meta.get("needs_ddns", False), + "extra_fields": extra_fields, + "conflicts_with": feat_meta.get("conflicts_with", []), + "port_requirements": feat_meta.get("port_requirements", []), + } + return { "name": entry.get("name", ""), "unit": unit, @@ -1495,6 +1529,7 @@ async def api_service_detail(unit: str): "port_statuses": port_statuses, "external_ip": external_ip, "internal_ip": internal_ip, + "feature": feature_entry, } diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index bef1aa3..77d70f7 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -575,9 +575,34 @@ async function openServiceDetailModal(unit, name) { '' + '' : "") + ''; - } else if (!data.enabled) { + } else if (!data.enabled && !data.feature) { html += '
' + - '

This service is not enabled in your configuration. You can enable it from the Feature Manager in the sidebar.

' + + '

This service is not enabled in your configuration.

' + + '
'; + } + + // Section F: Addon Feature toggle + if (data.feature) { + var feat = data.feature; + // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state + if (!_featuresData) { + _featuresData = { features: [feat], ssl_email_configured: false }; + } else { + var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); + if (fidx >= 0) { _featuresData.features[fidx] = feat; } + else { _featuresData.features.push(feat); } + } + var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; + var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; + var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; + var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; + html += '
' + + '
\uD83D\uDD27 Addon Feature
' + + '

This is an optional addon feature. You can enable or disable it at any time.

' + + '
' + + '' + addonStatusLabel + '' + + '' + + '
' + '
'; } @@ -590,6 +615,17 @@ async function openServiceDetailModal(unit, name) { if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); } + + if (data.feature) { + var addonBtn = document.getElementById("svc-detail-addon-btn"); + if (addonBtn) { + var addonFeat = data.feature; + addonBtn.addEventListener("click", function() { + closeCredsModal(); + handleFeatureToggle(addonFeat, !addonFeat.enabled); + }); + } + } } catch (err) { if ($credsBody) $credsBody.innerHTML = '

Could not load service details.

'; } @@ -1257,7 +1293,7 @@ function closeSslEmailModal() { function openDomainSetupModal(feat, onSaved) { if (!$domainSetupModal) return; - if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + if ($domainSetupTitle) $domainSetupTitle.textContent = "\uD83C\uDF10 Domain Setup \u2014 " + feat.name; var npubField = ""; if (feat.id === "haven") { @@ -1273,10 +1309,23 @@ function openDomainSetupModal(feat, onSaved) { npubField = '
'; } + var externalIp = _cachedExternalIp || "your external IP"; + $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

' + + '
' + + '

Before continuing:

' + + '
    ' + + '
  1. Create an account at https://njal.la
  2. ' + + '
  3. Purchase your domain on Njal.la
  4. ' + + '
  5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
    ' + + '' + escHtml(externalIp) + '
  6. ' + + '
  7. Njal.la will give you a curl command like:
    ' + + 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
  8. ' + + '
  9. Enter the subdomain and paste that curl command below
  10. ' + + '
' + + '
' + + '
' + + '

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

' + npubField + '
'; @@ -1569,9 +1618,7 @@ async function loadFeatureManager() { try { var data = await apiFetch("/api/features"); _featuresData = data; - renderFeatureManager(data); - // After rendering, do a batch domain check for all features that have a configured domain - _checkFeatureManagerDomains(data); + // Feature Manager is now integrated into tile modals; sidebar rendering removed. } catch (err) { console.warn("Failed to load features:", err); } diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index b7c0ee4..3ebe747 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -54,13 +54,19 @@ body { position: sticky; top: 0; z-index: 100; + justify-content: flex-end; } .header-bar .title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); - flex: 1; + position: absolute; + left: 0; + right: 0; + text-align: center; + pointer-events: none; + white-space: nowrap; } .header-logo { @@ -1530,6 +1536,19 @@ button.btn-reboot:hover:not(:disabled) { margin-top: 6px; } +.domain-setup-intro li { + margin-bottom: 6px; +} + +.domain-setup-intro code { + background-color: #12121c; + padding: 2px 8px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82em; + word-break: break-all; +} + .domain-field-group { margin-bottom: 14px; } @@ -1810,6 +1829,28 @@ button.btn-reboot:hover:not(:disabled) { gap: 8px; } +/* Addon feature toggle row in service detail modal */ +.svc-detail-addon-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; +} + +.svc-detail-addon-status { + font-size: 0.88rem; + font-weight: 600; +} + +.addon-status--on { + color: var(--green); +} + +.addon-status--off { + color: var(--text-dim); +} + /* ── Sidebar: compact feature card overrides ─────────────────────── */ .sidebar .feature-manager-section { diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index a981ed1..ea00a34 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -51,7 +51,7 @@ let ]; } { name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; } - { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus �� Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } + { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }