Files
vacuum-wall/docs/state-model.md
T
mteehan 332d14e37d ws: migrate push stream to data streaming
- 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
2026-08-20 01:38:00 +00:00

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 uses pending: {config_pending() result}.
  • A subsystem whose collection failed holds null/None in the state store — WS snapshots and deltas skip null payloads 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[].ipv6 hold "ip/prefix" strings (IPv6 list is separate).
  • The zone dict's rich-rules key is HYPHENATED ("rich-rules"); state.rich_rules is 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.