ws: migrate push stream to data streaming

- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
This commit is contained in:
2026-08-20 01:38:00 +00:00
parent 9c9f92ad04
commit 332d14e37d
45 changed files with 2819 additions and 496 deletions
+80
View File
@@ -0,0 +1,80 @@
/**
* Hoover — schema.js
*
* State-store schema defaults — one module per subsystem.
*
* `defaults` initializes model.data so pages don't need null guards
* during the first render (before the WS snapshot or HTTP fallback
* delivers real data). The shapes match the daemon state store
* (docs/state-model.md); the WebSocket streams these exact shapes.
*
* `POLL_INTERVALS` is client-side awareness of the daemon's expected
* refresh cadence per subsystem (for "last updated" displays).
*/
export const SUBSYSTEMS = {
firewall: {
defaults: {
config: {},
active_zones: {},
interfaces: [],
available_services: [],
zones: {},
rich_rules: {},
pending: {},
},
},
dnsmasq: {
defaults: {
config: {},
status: { service_active: false, config_file_exists: false, active_leases: 0, pending_changes: false },
leases: [],
},
},
nginx: {
defaults: {
config: {},
domains: [],
status: { pending_changes: false },
},
},
acme: {
defaults: {
certs: [],
email: '',
account: { registered: false, email: '', ca: '', key_length: null },
},
},
wireguard: {
defaults: {
config: {},
status: { up: false, interface: {}, peers: [], classes: {}, pending_changes: false },
peers: [],
},
},
networkd: {
defaults: {
config: {},
interfaces: {},
status: { pending_changes: false },
},
},
system: {
defaults: {
load: { load1: 0, load5: 0, load15: 0 },
memory: { total: 0, available: 0, used: 0, used_pct: 0 },
swap: { total: 0, used: 0, used_pct: 0 },
traffic: {},
},
},
};
export const POLL_INTERVALS = {
firewall: 30,
wireguard: 10,
dnsmasq: 10,
networkd: 10,
system: 1, // Phase 5: 30 → 1 (real-time metrics)
nginx: 60, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
acme: 300, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
};