diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index dec9bf5..099781c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1198,6 +1198,12 @@ async def api_services(): # Read runtime feature overrides from custom.nix Hub Managed section overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + # Cache port/firewall data once for the entire /api/services request + listening_ports, firewall_ports = await asyncio.gather( + loop.run_in_executor(None, _get_listening_ports), + loop.run_in_executor(None, _get_firewall_allowed_ports), + ) + async def get_status(entry): unit = entry.get("unit", "") scope = entry.get("type", "system") @@ -1235,6 +1241,34 @@ async def api_services(): except OSError: domain = None + # Compute composite health + if not enabled: + health = "disabled" + elif status == "active": + has_port_issues = False + if port_requirements: + for p in port_requirements: + ps = _check_port_status( + str(p.get("port", "")), + str(p.get("protocol", "TCP")), + listening_ports, + firewall_ports, + ) + if ps == "closed": + has_port_issues = True + break + has_domain_issues = False + if needs_domain: + if not domain: + has_domain_issues = True + health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + elif status == "inactive": + health = "inactive" + elif status == "failed": + health = "failed" + else: + health = status # loading states, etc. + return { "name": entry.get("name", ""), "unit": unit, @@ -1243,6 +1277,7 @@ async def api_services(): "enabled": enabled, "category": entry.get("category", "other"), "status": status, + "health": health, "has_credentials": has_credentials, "port_requirements": port_requirements, "needs_domain": needs_domain, @@ -1424,11 +1459,31 @@ async def api_service_detail(unit: str): "description": p.get("description", ""), }) + # Compute composite health + if not enabled: + health = "disabled" + elif status == "active": + has_port_issues = any(p["status"] == "closed" for p in port_statuses) + has_domain_issues = False + if needs_domain: + if not domain: + has_domain_issues = True + elif domain_status and domain_status.get("status") not in ("connected", None): + has_domain_issues = True + health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + elif status == "inactive": + health = "inactive" + elif status == "failed": + health = "failed" + else: + health = status # loading states, etc. + return { "name": entry.get("name", ""), "unit": unit, "icon": icon, "status": status, + "health": health, "enabled": enabled, "description": SERVICE_DESCRIPTIONS.get(unit, ""), "has_credentials": has_credentials and bool(resolved_creds), diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 5533b7d..bef1aa3 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -4,12 +4,9 @@ const POLL_INTERVAL_SERVICES = 5000; const POLL_INTERVAL_UPDATES = 1800000; -const POLL_INTERVAL_PORT_HEALTH = 15000; const UPDATE_POLL_INTERVAL = 2000; const REBOOT_CHECK_INTERVAL = 5000; const SUPPORT_TIMER_INTERVAL = 1000; -const BANNER_AUTO_FADE_DELAY = 5000; -const BANNER_FADE_TRANSITION_MS = 550; const CATEGORY_ORDER = [ "infrastructure", @@ -124,26 +121,34 @@ const $portReqBody = document.getElementById("port-req-body"); const $portReqClose = document.getElementById("port-req-close-btn"); // System status banner -const $statusBanner = document.getElementById("system-status-banner"); +// (removed — health is now shown per-tile via the composite health field) // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } -function statusClass(status) { - if (!status) return "unknown"; - if (status === "active") return "active"; - if (status === "inactive") return "inactive"; - if (status === "failed") return "failed"; - if (status === "disabled") return "disabled"; - if (STATUS_LOADING_STATES.has(status)) return "loading"; +function statusClass(health) { + if (!health) return "unknown"; + if (health === "healthy") return "active"; + if (health === "needs_attention") return "needs-attention"; + if (health === "active") return "active"; // backwards compat + if (health === "inactive") return "inactive"; + if (health === "failed") return "failed"; + if (health === "disabled") return "disabled"; + if (STATUS_LOADING_STATES.has(health)) return "loading"; return "unknown"; } -function statusText(status, enabled) { - if (!enabled) return "disabled"; - if (!status || status === "unknown") return "unknown"; - return status; +function statusText(health, enabled) { + if (!enabled) return "Disabled"; + if (health === "healthy") return "Active"; + if (health === "needs_attention") return "Needs Attention"; + if (health === "active") return "Active"; + if (health === "inactive") return "Inactive"; + if (health === "failed") return "Failed"; + if (!health || health === "unknown") return "Unknown"; + if (STATUS_LOADING_STATES.has(health)) return health; + return health; } function escHtml(str) { @@ -242,8 +247,8 @@ function renderSidebarSupport(supportServices) { function buildTile(svc) { var isSupport = svc.type === "support"; - var sc = statusClass(svc.status); - var st = statusText(svc.status, svc.enabled); + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); var dis = !svc.enabled; var tile = document.createElement("div"); @@ -279,8 +284,8 @@ function updateTiles(services) { var id = CSS.escape(tileId(svc)); var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); if (!tile) continue; - var sc = statusClass(svc.status); - var st = statusText(svc.status, svc.enabled); + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); var dot = tile.querySelector(".status-dot"); var text = tile.querySelector(".status-text"); if (dot) dot.className = "status-dot " + sc; @@ -396,8 +401,8 @@ async function openServiceDetailModal(unit, name) { } // Section B: Status - var sc = statusClass(data.status); - var st = statusText(data.status, data.enabled); + var sc = statusClass(data.health || data.status); + var st = statusText(data.health || data.status, data.enabled); html += '
' + escHtml(data.internal_ip || "—") + '💡 Search "how to set up port forwarding on [your router model]" for step-by-step instructions.
' + - 'These are shared system ports — you only need to set them up once and they cover all your domain-based services ' + + '(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).
' + + 'If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:
' + + 'http://192.168.1.1)' + escHtml(data.internal_ip || "—") + '💡 Once these two ports are forwarded, you won\'t see this warning on any service again.
' + ); + } + + if (specificPorts.length > 0) { + var portList = specificPorts.map(function(p) { + return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') — ' + escHtml(p.description); + }).join('' + portList + '
' + + '' + escHtml(data.internal_ip || "—") + '| Service | Closed Ports |
|---|