🚧 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
+10 -5
View File
@@ -203,11 +203,16 @@ Hardening done beyond the plan:
only, never the payload), retained-message listing and clearing
(audited as RETAINED_CLEARED); RBAC: viewing = operator+,
publishing/clearing = broker-admin+ — verified live end-to-end
- [ ] Topic explorer: consolidated topic view (spec §35.1) — one row
per known topic; highlight differences between the new and the
previous message per topic (payload, QoS, retain, MQTT 5
properties); briefly glow the row on new-message arrival;
browse the known history of each topic
- [x] Topic explorer: consolidated topic view (spec §35.1) — one row
per known topic (messages folded as they arrive); changed cells
(payload, QoS, retain, MQTT 5 properties) highlighted against the
previous message; changed tokens WITHIN a text payload highlighted
word-level (jsdiff diffWords — e.g. `{"cmd": "start"}` →
`{"cmd": "stop"}` marks only `stop`); fallbacks to plain text for
a topic's first message, binary payloads, and payloads >4 KB; row
glows briefly on new-message arrival; per-topic history browsable
(most recent first, per-topic ring buffer of 100 in the UI,
session buffer of 1000 server-side)
- [ ] Connection tester + client provisioning wizard
- [ ] Bridge configuration
- [ ] Retained-message administration (partially covered by the topic
+4 -1
View File
@@ -116,7 +116,10 @@ async def stream(session_id: str, user: OperatorRole):
raise HTTPException(status.HTTP_404_NOT_FOUND, "session not found") from exc
async def event_stream():
index = 0
# Start BELOW the first index: messages_since uses strictly-
# greater semantics, and a fresh session's first message has
# index 0 — a cursor starting at 0 would drop it.
index = -1
heartbeat = 0.0
try:
while True:
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -256,6 +256,7 @@ def client(db_session, monkeypatch):
self.id = id
self.username = username
self.topic_filter = topic_filter
self.buffer = [] # list of message dicts, index-tagged
class FakeSessions:
def __init__(self):
@@ -281,7 +282,8 @@ def client(db_session, monkeypatch):
monkeypatch.setattr(explorer_module, "sessions", fake_sessions)
def fake_messages_since(session_id, index):
return []
session = fake_sessions.get(session_id)
return [dict(m) for m in session.buffer if m["index"] > index]
def fake_list_retained(max_results=200, timeout=3.0):
return [
+52
View File
@@ -283,6 +283,58 @@ def test_topic_explorer_flow(admin_client):
assert any(m["topic"] == "site/config/level" for m in retained.json())
def test_topic_explorer_stream_delivers_first_message(admin_client, monkeypatch):
"""Regression test: the SSE cursor must include a session's FIRST
buffered message (index 0). messages_since is strictly-greater, so
a cursor starting at 0 dropped it — subscribe, publish {"cmd":"start"}
then {"cmd":"stop"}, and the stream must deliver BOTH."""
from app import explorer as explorer_module
sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "e2e/#"})
assert sub.status_code == 200
session_id = sub.json()["session_id"]
session = explorer_module.sessions.get(session_id)
# Simulate the broker delivering both payloads (as _on_message would).
session.buffer = [
{
"index": 0,
"topic": "e2e/diff/device1",
"qos": 1,
"retain": False,
"received_at": 1.0,
"payload_b64": "eyJjbWQiOiAic3RhcnQifQ==",
"payload_text": '{"cmd": "start"}',
"properties": None,
},
{
"index": 1,
"topic": "e2e/diff/device1",
"qos": 1,
"retain": False,
"received_at": 2.0,
"payload_b64": "eyJjbWQiOiAic3RvcCJ9",
"payload_text": '{"cmd": "stop"}',
"properties": None,
},
]
with admin_client.stream("GET", f"/api/v1/topics/stream/{session_id}") as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
received = []
for line in response.iter_lines():
if line.startswith("data:"):
received.append(json.loads(line[len("data:"):].strip()))
if len(received) == 2:
break
assert [m["payload_text"] for m in received] == [
'{"cmd": "start"}',
'{"cmd": "stop"}',
], "stream must deliver the session's first message (index 0), not drop it"
def test_topic_explorer_publish_audited(admin_client, db_session):
"""Publishes through the test console are audited (spec section 35),
and wildcards are rejected."""
+1003
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,11 +10,13 @@
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"diff": "^7.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/diff": "^5.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
+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>
+19
View File
@@ -163,6 +163,25 @@ h3[id] {
scroll-margin-top: 1rem;
}
/* --- Consolidated topic view (spec 35.1) ---------------------------------- */
/* Brief glow on a topic row when a new message arrives. Applied for
~1.2s via JS (topic removed from the `glowing` set), so the animation
runs once per message and the row settles back to normal. */
@keyframes topic-row-glow {
0% {
background: #ffe9a8;
box-shadow: 0 0 8px rgba(255, 200, 80, 0.7);
}
100% {
background: transparent;
box-shadow: none;
}
}
tr.row-glow {
animation: topic-row-glow 1.2s ease-out;
}
/* --- Restart-needed indicator (sidebar, below the header) ------------- */
.restart-needed {
display: block;
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/pages/BrokerConfig.tsx","./src/pages/Certificates.tsx","./src/pages/Clients.tsx","./src/pages/Groups.tsx","./src/pages/Login.tsx","./src/pages/Roles.tsx","./src/pages/Status.tsx","./src/pages/System.tsx","./src/pages/TopicExplorer.tsx"],"version":"5.7.3"}