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:
+84
-37
@@ -160,7 +160,14 @@ def refresh_state(subsystems: list[str] | None = None) -> None:
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
task = asyncio.create_task(broadcast_versions())
|
||||
# Broadcast each refreshed subsystem individually (gather for parallelism)
|
||||
async def _broadcast_all():
|
||||
await asyncio.gather(
|
||||
*[broadcast_versions(name) for name in targets],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_broadcast_all())
|
||||
task.add_done_callback(_ws_tasks.discard)
|
||||
_ws_tasks.add(task)
|
||||
|
||||
@@ -401,10 +408,12 @@ _last_blacklist_cleanup_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
async def _handle_ws(request: web.Request) -> web.Response:
|
||||
"""WebSocket endpoint for real-time state change notifications.
|
||||
"""WebSocket endpoint for real-time state streaming.
|
||||
|
||||
On connect: sends current versions. On state change: broadcasts
|
||||
updated subsystem versions. Clients disconnect to unsubscribe.
|
||||
On connect: sends a full state snapshot of every subsystem
|
||||
({type: snapshot, data: {subsystem: state, …}}). On state change:
|
||||
broadcasts a data-carrying per-subsystem delta (versions or tick).
|
||||
Clients disconnect to unsubscribe.
|
||||
|
||||
Authentication: JWT access token passed via:
|
||||
1. WebSocket subprotocol header — the bundled client sends the raw JWT
|
||||
@@ -452,7 +461,8 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
||||
await ws.prepare(request)
|
||||
_ws_subscribers.add(ws)
|
||||
|
||||
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
|
||||
snapshot = state_store.get_snapshot()
|
||||
await ws.send_json({"type": "snapshot", "data": snapshot})
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
@@ -466,35 +476,59 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
||||
return ws
|
||||
|
||||
|
||||
async def broadcast_versions() -> None:
|
||||
"""Broadcast updated subsystem versions to all WebSocket clients."""
|
||||
updated = state_store.get_updated_versions()
|
||||
if not updated or not _ws_subscribers:
|
||||
async def _send_all(message: str) -> None:
|
||||
"""Send a message string to all WS subscribers, removing dead ones."""
|
||||
dead: set[web.WebSocketResponse] = set()
|
||||
for ws in _ws_subscribers:
|
||||
try:
|
||||
await ws.send_str(message)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_ws_subscribers.difference_update(dead)
|
||||
if dead:
|
||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||
|
||||
|
||||
async def broadcast_versions(subsystem: str) -> None:
|
||||
"""Send {type: versions} + full subsystem data to WS clients.
|
||||
|
||||
NOTE: Does NOT call state_store.bump(). Callers who need version bumps
|
||||
(e.g. refresh_state) call bump themselves. poll_loop bumps before calling.
|
||||
No legacy `updated` field is emitted (no backward compat) — version
|
||||
counters still advance but are not sent over the wire.
|
||||
"""
|
||||
data = state_store.get(subsystem)
|
||||
if data is None:
|
||||
# Collector failed during the triggering populate/refresh — populate()
|
||||
# cleared this subsystem to None (state.py). Skip the broadcast:
|
||||
# a null payload would overwrite good client data. The next successful
|
||||
# poll or mutation broadcasts the real value.
|
||||
return
|
||||
data = json.dumps({"type": "versions", "updated": updated})
|
||||
dead: set[web.WebSocketResponse] = set()
|
||||
for ws in _ws_subscribers:
|
||||
try:
|
||||
await ws.send_str(data)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_ws_subscribers.difference_update(dead)
|
||||
if dead:
|
||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||
message = json.dumps(
|
||||
{
|
||||
"type": "versions",
|
||||
"subsystem": subsystem,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
await _send_all(message)
|
||||
|
||||
|
||||
async def broadcast_tick(subsystems: list[str]) -> None:
|
||||
"""Broadcast a lightweight tick to WS clients without version payload."""
|
||||
data = json.dumps({"type": "tick", "subsystems": subsystems})
|
||||
dead: set[web.WebSocketResponse] = set()
|
||||
for ws in _ws_subscribers:
|
||||
try:
|
||||
await ws.send_str(data)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_ws_subscribers.difference_update(dead)
|
||||
if dead:
|
||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||
async def broadcast_tick(subsystem: str) -> None:
|
||||
"""Send {type: tick} with the changed subsystem data.
|
||||
|
||||
No bump — tick is volatile-only; the version counter only bumps on
|
||||
structural changes.
|
||||
"""
|
||||
data = state_store.get(subsystem)
|
||||
message = json.dumps(
|
||||
{
|
||||
"type": "tick",
|
||||
"subsystem": subsystem,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
await _send_all(message)
|
||||
|
||||
|
||||
async def _poll_loop(subsystem: str, interval: int) -> None:
|
||||
@@ -512,9 +546,9 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
|
||||
structural, volatile = state_store.poll(subsystem)
|
||||
if structural:
|
||||
state_store.bump(subsystem)
|
||||
await broadcast_versions()
|
||||
await broadcast_versions(subsystem) # now per-subsystem, carries data
|
||||
elif volatile:
|
||||
await broadcast_tick([subsystem])
|
||||
await broadcast_tick(subsystem) # now per-subsystem, carries data
|
||||
# Periodic blacklist cleanup — coordinated across all poll loops
|
||||
async with _last_blacklist_cleanup_lock:
|
||||
now = time.time()
|
||||
@@ -565,11 +599,24 @@ async def refresh_status(_request: web.Request) -> web.Response:
|
||||
body = await _request.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body = None
|
||||
subsystems = None
|
||||
if body and "subsystems" in body:
|
||||
subsystems = body["subsystems"]
|
||||
subsystems = body.get("subsystems") if body else None
|
||||
state_store.populate(subsystems)
|
||||
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
|
||||
targets = subsystems or state_store.SUBSYSTEMS
|
||||
snapshot = {name: state_store.get(name) for name in targets}
|
||||
|
||||
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
|
||||
# Deliberately no version bump — versions advance on structural poll
|
||||
# diffs and on refresh_state() only.
|
||||
async def _broadcast_all():
|
||||
await asyncio.gather(
|
||||
*[broadcast_versions(name) for name in targets],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_broadcast_all())
|
||||
task.add_done_callback(_ws_tasks.discard)
|
||||
_ws_tasks.add(task)
|
||||
return ok(snapshot)
|
||||
|
||||
|
||||
async def _catch_all(request: web.Request) -> web.Response:
|
||||
|
||||
Reference in New Issue
Block a user