feat(hub): Sidebar v2.17.1 AOPC pattern + login fixes

- Sidebar v2.17.1: Async Over-Chronous Pre-Cache pattern
  - Cache is persistent reference, never discarded
  - Incremental LED updates, no full re-sort on refresh
  - Simplified LED update logic
  - L3 DOM cache layer survives master-top removal
  - 404/timeout = red error, no fallback
- Login: Fixed endpoint /api/v1/hub/auth/login
- Index: Fixed redirects to /portal/login.html
- Added AOPC pattern documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-08 14:26:50 +02:00
parent da8b7c5570
commit 5b64aa9028
5 changed files with 1349 additions and 405 deletions

View File

@ -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.*

View File

@ -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:**

View File

@ -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() {

View File

@ -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,

File diff suppressed because it is too large Load Diff