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
+233
View File
@@ -175,3 +175,236 @@ class TestStateVersions:
updated = s.get_updated_versions()
assert updated["firewall"] == 2
assert updated["dnsmasq"] == 1
class TestStripVolatile:
def test_list_of_dicts_strips_nested_keys(self):
"""Wireguard-style: status.peers[].transfer_received gets stripped."""
from lib.state import _strip_volatile
data = {
"status": {
"peers": [
{
"transfer_received": "100B",
"transfer_sent": "200B",
"latest_handshake": "ago",
"public_key": "abc",
},
{
"transfer_received": "300B",
"transfer_sent": "400B",
"latest_handshake": "now",
"public_key": "def",
},
]
}
}
vol = frozenset(
{"status.peers[].transfer_received", "status.peers[].latest_handshake"}
)
stripped = _strip_volatile(data, vol)
assert stripped["status"]["peers"][0]["transfer_received"] is None
assert stripped["status"]["peers"][0]["latest_handshake"] is None
assert stripped["status"]["peers"][0]["transfer_sent"] == "200B"
assert stripped["status"]["peers"][0]["public_key"] == "abc"
assert stripped["status"]["peers"][1]["transfer_received"] is None
def test_scalar_path_strips(self):
"""Scalar paths get zeroed to None."""
from lib.state import _strip_volatile
data = {"a": {"b": 1, "c": 2}}
vol = frozenset({"a.b"})
stripped = _strip_volatile(data, vol)
assert stripped["a"]["b"] is None
assert stripped["a"]["c"] == 2
def test_empty_volatile_returns_copy(self):
from lib.state import _strip_volatile
data = {"x": 1}
stripped = _strip_volatile(data, frozenset())
assert stripped == data
assert stripped is not data
def test_firewall_volatile_strips_ips(self):
"""Firewall-style: interfaces[].ips gets stripped."""
from lib.state import _strip_volatile
data = {
"interfaces": [
{
"name": "eth0",
"ips": ["10.0.0.1/24"],
"ipv6": ["fe80::1"],
"zone": "internal",
},
]
}
vol = frozenset({"interfaces[].ips", "interfaces[].ipv6"})
stripped = _strip_volatile(data, vol)
assert stripped["interfaces"][0]["ips"] is None
assert stripped["interfaces"][0]["ipv6"] is None
assert stripped["interfaces"][0]["name"] == "eth0"
assert stripped["interfaces"][0]["zone"] == "internal"
def test_networkd_volatile_strips_addresses(self):
"""Networkd-style: interfaces dict keyed by name, addresses stripped via fallback."""
from lib.state import _strip_volatile
data = {
"interfaces": {
"eth0": {
"addresses": ["10.0.0.1/24", "fe80::1"],
"state": "routable",
"type": "ether",
},
"lo": {
"addresses": ["127.0.0.1/8"],
"state": "degraded",
"type": "loopback",
},
}
}
vol = frozenset({"interfaces[].addresses"})
stripped = _strip_volatile(data, vol)
assert stripped["interfaces"]["eth0"]["addresses"] is None
assert stripped["interfaces"]["eth0"]["state"] == "routable"
assert stripped["interfaces"]["eth0"]["type"] == "ether"
assert stripped["interfaces"]["lo"]["addresses"] is None
assert stripped["interfaces"]["lo"]["state"] == "degraded"
class TestDiffLayers:
def test_no_change(self):
"""Identical data (minus timestamp) returns (False, False)."""
from lib.state import _diff_layers
old = {"zones": {"public": {}}, "timestamp": "t1"}
new = {"zones": {"public": {}}, "timestamp": "t2"}
structural, volatile = _diff_layers(old, new, frozenset())
assert structural is False
assert volatile is False
def test_structural_only(self):
"""Non-volatile change detected as structural."""
from lib.state import _diff_layers
old = {
"zones": {"public": {}},
"interfaces": [{"ips": None}],
"timestamp": "t1",
}
new = {
"zones": {"internal": {}},
"interfaces": [{"ips": None}],
"timestamp": "t2",
}
vol = frozenset({"interfaces[].ips"})
structural, volatile = _diff_layers(old, new, vol)
assert structural is True
assert volatile is False
def test_volatile_only(self):
"""Only volatile fields changed returns (False, True)."""
from lib.state import _diff_layers
old = {
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
"timestamp": "t1",
}
new = {
"status": {"peers": [{"transfer_received": "200B", "public_key": "abc"}]},
"timestamp": "t2",
}
vol = frozenset({"status.peers[].transfer_received"})
structural, volatile = _diff_layers(old, new, vol)
assert structural is False
assert volatile is True
def test_neither(self):
"""No change at all returns (False, False)."""
from lib.state import _diff_layers
old = {
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
"timestamp": "t1",
}
new = {
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
"timestamp": "t2",
}
vol = frozenset({"status.peers[].transfer_received"})
structural, volatile = _diff_layers(old, new, vol)
assert structural is False
assert volatile is False
def test_old_none_returns_both_true(self):
"""When old is None (first poll), both layers report True."""
from lib.state import _diff_layers
new = {"zones": {}}
structural, volatile = _diff_layers(None, new, frozenset())
assert structural is True
assert volatile is True
class TestPoll:
def test_poll_no_change(self):
"""poll() returns (False, False) when collector returns same data."""
import uuid
from lib.state import _COLLECTORS, State
name = f"test_{uuid.uuid4().hex}"
s = State()
data = {"value": 1, "timestamp": "t1"}
_COLLECTORS[name] = lambda: data
s.set(name, data)
structural, volatile = s.poll(name)
assert structural is False
assert volatile is False
del _COLLECTORS[name]
def test_poll_detects_structural(self):
"""poll() detects structural changes via collector."""
import uuid
from lib.state import _COLLECTORS, _VOLATILE, State
name = f"test_{uuid.uuid4().hex}"
s = State()
old = {
"zones": {"public": {}},
"interfaces": [{"ips": None}],
"timestamp": "t1",
}
new = {
"zones": {"internal": {}},
"interfaces": [{"ips": None}],
"timestamp": "t2",
}
_COLLECTORS[name] = lambda: new
_VOLATILE[name] = frozenset({"interfaces[].ips"})
s.set(name, old)
structural, _volatile = s.poll(name)
assert structural is True
del _COLLECTORS[name]
del _VOLATILE[name]
def test_poll_failure_returns_no_broadcast(self):
"""poll() returns (False, False) and preserves existing data on collector failure."""
import uuid
from lib.state import _COLLECTORS, State
name = f"test_{uuid.uuid4().hex}"
s = State()
s.set(name, {"value": 1})
_COLLECTORS[name] = lambda: (_ for _ in ()).throw(RuntimeError("fail"))
structural, volatile = s.poll(name)
assert structural is False
assert volatile is False
assert s.get(name) == {"value": 1}
del _COLLECTORS[name]