🚧 Add broker status page

This commit is contained in:
Jonas Bengtsson
2026-09-20 07:55:18 +02:00
parent 4322446fcf
commit 1e491642f1
9 changed files with 461 additions and 6 deletions
+6 -1
View File
@@ -179,12 +179,17 @@ Hardening done beyond the plan:
accepts them on create) accepts them on create)
- [x] Administration → System page: broker status + restart (the - [x] Administration → System page: broker status + restart (the
§28 restart gap is closed end-to-end from the UI) §28 restart gap is closed end-to-end from the UI)
- [x] Broker Status dashboard (§33/§55): $SYS telemetry collected via
the existing dynsec MQTT connection (extra $SYS/# subscription +
sys-observe role grant at startup), exposed at
GET /broker/status and shown on Broker → Status (version,
uptime, connected/stored clients, subscriptions, retained
messages, message/publish counters, 1-min load averages, bytes)
- [ ] Audit browsing UI (events are recorded; no viewer page yet) - [ ] Audit browsing UI (events are recorded; no viewer page yet)
- [ ] Forced password-change UI (backend flag + API exist; the frontend - [ ] Forced password-change UI (backend flag + API exist; the frontend
does not enforce `must_change_password` yet) does not enforce `must_change_password` yet)
- [ ] Client metadata editing in the UI - [ ] Client metadata editing in the UI
- [ ] ACL simulator (§15) - [ ] ACL simulator (§15)
- [ ] Dashboard ($SYS monitoring, broker status, §33)
- [ ] Certificate expiry monitoring - [ ] Certificate expiry monitoring
- [ ] Backup and restore (logical, encrypted, §45) - [ ] Backup and restore (logical, encrypted, §45)
- [ ] Configuration drift detection (§48) - [ ] Configuration drift detection (§48)
+24
View File
@@ -61,6 +61,9 @@ class DynsecClient:
self._lock = threading.Lock() self._lock = threading.Lock()
self._responses: dict[str, queue.Queue[dict[str, Any]]] = {} self._responses: dict[str, queue.Queue[dict[str, Any]]] = {}
self._connected = threading.Event() self._connected = threading.Event()
# Optional tap for $SYS telemetry (app.metrics registers itself
# at startup). Receives (topic, payload) for every $SYS message.
self._event_sink = None
# -- connection lifecycle ------------------------------------------------ # -- connection lifecycle ------------------------------------------------
def connect(self) -> None: def connect(self) -> None:
@@ -118,12 +121,25 @@ class DynsecClient:
return return
self._teardown_locked() self._teardown_locked()
def set_event_sink(self, sink) -> None:
"""Register a callable receiving every $SYS message (topic, payload).
Used by app.metrics for the broker dashboard (spec sections 33,
55). The sink must never raise: telemetry must not be able to
disturb the management channel.
"""
self._event_sink = sink
# -- paho callbacks ------------------------------------------------------- # -- paho callbacks -------------------------------------------------------
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None: def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
if reason_code == 0: if reason_code == 0:
# FIXED response topic — all commands reply here; requests are # FIXED response topic — all commands reply here; requests are
# matched by the correlationData echo inside the payload. # matched by the correlationData echo inside the payload.
client.subscribe(RESPONSE_TOPIC, qos=1) client.subscribe(RESPONSE_TOPIC, qos=1)
# $SYS telemetry for the dashboard (spec sections 33, 55).
# qos 0: telemetry is periodically republished, losing one
# is harmless.
client.subscribe("$SYS/#", qos=0)
self._connected.set() self._connected.set()
else: else:
logger.error("dynsec client connect failed: %s", reason_code) logger.error("dynsec client connect failed: %s", reason_code)
@@ -149,6 +165,14 @@ class DynsecClient:
def _on_message(self, client, userdata, msg) -> None: def _on_message(self, client, userdata, msg) -> None:
if msg.topic != RESPONSE_TOPIC: if msg.topic != RESPONSE_TOPIC:
# Not a dynsec reply: forward $SYS telemetry to the sink, if
# one is registered. Swallow sink errors — telemetry must
# never disturb the management channel.
if msg.topic.startswith("$SYS/") and self._event_sink is not None:
try:
self._event_sink(msg.topic, msg.payload)
except Exception: # noqa: BLE001
logger.warning("$SYS event sink raised; message dropped")
return return
try: try:
payload = json.loads(msg.payload.decode("utf-8")) payload = json.loads(msg.payload.decode("utf-8"))
+15
View File
@@ -66,6 +66,21 @@ async def lifespan(app: FastAPI):
try: try:
dynsec_client.connect() dynsec_client.connect()
logger.info("dynamic security channel connected") logger.info("dynamic security channel connected")
from app import metrics
dynsec_client.set_event_sink(metrics.handle_sys_message)
# Best effort: ensure the management identity can read $SYS —
# the 2.1 auto-generated admin role set does not guarantee it
# (see the compose healthcheck note). sys-observe is one of the
# plugin's default roles (read-only $SYS/#); addClientRole is
# idempotent broker-side.
try:
dynsec_client.add_client_role(
settings.mosquitto_dynsec_username, "sys-observe", priority=1
)
logger.info("sys-observe role granted to the management identity")
except Exception as exc: # noqa: BLE001
logger.warning("could not grant sys-observe: %s", exc)
except Exception as exc: # noqa: BLE001 - startup should not crash the API except Exception as exc: # noqa: BLE001 - startup should not crash the API
logger.warning("dynsec channel not ready yet: %s", exc) logger.warning("dynsec channel not ready yet: %s", exc)
yield yield
+123
View File
@@ -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
+14
View File
@@ -33,6 +33,7 @@ from app.models import (
from app.schemas import ( from app.schemas import (
BrokerSettingsIn, BrokerSettingsIn,
BrokerSettingsOut, BrokerSettingsOut,
BrokerStatusOut,
ConfigPreviewOut, ConfigPreviewOut,
DeployRequest, DeployRequest,
ListenerIn, ListenerIn,
@@ -316,6 +317,19 @@ def _publish_certificates(db: Session) -> None:
) )
@router.get("/status", response_model=BrokerStatusOut)
def broker_status(_user: AnyReader):
"""Live $SYS telemetry for the dashboard (spec sections 33, 55).
Read-only: every management role may view (spec section 9). Fields
are None until the first $SYS publish arrives — the broker publishes
on sys_interval (10 s), so a fresh backend needs a few seconds.
"""
from app import metrics
return BrokerStatusOut(**metrics.snapshot())
@router.get("/config/preview", response_model=ConfigPreviewOut) @router.get("/config/preview", response_model=ConfigPreviewOut)
def preview_config( def preview_config(
db: Annotated[Session, Depends(get_db)], _user: AnyReader db: Annotated[Session, Depends(get_db)], _user: AnyReader
+37
View File
@@ -230,6 +230,43 @@ class ConfigPreviewOut(BaseModel):
warnings: list[str] warnings: list[str]
class BrokerStatusOut(BaseModel):
"""$SYS snapshot for the dashboard (spec sections 33, 55).
Every metric is None until the first $SYS publish arrives (the
broker publishes on sys_interval, 10 s in the bootstrap config);
`available` says whether ANY $SYS message has been ingested.
"""
available: bool
last_update: datetime | None
version: str | None = None
uptime: str | None = None
build_timestamp: str | None = None
clients_connected: int | None = None
clients_disconnected: int | None = None
clients_maximum: int | None = None
clients_total: int | None = None
clients_expired: int | None = None
messages_received: int | None = None
messages_sent: int | None = None
messages_stored: int | None = None
messages_dropped: int | None = None
publish_received: int | None = None
publish_sent: int | None = None
publish_dropped: int | None = None
bytes_received: int | None = None
bytes_sent: int | None = None
subscriptions_count: int | None = None
retained_count: int | None = None
load_messages_received_1min: float | None = None
load_messages_sent_1min: float | None = None
load_publish_received_1min: float | None = None
load_publish_sent_1min: float | None = None
load_bytes_received_1min: float | None = None
load_bytes_sent_1min: float | None = None
class DeployRequest(BaseModel): class DeployRequest(BaseModel):
comment: str | None = None comment: str | None = None
+45
View File
@@ -194,6 +194,51 @@ def test_system_endpoints_rbac(auditor_client):
) )
def test_broker_status_endpoint(admin_client, auditor_client):
"""GET /broker/status returns the $SYS snapshot (spec 33/55).
No $SYS messages are ingested in tests (no real broker), so the
endpoint must report available: False with all metrics None; after
ingesting fake $SYS messages the snapshot reflects them. Read-only:
the auditor role may view (spec section 9).
"""
from app import metrics
metrics.reset()
empty = admin_client.get("/api/v1/broker/status")
assert empty.status_code == 200
body = empty.json()
assert body["available"] is False
assert body["version"] is None
assert body["clients_connected"] is None
# Ingest fake $SYS telemetry and verify the snapshot reflects it.
metrics.handle_sys_message("$SYS/broker/version", b"2.1.2")
metrics.handle_sys_message("$SYS/broker/clients/connected", b"4")
metrics.handle_sys_message("$SYS/broker/retained messages/count", b"7")
metrics.handle_sys_message("$SYS/broker/load/messages/received/1min", b"1.25")
body = admin_client.get("/api/v1/broker/status").json()
assert body["available"] is True
assert body["version"] == "2.1.2"
assert body["clients_connected"] == 4
assert body["retained_count"] == 7 # topic contains a literal space
assert body["load_messages_received_1min"] == 1.25
assert body["last_update"] is not None
# Unknown $SYS topics are ignored without breaking the snapshot.
metrics.handle_sys_message("$SYS/broker/something/unknown", b"x")
body = admin_client.get("/api/v1/broker/status").json()
assert body["available"] is True
# Read-only for every management role (spec section 9: auditor has
# read access to configuration/status).
assert auditor_client.get("/api/v1/broker/status").status_code == 200
metrics.reset()
def test_role_acl_lifecycle(admin_client): def test_role_acl_lifecycle(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"}) admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
+37 -5
View File
@@ -10,6 +10,7 @@ import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig"; import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates"; import { CertificatesPage } from "./pages/Certificates";
import { SystemPage } from "./pages/System"; import { SystemPage } from "./pages/System";
import { StatusPage } from "./pages/Status";
// Navigation structure per spec section 57. Entries with a `to` route are // Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out // implemented; entries without one are planned pages, rendered greyed out
@@ -21,7 +22,7 @@ const NAV_SECTIONS: {
{ {
title: "Broker", title: "Broker",
items: [ items: [
{ label: "Status", hint: "Dashboard / $SYS monitoring (Release 3)" }, { label: "Status", to: "/broker/status" },
{ label: "Configuration", to: "/broker" }, { label: "Configuration", to: "/broker" },
{ label: "Listeners", to: "/broker#listeners" }, { label: "Listeners", to: "/broker#listeners" },
{ label: "Bridges", hint: "Bridge management (post-MVP)" }, { label: "Bridges", hint: "Bridge management (post-MVP)" },
@@ -82,6 +83,19 @@ function RestartIndicator() {
); );
} }
// Hash-aware active matching: an item is active only when BOTH its path
// AND its hash (if any) match the current location. NavLink's default
// prefix matching lights up every /broker* item on /broker/status, and
// hash items (#listeners, #revisions) all match on pathname alone.
function isItemActive(
to: string,
location: { pathname: string; hash: string },
): boolean {
const [path, hash] = to.split("#");
if (path !== location.pathname) return false;
return hash ? location.hash === `#${hash}` : location.hash === "";
}
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const { session, logout } = useAuth(); const { session, logout } = useAuth();
const location = useLocation(); const location = useLocation();
@@ -92,9 +106,7 @@ function Layout({ children }: { children: React.ReactNode }) {
const activeSection = NAV_SECTIONS.find((section) => const activeSection = NAV_SECTIONS.find((section) =>
section.items.some( section.items.some(
(item) => (item) => item.to && isItemActive(item.to, location),
item.to &&
item.to.split("#")[0] === location.pathname,
), ),
); );
@@ -129,7 +141,19 @@ function Layout({ children }: { children: React.ReactNode }) {
<div> <div>
{section.items.map((item) => {section.items.map((item) =>
item.to ? ( item.to ? (
<NavLink key={item.label} to={item.to}> <NavLink
key={item.label}
to={item.to}
end
// Full control of the active class: NavLink's
// own prefix matching would mark every /broker*
// item active on /broker/status.
className={() =>
isItemActive(item.to!, location)
? "active"
: ""
}
>
{item.label} {item.label}
</NavLink> </NavLink>
) : ( ) : (
@@ -206,6 +230,14 @@ export function App() {
</Protected> </Protected>
} }
/> />
<Route
path="/broker/status"
element={
<Protected>
<StatusPage />
</Protected>
}
/>
<Route <Route
path="/certificates" path="/certificates"
element={ element={
+160
View File
@@ -0,0 +1,160 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api";
export interface BrokerStatus {
available: boolean;
last_update: string | null;
version: string | null;
uptime: string | null;
clients_connected: number | null;
clients_disconnected: number | null;
clients_total: number | null;
messages_received: number | null;
messages_sent: number | null;
messages_stored: number | null;
messages_dropped: number | null;
publish_received: number | null;
publish_sent: number | null;
bytes_received: number | null;
bytes_sent: number | null;
subscriptions_count: number | null;
retained_count: number | null;
load_messages_received_1min: number | null;
load_messages_sent_1min: number | null;
load_publish_received_1min: number | null;
load_publish_sent_1min: number | null;
load_bytes_received_1min: number | null;
load_bytes_sent_1min: number | null;
}
function fmt(n: number | null | undefined): string {
return n === null || n === undefined ? "—" : n.toLocaleString();
}
function Metric({
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
}) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e4e7eb",
borderRadius: "8px",
padding: "0.8rem 1rem",
minWidth: "150px",
flex: "1 1 150px",
}}
title={hint}
>
<div style={{ fontSize: "0.75rem", color: "#52606d" }}>{label}</div>
<div className="mono" style={{ fontSize: "1.15rem" }}>
{value}
</div>
</div>
);
}
export function StatusPage() {
const status = useQuery({
queryKey: ["broker-status"],
queryFn: () => api.get<BrokerStatus>("/broker/status"),
// Match the broker's sys_interval (10 s in the bootstrap config).
refetchInterval: 10000,
});
const s = status.data;
return (
<div>
<h2>Broker status</h2>
<p>
Live telemetry from the broker's <code>$SYS</code> topics. The broker
publishes every 10 seconds; this page refreshes to match.
</p>
{status.error && (
<div className="banner error">{(status.error as Error).message}</div>
)}
{!s?.available && !status.error && (
<div className="banner warn">
No $SYS telemetry received yet. This happens for a few seconds after
backend startup, or if the management identity lacks $SYS read access
(the sys-observe grant is applied at backend startup).
</div>
)}
{s?.available && (
<>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric label="Version" value={s.version ?? "—"} />
<Metric label="Uptime" value={s.uptime ?? "—"} />
<Metric
label="Connected clients"
value={fmt(s.clients_connected)}
hint="devices currently connected"
/>
<Metric
label="Stored sessions"
value={fmt(s.clients_disconnected)}
hint="persistent clients currently offline"
/>
<Metric label="Subscriptions" value={fmt(s.subscriptions_count)} />
<Metric label="Retained messages" value={fmt(s.retained_count)} />
</div>
<h3 style={{ marginTop: "1.5rem" }}>Messages</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric label="Received (total)" value={fmt(s.messages_received)} />
<Metric label="Sent (total)" value={fmt(s.messages_sent)} />
<Metric label="Stored" value={fmt(s.messages_stored)} />
<Metric label="Dropped" value={fmt(s.messages_dropped)} />
<Metric label="Publish received" value={fmt(s.publish_received)} />
<Metric label="Publish sent" value={fmt(s.publish_sent)} />
</div>
<h3 style={{ marginTop: "1.5rem" }}>Throughput (1 min avg / s)</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric
label="Messages in"
value={fmt(s.load_messages_received_1min)}
/>
<Metric
label="Messages out"
value={fmt(s.load_messages_sent_1min)}
/>
<Metric
label="Publishes in"
value={fmt(s.load_publish_received_1min)}
/>
<Metric
label="Publishes out"
value={fmt(s.load_publish_sent_1min)}
/>
<Metric label="Bytes in" value={fmt(s.load_bytes_received_1min)} />
<Metric label="Bytes out" value={fmt(s.load_bytes_sent_1min)} />
</div>
<h3 style={{ marginTop: "1.5rem" }}>Bytes (total)</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric label="Received" value={fmt(s.bytes_received)} />
<Metric label="Sent" value={fmt(s.bytes_sent)} />
</div>
<p style={{ color: "#52606d", fontSize: "0.8rem", marginTop: "1rem" }}>
Last $SYS update:{" "}
{s.last_update
? new Date(s.last_update).toLocaleTimeString()
: "—"}
</p>
</>
)}
</div>
);
}