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
@@ -0,0 +1,100 @@
"""Tests for broadcast_versions per-subsystem contract (daemon.server).
broadcast_versions(subsystem) sends exactly one data-carrying message for
its subsystem — no legacy `updated` field — and skips the broadcast
entirely when the subsystem's state is None (collector failed).
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import daemon.server as server
class TestBroadcastVersionsPerSubsystem:
def _ws(self):
ws = AsyncMock()
ws.send_str = AsyncMock()
server._ws_subscribers.add(ws)
return ws
def test_only_target_subsystem_sent(self):
"""Each subscriber gets one versions message carrying that subsystem."""
store = MagicMock()
store.get.side_effect = lambda name: {"v": name}
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
ws.send_str.assert_awaited_once()
msg = json.loads(ws.send_str.call_args[0][0])
assert msg == {
"type": "versions",
"subsystem": "firewall",
"data": {"v": "firewall"},
}
finally:
server._ws_subscribers.discard(ws)
def test_data_per_subsystem_not_shared(self):
"""The data payload is that subsystem's state, not another's."""
store = MagicMock()
store.get.side_effect = lambda name: {"name": name}
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("dnsmasq"))
asyncio.run(server.broadcast_versions("acme"))
msgs = [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
assert [(m["subsystem"], m["data"]) for m in msgs] == [
("dnsmasq", {"name": "dnsmasq"}),
("acme", {"name": "acme"}),
]
# No legacy diff field in any message.
for m in msgs:
assert "updated" not in m
finally:
server._ws_subscribers.discard(ws)
def test_none_state_produces_no_message(self):
"""A None payload (failed collection) is skipped — no clobber."""
store = MagicMock()
store.get.return_value = None
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
ws.send_str.assert_not_awaited()
finally:
server._ws_subscribers.discard(ws)
def test_no_bump_called(self):
"""broadcast_versions never bumps — callers own the version counter."""
store = MagicMock()
store.get.return_value = {"a": 1}
store.bump = MagicMock()
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
store.bump.assert_not_called()
finally:
server._ws_subscribers.discard(ws)
def test_dead_subscriber_removed(self):
"""A failing subscriber is pruned and healthy ones still receive data."""
store = MagicMock()
store.get.return_value = {"a": 1}
dead = AsyncMock()
dead.send_str = AsyncMock(side_effect=Exception("broken"))
healthy = self._ws()
server._ws_subscribers.add(dead)
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
assert dead not in server._ws_subscribers
healthy.send_str.assert_awaited_once()
finally:
server._ws_subscribers.discard(dead)
server._ws_subscribers.discard(healthy)