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
+49 -7
View File
@@ -1947,6 +1947,40 @@ Apply pending changes for all subsystems in dependency order.
| `applied` | `[string, ...]` | List of subsystems that were applied |
| `errors` | `[object, ...]` | Any errors encountered during apply |
---
#### Refresh State
```
POST /api/status/refresh
```
Re-collect state from the daemon, optionally filtered by subsystem. Proxies the daemon's `POST /status/refresh`, which populates the state store for the requested subsystems, replies with their current state, and broadcasts a `versions` WS delta for each so all connected viewers stay in sync.
**Request Body** (optional — `{}` or omitted refreshes all subsystems):
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subsystems` | `[string, ...]` | No | Subsystem names to refresh (e.g., `["firewall"]`) |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| — | `object` | Map of the requested subsystem name(s) to its full state dict (`null` = collector not populated / failed) |
**Example:**
```json
// Request
{"subsystems": ["firewall"]}
// Response
{"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}}
```
Returns HTTP `500` if the daemon is unreachable.
### Sysctl
#### Set Kernel Parameter
@@ -2069,14 +2103,22 @@ Returns HTTP `404` if the log file does not exist.
## WebSocket Protocol
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state change notifications. On connect, the server sends:
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state streaming. After authentication, the server pushes a full state snapshot on connect and then per-subsystem deltas — the client patches models in place (`modelSet`) with no HTTP round-trip.
```json
{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}}
```
### Handshake Authentication
The JWT **access** token travels as the **raw `Sec-WebSocket-Protocol` subprotocol name** (the bundled client sends the bare token, no `Bearer ` prefix — subprotocol names must be valid RFC 6455 tokens). The daemon additionally accepts a legacy `Bearer <token>` subprotocol (non-browser clients) and an `X-Auth-Token` header fallback. The token is validated without session binding (browsers cannot send custom headers on the WebSocket handshake) but with the jti revocation check. A missing or invalid token yields HTTP `401` and no socket is opened.
### Message Types
- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch.
- **`tick`** — Volatile-only change (stats, counters, DHCP IPs). `subsystems` contains affected subsystem names. Triggers lightweight per-subsystem re-fetch.
- **`notify`** — Single-topic notification. `topic` is the subsystem name.
| Type | Sent | Fields | Meaning |
|------|------|--------|---------|
| `snapshot` | On connect (after auth) | `data: {subsystem: state\|null, …}` | Full state for every subsystem. `null` = collector not populated / failed — clients skip those entries. |
| `versions` | Structural change | `subsystem`, `data` | The full state of the one changed subsystem (zone added, config changed, …). Version counter bumped; data pushed. |
| `tick` | Volatile-only change | `subsystem`, `data` | The full state of the one changed subsystem (stats/counters/DHCP IPs). No version bump. |
There is no legacy `updated` dict or `subsystems` array — each data-carrying message names a single `subsystem` and carries its full `data`.
### Manual Refresh
`POST /api/status/refresh` re-collects state (optionally filtered by a `subsystems` array) and broadcasts a `versions` delta for each requested subsystem. It is the HTTP fallback the client uses for the initial load (3s timer) and reconnect recovery. See the [Status API — Refresh State](#refresh-state) section for the full request/response contract.