🚧 Update topic explorer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 [
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Generated
+1003
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,19 +319,89 @@ 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>Topic</th>
|
||||
<th>Last update</th>
|
||||
<th>QoS</th>
|
||||
<th>Retain</th>
|
||||
<th>Latest payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
{topicRows.size === 0 && !streamError && (
|
||||
<p style={{ color: "#52606d" }}>
|
||||
{streaming
|
||||
? "Waiting for messages matching the filter…"
|
||||
: "Not subscribed — enter a filter above."}
|
||||
</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>Topic</th>
|
||||
<th>QoS</th>
|
||||
<th>Retain</th>
|
||||
<th>Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{messages.map((m) => (
|
||||
{topicRows.get(selectedTopic)!.history.map((m) => (
|
||||
<tr
|
||||
key={m.index}
|
||||
onClick={() => setSelected(m)}
|
||||
@@ -192,22 +410,27 @@ export function TopicExplorerPage() {
|
||||
<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" }}>
|
||||
<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>
|
||||
{topicRows.get(selectedTopic)!.history.length === 0 && (
|
||||
<p style={{ color: "#52606d" }}>No messages known for this topic.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user