Merge pull request #25 from naturallaw777/copilot/add-global-system-status-banner
Add global port health status banner to Sovran_SystemsOS Hub
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
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 '🔴 <span class="status-banner-port">' + escHtml(p.port) + '/' + escHtml(p.protocol) + '</span>'
|
||||
+ (p.description ? ' <span style="opacity:0.7">— ' + escHtml(p.description) + '</span>' : '');
|
||||
}).join(", ");
|
||||
return '<tr><td>' + escHtml(svc.name) + '</td><td>' + portList + '</td></tr>';
|
||||
}).join("");
|
||||
return '<table class="status-banner-table">'
|
||||
+ '<thead><tr><th>Service</th><th>Closed Ports</th></tr></thead>'
|
||||
+ '<tbody>' + rows + '</tbody>'
|
||||
+ '</table>';
|
||||
}
|
||||
|
||||
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 += ' <button class="status-banner-toggle" id="' + toggleId + '">'
|
||||
+ (_bannerDetailsOpen ? "Hide Details ▲" : "View Details ▼")
|
||||
+ '</button>'
|
||||
+ '<div class="status-banner-details" id="' + detailsId + '" style="display:'
|
||||
+ (_bannerDetailsOpen ? "block" : "none") + '">'
|
||||
+ detailsHtml
|
||||
+ '</div>';
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- System status banner -->
|
||||
<div id="system-status-banner" class="status-banner" style="display:none"></div>
|
||||
|
||||
<!-- Service tiles -->
|
||||
<main class="main-content">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
|
||||
Reference in New Issue
Block a user