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>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-04 14:49:30 +00:00
committed by GitHub
parent a3c75462c9
commit 8002b180b1
3 changed files with 294 additions and 6 deletions

View File

@@ -231,6 +231,19 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
"haven-relay.service": _PORTS_WEB, "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 # For features that share a unit, disambiguate by icon field
FEATURE_ICON_MAP = { FEATURE_ICON_MAP = {
"bip110": "bip110", "bip110": "bip110",
@@ -1123,6 +1136,18 @@ async def api_services():
port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, []) 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 { return {
"name": entry.get("name", ""), "name": entry.get("name", ""),
"unit": unit, "unit": unit,
@@ -1133,6 +1158,8 @@ async def api_services():
"status": status, "status": status,
"has_credentials": has_credentials, "has_credentials": has_credentials,
"port_requirements": port_requirements, "port_requirements": port_requirements,
"needs_domain": needs_domain,
"domain": domain,
} }
results = await asyncio.gather(*[get_status(s) for s in services]) results = await asyncio.gather(*[get_status(s) for s in services])
@@ -1753,6 +1780,56 @@ async def api_domains_status():
return {"domains": domains} 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 user management ────────────────────────────────────────
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users" MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"

View File

@@ -269,7 +269,16 @@ function buildTile(svc) {
portsHtml = '<div class="tile-ports" title="Click to view required router ports"><span class="tile-ports-icon">🔌</span><span class="tile-ports-label tile-ports-label--loading">Ports: ' + ports.length + ' required</span></div>'; portsHtml = '<div class="tile-ports" title="Click to view required router ports"><span class="tile-ports-icon">🔌</span><span class="tile-ports-label tile-ports-label--loading">Ports: ' + ports.length + ' required</span></div>';
} }
tile.innerHTML = infoBtn + '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>' + portsHtml; // Domain badge — ONLY for services that require a domain
var domainHtml = "";
if (svc.needs_domain) {
domainHtml = '<div class="tile-domain" title="Click to check domain status">'
+ '<span class="tile-domain-icon">🌐</span>'
+ '<span class="tile-domain-label tile-domain-label--checking">' + (svc.domain ? escHtml(svc.domain) : 'Not set') + '</span>'
+ '</div>';
}
tile.innerHTML = infoBtn + '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>' + portsHtml + domainHtml;
var infoBtnEl = tile.querySelector(".tile-info-btn"); var infoBtnEl = tile.querySelector(".tile-info-btn");
if (infoBtnEl) { 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; return tile;
} }
@@ -1398,11 +1452,94 @@ async function loadFeatureManager() {
var data = await apiFetch("/api/features"); var data = await apiFetch("/api/features");
_featuresData = data; _featuresData = data;
renderFeatureManager(data); renderFeatureManager(data);
// After rendering, do a batch domain check for all features that have a configured domain
_checkFeatureManagerDomains(data);
} catch (err) { } catch (err) {
console.warn("Failed to load features:", 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) { function renderFeatureManager(data) {
// Remove old feature manager section if it exists // Remove old feature manager section if it exists
var old = $sidebarFeatures.querySelector(".feature-manager-section"); var old = $sidebarFeatures.querySelector(".feature-manager-section");
@@ -1467,9 +1604,15 @@ function buildFeatureCard(feat) {
var domainHtml = ""; var domainHtml = "";
if (feat.needs_domain) { if (feat.needs_domain) {
if (feat.domain_configured) { if (feat.domain_configured) {
domainHtml = '<div class="feature-domain-badge configured">🌐 Domain: Configured</div>'; domainHtml = '<div class="feature-domain-badge configured" data-domain-name="' + escHtml(feat.domain_name || '') + '">'
+ '<span class="feature-domain-icon">🌐</span>'
+ '<span class="feature-domain-label feature-domain-label--checking">Domain: Checking\u2026</span>'
+ '</div>';
} else { } else {
domainHtml = '<div class="feature-domain-badge not-configured">🌐 Domain: Not configured</div>'; domainHtml = '<div class="feature-domain-badge not-configured">'
+ '<span class="feature-domain-icon">🌐</span>'
+ '<span class="feature-domain-label feature-domain-label--warn">Domain: Not configured</span>'
+ '</div>';
} }
} }

View File

@@ -1594,21 +1594,48 @@ button.btn-reboot:hover:not(:disabled) {
background-color: #fff; background-color: #fff;
} }
/* ── Feature domain badge ────────────────────────────────────────── */ /* ── Feature domain badge (consistent with tile domain badge) ────── */
.feature-domain-badge { .feature-domain-badge {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
margin-top: 6px; margin-top: 6px;
padding: 2px 0; 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 { .feature-domain-badge.configured {
color: var(--green); color: #a6e3a1;
} }
.feature-domain-badge.not-configured { .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 ────────────────────────────────────── */ /* ── Feature conflict warning ────────────────────────────────────── */
@@ -1827,6 +1854,47 @@ button.btn-reboot:hover:not(:disabled) {
color: var(--text-dim); 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: compact feature card overrides ─────────────────────── */
.sidebar .feature-manager-section { .sidebar .feature-manager-section {