Add state management, WebSocket polling, html.js templating, and refactor pages

- lib/state.py: per-subsystem collectors with versioned state store
- daemon/server.py: state refresh on request, batch routing updates
- webui/static/hoover/html.js: new html tag template helper via htm.js
- webui/static/hoover/websocket.js: real-time state change notifications
- webui/static/hoover/vdom.js: VDOM improvements for keyed diff
- All frontend pages refactored to use html templates
- Add tests for state management and polling
- Update docs and AGENTS.md
This commit is contained in:
2026-06-23 21:11:45 +00:00
parent 5025dfaf30
commit 5ba0f31767
26 changed files with 1193 additions and 495 deletions
+73
View File
@@ -5,6 +5,7 @@ Handles routing, batching, and request/response lifecycle.
"""
import asyncio
import hashlib
import json
import logging
import os
@@ -16,6 +17,7 @@ from typing import Any
from aiohttp import web
from daemon.iface import PathLike
from lib.state import _DEFAULT_POLL_INTERVALS
from lib.state import state as state_store
logger = logging.getLogger(__name__)
@@ -24,6 +26,23 @@ PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
# Polling intervals per subsystem (seconds). VACUUM_WALL_POLL_INTERVALS overrides.
_RAW_POLL = os.environ.get("VACUUM_WALL_POLL_INTERVALS", "")
if _RAW_POLL:
_POLL_OVERRIDE: dict[str, int] = {}
for pair in _RAW_POLL.split(","):
if ":" in pair:
name, _, val = pair.partition(":")
try:
_POLL_OVERRIDE[name.strip()] = int(val.strip())
except ValueError:
logger.warning(
"Invalid poll interval value %r for %r, skipping", val, name
)
_POLL_INTERVALS = {**_DEFAULT_POLL_INTERVALS, **_POLL_OVERRIDE}
else:
_POLL_INTERVALS = dict(_DEFAULT_POLL_INTERVALS)
class Handler:
"""Wrapper for a daemon handler function.
@@ -324,6 +343,7 @@ def create_app() -> web.Application:
# WebSocket subscribers
_ws_subscribers: set[web.WebSocketResponse] = set()
_ws_tasks: set[asyncio.Task[None]] = set()
_poll_tasks: set[asyncio.Task[None]] = set()
async def _handle_ws(request: web.Request) -> web.Response:
@@ -367,6 +387,54 @@ async def broadcast_versions() -> None:
logger.warning("Removed %d dead WS subscribers", len(dead))
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 _poll_loop(subsystem: str, interval: int) -> None:
"""Periodically poll a subsystem for state changes and broadcast as needed."""
offset = int(hashlib.md5(subsystem.encode()).hexdigest(), 16) % interval
await asyncio.sleep(offset)
while True:
try:
structural, volatile = state_store.poll(subsystem)
if structural:
state_store.bump(subsystem)
await broadcast_versions()
elif volatile:
await broadcast_tick([subsystem])
except asyncio.CancelledError:
raise
except Exception:
logger.error("Poll loop error for %s", subsystem, exc_info=True)
await asyncio.sleep(interval)
def start_polling() -> None:
"""Start one poll loop task per subsystem."""
for subsystem, interval in _POLL_INTERVALS.items():
task = asyncio.create_task(_poll_loop(subsystem, interval))
task.add_done_callback(_poll_tasks.discard)
_poll_tasks.add(task)
def _stop_polling() -> None:
"""Cancel all polling tasks."""
for task in _poll_tasks:
task.cancel()
_poll_tasks.clear()
async def _health(_request: web.Request) -> web.Response:
"""Return the health check response.
@@ -458,6 +526,7 @@ def main() -> None:
def _on_shutdown(_sig: int) -> None:
logger.info("Shutting down daemon...")
_stop_polling()
loop.stop()
for sig in (signal.SIGTERM, signal.SIGINT):
@@ -475,6 +544,10 @@ def main() -> None:
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
for subsystem in state_store.SUBSYSTEMS:
if state_store.get(subsystem) is not None:
state_store.bump(subsystem)
loop.run_until_complete(start_polling())
logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)