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:
+222
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user