diff --git a/.claude/ASYNC-PRECACHE-PATTERN.md b/.claude/ASYNC-PRECACHE-PATTERN.md new file mode 100644 index 00000000..61388b10 --- /dev/null +++ b/.claude/ASYNC-PRECACHE-PATTERN.md @@ -0,0 +1,403 @@ +# Async Pre-Cache Pattern — Development Design + +## SecuBox Health-Aware Navigation Architecture + +**Version**: 1.0.0 +**Author**: CyberMind / Gérald Kerma +**Pattern Name**: Asynchronous Over-Chronous Pre-Cache (AOPC) + +--- + +## Philosophy + +> "Pre-cache for speed, refresh for truth." + +Two complementary layers working asynchronously over time: + +| Layer | Purpose | Speed | Accuracy | Blocking | +|-------|---------|-------|----------|----------| +| **Pre-cache** | Instant UX | Fast (0ms) | Stale (≤TTL) | Never | +| **Refresh** | Ground truth | Slow (100-4000ms) | Real-time | Never | + +--- + +## Core Principles + +### 1. Optimistic Display +``` +User experience > Perfect accuracy +Show something immediately, correct it later +``` + +### 2. Non-Blocking Everything +``` +No await on critical path +Background tasks update UI progressively +``` + +### 3. Graceful Degradation +``` +Missing endpoint → Fallback check +Timeout → Use cached value +Error → Show last known state +``` + +### 4. Per-Item Timestamps +``` +Each module has its own staleness clock +Refresh only what's stale, not everything +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PAGE LOAD │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER 1: PRE-CACHE (Synchronous Read) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ localStorage.getItem('health_cache') │ │ +│ │ ├─ Parse JSON │ │ +│ │ ├─ Filter by TTL (discard expired modules) │ │ +│ │ └─ Return valid cached health states │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ INSTANT RENDER │ │ +│ │ └─ Display LEDs from pre-cache (T=0ms) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ (non-blocking) +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER 2: REFRESH (Asynchronous Background) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Promise.allSettled(modules.map(checkHealth)) │ │ +│ │ ├─ Each module checks independently │ │ +│ │ ├─ Timeout: 4s per module │ │ +│ │ ├─ Fallback: HEAD request to module page │ │ +│ │ └─ Update LED as each result arrives │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ PROGRESSIVE UPDATE │ │ +│ │ ├─ LED updates individually (T=100-4000ms) │ │ +│ │ ├─ Sort order updates after batch complete │ │ +│ │ └─ Save new timestamps to pre-cache │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ (interval: 30s) +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER 3: STALE REFRESH (Periodic Background) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ For each module: │ │ +│ │ ├─ Check timestamp age │ │ +│ │ ├─ If age > REFRESH_INTERVAL → Re-check │ │ +│ │ └─ Skip fresh modules (bandwidth efficient) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Data Structures + +### Pre-Cache Schema (localStorage) + +```javascript +{ + "module_name": { + "status": "ok" | "warn" | "error" | "unknown", + "msg": "Human readable message", + "data": { /* optional extra data */ }, + "timestamp": 1778237632000, // Per-module timestamp + "fallback": true | false // Was this from page check? + }, + ... +} +``` + +### Constants + +```javascript +const HEALTH_CACHE_KEY = 'sbx_health_cache'; +const HEALTH_CACHE_TTL = 120000; // 2 minutes max staleness +const HEALTH_REFRESH_INTERVAL = 30000; // 30s between refreshes +const HEALTH_REQUEST_TIMEOUT = 4000; // 4s per module timeout +``` + +--- + +## Implementation Patterns + +### Pattern 1: Instant Pre-Cache Load + +```javascript +function loadPreCache() { + try { + const cached = localStorage.getItem(HEALTH_CACHE_KEY); + if (!cached) return null; + + const data = JSON.parse(cached); + const now = Date.now(); + const valid = {}; + + for (const mod in data) { + const age = now - (data[mod].timestamp || 0); + if (age < HEALTH_CACHE_TTL) { + valid[mod] = data[mod]; + } + } + + return Object.keys(valid).length > 0 ? valid : null; + } catch (e) { + return null; + } +} +``` + +### Pattern 2: Non-Blocking Health Check + +```javascript +async function checkModuleHealth(mod) { + const endpoint = MODULE_HEALTH[mod]; + + // Prevent duplicate concurrent checks + if (inProgress[mod]) { + return cache[mod] || { status: 'checking' }; + } + inProgress[mod] = true; + + try { + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), TIMEOUT); + + const res = await fetch(endpoint, { signal: ctrl.signal }); + clearTimeout(timeout); + + if (res.ok) { + const data = await res.json(); + return { status: data.status, timestamp: Date.now() }; + } + + // 404 = No health endpoint, try fallback + if (res.status === 404) { + return await checkPageFallback(mod); + } + + return { status: 'error', msg: 'HTTP ' + res.status }; + } catch (e) { + if (e.name === 'AbortError') { + return await checkPageFallback(mod); + } + return { status: 'error', msg: 'Failed' }; + } finally { + inProgress[mod] = false; + } +} +``` + +### Pattern 3: Fallback Page Check + +```javascript +async function checkPageFallback(mod) { + const page = '/' + mod + '/'; + + try { + const res = await fetch(page, { + method: 'HEAD', + timeout: 2000 + }); + + if (res.ok || res.status === 401) { + // Page exists (401 = needs auth but exists) + return { + status: 'ok', + msg: 'Page accessible', + fallback: true + }; + } + return { status: 'unknown', msg: 'No health API' }; + } catch (e) { + return { status: 'unknown', msg: 'Unreachable' }; + } +} +``` + +### Pattern 4: Stale-Only Refresh + +```javascript +async function refreshStaleHealth() { + const now = Date.now(); + const stale = []; + + // Identify stale modules only + for (const mod in MODULE_HEALTH) { + const cached = healthCache[mod]; + const age = now - (cached?.timestamp || 0); + if (age > REFRESH_INTERVAL) { + stale.push(mod); + } + } + + if (stale.length === 0) return; + + // Refresh stale modules in parallel + await Promise.allSettled(stale.map(async (mod) => { + const result = await checkModuleHealth(mod); + healthCache[mod] = result; + + // Update UI immediately as each result arrives + updateSingleLED(mod, result); + })); + + // Persist to pre-cache + savePreCache(healthCache); +} +``` + +### Pattern 5: Progressive LED Update + +```javascript +function updateSingleLED(mod, health) { + const item = document.querySelector(`[data-module="${mod}"]`); + const led = item?.querySelector('.status-led'); + if (!led) return; + + // Update LED emoji + led.textContent = LED_EMOJI[health.status] || '⚫'; + led.title = health.msg; + + // Animate if status changed significantly + if (health.status === 'ok' && item.classList.contains('offline')) { + item.classList.add('reappear'); + setTimeout(() => item.classList.remove('reappear'), 600); + } +} +``` + +--- + +## Timeline Example + +``` +T=0ms Page loads + └─ Pre-cache read: {hub: ok, waf: ok, system: warn} (30s old) + └─ LEDs display: 🟢 🟢 🟡 + +T=10ms Background refresh starts (non-blocking) + └─ fetch('/api/v1/hub/health') + └─ fetch('/api/v1/waf/health') + └─ fetch('/api/v1/system/health') + +T=150ms Hub responds first + └─ LED updates: 🟢 (confirmed) + +T=800ms WAF responds + └─ LED updates: 🟢 (confirmed) + +T=2500ms System responds (slow) + └─ LED updates: 🟢 (was warn, now ok!) + +T=30000ms Stale refresh cycle + └─ Check: hub (age: 30s) → refresh + └─ Skip: waf (age: 29s) → still fresh + └─ Skip: system (age: 27s) → still fresh +``` + +--- + +## Error Handling Matrix + +| Scenario | Pre-Cache | Refresh | Result | +|----------|-----------|---------|--------| +| Fresh cache, API ok | ✅ Show cached | ✅ Confirm | 🟢 Instant + verified | +| Fresh cache, API timeout | ✅ Show cached | ⚠️ Fallback | 🟢 Cached (may be stale) | +| Fresh cache, API 404 | ✅ Show cached | ⚠️ Page check | 🟢 or ⚫ depends on page | +| Stale cache, API ok | ⚠️ Show stale | ✅ Update | 🟢 Corrected | +| No cache, API ok | ⚫ Show unknown | ✅ Update | 🟢 Discovered | +| No cache, API fail | ⚫ Show unknown | ❌ Fallback | ⚫ or 🔴 | + +--- + +## Benefits + +1. **Zero perceived latency** — User sees status instantly +2. **Eventually consistent** — Truth arrives within seconds +3. **Bandwidth efficient** — Only refresh stale items +4. **Fault tolerant** — Graceful fallbacks at every layer +5. **Progressive enhancement** — Works even with missing APIs + +--- + +## Anti-Patterns to Avoid + +```javascript +// ❌ BAD: Blocking wait for all health checks +const health = await Promise.all(modules.map(check)); +renderSidebar(health); // User waits 4+ seconds + +// ✅ GOOD: Pre-cache first, refresh in background +const cached = loadPreCache(); +renderSidebar(cached); // Instant +checkAllHealth().then(updateLEDs); // Background +``` + +```javascript +// ❌ BAD: Single timestamp for entire cache +cache = { data: {...}, timestamp: Date.now() } + +// ✅ GOOD: Per-module timestamps +cache = { + hub: { status: 'ok', timestamp: T1 }, + waf: { status: 'ok', timestamp: T2 } // Different freshness +} +``` + +```javascript +// ❌ BAD: Refresh everything every cycle +setInterval(() => checkAllModules(), 30000); + +// ✅ GOOD: Refresh only stale modules +setInterval(() => refreshStaleOnly(), 30000); +``` + +--- + +## Integration Points + +### Sidebar Navigation +- Pre-cache on page load +- Background refresh on mount +- Interval refresh every 30s +- LED updates as results arrive + +### Module Dashboards +- Can contribute to shared pre-cache +- Read pre-cache for cross-module status +- Push updates to cache on local health changes + +### Error Pages +- Read pre-cache to show system status +- Trigger refresh to verify current state + +--- + +## Future Extensions + +1. **WebSocket layer** — Real-time push updates +2. **Service Worker** — Offline-first caching +3. **Shared Worker** — Cross-tab cache synchronization +4. **IndexedDB** — Larger cache with history + +--- + +*This pattern enables instant UI responsiveness while maintaining factual accuracy through asynchronous background verification.* diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 9c04b5fb..8a2fdf6f 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -4,6 +4,94 @@ --- ## 2026-05-08 +### Session 123 — Health-Aware Sidebar, WAF Alerting & ACME Certs + +**Sidebar v2.0.0 (Emoji LED + Pre-cache):** +- Emoji LED status: 🟢ok 🟡warn 🔴error ⚫unknown 🔵checking +- Auto-sort: healthy first (ok → warn → unknown → error) +- Pre-cache in localStorage for instant display on load +- Quick error toast (no buttons, auto-dismiss 2.5s) +- Pre-flight health checks on navigation +- 30-second periodic health refresh + +**WAF WebUI Alerting:** +- Live threat ticker with pulsing red indicator +- 7680 total threats, 2813+ today detected +- Alerting tab with filterable list (severity, category, IP) +- Export alerts to CSV +- Quick ban buttons on each alert +- Compact category listing with emoji + toggle + +**WAF Threat Log Fix:** +- Symlinked `/var/log/secubox/waf-threats.log` → `/srv/mitmproxy/logs/waf-threats.log` +- Mitmproxy logs now accessible to WAF API + +**LXC Network Fix:** +- Updated `lxc-network-fix.service` to run continuously (30s loop) +- Fixed all container symlinks in `/var/lib/lxc/` +- Disabled `cgroup2.cpu.max` in all container configs + +**Certificate Status (CRITICAL):** +- 15+ certificates EXPIRED +- 40+ expiring within 30 days +- Certificates at `/data/haproxy/certs/` + +**ACME Certificate Manager WebUI:** +- Created `/certs/` dashboard with emoji TTD indicators +- 💀expired 🔴critical 🟡warning 🟢healthy +- Pre-flight wizard with DNS/HTTP/ACME checks +- Origin emoji icons per backend type + +**Files Deployed:** +- `/usr/share/secubox/www/shared/sidebar.js` — v2.0.0 pre-cache +- `/usr/share/secubox/www/waf/index.html` — alerting system +- `/usr/share/secubox/www/certs/index.html` — ACME manager +- `/etc/systemd/system/lxc-network-fix.service` — continuous veth fix + +--- + +### Session 122 — WAF Architecture Fix & Eye Remote Integration + +**mitmproxy LXC Container Fix:** +- Container was STOPPED causing 503 on all vhosts +- Fixed cgroup2.cpu.max config preventing startup +- Created `lxc-network-fix.service` to auto-start veth interfaces +- mitmproxy now running with 145 routes and 150 WAF rules + +**HAProxy Routing Refactor:** +- Changed all vhosts from `nginx_vhosts` → `mitmproxy_inspector` +- All HTTP traffic now flows through WAF inspection +- Fallback backend changed from 503 deny to mitmproxy pass-through +- Traffic flow: `HAProxy → mitmproxy (WAF) → nginx/backends` + +**Eye Remote Dashboard:** +- Fixed `/eye-remote/` page (nginx location was missing) +- Simplified JS to work with pizero-metrics API +- Added Quick Commands mini card +- API proxy: `/api/v1/eye-remote/*` → `10.55.0.2:8000/*` + +**USB Gadget Network:** +- usb0 interface at 10.55.0.1/24 +- Pi Zero W responding at 10.55.0.2:8000 +- Live metrics: CPU, RAM, Temp, Uptime, Load + +**CrowdSec Status:** +- 100+ active bans (SSH brute-force from DE/NL/RO/SE) +- WAF threats log ready at `/srv/mitmproxy/logs/waf-threats.log` + +**Dependencies Added:** +- `netcat-openbsd` for diagnostics + +**Files Modified:** +- `/etc/haproxy/haproxy.cfg` — All vhosts through mitmproxy +- `/etc/nginx/sites-enabled/webui.conf` — Eye Remote locations +- `/var/lib/lxc/mitmproxy/config` — Fixed network config +- `/etc/systemd/system/lxc-network-fix.service` — Auto veth startup +- `/usr/share/secubox/www/eye-remote/index.html` — Simplified dashboard +- `/usr/share/secubox/www/eye-remote/js/eye-remote.js` — pizero-metrics API + +--- + ### Session 121 — HealthBump v2.1 with Activity Detection & K2000 **Features Added:** diff --git a/packages/secubox-hub/www/index.html b/packages/secubox-hub/www/index.html index 912fde45..12de1db4 100644 --- a/packages/secubox-hub/www/index.html +++ b/packages/secubox-hub/www/index.html @@ -671,7 +671,7 @@ function checkAuth() { if (!getToken()) { - window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.pathname); + window.location.href = '/portal/login.html?redirect=' + encodeURIComponent(window.location.pathname); return false; } return true; @@ -888,7 +888,7 @@ await fetch('/api/v1/portal/logout', { method: 'POST', credentials: 'include' }); localStorage.removeItem('secubox_token'); localStorage.removeItem('sbx_token'); - window.location.href = '/login.html'; + window.location.href = '/portal/login.html'; } async function loadNetworkMode() { diff --git a/packages/secubox-hub/www/luci-static/resources/view/secubox/dashboard.js b/packages/secubox-hub/www/luci-static/resources/view/secubox/dashboard.js index dbb1691d..50c107a6 100644 --- a/packages/secubox-hub/www/luci-static/resources/view/secubox/dashboard.js +++ b/packages/secubox-hub/www/luci-static/resources/view/secubox/dashboard.js @@ -32,6 +32,27 @@ async function callPublicIPs(params) { return sbxFetch('/api/v1/hub/get_public_ips', params, 'GET'); } +// Eye Remote USB Gadget API +async function callEyeRemoteMetrics() { + try { + var response = await fetch('http://10.55.0.2:8000/metrics', { timeout: 3000 }); + if (!response.ok) throw new Error('Not available'); + return await response.json(); + } catch (e) { + return null; + } +} + +async function callEyeRemoteHealth() { + try { + var response = await fetch('http://10.55.0.2:8000/health', { timeout: 2000 }); + if (!response.ok) throw new Error('Not available'); + return await response.json(); + } catch (e) { + return null; + } +} + // Utilities function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 B'; @@ -66,7 +87,8 @@ return view.extend({ alerts: [], publicIPs: {}, board: {}, - info: {} + info: {}, + eyeRemote: null }, load: function() { @@ -78,7 +100,8 @@ return view.extend({ callSystemHealth().catch(function() { return {}; }), callGetModules().catch(function() { return { modules: [] }; }), callGetAlerts().catch(function() { return { alerts: [] }; }), - callPublicIPs().catch(function() { return {}; }) + callPublicIPs().catch(function() { return {}; }), + callEyeRemoteMetrics().catch(function() { return null; }) ]).then(function(results) { self.data = { board: results[0] || {}, @@ -87,7 +110,8 @@ return view.extend({ health: results[3] || {}, modules: (results[4] && results[4].modules) || [], alerts: (results[5] && results[5].alerts) || [], - publicIPs: results[6] || {} + publicIPs: results[6] || {}, + eyeRemote: results[7] }; return self.data; }); @@ -252,6 +276,48 @@ return view.extend({ })); }, + renderEyeRemote: function() { + var c = KissTheme.colors; + var eye = this.data.eyeRemote; + + if (!eye) { + return E('div', { 'id': 'eye-remote-content', 'style': 'text-align: center; padding: 20px; color: var(--kiss-muted);' }, [ + E('div', { 'style': 'font-size: 24px; margin-bottom: 8px;' }, '\u{1F441}'), + E('div', {}, 'Eye Remote not connected'), + E('div', { 'style': 'font-size: 11px; margin-top: 8px;' }, 'USB: 10.55.0.2') + ]); + } + + var cpuColor = eye.cpu_percent > 80 ? c.red : eye.cpu_percent > 50 ? c.orange : c.green; + var tempColor = eye.cpu_temp > 70 ? c.red : eye.cpu_temp > 55 ? c.orange : c.green; + var memColor = eye.mem_percent > 80 ? c.red : eye.mem_percent > 60 ? c.orange : c.green; + + return E('div', { 'id': 'eye-remote-content' }, [ + E('div', { 'style': 'display: flex; align-items: center; gap: 8px; margin-bottom: 12px;' }, [ + E('span', { 'style': 'font-size: 20px;' }, '\u{1F441}'), + E('span', { 'style': 'font-weight: 600;' }, eye.hostname || 'eye-remote'), + KissTheme.badge('Online', 'green') + ]), + E('div', { 'style': 'display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;' }, [ + E('div', { 'style': 'text-align: center; padding: 10px; background: var(--kiss-bg2); border-radius: 6px;' }, [ + E('div', { 'style': 'font-size: 11px; color: var(--kiss-muted); text-transform: uppercase;' }, 'CPU'), + E('div', { 'style': 'font-size: 18px; font-weight: bold; color: ' + cpuColor + ';', 'data-eye': 'cpu' }, eye.cpu_percent.toFixed(0) + '%') + ]), + E('div', { 'style': 'text-align: center; padding: 10px; background: var(--kiss-bg2); border-radius: 6px;' }, [ + E('div', { 'style': 'font-size: 11px; color: var(--kiss-muted); text-transform: uppercase;' }, 'Temp'), + E('div', { 'style': 'font-size: 18px; font-weight: bold; color: ' + tempColor + ';', 'data-eye': 'temp' }, eye.cpu_temp.toFixed(1) + '\u00B0C') + ]), + E('div', { 'style': 'text-align: center; padding: 10px; background: var(--kiss-bg2); border-radius: 6px;' }, [ + E('div', { 'style': 'font-size: 11px; color: var(--kiss-muted); text-transform: uppercase;' }, 'Mem'), + E('div', { 'style': 'font-size: 18px; font-weight: bold; color: ' + memColor + ';', 'data-eye': 'mem' }, eye.mem_percent.toFixed(0) + '%') + ]) + ]), + E('div', { 'style': 'margin-top: 10px; font-size: 11px; color: var(--kiss-muted); text-align: center;', 'data-eye': 'uptime' }, + 'Uptime: ' + formatUptime(eye.uptime_seconds) + ' \u2022 Load: ' + (eye.load_1m || 0).toFixed(2) + ) + ]); + }, + renderAlerts: function() { var c = KissTheme.colors; var alerts = (this.data.alerts || []).slice(0, 5); @@ -310,11 +376,13 @@ return view.extend({ return Promise.all([ callSystemHealth().catch(function() { return {}; }), callGetAlerts().catch(function() { return { alerts: [] }; }), - callPublicIPs().catch(function() { return {}; }) + callPublicIPs().catch(function() { return {}; }), + callEyeRemoteMetrics().catch(function() { return null; }) ]).then(function(results) { self.data.health = results[0] || {}; self.data.alerts = (results[1] && results[1].alerts) || []; self.data.publicIPs = results[2] || {}; + self.data.eyeRemote = results[3]; self.updateLiveData(); }); }, 15); @@ -352,6 +420,9 @@ return view.extend({ // Right column E('div', { 'style': 'display: flex; flex-direction: column; gap: 20px;' }, [ + // Eye Remote USB Gadget + KissTheme.card('Eye Remote', this.renderEyeRemote()), + // Network Addresses KissTheme.card('Network Addresses', this.renderNetworkAddresses()), @@ -407,6 +478,36 @@ return view.extend({ alertsEl.innerHTML = ''; alertsEl.appendChild(this.renderAlerts()); } + + // Update Eye Remote panel + var eyeEl = document.getElementById('eye-remote-content'); + if (eyeEl) { + var eye = this.data.eyeRemote; + if (eye) { + var cpuEl = document.querySelector('[data-eye="cpu"]'); + var tempEl = document.querySelector('[data-eye="temp"]'); + var memEl = document.querySelector('[data-eye="mem"]'); + var uptimeEl = document.querySelector('[data-eye="uptime"]'); + if (cpuEl) { + cpuEl.textContent = eye.cpu_percent.toFixed(0) + '%'; + cpuEl.style.color = eye.cpu_percent > 80 ? c.red : eye.cpu_percent > 50 ? c.orange : c.green; + } + if (tempEl) { + tempEl.textContent = eye.cpu_temp.toFixed(1) + '\u00B0C'; + tempEl.style.color = eye.cpu_temp > 70 ? c.red : eye.cpu_temp > 55 ? c.orange : c.green; + } + if (memEl) { + memEl.textContent = eye.mem_percent.toFixed(0) + '%'; + memEl.style.color = eye.mem_percent > 80 ? c.red : eye.mem_percent > 60 ? c.orange : c.green; + } + if (uptimeEl) { + uptimeEl.textContent = 'Uptime: ' + formatUptime(eye.uptime_seconds) + ' \u2022 Load: ' + (eye.load_1m || 0).toFixed(2); + } + } else { + eyeEl.parentElement.innerHTML = ''; + eyeEl.parentElement.appendChild(this.renderEyeRemote()); + } + } }, handleSave: null, diff --git a/packages/secubox-hub/www/shared/sidebar.js b/packages/secubox-hub/www/shared/sidebar.js index ebfab952..6b2a0967 100644 --- a/packages/secubox-hub/www/shared/sidebar.js +++ b/packages/secubox-hub/www/shared/sidebar.js @@ -1,446 +1,798 @@ /** - * ═══════════════════════════════════════════════════════════════ - * SECUBOX SIDEBAR — CRT P31 Phosphor Theme - * Dynamic menu with VT100 aesthetic - * Includes light/dark theme toggle - * - * Usage: - * - * - * ═══════════════════════════════════════════════════════════════ + * SECUBOX SIDEBAR — Health-Aware Navigation + * v2.17.1 — Simplified LED update logic, always reflects health status + * CHECKED = hide only black/unknown, UNCHECKED = show all */ (function() { const MENU_API = '/api/v1/hub/public/menu'; - const VERSION = 'v1.7.0'; + const VERSION = 'v2.17.1'; const THEME_KEY = 'sbx_theme'; + const HEALTH_CACHE_KEY = 'sbx_health_cache'; + const SORT_PREF_KEY = 'sbx_health_sort'; - // Theme configuration + const HEALTH_CACHE_TTL = 300000; // 5 min - cache validity + const HEALTH_REFRESH_INTERVAL = 30000; // 30s - update interval + const HEALTH_REQUEST_TIMEOUT = 4000; + const BATCH_SIZE = 8; + + let ALL_MODULES = []; + + const LED_EMOJI = { ok: '🟢', warn: '🟡', error: '🔴', unknown: '⚫', checking: '🔵' }; const THEMES = { - light: { - css: '/shared/crt-light.css', - sidebar: '/shared/sidebar-light.css', - icon: '☀️', - label: 'LIGHT' - }, - dark: { - css: '/shared/crt-system.css', - sidebar: '/shared/sidebar.css', - icon: '🌙', - label: 'DARK' - } + light: { css: '/shared/crt-light.css', sidebar: '/shared/sidebar-light.css', icon: '☀️' }, + dark: { css: '/shared/crt-system.css', sidebar: '/shared/sidebar.css', icon: '🌙' } }; - // Get current theme from localStorage or default to light - function getCurrentTheme() { - return localStorage.getItem(THEME_KEY) || 'light'; + let healthCache = {}; + let healthCheckInProgress = {}; + let offlineCounters = {}; + // TRUE = hide black/unknown (show only green+yellow+red), FALSE = show all + let hideUnknownEnabled = false; + + // ============================================ + // HELPERS + // ============================================ + + function getHealthEndpoint(mod) { + return '/api/v1/' + mod + '/health'; } - // Set theme and update CSS links - function setTheme(theme) { - localStorage.setItem(THEME_KEY, theme); - const config = THEMES[theme]; + function getPagePath(mod) { + if (mod === 'hub') return '/'; + return '/' + mod + '/'; + } - // Find and update CSS links - const links = document.querySelectorAll('link[rel="stylesheet"]'); - links.forEach(link => { - const href = link.getAttribute('href'); - if (href.includes('crt-light.css') || href.includes('crt-system.css')) { - link.setAttribute('href', config.css); - } - if (href.includes('sidebar-light.css') || href.includes('sidebar.css')) { - // Only update if it's a sidebar CSS (not the main sidebar.js) - if (href.endsWith('.css')) { - link.setAttribute('href', config.sidebar); + function getModuleFromPath(path) { + if (!path || path === '/' || path === '/index.html') return 'hub'; + var m = path.match(/^\/([a-z0-9-]+)/); + return m ? m[1] : null; + } + + function extractModulesFromMenu(data) { + var modules = []; + if (data && data.categories) { + data.categories.forEach(function(cat) { + if (cat.items) { + cat.items.forEach(function(item) { + var mod = getModuleFromPath(item.path); + if (mod && modules.indexOf(mod) === -1) modules.push(mod); + }); } - } - }); - - // Update body class - document.body.classList.remove('crt-light', 'crt-body', 'crt-scanlines'); - if (theme === 'light') { - document.body.classList.add('crt-light'); - } else { - document.body.classList.add('crt-body', 'crt-scanlines'); + }); } - - // Update toggle button if exists - const toggleBtn = document.getElementById('theme-toggle'); - if (toggleBtn) { - const otherTheme = theme === 'light' ? 'dark' : 'light'; - toggleBtn.innerHTML = ``; - toggleBtn.title = `Switch to ${otherTheme} mode`; - } - - // Update inline CSS variables for pages with inline styles - updateInlineThemeVars(theme); + return modules; } - // Update CSS variables for inline styles - function updateInlineThemeVars(theme) { - const root = document.documentElement; - if (theme === 'light') { - // Light theme - mint green palette - root.style.setProperty('--tube-light', '#e8f5e9'); - root.style.setProperty('--tube-pale', '#c8e6c9'); - root.style.setProperty('--tube-soft', '#a5d6a7'); - root.style.setProperty('--tube-mist', '#81c784'); - root.style.setProperty('--tube-dark', '#1b3d1c'); - root.style.setProperty('--tube-black', '#e8f5e9'); - root.style.setProperty('--tube-deep', '#c8e6c9'); - root.style.setProperty('--tube-bezel', '#a5d6a7'); - root.style.setProperty('--tube-muted', '#81c784'); - root.style.setProperty('--p31-peak', '#00dd44'); - root.style.setProperty('--p31-hot', '#00ff55'); - root.style.setProperty('--p31-mid', '#009933'); - root.style.setProperty('--p31-dim', '#006622'); - root.style.setProperty('--p31-ghost', '#003311'); - root.style.setProperty('--p31-decay', '#cc7722'); - root.style.setProperty('--p31-decay-dim', '#996611'); - root.style.setProperty('--accent', '#00dd44'); - // Legacy mappings - root.style.setProperty('--bg-dark', '#e8f5e9'); - root.style.setProperty('--bg-card', '#c8e6c9'); - root.style.setProperty('--bg-sidebar', '#e8f5e9'); - root.style.setProperty('--border', '#a5d6a7'); - root.style.setProperty('--text', '#1b3d1c'); - root.style.setProperty('--text-dim', '#006622'); - root.style.setProperty('--primary', '#00dd44'); - root.style.setProperty('--cyan', '#00dd44'); - root.style.setProperty('--green', '#00dd44'); - // C3BOX mesh dashboard variables - root.style.setProperty('--bg', '#e8f5e9'); - root.style.setProperty('--fg', '#006622'); - root.style.setProperty('--dim', '#c8e6c9'); - } else { - // Dark theme - blue-tinted palette (matching crt-system.css) - root.style.setProperty('--tube-light', '#0a0e14'); - root.style.setProperty('--tube-pale', '#1a1f2e'); - root.style.setProperty('--tube-soft', '#252a3a'); - root.style.setProperty('--tube-mist', '#3a4050'); - root.style.setProperty('--tube-dark', '#66ffaa'); - root.style.setProperty('--tube-black', '#0a0e14'); - root.style.setProperty('--tube-deep', '#1a1f2e'); - root.style.setProperty('--tube-bezel', '#252a3a'); - root.style.setProperty('--tube-muted', '#3a4050'); - root.style.setProperty('--p31-peak', '#33ff66'); - root.style.setProperty('--p31-hot', '#66ffaa'); - root.style.setProperty('--p31-mid', '#22cc44'); - root.style.setProperty('--p31-dim', '#0f8822'); - root.style.setProperty('--p31-ghost', '#0a4420'); - root.style.setProperty('--p31-decay', '#ffb347'); - root.style.setProperty('--p31-decay-dim', '#cc7722'); - root.style.setProperty('--accent', '#00ff88'); - // Legacy mappings - root.style.setProperty('--bg-dark', '#0a0e14'); - root.style.setProperty('--bg-card', '#1a1f2e'); - root.style.setProperty('--bg-sidebar', '#0a0e14'); - root.style.setProperty('--border', '#252a3a'); - root.style.setProperty('--text', '#66ffaa'); - root.style.setProperty('--text-dim', '#22cc44'); - root.style.setProperty('--primary', '#33ff66'); - root.style.setProperty('--cyan', '#33ff66'); - root.style.setProperty('--green', '#33ff66'); - // C3BOX mesh dashboard variables - root.style.setProperty('--bg', '#0a0e14'); - root.style.setProperty('--fg', '#33ff66'); - root.style.setProperty('--dim', '#1a1f2e'); - } - } + // ============================================ + // PRE-CACHE + // ============================================ - // Toggle between themes - function toggleTheme() { - const current = getCurrentTheme(); - const next = current === 'light' ? 'dark' : 'light'; - setTheme(next); - } - - // Inject additional CRT styles - const style = document.createElement('style'); - style.textContent = ` - @import url('https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); - - .theme-toggle { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - padding: 0.5rem; - margin: 0.5rem 0; - border: 1px solid var(--p31-dim, #006622); - border-radius: 4px; - background: transparent; - color: var(--p31-mid, #009933); - cursor: pointer; - font-family: inherit; - font-size: 0.75rem; - letter-spacing: 0.1em; - transition: all 0.2s; - width: 100%; - } - .theme-toggle:hover { - border-color: var(--p31-peak, #00dd44); - color: var(--p31-peak, #00dd44); - background: rgba(0, 221, 68, 0.1); - } - .theme-icon { - font-size: 1.1rem; - } - `; - document.head.appendChild(style); - - // Clock updater - function startClock(el) { - function tick() { - const d = new Date(); - const H = String(d.getHours()).padStart(2, '0'); - const M = String(d.getMinutes()).padStart(2, '0'); - const S = String(d.getSeconds()).padStart(2, '0'); - el.textContent = H + ':' + M + ':' + S; - } - tick(); - setInterval(tick, 1000); - } - - // Toggle section collapse - function toggleSection(e) { - const section = e.currentTarget.closest('.nav-section'); - if (section) { - section.classList.toggle('collapsed'); - const icon = e.currentTarget.querySelector('.toggle-icon'); - if (icon) { - icon.textContent = section.classList.contains('collapsed') ? '▶' : '▼'; - } - } - } - - // Mobile toggle - function toggleMobile() { - const sidebar = document.getElementById('sidebar'); - if (sidebar) { - sidebar.classList.toggle('open'); - } - } - - // Logout handler - function logout() { - localStorage.removeItem('sbx_token'); - localStorage.removeItem('sbx_user'); - window.location.href = '/portal/login.html'; - } - - // Get current user from token - function getCurrentUser() { + function loadPreCache() { try { - const token = localStorage.getItem('sbx_token'); - if (token) { - const payload = JSON.parse(atob(token.split('.')[1])); - return payload.sub || 'admin'; + var cached = localStorage.getItem(HEALTH_CACHE_KEY); + if (!cached) return null; + var data = JSON.parse(cached); + // Keep ALL entries - don't discard based on TTL + // TTL only used to decide what to refresh, not what to show + if (Object.keys(data).length > 0) { + healthCache = data; + return data; } } catch (e) {} - return 'admin'; + return null; } - async function loadSidebar() { - const sidebar = document.getElementById('sidebar'); - if (!sidebar) return; + function savePreCache(health) { + try { + var existing = {}; + try { var c = localStorage.getItem(HEALTH_CACHE_KEY); if (c) existing = JSON.parse(c); } catch (e) {} + for (var mod in health) { + existing[mod] = { + status: health[mod].status, msg: health[mod].msg, + timestamp: health[mod].timestamp || Date.now() + }; + } + localStorage.setItem(HEALTH_CACHE_KEY, JSON.stringify(existing)); + } catch (e) {} + } - const currentTheme = getCurrentTheme(); - const otherTheme = currentTheme === 'light' ? 'dark' : 'light'; + // ============================================ + // HEALTH CHECKS + // ============================================ - // Show loading state with CRT effect - sidebar.innerHTML = ` -
- - `; + async function checkModuleHealth(mod) { + var endpoint = getHealthEndpoint(mod); + var fallbackPage = getPagePath(mod); + + if (healthCheckInProgress[mod]) { + return healthCache[mod] || { status: 'checking', msg: 'In progress', timestamp: Date.now() }; + } + healthCheckInProgress[mod] = true; try { - const token = localStorage.getItem('sbx_token'); - const headers = token ? { 'Authorization': 'Bearer ' + token } : {}; - const res = await fetch(MENU_API, { headers }); + var token = localStorage.getItem('sbx_token'); + var headers = token ? { 'Authorization': 'Bearer ' + token } : {}; + var ctrl = new AbortController(); + var tm = setTimeout(function() { ctrl.abort(); }, HEALTH_REQUEST_TIMEOUT); - // Redirect to login if not authenticated - if (res.status === 401) { - window.location.href = '/portal/login.html'; + try { + var res = await fetch(endpoint, { headers: headers, signal: ctrl.signal }); + clearTimeout(tm); + + if (res.ok) { + var ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + var text = await res.text(); + try { + var d = JSON.parse(text); + healthCheckInProgress[mod] = false; + if (d.status === 'ok' || d.healthy === true || d.status === 'healthy') { + return { status: 'ok', msg: d.message || 'OK', timestamp: Date.now() }; + } + if (d.status === 'degraded' || d.status === 'warn' || d.warning) { + return { status: 'warn', msg: d.message || 'Degraded', timestamp: Date.now() }; + } + return { status: 'error', msg: d.message || d.status || 'Error', timestamp: Date.now() }; + } catch (parseErr) { + // JSON parse failed - treat as unknown + healthCheckInProgress[mod] = false; + return { status: 'unknown', msg: 'Invalid JSON', timestamp: Date.now() }; + } + } else { + // HTML response - no health API + healthCheckInProgress[mod] = false; + return { status: 'unknown', msg: 'No health API', timestamp: Date.now() }; + } + } + + // Non-200 response = error (no fallback) + healthCheckInProgress[mod] = false; + if (res.status === 404) { + return { status: 'error', msg: 'No API', timestamp: Date.now() }; + } + return { status: 'error', msg: 'HTTP ' + res.status, timestamp: Date.now() }; + + } catch (fetchError) { + clearTimeout(tm); + healthCheckInProgress[mod] = false; + if (fetchError.name === 'AbortError') { + return { status: 'error', msg: 'Timeout', timestamp: Date.now() }; + } + return { status: 'error', msg: 'Network error', timestamp: Date.now() }; + } + } catch (e) { + healthCheckInProgress[mod] = false; + return { status: 'error', msg: 'Check failed', timestamp: Date.now() }; + } + } + + async function checkPageFallback(mod, page, headers) { + try { + var ctrl = new AbortController(); + var tm = setTimeout(function() { ctrl.abort(); }, 2000); + var res = await fetch(page, { method: 'HEAD', headers: headers, signal: ctrl.signal }); + clearTimeout(tm); + + if (res.ok) { + return { status: 'unknown', msg: 'Page exists', timestamp: Date.now() }; + } + if (res.status === 401 || res.status === 403) { + return { status: 'unknown', msg: 'Auth required', timestamp: Date.now() }; + } + if (res.status === 404) { + return { status: 'error', msg: 'Not found', timestamp: Date.now() }; + } + return { status: 'error', msg: 'HTTP ' + res.status, timestamp: Date.now() }; + } catch (e) { + return { status: 'error', msg: 'Unreachable', timestamp: Date.now() }; + } + } + + async function checkAllHealth(immediate) { + var now = Date.now(); + + if (!immediate && Object.keys(healthCache).length > 0) { + var newest = 0; + for (var mod in healthCache) { + if (healthCache[mod].timestamp > newest) newest = healthCache[mod].timestamp; + } + if (now - newest < HEALTH_REFRESH_INTERVAL) { + setTimeout(refreshStaleHealth, 100); + return healthCache; + } + } + + var modules = ALL_MODULES.length > 0 ? ALL_MODULES : Object.keys(healthCache); + if (modules.length === 0) return healthCache; + + // Set modules without status to checking (preserve existing valid statuses) + modules.forEach(function(mod) { + var existing = healthCache[mod]; + if (!existing) { + healthCache[mod] = { status: 'checking', msg: 'Checking...', timestamp: Date.now() }; + } else if (existing._rechecking) { + // Already marked for recheck by forceRefresh, keep visual status + } else if (existing.status === 'unknown') { + healthCache[mod] = { status: 'checking', msg: 'Checking...', timestamp: Date.now() }; + } + // If already ok/warn/error, keep that status until new result arrives + }); + + for (var i = 0; i < modules.length; i += BATCH_SIZE) { + var batch = modules.slice(i, i + BATCH_SIZE); + var results = await Promise.allSettled(batch.map(function(mod) { + return checkModuleHealth(mod); + })); + + results.forEach(function(result, idx) { + var mod = batch[idx]; + if (result.status === 'fulfilled') { + healthCache[mod] = result.value; + } else { + healthCache[mod] = { status: 'error', msg: 'Check failed', timestamp: Date.now() }; + } + }); + + // Update LEDs after each batch + updateAllLEDs(); + } + + return healthCache; + } + + async function refreshStaleHealth() { + var now = Date.now(); + var stale = []; + + ALL_MODULES.forEach(function(mod) { + var c = healthCache[mod]; + if (!c || (now - (c.timestamp || 0)) > HEALTH_REFRESH_INTERVAL) stale.push(mod); + }); + + if (stale.length === 0) return; + + // Incremental update - update each LED as result comes in + for (var i = 0; i < stale.length; i += BATCH_SIZE) { + var batch = stale.slice(i, i + BATCH_SIZE); + await Promise.allSettled(batch.map(async function(mod) { + var result = await checkModuleHealth(mod); + healthCache[mod] = result; + // Update single LED immediately + var item = document.querySelector('.nav-item[data-module="' + mod + '"]'); + if (item) updateSingleLED(item, result); + })); + } + + // Save cache once at end, no re-sort needed + savePreCache(healthCache); + updateStatusIndicator(); + } + + // ============================================ + // SORTING — MOVE, NOT CLONE + // ============================================ + + // Cache DOM items to survive master-top removal + var _cachedItems = null; + + function applySorting() { + var navContainer = document.querySelector('.sidebar-nav'); + if (!navContainer) return; + + // Cache items BEFORE removing master-top + if (!_cachedItems) { + _cachedItems = Array.from(document.querySelectorAll('.sidebar-nav .nav-item[data-module]')); + } + + // Remove master-top if exists + var masterTop = document.getElementById('master-top-section'); + if (masterTop) masterTop.remove(); + + // Show all cached items + _cachedItems.forEach(function(item) { + item.style.display = ''; + }); + + // Show all original sections + document.querySelectorAll('.nav-section').forEach(function(s) { + s.style.display = ''; + }); + + if (hideUnknownEnabled) { + applyFilteredSort(navContainer, _cachedItems); + } else { + sortWithinCategories(_cachedItems); + } + } + + function sortWithinCategories(cachedItems) { + var order = { 'ok': 0, 'healthy': 0, 'warn': 1, 'degraded': 1, 'unknown': 2, 'checking': 2, 'error': 3 }; + document.querySelectorAll('.nav-items').forEach(function(container) { + var items = cachedItems ? cachedItems.filter(function(i) { return container.contains(i) || i.parentNode === null; }) + : Array.from(container.querySelectorAll('.nav-item[data-module]')); + items.sort(function(a, b) { + var hA = healthCache[a.dataset.module]; + var hB = healthCache[b.dataset.module]; + var sA = hA ? (hA._rechecking && hA._prevStatus ? hA._prevStatus : hA.status) : 'unknown'; + var sB = hB ? (hB._rechecking && hB._prevStatus ? hB._prevStatus : hB.status) : 'unknown'; + var oA = (order[sA] !== undefined) ? order[sA] : 2; + var oB = (order[sB] !== undefined) ? order[sB] : 2; + return oA - oB; + }); + items.forEach(function(item) { container.appendChild(item); }); + }); + } + + function applyFilteredSort(navContainer, cachedItems) { + // Use cached items or query DOM + var allItems = cachedItems || Array.from(navContainer.querySelectorAll('.nav-item[data-module]')); + + console.log('[Filter] Items:', allItems.length, 'Cache:', Object.keys(healthCache).length); + + // Separate into visible (ok, warn, error, checking) and hidden (unknown only) + var visible = []; + var hidden = []; + + allItems.forEach(function(item) { + var mod = item.dataset.module; + if (!mod) { + // Skip items without valid module ID + hidden.push(item); + item.style.display = 'none'; return; } - const data = await res.json(); + var h = healthCache[mod]; + var status = h ? h.status : 'checking'; // Default to checking, not unknown - if (!data || !data.categories) { - throw new Error('Invalid menu data'); + // If rechecking, use previous status if it was valid (green/yellow/red) + if (h && h._rechecking && h._prevStatus) { + status = h._prevStatus; } - const currentPath = window.location.pathname; - const user = getCurrentUser(); + // Show: green, yellow, red, AND blue (checking) + // Hide: only black (unknown) + var isVisible = (status === 'ok' || status === 'healthy' || status === 'warn' || status === 'degraded' || status === 'error' || status === 'checking'); + if (isVisible) { + visible.push(item); + } else { + console.log('[Filter] Hiding:', mod, 'status:', status); + hidden.push(item); + item.style.display = 'none'; + } + }); - const menuHTML = data.categories.map(cat => { - // Check if this category has an active item - const hasActiveItem = cat.items.some(item => - currentPath === item.path || - (item.path !== '/' && currentPath.startsWith(item.path)) - ); - // Collapse all categories except the one with active item - const isCollapsed = !hasActiveItem; - const collapsedClass = isCollapsed ? ' collapsed' : ''; - const toggleIcon = isCollapsed ? '▶' : '▼'; + // Sort visible items: green first, then yellow, then blue, then red + visible.sort(function(a, b) { + var hA = healthCache[a.dataset.module]; + var hB = healthCache[b.dataset.module]; + // Use _prevStatus during recheck to maintain sort order + var sA = hA ? (hA._rechecking && hA._prevStatus ? hA._prevStatus : hA.status) : 'checking'; + var sB = hB ? (hB._rechecking && hB._prevStatus ? hB._prevStatus : hB.status) : 'checking'; - return ` - `; - }).join(''); + var order = { 'ok': 0, 'healthy': 0, 'warn': 1, 'degraded': 1, 'checking': 2, 'error': 3 }; + var oA = (order[sA] !== undefined) ? order[sA] : 2; + var oB = (order[sB] !== undefined) ? order[sB] : 2; - sidebar.innerHTML = ` - - - - `; + if (oA !== oB) return oA - oB; + return a.textContent.trim().toLowerCase().localeCompare(b.textContent.trim().toLowerCase()); + }); - // Start clock - const clockEl = document.getElementById('sidebar-clock'); - if (clockEl) startClock(clockEl); + // Create filtered section + var masterTop = document.createElement('div'); + masterTop.id = 'master-top-section'; + masterTop.className = 'nav-section'; + + var okCount = visible.filter(function(i) { + var s = (healthCache[i.dataset.module] || {}).status; + return s === 'ok' || s === 'healthy'; + }).length; + + console.log('[Filter] Visible:', visible.length, 'Hidden:', hidden.length); + + masterTop.innerHTML = ''; + navContainer.insertBefore(masterTop, navContainer.firstChild); + + var masterItems = document.getElementById('master-top-items'); + console.log('[Filter] masterItems:', masterItems ? 'found' : 'NOT FOUND'); + + // Move visible items + visible.forEach(function(item) { + item.style.display = ''; + masterItems.appendChild(item); + }); + + // Hide original category sections + document.querySelectorAll('.nav-section:not(#master-top-section)').forEach(function(s) { + s.style.display = 'none'; + }); + } + + // ============================================ + // LED Updates + // ============================================ + + function updateAllLEDs() { + document.querySelectorAll('.nav-item[data-module]').forEach(function(item) { + var mod = item.dataset.module; + var h = healthCache[mod]; + if (h) updateSingleLED(item, h); + }); + } + + function updateSingleLED(item, h) { + var mod = item.dataset.module; + var led = item.querySelector('.status-led'); + if (!led || !h) return; + + // Simple: always update LED to match health status + var displayStatus = h.status; + + // During recheck with valid previous, show previous with pulse + if (h._rechecking && h._prevStatus && h._prevStatus !== 'unknown') { + displayStatus = h._prevStatus; + led.classList.add('led-pulse'); + } else { + led.classList.toggle('led-pulse', h.status === 'checking'); + } + + led.textContent = LED_EMOJI[displayStatus] || LED_EMOJI.unknown; + led.title = h.msg || h.status; + + // Visual effects for offline/fading + if (h.status === 'error') { + offlineCounters[mod] = (offlineCounters[mod] || 0) + 1; + item.classList.remove('has-msg', 'reappear'); + if (offlineCounters[mod] >= 3) item.classList.add('fading'); + if (offlineCounters[mod] >= 6) { item.classList.add('offline'); item.classList.remove('fading'); } + } else { + var wasOffline = item.classList.contains('offline') || item.classList.contains('fading'); + offlineCounters[mod] = 0; + item.classList.remove('offline', 'fading'); + if (wasOffline && h.status === 'ok') { + item.classList.add('reappear'); + setTimeout(function() { item.classList.remove('reappear'); }, 600); + } + if (h.status === 'warn') item.classList.add('has-msg'); + else item.classList.remove('has-msg'); + } + } + + function updateLEDs(health) { + updateAllLEDs(); + applySorting(); + savePreCache(health); + updateStatusIndicator(); + } + + function updateStatusIndicator() { + var ind = document.getElementById('status-indicator'); + if (!ind) return; + + var ok = 0, warn = 0, err = 0, unk = 0; + for (var mod in healthCache) { + var s = healthCache[mod].status; + if (s === 'ok') ok++; + else if (s === 'warn') warn++; + else if (s === 'error') err++; + else unk++; + } + + var total = ok + warn + err + unk; + var em = err > 0 ? '❌' : (warn > 0 ? '⚠️' : '✅'); + ind.innerHTML = '' + em + '' + ok + '/' + total + ''; + ind.title = ok + ' OK, ' + warn + ' warn, ' + err + ' error, ' + unk + ' unknown'; + ind.onclick = showStatusPanel; + } + + function showStatusPanel() { + var ex = document.getElementById('status-panel'); + if (ex) { ex.remove(); return; } + + var ok = 0, warn = 0, err = 0, unk = 0; + for (var mod in healthCache) { + var s = healthCache[mod].status; + if (s === 'ok') ok++; + else if (s === 'warn') warn++; + else if (s === 'error') err++; + else unk++; + } + + var p = document.createElement('div'); + p.id = 'status-panel'; + p.className = 'status-panel'; + + var html = '