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:
- A subdomain purchased on njal.la
- A Dynamic DNS record for it
' +
- '' +
- '' +
+ '' +
+ '
Before continuing:
' +
+ '
' +
+ '- Create an account at https://njal.la
' +
+ '- Purchase your domain on Njal.la
' +
+ '- In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
' +
+ '' + escHtml(externalIp) + ' ' +
+ '- Njal.la will give you a curl command like:
' +
+ 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto" ' +
+ '- Enter the subdomain and paste that curl command below
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
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://"; }