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
+222
View File
@@ -28,6 +28,13 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
"firewall": 30,
"wireguard": 10,
"dnsmasq": 10,
"networkd": 10,
}
# ---------------------------------------------------------------------------
# State store
@@ -148,6 +155,59 @@ class State:
"""
return all(v is not None for v in self._data.values())
def poll(self, subsystem: str) -> tuple[bool, bool]:
"""Run the collector for *subsystem* and compare against current state.
The polled equivalent of ``populate()`` — same try/except safety,
but with two-layer diff before storing.
Args:
subsystem: Subsystem name to poll.
Returns:
``(structural_changed, volatile_changed)``. ``(False, False)`` on
collector failure (no broadcast on failure to avoid noisy ticks).
"""
collector = _COLLECTORS.get(subsystem)
if collector is None:
return (False, False)
vol = _VOLATILE.get(subsystem, frozenset())
try:
new_data = collector()
except Exception:
logger.warning(
"Poll collection failed for %s",
subsystem,
exc_info=True,
)
return (False, False)
old_data = self._data.get(subsystem)
structural, volatile = _diff_layers(old_data, new_data, vol)
self._data[subsystem] = new_data
return (structural, volatile)
def poll_all(
self,
intervals: dict[str, int] | None = None,
) -> dict[str, tuple[bool, bool]]:
"""Poll all subsystems that have a polling interval configured.
Args:
intervals: Subsystems to poll keyed by name. Defaults to
``_DEFAULT_POLL_INTERVALS``.
Returns:
Dict of ``{subsystem: (structural, volatile)}`` for each polled
subsystem.
"""
targets = intervals or _DEFAULT_POLL_INTERVALS
results: dict[str, tuple[bool, bool]] = {}
for name in targets:
results[name] = self.poll(name)
return results
# Singleton
state = State()
@@ -158,6 +218,7 @@ state = State()
# ---------------------------------------------------------------------------
_COLLECTORS: dict[str, Any] = {}
_VOLATILE: dict[str, frozenset[str]] = {}
def register_collector(subsystem: str, fn: Any) -> Any:
@@ -174,6 +235,135 @@ def register_collector(subsystem: str, fn: Any) -> Any:
return fn
def register_volatile(subsystem: str, keys: frozenset[str]) -> None:
"""Register volatile field paths for *subsystem*.
Args:
subsystem: Subsystem name.
keys: Frozenset of dot-separated volatile field paths
(e.g. ``status.peers[].transfer_received``).
"""
_VOLATILE[subsystem] = keys
# ---------------------------------------------------------------------------
# Two-layer diff
# ---------------------------------------------------------------------------
def _strip_volatile(
data: dict[str, Any],
volatile: frozenset[str],
pop_keys: frozenset[str] | None = None,
) -> dict[str, Any]:
"""Return a copy of *data* with volatile fields zeroed out.
For list-of-dicts fields (``key[].subkey``), strips the volatile sub-keys
from each dict in the list. For scalar/dict fields, sets them to ``None``.
Args:
data: State dict to process.
volatile: Frozenset of dot-separated volatile field paths.
pop_keys: Optional keys to remove from the root dict before stripping.
Returns:
A new dict with volatile fields replaced by ``None``.
"""
stripped = deepcopy(data)
if pop_keys:
for k in pop_keys:
stripped.pop(k, None)
for vpath in volatile:
# Determine if this is a list-of-dicts pattern
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker != -1:
# Split into prefix (before []), item keys (after [])
prefix = vpath[:list_marker].split(".")
item_keys = (
vpath[list_marker + 3 :].split(".")
if list_marker + 3 < len(vpath)
else []
)
parent = stripped
for seg in prefix:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
break
if isinstance(parent, list):
items = parent
elif isinstance(parent, dict):
logger.debug(
"_strip_volatile: %s resolved to dict, falling back to .values()",
vpath,
)
items = parent.values()
else:
continue
for item in items:
if isinstance(item, dict):
curr = item
for i, ik in enumerate(item_keys):
if i == len(item_keys) - 1:
curr[ik] = None
else:
if isinstance(curr, dict) and ik in curr:
curr = curr[ik]
else:
break
else:
# Scalar/dict path
segments = vpath.split(".")
parent = stripped
for i, seg in enumerate(segments):
if i == len(segments) - 1:
if isinstance(parent, dict) and seg in parent:
parent[seg] = None
else:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
break
return stripped
def _diff_layers(
old: dict[str, Any] | None,
new: dict[str, Any],
volatile: frozenset[str],
) -> tuple[bool, bool]:
"""Compare *old* and *new* state using two-layer diff.
Strips ``timestamp`` from both before comparing.
Returns:
``(structural_changed, volatile_changed)`` —
``True`` means that layer differs between old and new.
If structural data changed, volatile is always ``False``
(the structural change already triggers a full re-fetch, so
the volatile signal is suppressed).
"""
if old is None:
return (True, True)
# Structural diff: compare with volatile/timestamp fields zeroed
pop_keys = frozenset(("timestamp",))
old_struct = _strip_volatile(old, volatile, pop_keys)
new_struct = _strip_volatile(new, volatile, pop_keys)
structural = old_struct != new_struct
# Volatile diff: compare without timestamp
volatile_changed = False
if not structural:
old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
new_no_ts = {k: v for k, v in new.items() if k != "timestamp"}
volatile_changed = old_no_ts != new_no_ts
return (structural, volatile_changed)
# ---------------------------------------------------------------------------
# Firewall collector
# ---------------------------------------------------------------------------
@@ -307,6 +497,15 @@ def _collect_firewall() -> dict[str, Any]:
register_collector("firewall", _collect_firewall)
register_volatile(
"firewall",
frozenset(
{
"interfaces[].ips",
"interfaces[].ipv6",
}
),
)
# ---------------------------------------------------------------------------
@@ -401,6 +600,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
register_collector("dnsmasq", _collect_dnsmasq)
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
# ---------------------------------------------------------------------------
@@ -856,6 +1056,16 @@ def _collect_wireguard() -> dict[str, Any]:
register_collector("wireguard", _collect_wireguard)
register_volatile(
"wireguard",
frozenset(
{
"status.peers[].transfer_received",
"status.peers[].transfer_sent",
"status.peers[].latest_handshake",
}
),
)
# ---------------------------------------------------------------------------
# Networkd collector
@@ -889,8 +1099,20 @@ def _collect_networkd() -> dict[str, Any]:
register_collector("networkd", _collect_networkd)
register_volatile(
"networkd",
frozenset(
{
"interfaces[].addresses",
}
),
)
__all__ = [
"_DEFAULT_POLL_INTERVALS",
"State",
"_diff_layers",
"_strip_volatile",
"register_volatile",
"state",
]