From a3c75462c9d1b015d5c66b0ad60e10daa7347575 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:42:09 +0000 Subject: [PATCH 1/2] Initial plan 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 2/2] 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 + '' + escHtml(svc.name) + '
' + 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 + '' + escHtml(svc.name) + '
' + 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 {