🚧 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
|
only, never the payload), retained-message listing and clearing
|
||||||
(audited as RETAINED_CLEARED); RBAC: viewing = operator+,
|
(audited as RETAINED_CLEARED); RBAC: viewing = operator+,
|
||||||
publishing/clearing = broker-admin+ — verified live end-to-end
|
publishing/clearing = broker-admin+ — verified live end-to-end
|
||||||
- [ ] Topic explorer: consolidated topic view (spec §35.1) — one row
|
- [x] Topic explorer: consolidated topic view (spec §35.1) — one row
|
||||||
per known topic; highlight differences between the new and the
|
per known topic (messages folded as they arrive); changed cells
|
||||||
previous message per topic (payload, QoS, retain, MQTT 5
|
(payload, QoS, retain, MQTT 5 properties) highlighted against the
|
||||||
properties); briefly glow the row on new-message arrival;
|
previous message; changed tokens WITHIN a text payload highlighted
|
||||||
browse the known history of each topic
|
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
|
- [ ] Connection tester + client provisioning wizard
|
||||||
- [ ] Bridge configuration
|
- [ ] Bridge configuration
|
||||||
- [ ] Retained-message administration (partially covered by the topic
|
- [ ] 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
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "session not found") from exc
|
||||||
|
|
||||||
async def event_stream():
|
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
|
heartbeat = 0.0
|
||||||
try:
|
try:
|
||||||
while True:
|
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.id = id
|
||||||
self.username = username
|
self.username = username
|
||||||
self.topic_filter = topic_filter
|
self.topic_filter = topic_filter
|
||||||
|
self.buffer = [] # list of message dicts, index-tagged
|
||||||
|
|
||||||
class FakeSessions:
|
class FakeSessions:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -281,7 +282,8 @@ def client(db_session, monkeypatch):
|
|||||||
monkeypatch.setattr(explorer_module, "sessions", fake_sessions)
|
monkeypatch.setattr(explorer_module, "sessions", fake_sessions)
|
||||||
|
|
||||||
def fake_messages_since(session_id, index):
|
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):
|
def fake_list_retained(max_results=200, timeout=3.0):
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -283,6 +283,58 @@ def test_topic_explorer_flow(admin_client):
|
|||||||
assert any(m["topic"] == "site/config/level" for m in retained.json())
|
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):
|
def test_topic_explorer_publish_audited(admin_client, db_session):
|
||||||
"""Publishes through the test console are audited (spec section 35),
|
"""Publishes through the test console are audited (spec section 35),
|
||||||
and wildcards are rejected."""
|
and wildcards are rejected."""
|
||||||
|
|||||||
Generated
+1003
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"diff": "^7.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0"
|
"react-router-dom": "^7.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/diff": "^5.2.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -1,8 +1,83 @@
|
|||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { diffWords } from "diff";
|
||||||
import { api, type ExplorerMessage, type RetainedMessage } from "../api";
|
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 {
|
function toHex(b64: string): string {
|
||||||
const bytes = atob(b64);
|
const bytes = atob(b64);
|
||||||
@@ -11,6 +86,59 @@ function toHex(b64: string): string {
|
|||||||
.join(" ");
|
.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 }) {
|
function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) {
|
||||||
const [mode, setMode] = useState<"text" | "hex">(
|
const [mode, setMode] = useState<"text" | "hex">(
|
||||||
msg.payload_text !== null ? "text" : "hex",
|
msg.payload_text !== null ? "text" : "hex",
|
||||||
@@ -38,7 +166,12 @@ function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) {
|
|||||||
export function TopicExplorerPage() {
|
export function TopicExplorerPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [filter, setFilter] = useState("");
|
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 [selected, setSelected] = useState<ExplorerMessage | null>(null);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
@@ -65,7 +198,8 @@ export function TopicExplorerPage() {
|
|||||||
|
|
||||||
const startStream = async (topicFilter: string) => {
|
const startStream = async (topicFilter: string) => {
|
||||||
stopStream();
|
stopStream();
|
||||||
setMessages([]);
|
setTopicRows(new Map());
|
||||||
|
setSelectedTopic(null);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
try {
|
try {
|
||||||
@@ -77,10 +211,24 @@ export function TopicExplorerPage() {
|
|||||||
es.onopen = () => setStreaming(true);
|
es.onopen = () => setStreaming(true);
|
||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
const msg: ExplorerMessage = JSON.parse(event.data);
|
const msg: ExplorerMessage = JSON.parse(event.data);
|
||||||
setMessages((prev) => {
|
// Fold into the per-topic map (spec 35.1: one row per topic).
|
||||||
const next = [...prev, msg];
|
setTopicRows((prev) => {
|
||||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
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 = () => {
|
es.onerror = () => {
|
||||||
setStreamError(
|
setStreamError(
|
||||||
@@ -171,38 +319,65 @@ export function TopicExplorerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
|
||||||
<th>Topic</th>
|
<th>Topic</th>
|
||||||
|
<th>Last update</th>
|
||||||
<th>QoS</th>
|
<th>QoS</th>
|
||||||
<th>Retain</th>
|
<th>Retain</th>
|
||||||
<th>Payload</th>
|
<th>Latest payload</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{messages.map((m) => (
|
{Array.from(topicRows.values())
|
||||||
<tr
|
.sort((a, b) => b.updated_at - a.updated_at)
|
||||||
key={m.index}
|
.map((row) => {
|
||||||
onClick={() => setSelected(m)}
|
const changed = diffFields(row.latest, row.previous);
|
||||||
style={{ cursor: "pointer" }}
|
return (
|
||||||
>
|
<tr
|
||||||
<td className="mono">
|
key={row.topic}
|
||||||
{new Date(m.received_at * 1000).toLocaleTimeString()}
|
onClick={() => {
|
||||||
</td>
|
setSelectedTopic(
|
||||||
<td className="mono">{m.topic}</td>
|
selectedTopic === row.topic ? null : row.topic,
|
||||||
<td>{m.qos}</td>
|
);
|
||||||
<td>{m.retain ? "yes" : "—"}</td>
|
setSelected(null);
|
||||||
<td className="mono" style={{ maxWidth: "260px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
}}
|
||||||
{m.payload_text ?? `(binary, ${atob(m.payload_b64).length} B)`}
|
style={{ cursor: "pointer" }}
|
||||||
</td>
|
className={glowing.has(row.topic) ? "row-glow" : undefined}
|
||||||
</tr>
|
>
|
||||||
))}
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{messages.length === 0 && !streamError && (
|
{topicRows.size === 0 && !streamError && (
|
||||||
<p style={{ color: "#52606d" }}>
|
<p style={{ color: "#52606d" }}>
|
||||||
{streaming
|
{streaming
|
||||||
? "Waiting for messages matching the filter…"
|
? "Waiting for messages matching the filter…"
|
||||||
@@ -210,6 +385,54 @@ export function TopicExplorerPage() {
|
|||||||
</p>
|
</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 && (
|
{selected && (
|
||||||
<section style={{ marginTop: "1.5rem" }}>
|
<section style={{ marginTop: "1.5rem" }}>
|
||||||
<h3>
|
<h3>
|
||||||
|
|||||||
@@ -163,6 +163,25 @@ h3[id] {
|
|||||||
scroll-margin-top: 1rem;
|
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 indicator (sidebar, below the header) ------------- */
|
||||||
.restart-needed {
|
.restart-needed {
|
||||||
display: block;
|
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