🚧 Add topic explorer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user