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
This commit is contained in:
+485
@@ -0,0 +1,485 @@
|
||||
"""TypedDict schemas for every state collector's return value.
|
||||
|
||||
Single source of truth for the state-store data shapes. The Markdown
|
||||
reference is ``docs/state-model.md``.
|
||||
"""
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
__all__ = [
|
||||
"AcmeAccount",
|
||||
"AcmeCert",
|
||||
"AcmeState",
|
||||
"CpuLoad",
|
||||
"DnsmasqDhcpLease",
|
||||
"DnsmasqState",
|
||||
"DnsmasqStatus",
|
||||
"FirewallInterface",
|
||||
"FirewallState",
|
||||
"FirewallZone",
|
||||
"MemoryStats",
|
||||
"NetworkdInterface",
|
||||
"NetworkdState",
|
||||
"NginxDomain",
|
||||
"NginxState",
|
||||
"SwapStats",
|
||||
"SystemState",
|
||||
"TrafficStats",
|
||||
"WgClassStatus",
|
||||
"WgPeer",
|
||||
"WgState",
|
||||
"WgStatus",
|
||||
"WgStatusPeer",
|
||||
]
|
||||
|
||||
|
||||
# ── 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}`.
|
||||
|
||||
# ── Firewall ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FirewallInterface(TypedDict):
|
||||
"""A network interface as parsed from `ip link` / `ip addr`.
|
||||
|
||||
Attributes:
|
||||
name: Interface name (e.g. "eth0").
|
||||
mac: MAC address, or ``None`` if unavailable.
|
||||
state: Link state from `ip link` ("UP", "DOWN", "UNKNOWN", ...).
|
||||
mtu: MTU value, or ``None`` if unavailable.
|
||||
ips: IPv4 addresses as "ip/prefix" strings.
|
||||
ipv6: IPv6 addresses as "ip/prefix" strings.
|
||||
zone: Assigned firewalld zone name, or ``None``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
mac: str | None
|
||||
state: str
|
||||
mtu: int | None
|
||||
ips: list[str]
|
||||
ipv6: list[str]
|
||||
zone: str | None
|
||||
|
||||
|
||||
class FirewallZone(TypedDict, total=False):
|
||||
"""A firewalld zone as parsed from `--list-all-zones`.
|
||||
|
||||
The zone dict carries the HYPHENATED key "rich-rules" (see
|
||||
``lib.firewall._parse_all_zones_output``), which TypedDict fields
|
||||
cannot express. Additional firewalld keys may also appear:
|
||||
"sources", "ports", "protocols", "forward-ports", "ics",
|
||||
"icmp-blocks", "module", "rich-rules".
|
||||
"""
|
||||
|
||||
target: str
|
||||
interfaces: list[str]
|
||||
services: list[str]
|
||||
masquerade: bool
|
||||
# snake_case `rich_rules` exists only at the top-level FirewallState
|
||||
# (collector re-derivation, lib/state.py); the zone dict itself uses
|
||||
# the hyphenated "rich-rules" key.
|
||||
rich_rules: list[str]
|
||||
|
||||
|
||||
class FirewallState(TypedDict):
|
||||
"""Complete firewalld state (collector: `_collect_firewall`).
|
||||
|
||||
Attributes:
|
||||
config: Contents of config/firewall/config.json.
|
||||
active_zones: Zone name → assigned interfaces.
|
||||
default_zone: firewalld default zone name ("--get-default-zone");
|
||||
catch-all zone for interfaces with no explicit assignment.
|
||||
interfaces: All system interfaces (see FirewallInterface).
|
||||
available_services: firewalld service catalog ("--get-services").
|
||||
zones: All zones as runtime dicts (see FirewallZone).
|
||||
rich_rules: Zone name → raw firewalld rich-rule strings.
|
||||
pending: config_pending() result:
|
||||
``{pending: [...], needs_apply: bool,
|
||||
unmanaged_zones: {zone: {interfaces: [...]}}}``.
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
config: dict[str, Any]
|
||||
active_zones: dict[str, list[str]]
|
||||
default_zone: str
|
||||
interfaces: list[FirewallInterface]
|
||||
available_services: list[str]
|
||||
zones: dict[str, FirewallZone]
|
||||
rich_rules: dict[str, list[str]]
|
||||
pending: dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
# ── Dnsmasq ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DnsmasqDhcpLease(TypedDict):
|
||||
"""A single dnsmasq DHCP lease from the lease file.
|
||||
|
||||
Attributes:
|
||||
expires: ISO-8601 expiry timestamp, or "" if unparseable.
|
||||
mac: Client MAC address.
|
||||
ip: Leased IP address.
|
||||
hostname: Client hostname (may be "").
|
||||
interface: Interface the lease was granted on (may be "").
|
||||
"""
|
||||
|
||||
expires: str
|
||||
mac: str
|
||||
ip: str
|
||||
hostname: str
|
||||
interface: str
|
||||
|
||||
|
||||
class DnsmasqStatus(TypedDict):
|
||||
"""Dnsmasq service status snapshot.
|
||||
|
||||
Attributes:
|
||||
service_active: Whether the dnsmasq systemd unit is active.
|
||||
config_file_exists: Whether the rendered .conf is on disk.
|
||||
active_leases: Count of currently active leases.
|
||||
pending_changes: Whether the config is dirty vs the applied state.
|
||||
"""
|
||||
|
||||
service_active: bool
|
||||
config_file_exists: bool
|
||||
active_leases: int
|
||||
pending_changes: bool
|
||||
|
||||
|
||||
class DnsmasqState(TypedDict):
|
||||
"""Dnsmasq state (collector: `_collect_dnsmasq`).
|
||||
|
||||
Attributes:
|
||||
config: config/dnsmasq/config.json, deep-merged with defaults.
|
||||
status: Service/config status (see DnsmasqStatus).
|
||||
leases: Active DHCP leases.
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
config: dict[str, Any]
|
||||
status: DnsmasqStatus
|
||||
leases: list[DnsmasqDhcpLease]
|
||||
timestamp: str
|
||||
|
||||
|
||||
# ── Nginx ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NginxDomain(TypedDict, total=False):
|
||||
"""One flattened domain+path entry (see `_resolve_paths`).
|
||||
|
||||
Attributes:
|
||||
domain: Domain name (config key).
|
||||
path: Path prefix for this entry.
|
||||
backend: Resolved backend config dict.
|
||||
online: Whether a site .conf exists on disk.
|
||||
force_ssl: Redirect-to-HTTPS flag.
|
||||
backend_name: Backend config key this domain points at.
|
||||
cert: Certificate reference (e.g. "acme", "selfsigned", ...).
|
||||
is_management: Management UI path marker.
|
||||
is_websocket: WebSocket-capable path marker.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
path: str
|
||||
backend: dict[str, Any]
|
||||
online: bool
|
||||
force_ssl: bool
|
||||
backend_name: str
|
||||
cert: str | None
|
||||
is_management: bool
|
||||
is_websocket: bool
|
||||
|
||||
|
||||
class NginxState(TypedDict):
|
||||
"""Nginx state (collector: `_collect_nginx`).
|
||||
|
||||
Attributes:
|
||||
config: config/nginx/config.json.
|
||||
domains: Flattened domain entries (one per domain+path).
|
||||
status: ``{"pending_changes": bool}``.
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
config: dict[str, Any]
|
||||
domains: list[NginxDomain]
|
||||
status: dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
# ── ACME ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AcmeAccount(TypedDict):
|
||||
"""ACME account status (see `_parse_account_conf`).
|
||||
|
||||
Attributes:
|
||||
registered: Whether an account exists.
|
||||
email: Registered email ("" when not registered).
|
||||
ca: Human-readable CA name ("" when not registered).
|
||||
key_length: Account key size, or ``None``.
|
||||
"""
|
||||
|
||||
registered: bool
|
||||
email: str
|
||||
ca: str
|
||||
key_length: int | None
|
||||
|
||||
|
||||
class AcmeCert(TypedDict, total=False):
|
||||
"""One certificate entry from `list_certs()` output.
|
||||
|
||||
Attributes:
|
||||
domain: Certificate domain name.
|
||||
expiry: Expiry date string.
|
||||
renewed: Last renewal date string.
|
||||
status: "valid" | "expired" | "active" | ...
|
||||
days_remaining: Days until expiry.
|
||||
|
||||
Additional keys from `lib.acme.list_certs()` output may appear.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
expiry: str
|
||||
renewed: str
|
||||
status: str
|
||||
days_remaining: int
|
||||
|
||||
|
||||
class AcmeState(TypedDict):
|
||||
"""ACME state (collector: `_collect_acme`).
|
||||
|
||||
Attributes:
|
||||
certs: Certificate list.
|
||||
email: Registered ACME email.
|
||||
account: Account status (see AcmeAccount).
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
certs: list[AcmeCert]
|
||||
email: str
|
||||
account: AcmeAccount
|
||||
timestamp: str
|
||||
|
||||
|
||||
# ── WireGuard ─────────────────────────────────────────────────
|
||||
# The collector tracks BOTH a legacy single interface (wg0) and one
|
||||
# wg-<class> interface per access class.
|
||||
|
||||
|
||||
class WgStatusPeer(TypedDict):
|
||||
"""A runtime peer parsed from `wg show` output.
|
||||
|
||||
Attributes:
|
||||
public_key: Peer public key.
|
||||
endpoint: Last-seen endpoint, or ``None``.
|
||||
allowed_ips: Allowed IP/CIDR list.
|
||||
latest_handshake: Human-readable last handshake time, or ``None``.
|
||||
transfer_received: Received-bytes string from the transfer line.
|
||||
transfer_sent: Sent-bytes string from the transfer line.
|
||||
persistent_keepalive: Keepalive seconds, or ``None``.
|
||||
"""
|
||||
|
||||
public_key: str
|
||||
endpoint: str | None
|
||||
allowed_ips: list[str]
|
||||
latest_handshake: str | None
|
||||
transfer_received: str
|
||||
transfer_sent: str
|
||||
persistent_keepalive: int | None
|
||||
|
||||
|
||||
class WgClassStatus(TypedDict):
|
||||
"""Runtime status for one wg-<class> interface.
|
||||
|
||||
Attributes:
|
||||
up: Whether the class interface is up.
|
||||
interface: ``{public_key, listen_port}`` (empty when down).
|
||||
peers: Runtime peers of this class interface.
|
||||
"""
|
||||
|
||||
up: bool
|
||||
interface: dict[str, Any]
|
||||
peers: list[WgStatusPeer]
|
||||
|
||||
|
||||
class WgStatus(TypedDict):
|
||||
"""Aggregated WireGuard runtime status.
|
||||
|
||||
Attributes:
|
||||
up: True when any managed interface is up.
|
||||
interface: Legacy single-interface info (public_key, listen_port).
|
||||
peers: Legacy single-interface runtime peers.
|
||||
classes: Per-access-class runtime status (keyed by class name).
|
||||
pending_changes: Whether the config is dirty vs the applied state.
|
||||
"""
|
||||
|
||||
up: bool
|
||||
interface: dict[str, Any]
|
||||
peers: list[WgStatusPeer]
|
||||
classes: dict[str, WgClassStatus]
|
||||
pending_changes: bool
|
||||
|
||||
|
||||
class WgPeer(TypedDict, total=False):
|
||||
"""A config-file peer (``private_key`` stripped).
|
||||
|
||||
Attributes:
|
||||
name: Peer config key.
|
||||
public_key: Peer public key.
|
||||
endpoint: Configured endpoint, or ``None``.
|
||||
allowed_ips: Allowed IP/CIDR list.
|
||||
persistent_keepalive: Keepalive seconds, or ``None``.
|
||||
preshared_key: Preshared key, or ``None``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
public_key: str
|
||||
endpoint: str | None
|
||||
allowed_ips: list[str]
|
||||
persistent_keepalive: int | None
|
||||
preshared_key: str | None
|
||||
|
||||
|
||||
class WgState(TypedDict):
|
||||
"""WireGuard state (collector: `_collect_wireguard`).
|
||||
|
||||
Attributes:
|
||||
config: config/wireguard/config.json; ``private_key`` stripped
|
||||
from the interface AND from every access class.
|
||||
status: Runtime status (see WgStatus).
|
||||
peers: Config peers, private keys stripped.
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
config: dict[str, Any]
|
||||
status: WgStatus
|
||||
peers: list[WgPeer]
|
||||
timestamp: str
|
||||
|
||||
|
||||
# ── Networkd ──────────────────────────────────────────────────
|
||||
# Matches parse_networkctl_status() output exactly (lib/network.py).
|
||||
# There is a single combined `addresses` list — no separate
|
||||
# `ipv6_addresses` or `routes` keys.
|
||||
|
||||
|
||||
class NetworkdInterface(TypedDict):
|
||||
"""One networkd interface as parsed by `parse_networkctl_status`.
|
||||
|
||||
Attributes:
|
||||
addresses: "ip/prefix" entries, IPv4+IPv6 combined.
|
||||
gateway: Default-route gateway, or ``None``.
|
||||
dns: Configured DNS server list.
|
||||
mac: MAC address, or ``None``.
|
||||
state: OperationalState (e.g. "routable", "degraded", "off").
|
||||
link: Link type (e.g. "ether", "loopback", ...).
|
||||
"""
|
||||
|
||||
addresses: list[str]
|
||||
gateway: str | None
|
||||
dns: list[str]
|
||||
mac: str | None
|
||||
state: str
|
||||
link: str
|
||||
|
||||
|
||||
class NetworkdState(TypedDict):
|
||||
"""Networkd state (collector: `_collect_networkd`).
|
||||
|
||||
Attributes:
|
||||
config: config/network/config.json.
|
||||
interfaces: Runtime state keyed by interface name.
|
||||
status: ``{"pending_changes": bool}``.
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
config: dict[str, Any]
|
||||
interfaces: dict[str, NetworkdInterface]
|
||||
status: dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
# ── System ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CpuLoad(TypedDict):
|
||||
"""CPU load averages (see /proc/loadavg).
|
||||
|
||||
Attributes:
|
||||
load1: 1-minute load average.
|
||||
load5: 5-minute load average.
|
||||
load15: 15-minute load average.
|
||||
"""
|
||||
|
||||
load1: float
|
||||
load5: float
|
||||
load15: float
|
||||
|
||||
|
||||
class MemoryStats(TypedDict):
|
||||
"""Memory usage (see /proc/meminfo).
|
||||
|
||||
Attributes:
|
||||
total: Total memory in bytes.
|
||||
available: Available memory in bytes.
|
||||
used: Used memory in bytes.
|
||||
used_pct: Used percentage (0-100), rounded to 0.1.
|
||||
"""
|
||||
|
||||
total: int
|
||||
available: int
|
||||
used: int
|
||||
used_pct: float
|
||||
|
||||
|
||||
class SwapStats(TypedDict):
|
||||
"""Swap usage (see /proc/meminfo).
|
||||
|
||||
Attributes:
|
||||
total: Total swap in bytes.
|
||||
used: Used swap in bytes.
|
||||
used_pct: Used percentage (0-100), rounded to 0.1.
|
||||
"""
|
||||
|
||||
total: int
|
||||
used: int
|
||||
used_pct: float
|
||||
|
||||
|
||||
class TrafficStats(TypedDict):
|
||||
"""Per-interface traffic counters (see /sys/class/net/<iface>/statistics).
|
||||
|
||||
Attributes:
|
||||
rx_bytes: Total bytes received.
|
||||
tx_bytes: Total bytes transmitted.
|
||||
rx_packets: Total packets received.
|
||||
tx_packets: Total packets transmitted.
|
||||
"""
|
||||
|
||||
rx_bytes: int
|
||||
tx_bytes: int
|
||||
rx_packets: int
|
||||
tx_packets: int
|
||||
|
||||
|
||||
class SystemState(TypedDict):
|
||||
"""System-wide metrics (collector: `_collect_system`).
|
||||
|
||||
Attributes:
|
||||
load: CPU load averages (see CpuLoad).
|
||||
memory: Memory usage (see MemoryStats).
|
||||
swap: Swap usage (see SwapStats).
|
||||
traffic: Per-interface counters keyed by interface name.
|
||||
timestamp: ISO-8601 collection time.
|
||||
"""
|
||||
|
||||
load: CpuLoad
|
||||
memory: MemoryStats
|
||||
swap: SwapStats
|
||||
traffic: dict[str, TrafficStats]
|
||||
timestamp: str
|
||||
+34
-8
@@ -12,6 +12,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from lib import schema
|
||||
from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc
|
||||
from lib.firewall import (
|
||||
_parse_active_zones,
|
||||
@@ -36,7 +37,7 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
||||
"wireguard": 10,
|
||||
"dnsmasq": 10,
|
||||
"networkd": 10,
|
||||
"system": 30,
|
||||
"system": 1,
|
||||
# nginx/acme state derives from config files (and lazy in-place migration
|
||||
# can rewrite them without a mutation); poll so drift self-heals.
|
||||
"nginx": 60,
|
||||
@@ -125,6 +126,17 @@ class State:
|
||||
"""
|
||||
return self._data.get(subsystem)
|
||||
|
||||
def get_snapshot(self) -> dict[str, dict[str, Any] | None]:
|
||||
"""Return all subsystem state dicts.
|
||||
|
||||
Used for the initial WS snapshot on connect.
|
||||
|
||||
Returns:
|
||||
Dict mapping every subsystem name to its state data
|
||||
(``None`` when not populated or the last collection failed).
|
||||
"""
|
||||
return {name: self._data.get(name) for name in self.SUBSYSTEMS}
|
||||
|
||||
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
|
||||
"""Set state data for *subsystem*.
|
||||
|
||||
@@ -424,7 +436,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _collect_firewall() -> dict[str, Any]:
|
||||
def _collect_firewall() -> schema.FirewallState:
|
||||
"""Return the complete current state of firewalld.
|
||||
|
||||
Returns:
|
||||
@@ -433,6 +445,7 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
"""
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
active = _parse_active_zones(active_raw)
|
||||
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
||||
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
|
||||
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||
@@ -505,6 +518,7 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
# Pending changes
|
||||
full_state = {
|
||||
"active_zones": active,
|
||||
"default_zone": default_zone,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
"zones": zones,
|
||||
@@ -517,6 +531,7 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
|
||||
return {
|
||||
"active_zones": active,
|
||||
"default_zone": default_zone,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
"zones": zones,
|
||||
@@ -544,7 +559,7 @@ register_volatile(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_dnsmasq() -> dict[str, Any]:
|
||||
def _collect_dnsmasq() -> schema.DnsmasqState:
|
||||
"""Collect dnsmasq status, config, and leases.
|
||||
|
||||
Returns:
|
||||
@@ -645,7 +660,7 @@ register_collector("dnsmasq", _collect_dnsmasq)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_nginx() -> dict[str, Any]:
|
||||
def _collect_nginx() -> schema.NginxState:
|
||||
"""Collect nginx config and domains list.
|
||||
|
||||
Returns:
|
||||
@@ -846,7 +861,7 @@ def _get_acme_email() -> str:
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _collect_acme() -> dict[str, Any]:
|
||||
def _collect_acme() -> schema.AcmeState:
|
||||
"""Collect ACME certificate list and email.
|
||||
|
||||
Returns:
|
||||
@@ -883,7 +898,7 @@ register_collector("acme", _collect_acme)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_wireguard() -> dict[str, Any]:
|
||||
def _collect_wireguard() -> schema.WgState:
|
||||
"""Collect WireGuard config, per-class status, and peers.
|
||||
|
||||
Returns:
|
||||
@@ -1129,7 +1144,7 @@ register_volatile(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_networkd() -> dict[str, Any]:
|
||||
def _collect_networkd() -> schema.NetworkdState:
|
||||
"""Collect networkd interface state from networkctl.
|
||||
|
||||
Returns:
|
||||
@@ -1212,7 +1227,7 @@ def _parse_meminfo() -> dict[str, Any]:
|
||||
return info
|
||||
|
||||
|
||||
def _collect_system() -> dict[str, Any]:
|
||||
def _collect_system() -> schema.SystemState:
|
||||
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
||||
|
||||
Reads from /proc and /sys — no subprocess needed.
|
||||
@@ -1297,6 +1312,17 @@ def _collect_system() -> dict[str, Any]:
|
||||
|
||||
|
||||
register_collector("system", _collect_system)
|
||||
register_volatile(
|
||||
"system",
|
||||
frozenset(
|
||||
{
|
||||
"load",
|
||||
"memory",
|
||||
"swap",
|
||||
"traffic",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
Reference in New Issue
Block a user