🚧 Add topic explorer

This commit is contained in:
Jonas Bengtsson
2026-09-20 09:23:04 +02:00
parent 1e491642f1
commit 6385a27411
11 changed files with 1184 additions and 6 deletions
+13
View File
@@ -17,4 +17,17 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
# Server-Sent Events (topic explorer): buffering would hold stream
# chunks until the buffer fills — disable it and allow long-lived
# connections. The backend also sends X-Accel-Buffering: no.
location /api/v1/topics/stream/ {
proxy_pass http://mqtt-backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+10 -1
View File
@@ -11,6 +11,7 @@ import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates";
import { SystemPage } from "./pages/System";
import { StatusPage } from "./pages/Status";
import { TopicExplorerPage } from "./pages/TopicExplorer";
// Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out
@@ -52,7 +53,7 @@ const NAV_SECTIONS: {
{
title: "Tools",
items: [
{ label: "Topic Explorer", hint: "Test console (post-MVP)" },
{ label: "Topic Explorer", to: "/topics" },
{ label: "Connection Test", hint: "Connectivity tester (post-MVP)" },
{ label: "Client Provisioning", hint: "Provisioning wizard (post-MVP)" },
],
@@ -254,6 +255,14 @@ export function App() {
</Protected>
}
/>
<Route
path="/topics"
element={
<Protected>
<TopicExplorerPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} />
</Routes>
);
+25
View File
@@ -248,4 +248,29 @@ export interface Revision {
export interface SystemStatus {
broker_alive: boolean;
restart_required: boolean;
}
export interface SubscribeResponse {
session_id: string;
}
export interface ExplorerMessage {
index: number;
topic: string;
qos: number;
retain: boolean;
received_at: number;
payload_b64: string;
payload_text: string | null;
properties: Record<string, unknown> | null;
}
export interface RetainedMessage {
index: number;
topic: string;
qos: number;
retain: boolean;
received_at: number;
payload_b64: string;
payload_text: string | null;
}
+345
View File
@@ -0,0 +1,345 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ExplorerMessage, type RetainedMessage } from "../api";
const MAX_MESSAGES = 500; // UI-side cap; the backend buffers 1000
function toHex(b64: string): string {
const bytes = atob(b64);
return Array.from(bytes)
.map((b) => b.charCodeAt(0).toString(16).padStart(2, "0"))
.join(" ");
}
function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) {
const [mode, setMode] = useState<"text" | "hex">(
msg.payload_text !== null ? "text" : "hex",
);
return (
<div>
<button onClick={() => setMode(mode === "text" ? "hex" : "text")}>
{mode === "text" ? "Show hex" : "Show text"}
</button>
<pre className="config mono" style={{ maxHeight: "180px" }}>
{mode === "text"
? msg.payload_text ?? "(not valid UTF-8 — use hex view)"
: toHex(msg.payload_b64) || "(empty payload)"}
</pre>
{"properties" in msg && msg.properties && (
<div style={{ fontSize: "0.8rem", color: "#52606d" }}>
<strong>MQTT 5 properties:</strong>{" "}
<span className="mono">{JSON.stringify(msg.properties)}</span>
</div>
)}
</div>
);
}
export function TopicExplorerPage() {
const queryClient = useQueryClient();
const [filter, setFilter] = useState("");
const [messages, setMessages] = useState<ExplorerMessage[]>([]);
const [selected, setSelected] = useState<ExplorerMessage | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
// Publish form state
const [pubTopic, setPubTopic] = useState("");
const [pubPayload, setPubPayload] = useState("");
const [pubQos, setPubQos] = useState(0);
const [pubRetain, setPubRetain] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// Retained panel
const [showRetained, setShowRetained] = useState(false);
const [retainedSelected, setRetainedSelected] =
useState<RetainedMessage | null>(null);
const eventSource = useRef<EventSource | null>(null);
const stopStream = () => {
eventSource.current?.close();
eventSource.current = null;
setStreaming(false);
};
const startStream = async (topicFilter: string) => {
stopStream();
setMessages([]);
setSelected(null);
setStreamError(null);
try {
const { session_id } = await api.post<{ session_id: string }>(
"/topics/subscribe",
{ topic_filter: topicFilter },
);
const es = new EventSource(`/api/v1/topics/stream/${session_id}`);
es.onopen = () => setStreaming(true);
es.onmessage = (event) => {
const msg: ExplorerMessage = JSON.parse(event.data);
setMessages((prev) => {
const next = [...prev, msg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
};
es.onerror = () => {
setStreamError(
"Stream closed (session ended or broker unreachable). Restart the subscription to continue.",
);
stopStream();
};
eventSource.current = es;
} catch (e) {
setStreamError((e as Error).message);
}
};
// Cleanup on unmount: closing the EventSource triggers the backend
// session teardown (stream generator's finally).
useEffect(() => () => eventSource.current?.close(), []);
const publish = useMutation({
mutationFn: () =>
api.post("/topics/publish", {
topic: pubTopic,
payload_b64: btoa(pubPayload),
qos: pubQos,
retain: pubRetain,
}),
onSuccess: () => {
setActionError(null);
setPubPayload("");
},
onError: (e: Error) => setActionError(e.message),
});
const retainedQuery = useQuery({
queryKey: ["retained"],
queryFn: () => api.get<RetainedMessage[]>("/topics/retained"),
enabled: showRetained,
});
const clearRetained = useMutation({
mutationFn: (topic: string) =>
api.post("/topics/retained/clear", { topic }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["retained"] }),
onError: (e: Error) => setActionError(e.message),
});
const submitSubscribe = (e: FormEvent) => {
e.preventDefault();
if (filter.trim()) void startStream(filter.trim());
};
const submitPublish = (e: FormEvent) => {
e.preventDefault();
if (pubTopic.trim()) publish.mutate();
};
return (
<div>
<h2>Topic explorer</h2>
<p>
Live view of broker traffic (spec section 35). Subscribes with the
management identity — every topic the broker carries is visible.
Publishes are recorded in the audit log.
</p>
{streamError && <div className="banner error">{streamError}</div>}
{actionError && <div className="banner error">{actionError}</div>}
<form className="stack" onSubmit={submitSubscribe}>
<label htmlFor="topic-filter">Topic filter</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
id="topic-filter"
className="mono"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="e.g. site/+/telemetry/#"
style={{ maxWidth: "300px" }}
/>
{streaming ? (
<button type="button" onClick={stopStream}>
Stop
</button>
) : null}
<button className="primary" type="submit" disabled={!filter.trim()}>
{streaming ? "Restart subscription" : "Subscribe"}
</button>
</div>
</form>
<h3 style={{ marginTop: "1.5rem" }}>Messages</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>Topic</th>
<th>QoS</th>
<th>Retain</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{messages.map((m) => (
<tr
key={m.index}
onClick={() => setSelected(m)}
style={{ cursor: "pointer" }}
>
<td className="mono">
{new Date(m.received_at * 1000).toLocaleTimeString()}
</td>
<td className="mono">{m.topic}</td>
<td>{m.qos}</td>
<td>{m.retain ? "yes" : "—"}</td>
<td className="mono" style={{ maxWidth: "260px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{m.payload_text ?? `(binary, ${atob(m.payload_b64).length} B)`}
</td>
</tr>
))}
</tbody>
</table>
{messages.length === 0 && !streamError && (
<p style={{ color: "#52606d" }}>
{streaming
? "Waiting for messages matching the filter…"
: "Not subscribed — enter a filter above."}
</p>
)}
{selected && (
<section style={{ marginTop: "1.5rem" }}>
<h3>
Message on <span className="mono">{selected.topic}</span>
</h3>
<PayloadView msg={selected} />
</section>
)}
<h3 style={{ marginTop: "2rem" }}>Publish test message</h3>
<form className="stack" onSubmit={submitPublish}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label htmlFor="pub-topic">Topic</label>
<input
id="pub-topic"
className="mono"
value={pubTopic}
onChange={(e) => setPubTopic(e.target.value)}
placeholder="e.g. site/test/command"
/>
</div>
<div>
<label htmlFor="pub-qos">QoS</label>
<select
id="pub-qos"
value={pubQos}
onChange={(e) => setPubQos(Number(e.target.value))}
>
<option value={0}>0</option>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</div>
<div>
<label htmlFor="pub-retain">Retain</label>
<select
id="pub-retain"
value={pubRetain ? "true" : "false"}
onChange={(e) => setPubRetain(e.target.value === "true")}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</div>
<div style={{ flex: "1 1 240px" }}>
<label htmlFor="pub-payload">Payload (UTF-8 text)</label>
<input
id="pub-payload"
className="mono"
value={pubPayload}
onChange={(e) => setPubPayload(e.target.value)}
placeholder='e.g. {"cmd": "restart"}'
/>
</div>
<button
className="primary"
type="submit"
disabled={!pubTopic.trim() || publish.isPending}
>
{publish.isPending ? "Publishing…" : "Publish"}
</button>
</div>
</form>
<h3 style={{ marginTop: "2rem" }}>Retained messages</h3>
<p>
<button onClick={() => setShowRetained(!showRetained)}>
{showRetained ? "Hide" : "List retained messages"}
</button>{" "}
<span style={{ color: "#52606d", fontSize: "0.8rem" }}>
Briefly subscribes to # on the broker (spec section 36).
</span>
</p>
{showRetained && (
<>
{retainedQuery.error && (
<div className="banner error">
{(retainedQuery.error as Error).message}
</div>
)}
<table>
<thead>
<tr>
<th>Topic</th>
<th>QoS</th>
<th>Payload</th>
<th></th>
</tr>
</thead>
<tbody>
{(retainedQuery.data ?? []).map((m) => (
<tr key={m.topic}>
<td className="mono">{m.topic}</td>
<td>{m.qos}</td>
<td
className="mono"
style={{ maxWidth: "260px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
onClick={() => setRetainedSelected(m)}
>
{m.payload_text ?? "(binary)"}
</td>
<td>
<button
className="danger"
onClick={() => {
if (confirm(`Clear the retained message on ${m.topic}?`))
clearRetained.mutate(m.topic);
}}
>
Clear
</button>
</td>
</tr>
))}
</tbody>
</table>
{(retainedQuery.data ?? []).length === 0 && !retainedQuery.isLoading && (
<p style={{ color: "#52606d" }}>No retained messages.</p>
)}
</>
)}
{retainedSelected && (
<section style={{ marginTop: "1rem" }}>
<h4>
Retained payload on <span className="mono">{retainedSelected.topic}</span>
</h4>
<PayloadView msg={retainedSelected} />
</section>
)}
</div>
);
}