🚧 Add broker status page
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""$SYS broker telemetry (spec sections 33, 55).
|
||||
|
||||
The dynsec channel's MQTT connection subscribes to $SYS/# and forwards
|
||||
every message here (see DynsecClient.set_event_sink). Values are parsed
|
||||
ONCE at ingest; snapshot() returns the latest known state. The broker
|
||||
publishes $SYS on the configured sys_interval (10 s in the bootstrap
|
||||
config), so a fresh backend needs a few seconds before the first
|
||||
snapshot is populated — every field is therefore Optional and the
|
||||
`available` flag says whether ANY $SYS message has been seen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
SYS_PREFIX = "$SYS/broker/"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_values: dict[str, Any] = {}
|
||||
_last_update: datetime | None = None
|
||||
|
||||
|
||||
def _as_int(payload: str) -> object:
|
||||
try:
|
||||
return int(payload)
|
||||
except ValueError:
|
||||
return payload
|
||||
|
||||
|
||||
def _as_float(payload: str) -> object:
|
||||
try:
|
||||
return float(payload)
|
||||
except ValueError:
|
||||
return payload
|
||||
|
||||
|
||||
# $SYS topic -> (snapshot field, parser). Field names mirror
|
||||
# BrokerStatusOut (schemas.py). NOTE: "retained messages/count"
|
||||
# genuinely contains a space in the Mosquitto topic name.
|
||||
_TOPIC_FIELDS: dict[str, tuple[str, Callable[[str], object]]] = {
|
||||
f"{SYS_PREFIX}version": ("version", str),
|
||||
f"{SYS_PREFIX}uptime": ("uptime", str),
|
||||
f"{SYS_PREFIX}timestamp": ("build_timestamp", str),
|
||||
# clients/disconnected = persistent clients currently offline,
|
||||
# i.e. the "stored sessions" of the dashboard (spec section 33).
|
||||
f"{SYS_PREFIX}clients/connected": ("clients_connected", _as_int),
|
||||
f"{SYS_PREFIX}clients/disconnected": ("clients_disconnected", _as_int),
|
||||
f"{SYS_PREFIX}clients/maximum": ("clients_maximum", _as_int),
|
||||
f"{SYS_PREFIX}clients/total": ("clients_total", _as_int),
|
||||
f"{SYS_PREFIX}clients/expired": ("clients_expired", _as_int),
|
||||
f"{SYS_PREFIX}messages/received": ("messages_received", _as_int),
|
||||
f"{SYS_PREFIX}messages/sent": ("messages_sent", _as_int),
|
||||
f"{SYS_PREFIX}messages/stored": ("messages_stored", _as_int),
|
||||
f"{SYS_PREFIX}messages/dropped": ("messages_dropped", _as_int),
|
||||
f"{SYS_PREFIX}publish/messages/received": ("publish_received", _as_int),
|
||||
f"{SYS_PREFIX}publish/messages/sent": ("publish_sent", _as_int),
|
||||
f"{SYS_PREFIX}publish/messages/dropped": ("publish_dropped", _as_int),
|
||||
f"{SYS_PREFIX}bytes/received": ("bytes_received", _as_int),
|
||||
f"{SYS_PREFIX}bytes/sent": ("bytes_sent", _as_int),
|
||||
f"{SYS_PREFIX}subscriptions/count": ("subscriptions_count", _as_int),
|
||||
f"{SYS_PREFIX}retained messages/count": ("retained_count", _as_int),
|
||||
# load/* are per-second averages over the last minute ("x.xx").
|
||||
f"{SYS_PREFIX}load/messages/received/1min": (
|
||||
"load_messages_received_1min",
|
||||
_as_float,
|
||||
),
|
||||
f"{SYS_PREFIX}load/messages/sent/1min": (
|
||||
"load_messages_sent_1min",
|
||||
_as_float,
|
||||
),
|
||||
f"{SYS_PREFIX}load/publish/received/1min": (
|
||||
"load_publish_received_1min",
|
||||
_as_float,
|
||||
),
|
||||
f"{SYS_PREFIX}load/publish/sent/1min": (
|
||||
"load_publish_sent_1min",
|
||||
_as_float,
|
||||
),
|
||||
f"{SYS_PREFIX}load/bytes/received/1min": (
|
||||
"load_bytes_received_1min",
|
||||
_as_float,
|
||||
),
|
||||
f"{SYS_PREFIX}load/bytes/sent/1min": (
|
||||
"load_bytes_sent_1min",
|
||||
_as_float,
|
||||
),
|
||||
}
|
||||
|
||||
_FIELDS = [field for field, _ in _TOPIC_FIELDS.values()]
|
||||
|
||||
|
||||
def handle_sys_message(topic: str, payload: bytes) -> None:
|
||||
"""Ingest one $SYS message (wired as the dynsec client's event sink)."""
|
||||
entry = _TOPIC_FIELDS.get(topic)
|
||||
if entry is None:
|
||||
return # unknown $SYS topic — deliberately ignored
|
||||
field, parser = entry
|
||||
text = payload.decode("utf-8", errors="replace").strip()
|
||||
global _last_update
|
||||
with _lock:
|
||||
_values[field] = parser(text)
|
||||
_last_update = datetime.now(UTC)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Clear all telemetry (used by tests)."""
|
||||
global _last_update
|
||||
with _lock:
|
||||
_values.clear()
|
||||
_last_update = None
|
||||
|
||||
|
||||
def snapshot() -> dict[str, Any]:
|
||||
"""Latest known $SYS state; every field None until first publish."""
|
||||
with _lock:
|
||||
values = dict(_values)
|
||||
last_update = _last_update
|
||||
snap: dict[str, Any] = {field: values.get(field) for field in _FIELDS}
|
||||
snap["available"] = last_update is not None
|
||||
snap["last_update"] = last_update
|
||||
return snap
|
||||
Reference in New Issue
Block a user