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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user