🚧 Update topic explorer

This commit is contained in:
Jonas Bengtsson
2026-09-20 17:57:05 +02:00
parent 6385a27411
commit b8e7c14368
10 changed files with 1344 additions and 3888 deletions
+250 -27
View File
@@ -1,8 +1,83 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { diffWords } from "diff";
import { api, type ExplorerMessage, type RetainedMessage } from "../api";
const MAX_MESSAGES = 500; // UI-side cap; the backend buffers 1000
const HISTORY_PER_TOPIC = 100; // history kept per topic in the UI
// --- Consolidated topic view (spec 35.1) ------------------------------------
interface TopicRow {
topic: string;
latest: ExplorerMessage;
previous: ExplorerMessage | null;
history: ExplorerMessage[]; // most recent first
updated_at: number; // for the glow animation key
}
/** Which fields changed between the previous and the latest message. */
function diffFields(
latest: ExplorerMessage,
previous: ExplorerMessage | null,
): Set<string> {
if (previous === null) return new Set(["payload", "qos", "retain", "properties"]);
const changed = new Set<string>();
if (latest.payload_b64 !== previous.payload_b64) changed.add("payload");
if (latest.qos !== previous.qos) changed.add("qos");
if (latest.retain !== previous.retain) changed.add("retain");
if (JSON.stringify(latest.properties ?? null) !== JSON.stringify(previous.properties ?? null))
changed.add("properties");
return changed;
}
/** Fold a live message into the per-topic map (spec 35.1: one row per topic). */
function foldMessage(
rows: Map<string, TopicRow>,
msg: ExplorerMessage,
): Map<string, TopicRow> {
const existing = rows.get(msg.topic);
if (existing) {
existing.previous = existing.latest;
existing.latest = msg;
existing.history = [msg, ...existing.history].slice(0, HISTORY_PER_TOPIC);
existing.updated_at = msg.received_at;
} else {
rows.set(msg.topic, {
topic: msg.topic,
latest: msg,
previous: null,
history: [msg],
updated_at: msg.received_at,
});
}
return rows;
}
/** Cell that highlights when its field changed vs the previous message. */
function DiffCell({
changed,
children,
className,
style,
}: {
changed: boolean;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
return (
<td
className={className}
style={{
...style,
...(changed ? { background: "#fff3cd" } : {}),
transition: "background 1.5s ease",
}}
>
{children}
</td>
);
}
function toHex(b64: string): string {
const bytes = atob(b64);
@@ -11,6 +86,59 @@ function toHex(b64: string): string {
.join(" ");
}
// Word-level payload highlighting (spec 35.1): only the tokens that
// CHANGED between the previous and the latest payload are marked —
// e.g. {"cmd": "start"} -> {"cmd": "stop"} highlights just `stop`.
const DIFF_MAX_BYTES = 4096; // diffing huge payloads on every render is not worth it
function PayloadDiff({
latest,
previous,
}: {
latest: ExplorerMessage;
previous: ExplorerMessage | null;
}) {
const text = latest.payload_text;
const prev = previous?.payload_text ?? null;
// Fallbacks: first message for the topic, either side binary, or
// oversized payloads render plain (the amber cell background from
// diffFields still marks the change at field level).
if (
text === null ||
prev === null ||
atob(latest.payload_b64).length > DIFF_MAX_BYTES ||
atob(previous!.payload_b64).length > DIFF_MAX_BYTES
) {
return <>{text ?? `(binary, ${atob(latest.payload_b64).length} B)`}</>;
}
try {
const parts = diffWords(prev, text);
return (
<>
{parts.map((part, i) =>
part.added ? (
<mark
key={i}
style={{
background: "#bcf2a2",
color: "inherit",
padding: 0,
}}
>
{part.value}
</mark>
) : part.removed ? null : (
<span key={i}>{part.value}</span>
),
)}
</>
);
} catch {
// diff can throw on pathological inputs — never break rendering.
return <>{text}</>;
}
}
function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) {
const [mode, setMode] = useState<"text" | "hex">(
msg.payload_text !== null ? "text" : "hex",
@@ -38,7 +166,12 @@ function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) {
export function TopicExplorerPage() {
const queryClient = useQueryClient();
const [filter, setFilter] = useState("");
const [messages, setMessages] = useState<ExplorerMessage[]>([]);
// Consolidated view (spec 35.1): one entry per KNOWN TOPIC. The flat
// message stream is folded into this map as messages arrive.
const [topicRows, setTopicRows] = useState<Map<string, TopicRow>>(new Map());
// Topics that just received a message — drives the brief glow.
const [glowing, setGlowing] = useState<Set<string>>(new Set());
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [selected, setSelected] = useState<ExplorerMessage | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
@@ -65,7 +198,8 @@ export function TopicExplorerPage() {
const startStream = async (topicFilter: string) => {
stopStream();
setMessages([]);
setTopicRows(new Map());
setSelectedTopic(null);
setSelected(null);
setStreamError(null);
try {
@@ -77,10 +211,24 @@ export function TopicExplorerPage() {
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;
// Fold into the per-topic map (spec 35.1: one row per topic).
setTopicRows((prev) => {
const next = new Map(prev);
return foldMessage(next, msg);
});
// Brief glow for this topic, cleared after the animation.
setGlowing((prev) => {
const next = new Set(prev);
next.add(msg.topic);
return next;
});
window.setTimeout(() => {
setGlowing((prev) => {
const next = new Set(prev);
next.delete(msg.topic);
return next;
});
}, 1200);
};
es.onerror = () => {
setStreamError(
@@ -171,38 +319,65 @@ export function TopicExplorerPage() {
</div>
</form>
<h3 style={{ marginTop: "1.5rem" }}>Messages</h3>
<h3 style={{ marginTop: "1.5rem" }}>Topics</h3>
<p style={{ color: "#52606d", fontSize: "0.8rem" }}>
One row per known topic (spec §35.1). Highlighted cells changed vs the
previous message; the row glows briefly on new-message arrival. Click
a topic to browse its known history.
</p>
<table>
<thead>
<tr>
<th>Time</th>
<th>Topic</th>
<th>Last update</th>
<th>QoS</th>
<th>Retain</th>
<th>Payload</th>
<th>Latest 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>
))}
{Array.from(topicRows.values())
.sort((a, b) => b.updated_at - a.updated_at)
.map((row) => {
const changed = diffFields(row.latest, row.previous);
return (
<tr
key={row.topic}
onClick={() => {
setSelectedTopic(
selectedTopic === row.topic ? null : row.topic,
);
setSelected(null);
}}
style={{ cursor: "pointer" }}
className={glowing.has(row.topic) ? "row-glow" : undefined}
>
<td className="mono">{row.topic}</td>
<DiffCell changed={glowing.has(row.topic)} className="mono">
{new Date(row.latest.received_at * 1000).toLocaleTimeString()}
</DiffCell>
<DiffCell changed={changed.has("qos")}>{row.latest.qos}</DiffCell>
<DiffCell changed={changed.has("retain")}>
{row.latest.retain ? "yes" : "—"}
</DiffCell>
<DiffCell
changed={changed.has("payload")}
className="mono"
style={{
maxWidth: "260px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
<PayloadDiff latest={row.latest} previous={row.previous} />
</DiffCell>
</tr>
);
})}
</tbody>
</table>
{messages.length === 0 && !streamError && (
{topicRows.size === 0 && !streamError && (
<p style={{ color: "#52606d" }}>
{streaming
? "Waiting for messages matching the filter…"
@@ -210,6 +385,54 @@ export function TopicExplorerPage() {
</p>
)}
{selectedTopic && topicRows.get(selectedTopic) && (
<section style={{ marginTop: "1.5rem" }}>
<h3>
History of <span className="mono">{selectedTopic}</span>{" "}
(most recent first)
</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>QoS</th>
<th>Retain</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{topicRows.get(selectedTopic)!.history.map((m) => (
<tr
key={m.index}
onClick={() => setSelected(m)}
style={{ cursor: "pointer" }}
>
<td className="mono">
{new Date(m.received_at * 1000).toLocaleTimeString()}
</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>
{topicRows.get(selectedTopic)!.history.length === 0 && (
<p style={{ color: "#52606d" }}>No messages known for this topic.</p>
)}
</section>
)}
{selected && (
<section style={{ marginTop: "1.5rem" }}>
<h3>