From 8002b180b18687c0b776868d41dd66a529897f26 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Apr 2026 14:49:30 +0000
Subject: [PATCH] Add domain health status to hub tiles and Feature Manager
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/52147672-b757-4524-971a-9e0dab981354
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
---
app/sovran_systemsos_web/server.py | 77 +++++++++++
app/sovran_systemsos_web/static/app.js | 149 +++++++++++++++++++++-
app/sovran_systemsos_web/static/style.css | 74 ++++++++++-
3 files changed, 294 insertions(+), 6 deletions(-)
diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py
index f5b278f..631e3ef 100644
--- a/app/sovran_systemsos_web/server.py
+++ b/app/sovran_systemsos_web/server.py
@@ -231,6 +231,19 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
"haven-relay.service": _PORTS_WEB,
}
+# Maps service unit names to their domain file name in DOMAINS_DIR.
+# Only services that require a domain are listed here.
+SERVICE_DOMAIN_MAP: dict[str, str] = {
+ "matrix-synapse.service": "matrix",
+ "btcpayserver.service": "btcpayserver",
+ "vaultwarden.service": "vaultwarden",
+ "phpfpm-nextcloud.service": "nextcloud",
+ "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
FEATURE_ICON_MAP = {
"bip110": "bip110",
@@ -1123,6 +1136,18 @@ async def api_services():
port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, [])
+ domain_key = SERVICE_DOMAIN_MAP.get(unit)
+ needs_domain = domain_key is not None
+ domain: str | None = None
+ if domain_key:
+ domain_path = os.path.join(DOMAINS_DIR, domain_key)
+ try:
+ with open(domain_path, "r") as f:
+ val = f.read(512).strip()
+ domain = val if val else None
+ except OSError:
+ domain = None
+
return {
"name": entry.get("name", ""),
"unit": unit,
@@ -1133,6 +1158,8 @@ async def api_services():
"status": status,
"has_credentials": has_credentials,
"port_requirements": port_requirements,
+ "needs_domain": needs_domain,
+ "domain": domain,
}
results = await asyncio.gather(*[get_status(s) for s in services])
@@ -1753,6 +1780,56 @@ async def api_domains_status():
return {"domains": domains}
+class DomainCheckRequest(BaseModel):
+ domains: list[str]
+
+
+@app.post("/api/domains/check")
+async def api_domains_check(req: DomainCheckRequest):
+ """Check DNS resolution for each domain and verify it points to this server."""
+ loop = asyncio.get_event_loop()
+ external_ip = await loop.run_in_executor(None, _get_external_ip)
+
+ def check_domain(domain: str) -> dict:
+ try:
+ results = socket.getaddrinfo(domain, None)
+ if not results:
+ return {
+ "domain": domain, "status": "unresolvable",
+ "resolved_ip": None, "expected_ip": external_ip,
+ }
+ resolved_ip = results[0][4][0]
+ if external_ip == "unavailable":
+ return {
+ "domain": domain, "status": "error",
+ "resolved_ip": resolved_ip, "expected_ip": external_ip,
+ }
+ if resolved_ip == external_ip:
+ return {
+ "domain": domain, "status": "connected",
+ "resolved_ip": resolved_ip, "expected_ip": external_ip,
+ }
+ return {
+ "domain": domain, "status": "dns_mismatch",
+ "resolved_ip": resolved_ip, "expected_ip": external_ip,
+ }
+ except socket.gaierror:
+ return {
+ "domain": domain, "status": "unresolvable",
+ "resolved_ip": None, "expected_ip": external_ip,
+ }
+ except Exception:
+ return {
+ "domain": domain, "status": "error",
+ "resolved_ip": None, "expected_ip": external_ip,
+ }
+
+ check_results = await asyncio.gather(*[
+ loop.run_in_executor(None, check_domain, d) for d in req.domains
+ ])
+ return {"domains": list(check_results)}
+
+
# ── Matrix user management ────────────────────────────────────────
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js
index bf402c5..1ab67e0 100644
--- a/app/sovran_systemsos_web/static/app.js
+++ b/app/sovran_systemsos_web/static/app.js
@@ -269,7 +269,16 @@ function buildTile(svc) {
portsHtml = '
🔌Ports: ' + ports.length + ' required
';
}
- tile.innerHTML = infoBtn + ' + '.svg)
?
' + escHtml(svc.name) + '
' + st + '
' + portsHtml;
+ // Domain badge — ONLY for services that require a domain
+ var domainHtml = "";
+ if (svc.needs_domain) {
+ domainHtml = ''
+ + '🌐'
+ + '' + (svc.domain ? escHtml(svc.domain) : 'Not set') + ''
+ + '
';
+ }
+
+ tile.innerHTML = infoBtn + ' + '.svg)
?
' + escHtml(svc.name) + '
' + st + '
' + portsHtml + domainHtml;
var infoBtnEl = tile.querySelector(".tile-info-btn");
if (infoBtnEl) {
@@ -322,6 +331,51 @@ function buildTile(svc) {
}
}
+ // Domain badge async check
+ var domainEl = tile.querySelector(".tile-domain");
+ if (domainEl && svc.needs_domain) {
+ domainEl.style.cursor = "pointer";
+ domainEl.addEventListener("click", function(e) {
+ e.stopPropagation();
+ });
+
+ if (svc.domain) {
+ fetch("/api/domains/check", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ domains: [svc.domain] }),
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ var d = (data.domains || [])[0];
+ var lbl = domainEl.querySelector(".tile-domain-label");
+ if (!lbl || !d) return;
+ lbl.classList.remove("tile-domain-label--checking");
+ if (d.status === "connected") {
+ lbl.className = "tile-domain-label tile-domain-label--ok";
+ lbl.textContent = svc.domain + " ✓";
+ } else if (d.status === "dns_mismatch") {
+ lbl.className = "tile-domain-label tile-domain-label--warn";
+ lbl.textContent = svc.domain + " (IP mismatch)";
+ } else if (d.status === "unresolvable") {
+ lbl.className = "tile-domain-label tile-domain-label--error";
+ lbl.textContent = svc.domain + " (DNS error)";
+ } else {
+ lbl.className = "tile-domain-label tile-domain-label--warn";
+ lbl.textContent = svc.domain + " (unknown)";
+ }
+ })
+ .catch(function() {});
+ } else {
+ var lbl = domainEl.querySelector(".tile-domain-label");
+ if (lbl) {
+ lbl.classList.remove("tile-domain-label--checking");
+ lbl.className = "tile-domain-label tile-domain-label--warn";
+ lbl.textContent = "Domain: Not set";
+ }
+ }
+ }
+
return tile;
}
@@ -1398,11 +1452,94 @@ async function loadFeatureManager() {
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);
} catch (err) {
console.warn("Failed to load features:", err);
}
}
+function _checkFeatureManagerDomains(data) {
+ // Collect all features with a configured domain
+ var featsWithDomain = (data.features || []).filter(function(f) {
+ return f.needs_domain && f.domain_configured;
+ });
+ if (!featsWithDomain.length) return;
+
+ // Get the actual domain values from /api/domains/status, then check them
+ fetch("/api/domains/status")
+ .then(function(r) { return r.json(); })
+ .then(function(statusData) {
+ var domainFileMap = statusData.domains || {};
+ // Build list of domains to check and a map from domain value → feature id
+ var domainsToCheck = [];
+ var domainToFeatIds = {};
+ featsWithDomain.forEach(function(feat) {
+ var domainName = feat.domain_name;
+ var domainVal = domainName ? domainFileMap[domainName] : null;
+ if (domainVal) {
+ domainsToCheck.push(domainVal);
+ if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = [];
+ domainToFeatIds[domainVal].push(feat.id);
+ } else {
+ // Domain file missing — update badge to warn
+ _updateFeatureDomainBadge(feat.id, null, "unresolvable");
+ }
+ });
+
+ if (!domainsToCheck.length) return;
+
+ return fetch("/api/domains/check", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ domains: domainsToCheck }),
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(checkData) {
+ (checkData.domains || []).forEach(function(d) {
+ var featIds = domainToFeatIds[d.domain] || [];
+ featIds.forEach(function(featId) {
+ _updateFeatureDomainBadge(featId, d.domain, d.status);
+ });
+ });
+ });
+ })
+ .catch(function() {});
+}
+
+function _updateFeatureDomainBadge(featId, domainVal, status) {
+ var section = $sidebarFeatures.querySelector(".feature-manager-section");
+ if (!section) return;
+ // Find the card — cards don't have a data-feat-id, so find via name match
+ var badges = section.querySelectorAll(".feature-domain-badge.configured");
+ badges.forEach(function(badge) {
+ var domainNameAttr = badge.getAttribute("data-domain-name");
+ // Match by domain_name attribute — we need to look up the feat's domain_name
+ var feat = _featuresData && _featuresData.features
+ ? _featuresData.features.find(function(f) { return f.id === featId; })
+ : null;
+ if (!feat) return;
+ if (domainNameAttr !== (feat.domain_name || "")) return;
+
+ var lbl = badge.querySelector(".feature-domain-label");
+ if (!lbl) return;
+ lbl.classList.remove("feature-domain-label--checking");
+ if (status === "connected") {
+ lbl.className = "feature-domain-label feature-domain-label--ok";
+ lbl.textContent = (domainVal || "Domain") + " ✓";
+ } else if (status === "dns_mismatch") {
+ lbl.className = "feature-domain-label feature-domain-label--warn";
+ lbl.textContent = (domainVal || "Domain") + " (IP mismatch)";
+ } else if (status === "unresolvable") {
+ lbl.className = "feature-domain-label feature-domain-label--error";
+ lbl.textContent = (domainVal || "Domain") + " (DNS error)";
+ } else {
+ lbl.className = "feature-domain-label feature-domain-label--warn";
+ lbl.textContent = (domainVal || "Domain") + " (unknown)";
+ }
+ });
+}
+
function renderFeatureManager(data) {
// Remove old feature manager section if it exists
var old = $sidebarFeatures.querySelector(".feature-manager-section");
@@ -1467,9 +1604,15 @@ function buildFeatureCard(feat) {
var domainHtml = "";
if (feat.needs_domain) {
if (feat.domain_configured) {
- domainHtml = '🌐 Domain: Configured
';
+ domainHtml = ''
+ + '🌐'
+ + 'Domain: Checking\u2026'
+ + '
';
} else {
- domainHtml = '🌐 Domain: Not configured
';
+ domainHtml = ''
+ + '🌐'
+ + 'Domain: Not configured'
+ + '
';
}
}
diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css
index 94fe13c..e7dee5a 100644
--- a/app/sovran_systemsos_web/static/style.css
+++ b/app/sovran_systemsos_web/static/style.css
@@ -1594,21 +1594,48 @@ button.btn-reboot:hover:not(:disabled) {
background-color: #fff;
}
-/* ── Feature domain badge ────────────────────────────────────────── */
+/* ── Feature domain badge (consistent with tile domain badge) ────── */
.feature-domain-badge {
font-size: 0.75rem;
font-weight: 600;
margin-top: 6px;
padding: 2px 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.feature-domain-icon {
+ flex-shrink: 0;
+}
+
+.feature-domain-label {
+ word-break: break-word;
}
.feature-domain-badge.configured {
- color: var(--green);
+ color: #a6e3a1;
}
.feature-domain-badge.not-configured {
- color: var(--yellow);
+ color: #f9e2af;
+}
+
+.feature-domain-label--checking {
+ color: var(--text-dim);
+}
+
+.feature-domain-label--ok {
+ color: #a6e3a1;
+}
+
+.feature-domain-label--warn {
+ color: #f9e2af;
+}
+
+.feature-domain-label--error {
+ color: #f38ba8;
}
/* ── Feature conflict warning ────────────────────────────────────── */
@@ -1827,6 +1854,47 @@ button.btn-reboot:hover:not(:disabled) {
color: var(--text-dim);
}
+/* ── Tile: Domain Status badge ──────────────────────────────────── */
+
+.tile-domain {
+ margin-top: 6px;
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ line-height: 1.4;
+ flex-wrap: wrap;
+}
+
+.tile-domain:hover {
+ color: var(--accent-color);
+}
+
+.tile-domain-icon {
+ flex-shrink: 0;
+}
+
+.tile-domain-label {
+ word-break: break-word;
+}
+
+.tile-domain-label--checking {
+ color: var(--text-dim);
+}
+
+.tile-domain-label--ok {
+ color: #a6e3a1;
+}
+
+.tile-domain-label--warn {
+ color: #f9e2af;
+}
+
+.tile-domain-label--error {
+ color: #f38ba8;
+}
+
/* ── Sidebar: compact feature card overrides ─────────────────────── */
.sidebar .feature-manager-section {