🚧 Add broker status page
This commit is contained in:
+37
-5
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user