🚧 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
+37 -5
View File
@@ -10,6 +10,7 @@ import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates";
import { SystemPage } from "./pages/System";
import { StatusPage } from "./pages/Status";
// Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out
@@ -21,7 +22,7 @@ const NAV_SECTIONS: {
{
title: "Broker",
items: [
{ label: "Status", hint: "Dashboard / $SYS monitoring (Release 3)" },
{ label: "Status", to: "/broker/status" },
{ label: "Configuration", to: "/broker" },
{ label: "Listeners", to: "/broker#listeners" },
{ 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 }) {
const { session, logout } = useAuth();
const location = useLocation();
@@ -92,9 +106,7 @@ function Layout({ children }: { children: React.ReactNode }) {
const activeSection = NAV_SECTIONS.find((section) =>
section.items.some(
(item) =>
item.to &&
item.to.split("#")[0] === location.pathname,
(item) => item.to && isItemActive(item.to, location),
),
);
@@ -129,7 +141,19 @@ function Layout({ children }: { children: React.ReactNode }) {
<div>
{section.items.map((item) =>
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}
</NavLink>
) : (
@@ -206,6 +230,14 @@ export function App() {
</Protected>
}
/>
<Route
path="/broker/status"
element={
<Protected>
<StatusPage />
</Protected>
}
/>
<Route
path="/certificates"
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>
);
}