From 0d3e1814588ee50649f773f078af20113406fd7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:25:24 +0000 Subject: [PATCH] feat: add global system status banner for port health Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c41a2529-e172-4c84-90c0-1b5477ea4f9d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 98 +++++++++++++ app/sovran_systemsos_web/static/app.js | 130 +++++++++++++++++- app/sovran_systemsos_web/static/style.css | 119 ++++++++++++++++ app/sovran_systemsos_web/templates/index.html | 3 + 4 files changed, 345 insertions(+), 5 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 3d37110..2708b95 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -960,6 +960,104 @@ async def api_ports_status(req: PortCheckRequest): return {"internal_ip": internal_ip, "ports": port_results} +@app.get("/api/ports/health") +async def api_ports_health(): + """Aggregate port health across all enabled services.""" + cfg = load_config() + services = cfg.get("services", []) + + # Build reverse map: unit → feature_id (for features with a unit) + unit_to_feature = { + unit: feat_id + for feat_id, unit in FEATURE_SERVICE_MAP.items() + if unit is not None + } + + loop = asyncio.get_event_loop() + + # Read runtime feature overrides from custom.nix Hub Managed section + overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + + # Collect port requirements for enabled services only + enabled_port_requirements: list[tuple[str, str, list[dict]]] = [] + for entry in services: + unit = entry.get("unit", "") + icon = entry.get("icon", "") + enabled = entry.get("enabled", True) + + feat_id = unit_to_feature.get(unit) + if feat_id is None: + feat_id = FEATURE_ICON_MAP.get(icon) + if feat_id is not None and feat_id in overrides: + enabled = overrides[feat_id] + + if not enabled: + continue + + ports = SERVICE_PORT_REQUIREMENTS.get(unit, []) + if ports: + enabled_port_requirements.append((entry.get("name", unit), unit, ports)) + + # If no enabled services have port requirements, return ok with zero ports + if not enabled_port_requirements: + return { + "total_ports": 0, + "open_ports": 0, + "closed_ports": 0, + "status": "ok", + "affected_services": [], + } + + # Run port checks in parallel + listening, allowed = await asyncio.gather( + loop.run_in_executor(None, _get_listening_ports), + loop.run_in_executor(None, _get_firewall_allowed_ports), + ) + + total_ports = 0 + open_ports = 0 + affected_services = [] + + for name, unit, ports in enabled_port_requirements: + closed = [] + for p in ports: + port_str = str(p.get("port", "")) + protocol = str(p.get("protocol", "TCP")) + status = _check_port_status(port_str, protocol, listening, allowed) + total_ports += 1 + if status in ("listening", "firewall_open"): + open_ports += 1 + else: + closed.append({ + "port": port_str, + "protocol": protocol, + "description": p.get("description", ""), + }) + if closed: + affected_services.append({ + "name": name, + "unit": unit, + "closed_ports": closed, + }) + + closed_ports = total_ports - open_ports + + if closed_ports == 0: + health_status = "ok" + elif open_ports == 0: + health_status = "critical" + else: + health_status = "partial" + + return { + "total_ports": total_ports, + "open_ports": open_ports, + "closed_ports": closed_ports, + "status": health_status, + "affected_services": affected_services, + } + + @app.get("/api/updates/check") async def api_updates_check(): loop = asyncio.get_event_loop() diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index a2ab79b..4f47ddb 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -2,11 +2,14 @@ v7 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; -const POLL_INTERVAL_SERVICES = 5000; -const POLL_INTERVAL_UPDATES = 1800000; -const UPDATE_POLL_INTERVAL = 2000; -const REBOOT_CHECK_INTERVAL = 5000; -const SUPPORT_TIMER_INTERVAL = 1000; +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", @@ -118,6 +121,9 @@ const $portReqModal = document.getElementById("port-requirements-modal"); 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"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } @@ -1356,6 +1362,116 @@ if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $mod if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); +// ── Port health banner ──────────────────────────────────────────── + +var _bannerFadeTimer = null; +var _bannerDetailsOpen = false; + +async function loadPortHealth() { + if (!$statusBanner) return; + try { + var data = await apiFetch("/api/ports/health"); + _renderPortHealthBanner(data); + } catch (_) { + // Silently ignore — banner stays hidden on error + } +} + +function _renderPortHealthBanner(data) { + if (!$statusBanner) return; + + // Clear any pending fade-out timer + if (_bannerFadeTimer) { + clearTimeout(_bannerFadeTimer); + _bannerFadeTimer = null; + } + + var status = data.status || "ok"; + var totalPorts = data.total_ports || 0; + var closedPorts = data.closed_ports || 0; + var affectedSvcs = data.affected_services || []; + + // No port requirements — hide banner + if (totalPorts === 0) { + $statusBanner.style.display = "none"; + $statusBanner.className = "status-banner"; + return; + } + + // Build expandable details for warn/critical states + function buildDetailsHtml(svcs) { + if (!svcs.length) return ""; + var rows = svcs.map(function(svc) { + var portList = (svc.closed_ports || []).map(function(p) { + return '🔴 ' + escHtml(p.port) + '/' + escHtml(p.protocol) + '' + + (p.description ? ' — ' + escHtml(p.description) + '' : ''); + }).join(", "); + return '' + escHtml(svc.name) + '' + portList + ''; + }).join(""); + return '' + + '' + + '' + rows + '' + + '
ServiceClosed Ports
'; + } + + var html = ""; + $statusBanner.className = "status-banner"; + + if (status === "ok") { + // Switching from warn/critical to ok: reset details-open state + _bannerDetailsOpen = false; + $statusBanner.classList.add("status-banner--ok"); + html = "✅ All Systems Operational — All ports open for all enabled services"; + $statusBanner.style.display = "block"; + $statusBanner.style.opacity = "1"; + $statusBanner.innerHTML = html; + // Auto-fade after BANNER_AUTO_FADE_DELAY + _bannerFadeTimer = setTimeout(function() { + $statusBanner.classList.add("status-banner--fade-out"); + _bannerFadeTimer = setTimeout(function() { + $statusBanner.style.display = "none"; + }, BANNER_FADE_TRANSITION_MS); + }, BANNER_AUTO_FADE_DELAY); + return; + } + + if (status === "partial") { + $statusBanner.classList.add("status-banner--warn"); + html = "⚠️ Some Services May Be Affected — " + closedPorts + " of " + totalPorts + " ports closed"; + } else { + // critical + $statusBanner.classList.add("status-banner--critical"); + html = "🔴 System Alert: Ports Are Down — Some services may not work"; + } + + var detailsId = "status-banner-detail-body"; + var toggleId = "status-banner-toggle"; + var detailsHtml = buildDetailsHtml(affectedSvcs); + + html += ' ' + + '
' + + detailsHtml + + '
'; + + $statusBanner.style.display = "block"; + $statusBanner.style.opacity = "1"; + $statusBanner.innerHTML = html; + + var toggleBtn = document.getElementById(toggleId); + var detailsBody = document.getElementById(detailsId); + + if (toggleBtn && detailsBody) { + toggleBtn.addEventListener("click", function() { + _bannerDetailsOpen = !_bannerDetailsOpen; + detailsBody.style.display = _bannerDetailsOpen ? "block" : "none"; + toggleBtn.textContent = _bannerDetailsOpen ? "Hide Details ▲" : "View Details ▼"; + }); + } +} + // ── Init ────────────────────────────────────────────────────────── async function init() { @@ -1372,9 +1488,11 @@ async function init() { await refreshServices(); loadNetwork(); checkUpdates(); + loadPortHealth(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + setInterval(loadPortHealth, POLL_INTERVAL_PORT_HEALTH); if (cfg.feature_manager) { loadFeatureManager(); @@ -1383,8 +1501,10 @@ async function init() { await refreshServices(); loadNetwork(); checkUpdates(); + loadPortHealth(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + setInterval(loadPortHealth, POLL_INTERVAL_PORT_HEALTH); } } diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index be58928..46feafa 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -189,6 +189,125 @@ button:disabled { color: var(--border-color); } +/* ── System status banner ────────────────────────────────────────── */ + +.status-banner { + padding: 10px 24px; + font-size: 0.85rem; + font-weight: 600; + text-align: center; + transition: opacity 0.5s ease, max-height 0.3s ease; + overflow: hidden; +} + +.status-banner--ok { + background-color: rgba(46, 194, 126, 0.15); + border-bottom: 1px solid var(--green); + color: var(--green); +} + +.status-banner--warn { + background-color: rgba(229, 165, 10, 0.15); + border-bottom: 1px solid var(--yellow); + color: var(--yellow); +} + +.status-banner--critical { + background-color: rgba(224, 27, 36, 0.15); + border-bottom: 1px solid var(--red); + color: var(--red); + animation: pulse-banner-bg 2s ease-in-out infinite; +} + +.status-banner--fade-out { + opacity: 0; + pointer-events: none; +} + +@keyframes pulse-banner-bg { + 0%, 100% { background-color: rgba(224, 27, 36, 0.15); } + 50% { background-color: rgba(224, 27, 36, 0.28); } +} + +.status-banner-details { + margin-top: 8px; + text-align: left; + max-width: 720px; + margin-left: auto; + margin-right: auto; +} + +.status-banner-toggle { + background: none; + border: none; + font: inherit; + color: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + padding: 0; + opacity: 0.8; +} + +.status-banner-toggle:hover { + opacity: 1; +} + +.status-banner-table { + width: 100%; + border-collapse: collapse; + margin-top: 8px; + font-size: 0.8rem; +} + +.status-banner-table th { + text-align: left; + padding: 4px 8px; + opacity: 0.7; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.72rem; + border-bottom: 1px solid currentColor; +} + +.status-banner-table td { + padding: 4px 8px; + vertical-align: top; +} + +.status-banner-table td:first-child { + font-weight: 600; + white-space: nowrap; +} + +.status-banner-port { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + font-weight: 600; +} + +@media (max-width: 768px) { + .status-banner { + padding: 10px 16px; + } + .status-banner-details { + max-width: 100%; + } +} + +@media (max-width: 600px) { + .status-banner { + padding: 10px 14px; + font-size: 0.82rem; + } + .status-banner-table th, + .status-banner-table td { + padding: 4px 4px; + } +} + /* ── Main content ───────────────────────────────────────────────── */ .main-content { diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 8fe916f..20e6a7c 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -32,6 +32,9 @@ + +
+