- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
5.7 KiB
State Model Reference
Authoritative reference for the shapes returned by the daemon's
pre-computed state store (lib/state.py), collected per subsystem and
pushed over the WebSocket (snapshot on connect, per-subsystem deltas
after every change).
Python schemas live in lib/schema.py (TypedDicts); each collector's
return annotation references them.
Shared notes
- Every collector return carries a top-level
timestamp(ISO-8601). - Subsystems with a declarative config expose pending state as a status
dict:
status: {"pending_changes": bool}, except firewall, which usespending: {config_pending() result}. - A subsystem whose collection failed holds
null/Nonein the state store — WS snapshots and deltas skipnullpayloads so a failed collector never overwrites good client data.
State shape summary
state_store.get(<subsystem>) returns:
| Subsystem | Poll | Volatile fields | Top-level keys |
|---|---|---|---|
firewall |
30s | interfaces[].ips, interfaces[].ipv6 |
config, active_zones, interfaces, available_services, zones, rich_rules, pending, timestamp |
dnsmasq |
10s | (none) | config, status, leases, timestamp |
nginx |
60s | (none) | config, domains, status, timestamp |
acme |
300s | (none) | certs, email, account, timestamp |
wireguard |
10s | status.peers[].transfer_received/.transfer_sent/.latest_handshake and the same three under status.classes[].peers[] |
config, status, peers, timestamp |
networkd |
10s | interfaces[].addresses |
config, interfaces, status, timestamp |
system |
1s | load, memory, swap, traffic |
load, memory, swap, traffic, timestamp |
Poll intervals are overridable via VACUUM_WALL_POLL_INTERVALS
(subsystem:seconds,subsystem:seconds).
Firewall
Top-level FirewallState:
{
config: {}, // config/firewall/config.json
active_zones: {zone: [iface]}, // zones with assigned interfaces
interfaces: [ // ip link/addr parsing
{name, mac, state, mtu, ips, ipv6, zone}
],
available_services: [str], // firewall-cmd --get-services
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports",
// "protocols", "forward-ports", "ics",
// "icmp-blocks", "module", "rich-rules"
rich_rules: {zone: [str]}, // raw firewalld rich-rule strings,
// re-derived from zones (NO ids —
// deletion-by-id uses config.zones[].rich_rules)
pending: { // config_pending() (lib/firewall.py)
pending: [...], needs_apply: bool,
unmanaged_zones: {zone: {interfaces: [...]}}
},
timestamp: str,
}
Notes:
interfaces[].ips/interfaces[].ipv6hold"ip/prefix"strings (IPv6 list is separate).- The zone dict's rich-rules key is HYPHENATED (
"rich-rules");state.rich_rulesis the snake_case top-level re-derivation.
Dnsmasq
{
config: {}, // config/dnsmasq/config.json, deep-merged
status: {
service_active: bool, config_file_exists: bool,
active_leases: int, pending_changes: bool
},
leases: [
{expires, mac, ip, hostname, interface} // expires = ISO-8601 or ""
],
timestamp: str,
}
Nginx
{
config: {}, // config/nginx/config.json
domains: [ // flattened: one entry per domain+path
{domain, path, backend, online, force_ssl, backend_name, cert,
[is_management], [is_websocket]}
],
status: {pending_changes: bool},
timestamp: str,
}
ACME
{
certs: [ // list_certs(); extra keys possible
{domain, expiry, renewed, status, days_remaining, ...}
],
email: str,
account: {registered, email, ca, key_length},
timestamp: str,
}
WireGuard
{
config: {}, // private_key stripped from interface
// AND every access class
status: {
up: bool, // true when ANY managed iface is up
interface: {}, peers: [], // legacy single interface (wg0)
classes: {class: {up, interface, peers}}, // per wg-<class>
pending_changes: bool
},
peers: [ // config peers, private keys stripped
{name, public_key, endpoint, allowed_ips,
persistent_keepalive, preshared_key, ...}
],
timestamp: str,
}
Runtime peers (status.peers[], status.classes[].peers[]) carry:
public_key, endpoint, allowed_ips, latest_handshake,
transfer_received, transfer_sent, persistent_keepalive.
Networkd
Matches parse_networkctl_status() output (lib/network.py) exactly:
{
config: {}, // config/network/config.json
interfaces: {iface: { // flat runtime entry per interface;
addresses: ["ip/prefix"], // a single combined addresses list
gateway, dns: [str], mac, // (no ipv6_addresses/routes keys)
state, link}
},
status: {pending_changes: bool},
timestamp: str,
}
The parser does not filter lo; clients that don't want it filter
client-side.
System
Metrics only — no config, no pending state.
{
load: {load1, load5, load15},
memory: {total, available, used, used_pct}, // bytes; 0-100
swap: {total, used, used_pct}, // bytes; 0-100
traffic: {iface: {rx_bytes, tx_bytes,
rx_packets, tx_packets}},
timestamp: str,
}
All four metric fields are volatile (1s tick cadence); structural diffs only fire on interface-set changes.