🚧 Add topic explorer
This commit is contained in:
@@ -195,12 +195,23 @@ Hardening done beyond the plan:
|
||||
- [ ] Configuration drift detection (§48)
|
||||
- [ ] Last-known-connection tracking + connected-client view (§34)
|
||||
|
||||
### Post-MVP (§68.4 / §69 / §70) — NOT STARTED
|
||||
### Post-MVP (§68.4 / §69 / §70) — PARTIAL
|
||||
|
||||
- [ ] Topic explorer / MQTT test client
|
||||
- [x] Topic explorer (spec §35/§36): live message stream over SSE
|
||||
(one MQTT v5 client per browser session, SUBACK-gated subscribe),
|
||||
publish with QoS/retain (audited as TOPIC_PUBLISHED — metadata
|
||||
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
|
||||
- [ ] Connection tester + client provisioning wizard
|
||||
- [ ] Bridge configuration
|
||||
- [ ] Retained-message administration
|
||||
- [ ] Retained-message administration (partially covered by the topic
|
||||
explorer: listing + clearing; a dedicated §36 page can follow)
|
||||
- [ ] Certificate expiry alerts
|
||||
- [ ] Prometheus metrics + external notifications
|
||||
- [ ] Change approval (four-eyes) workflow
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Topic explorer sessions (spec sections 35, 36).
|
||||
|
||||
One MQTT v5 client per active browser stream. Sessions live only while
|
||||
the Server-Sent-Events connection is open; the stream generator's
|
||||
finally-clause stops the session, which disconnects the client — no
|
||||
lingering subscriptions, no manual cleanup by the operator.
|
||||
|
||||
Why MQTT v5: the explorer must surface MQTT 5 publish properties (spec
|
||||
section 35), which only arrive when the client itself speaks v5. The
|
||||
dynsec management channel deliberately stays on its own connection.
|
||||
|
||||
Payloads are base64 with a looks-like-UTF-8 flag: messages can be binary,
|
||||
and the UI offers text/hex views accordingly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUFFER_SIZE = 1000 # ring buffer per session
|
||||
_GRACE_SECONDS = 30.0 # page reload / SSE reconnect window
|
||||
|
||||
|
||||
def _payload_info(payload: bytes) -> dict[str, Any]:
|
||||
"""base64 + heuristic UTF-8 decoding for the UI."""
|
||||
info: dict[str, Any] = {"payload_b64": base64.b64encode(payload).decode()}
|
||||
try:
|
||||
info["payload_text"] = payload.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
info["payload_text"] = None # binary — UI shows hex
|
||||
return info
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Message:
|
||||
index: int
|
||||
topic: str
|
||||
qos: int
|
||||
retain: bool
|
||||
received_at: float
|
||||
payload_b64: str
|
||||
payload_text: str | None
|
||||
properties: dict[str, Any] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"index": self.index,
|
||||
"topic": self.topic,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
"received_at": self.received_at,
|
||||
"payload_b64": self.payload_b64,
|
||||
"payload_text": self.payload_text,
|
||||
"properties": self.properties,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExplorerSession:
|
||||
id: str
|
||||
username: str
|
||||
topic_filter: str
|
||||
client: mqtt.Client = field(repr=False)
|
||||
buffer: deque[_Message] = field(default_factory=lambda: deque(maxlen=_BUFFER_SIZE))
|
||||
_next_index: int = 0
|
||||
_cond: threading.Condition = field(default_factory=threading.Condition)
|
||||
_subscribed: threading.Event = field(default_factory=threading.Event)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||
if reason_code == 0:
|
||||
client.subscribe(self.topic_filter, qos=0)
|
||||
else:
|
||||
logger.error("explorer session %s connect failed: %s", self.id, reason_code)
|
||||
|
||||
def _on_subscribe(self, client, userdata, mid, reason_codes, properties) -> None:
|
||||
# SUBACK received: the subscription is now live on the broker.
|
||||
self._subscribed.set()
|
||||
|
||||
def _on_message(self, client, userdata, msg) -> None:
|
||||
# MQTT v5: surface publish properties (content type, response
|
||||
# topic, user properties, ...). paho exposes them as msg.properties.
|
||||
props: dict[str, Any] | None = None
|
||||
raw_props = getattr(msg, "properties", None)
|
||||
if raw_props is not None:
|
||||
try:
|
||||
props = {}
|
||||
if getattr(raw_props, "ContentType", None):
|
||||
props["content_type"] = raw_props.ContentType
|
||||
if getattr(raw_props, "ResponseTopic", None):
|
||||
props["response_topic"] = raw_props.ResponseTopic
|
||||
if getattr(raw_props, "CorrelationData", None):
|
||||
props["correlation_data"] = raw_props.CorrelationData.decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
if getattr(raw_props, "UserProperty", None):
|
||||
props["user_properties"] = [
|
||||
[k, v] for k, v in raw_props.UserProperty
|
||||
]
|
||||
except Exception: # noqa: BLE001 - properties must never break capture
|
||||
props = None
|
||||
info = _payload_info(msg.payload)
|
||||
with self._cond:
|
||||
message = _Message(
|
||||
index=self._next_index,
|
||||
topic=msg.topic,
|
||||
qos=msg.qos,
|
||||
retain=bool(getattr(msg, "retain", False)),
|
||||
received_at=time.time(),
|
||||
payload_b64=info["payload_b64"],
|
||||
payload_text=info["payload_text"],
|
||||
properties=props,
|
||||
)
|
||||
self._next_index += 1
|
||||
self.buffer.append(message)
|
||||
self._cond.notify_all()
|
||||
|
||||
|
||||
class ExplorerSessions:
|
||||
"""Thread-safe registry of live explorer sessions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._sessions: dict[str, ExplorerSession] = {}
|
||||
|
||||
def start(self, username: str, topic_filter: str) -> ExplorerSession:
|
||||
settings = get_settings()
|
||||
session_id = uuid.uuid4().hex
|
||||
client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"mqtt-explorer-{session_id[:8]}",
|
||||
protocol=mqtt.MQTTv5,
|
||||
)
|
||||
client.username_pw_set(
|
||||
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
|
||||
)
|
||||
session = ExplorerSession(
|
||||
id=session_id,
|
||||
username=username,
|
||||
topic_filter=topic_filter,
|
||||
client=client,
|
||||
)
|
||||
client.on_connect = session._on_connect
|
||||
client.on_subscribe = session._on_subscribe
|
||||
client.on_message = session._on_message
|
||||
client.connect(
|
||||
settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30
|
||||
)
|
||||
client.loop_start()
|
||||
# Block until the broker acknowledges the subscription: without
|
||||
# this, a message published immediately after subscribe returns
|
||||
# can be missed (SUBACK arrives asynchronously on the network
|
||||
# loop). 5 s covers a cold broker start; on timeout the session
|
||||
# still proceeds — delivery may just start slightly later.
|
||||
if not session._subscribed.wait(timeout=5.0):
|
||||
logger.warning(
|
||||
"explorer session %s: SUBACK not received within 5 s",
|
||||
session_id,
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def get(self, session_id: str) -> ExplorerSession:
|
||||
with self._lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError(session_id)
|
||||
return session
|
||||
|
||||
def stop(self, session_id: str) -> None:
|
||||
with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session is None:
|
||||
return
|
||||
try:
|
||||
session.client.disconnect()
|
||||
except OSError:
|
||||
pass
|
||||
session.client.loop_stop()
|
||||
|
||||
def sweep(self) -> None:
|
||||
"""Drop sessions whose buffer has gone silent past the grace
|
||||
window (browser tab closed without the SSE finally firing)."""
|
||||
cutoff = time.time() - _GRACE_SECONDS
|
||||
with self._lock:
|
||||
stale = [
|
||||
sid
|
||||
for sid, s in self._sessions.items()
|
||||
if s.buffer and s.buffer[-1].received_at < cutoff
|
||||
and time.time() - s.created_at > _GRACE_SECONDS
|
||||
]
|
||||
for sid in stale:
|
||||
self.stop(sid)
|
||||
|
||||
def stop_all(self) -> None:
|
||||
with self._lock:
|
||||
ids = list(self._sessions)
|
||||
for sid in ids:
|
||||
self.stop(sid)
|
||||
|
||||
|
||||
sessions = ExplorerSessions()
|
||||
|
||||
|
||||
def messages_since(session_id: str, index: int) -> list[dict[str, Any]]:
|
||||
"""All buffered messages with index > the given one."""
|
||||
session = sessions.get(session_id)
|
||||
with session._cond:
|
||||
return [m.as_dict() for m in session.buffer if m.index > index]
|
||||
|
||||
|
||||
def list_retained(max_results: int = 200, timeout: float = 3.0) -> list[dict[str, Any]]:
|
||||
"""Enumerate retained messages (spec section 36).
|
||||
|
||||
MQTT brokers expose no "list retained" API: the defined mechanism is
|
||||
to subscribe to # — the broker immediately delivers every retained
|
||||
message matching the filter (retain flag set on delivery) — collect
|
||||
briefly, then unsubscribe. $SYS is filtered out of the listing.
|
||||
"""
|
||||
settings = get_settings()
|
||||
collected: dict[str, _Message] = {}
|
||||
done = threading.Event()
|
||||
client_id = f"mqtt-explorer-retained-{uuid.uuid4().hex[:8]}"
|
||||
client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=client_id,
|
||||
protocol=mqtt.MQTTv5,
|
||||
)
|
||||
client.username_pw_set(
|
||||
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
|
||||
)
|
||||
|
||||
def _on_connect(cl, userdata, flags, reason_code, properties) -> None:
|
||||
if reason_code == 0:
|
||||
cl.subscribe("#", qos=0)
|
||||
else:
|
||||
done.set() # nothing to collect without a subscription
|
||||
|
||||
def _on_message(cl, userdata, msg) -> None:
|
||||
if not getattr(msg, "retain", False):
|
||||
return # only retained deliveries belong in the listing
|
||||
if msg.topic.startswith("$SYS"):
|
||||
return # telemetry noise, not retained app data
|
||||
if len(collected) >= max_results:
|
||||
done.set()
|
||||
return
|
||||
info = _payload_info(msg.payload)
|
||||
collected[msg.topic] = _Message(
|
||||
index=len(collected),
|
||||
topic=msg.topic,
|
||||
qos=msg.qos,
|
||||
retain=True,
|
||||
received_at=time.time(),
|
||||
payload_b64=info["payload_b64"],
|
||||
payload_text=info["payload_text"],
|
||||
)
|
||||
|
||||
client.on_connect = _on_connect
|
||||
client.on_message = _on_message
|
||||
client.connect(settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30)
|
||||
client.loop_start()
|
||||
try:
|
||||
done.wait(timeout)
|
||||
finally:
|
||||
try:
|
||||
client.disconnect()
|
||||
except OSError:
|
||||
pass
|
||||
client.loop_stop()
|
||||
return [m.as_dict() for _, m in sorted(collected.items())]
|
||||
|
||||
|
||||
def publish_message(
|
||||
topic: str, payload: bytes, qos: int, retain: bool, timeout: float = 10.0
|
||||
) -> None:
|
||||
"""Publish a test message as the management identity (spec section 35).
|
||||
|
||||
A short-lived dedicated client keeps the explorer's session clients
|
||||
purely read-only.
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"mqtt-explorer-pub-{uuid.uuid4().hex[:8]}",
|
||||
protocol=mqtt.MQTTv5,
|
||||
)
|
||||
client.username_pw_set(
|
||||
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
|
||||
)
|
||||
client.connect(settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30)
|
||||
client.loop_start()
|
||||
try:
|
||||
info = client.publish(topic, payload, qos=qos, retain=retain)
|
||||
info.wait_for_publish(timeout=timeout)
|
||||
if not info.is_published():
|
||||
raise RuntimeError("publish did not complete in time")
|
||||
finally:
|
||||
try:
|
||||
client.disconnect()
|
||||
except OSError:
|
||||
pass
|
||||
client.loop_stop()
|
||||
+42
-2
@@ -81,9 +81,48 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("sys-observe role granted to the management identity")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("could not grant sys-observe: %s", exc)
|
||||
# The topic explorer (spec section 35) publishes and subscribes
|
||||
# to APPLICATION topics as the management identity. The 2.1
|
||||
# auto-generated admin role set does NOT include publish/subscribe
|
||||
# on # (deliberately: "the admin user does not have access to
|
||||
# publish to normal application topics"). A dedicated role keeps
|
||||
# the grant explicit and auditable; publishes through the
|
||||
# explorer remain audited as TOPIC_PUBLISHED (spec section 35).
|
||||
from app.dynsec import DynsecError
|
||||
|
||||
try:
|
||||
try:
|
||||
dynsec_client.create_role(
|
||||
"topic-explorer",
|
||||
acls=[
|
||||
{
|
||||
"acltype": "publishClientSend",
|
||||
"topic": "#",
|
||||
"allow": True,
|
||||
"priority": 0,
|
||||
},
|
||||
{
|
||||
"acltype": "subscribePattern",
|
||||
"topic": "#",
|
||||
"allow": True,
|
||||
"priority": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
except DynsecError:
|
||||
pass # role already exists (idempotent startup)
|
||||
dynsec_client.add_client_role(
|
||||
settings.mosquitto_dynsec_username, "topic-explorer", priority=1
|
||||
)
|
||||
logger.info("topic-explorer role granted to the management identity")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("could not grant topic-explorer: %s", exc)
|
||||
except Exception as exc: # noqa: BLE001 - startup should not crash the API
|
||||
logger.warning("dynsec channel not ready yet: %s", exc)
|
||||
yield
|
||||
from app import explorer
|
||||
|
||||
explorer.sessions.stop_all()
|
||||
dynsec_client.close()
|
||||
|
||||
|
||||
@@ -140,11 +179,12 @@ def health_ready():
|
||||
|
||||
|
||||
# Routers
|
||||
from app.routers import auth, broker, mqtt, pki, system, users # noqa: E402
|
||||
from app.routers import auth, broker, mqtt, pki, system, topics, users # noqa: E402
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(mqtt.router)
|
||||
app.include_router(broker.router)
|
||||
app.include_router(pki.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(topics.router)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Topic explorer API (spec sections 35, 36, 41).
|
||||
|
||||
Endpoints:
|
||||
POST /topics/subscribe -> open an explorer session
|
||||
GET /topics/stream/{id} -> Server-Sent Events message stream
|
||||
POST /topics/publish -> publish a test message (audited)
|
||||
GET /topics/retained -> list retained messages (spec 36)
|
||||
POST /topics/retained/clear -> clear one retained message (audited)
|
||||
|
||||
Security model (spec section 35: access restricted through management
|
||||
RBAC; all publishes audited):
|
||||
* subscribe/stream/retained listing: OPERATOR and above (spec
|
||||
section 9: "perform permitted diagnostic operations")
|
||||
* publish / clear retained: BROKER_ADMIN and above (state-changing)
|
||||
* every publish and clear writes an audit event with topic and QoS
|
||||
metadata — NEVER the payload (audit rows are not a message store).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, explorer
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import ManagementRole
|
||||
|
||||
router = APIRouter(prefix="/api/v1/topics", tags=["topics"])
|
||||
|
||||
OperatorRole = Annotated[
|
||||
CurrentUser,
|
||||
Depends(
|
||||
require_roles(
|
||||
ManagementRole.BROKER_ADMIN,
|
||||
ManagementRole.OPERATOR,
|
||||
)
|
||||
),
|
||||
]
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
topic_filter: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class SubscribeResponse(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=200)
|
||||
payload_b64: str = Field(default="", description="Empty clears retained")
|
||||
qos: int = Field(default=0, ge=0, le=2)
|
||||
retain: bool = False
|
||||
|
||||
|
||||
class RetainedMessage(BaseModel):
|
||||
index: int
|
||||
topic: str
|
||||
qos: int
|
||||
retain: bool
|
||||
received_at: float
|
||||
payload_b64: str
|
||||
payload_text: str | None
|
||||
properties: dict | None = None
|
||||
|
||||
|
||||
class ClearRetainedRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
def _valid_topic_filter(value: str) -> None:
|
||||
# Reject wildcard misuse the broker would reject anyway, with a
|
||||
# clearer error than a generic MQTT rc.
|
||||
if "#" in value and not value.endswith("#"):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"'#' is only valid as the last level of a filter",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=SubscribeResponse)
|
||||
def subscribe(body: SubscribeRequest, user: OperatorRole):
|
||||
"""Open an explorer session for one topic filter (spec section 35)."""
|
||||
_valid_topic_filter(body.topic_filter)
|
||||
explorer.sessions.sweep()
|
||||
try:
|
||||
session = explorer.sessions.start(user.username, body.topic_filter)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"could not connect to the broker: {exc}",
|
||||
) from exc
|
||||
return SubscribeResponse(session_id=session.id)
|
||||
|
||||
|
||||
@router.get("/stream/{session_id}")
|
||||
async def stream(session_id: str, user: OperatorRole):
|
||||
"""Server-Sent Events stream of live messages for the session.
|
||||
|
||||
Closing the EventSource stops the session (and its MQTT client) via
|
||||
the generator's finally — the lifecycle is tied to the connection.
|
||||
"""
|
||||
try:
|
||||
explorer.sessions.get(session_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "session not found") from exc
|
||||
|
||||
async def event_stream():
|
||||
index = 0
|
||||
heartbeat = 0.0
|
||||
try:
|
||||
while True:
|
||||
for message in explorer.messages_since(session_id, index):
|
||||
index = message["index"]
|
||||
yield f"data: {json.dumps(message)}\n\n"
|
||||
# Heartbeat comment keeps proxies from idling out.
|
||||
heartbeat += 1
|
||||
if heartbeat % 60 == 0: # ~15s at 0.25s cadence
|
||||
yield ": keepalive\n\n"
|
||||
await asyncio.sleep(0.25)
|
||||
finally:
|
||||
explorer.sessions.stop(session_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # nginx: disable response buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/publish", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def publish(
|
||||
body: PublishRequest,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
user: BrokerAdmin,
|
||||
):
|
||||
"""Publish a test message (spec section 35: all publishes audited).
|
||||
|
||||
An empty payload with retain=true is MQTT's clear-retained mechanism
|
||||
(spec section 36) — the same call serves both purposes.
|
||||
"""
|
||||
_valid_topic_filter(body.topic)
|
||||
if any(c in body.topic for c in ("#", "+")):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"wildcards cannot be published to — use a concrete topic",
|
||||
)
|
||||
try:
|
||||
payload = base64.b64decode(body.payload_b64) if body.payload_b64 else b""
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid base64 payload") from exc
|
||||
try:
|
||||
explorer.publish_message(body.topic, payload, body.qos, body.retain)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"publish failed: {exc}",
|
||||
) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"TOPIC_PUBLISHED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="topic",
|
||||
object_id=body.topic,
|
||||
new_value={"qos": body.qos, "retain": body.retain, "bytes": len(payload)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/retained", response_model=list[RetainedMessage])
|
||||
def retained(user: OperatorRole):
|
||||
"""List retained messages (spec section 36).
|
||||
|
||||
Brokers have no list-retained API: this subscribes briefly to #,
|
||||
collects retain-flag deliveries, and unsubscribes. Capped; large
|
||||
brokers deliver everything on subscribe.
|
||||
"""
|
||||
try:
|
||||
return explorer.list_retained()
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"could not connect to the broker: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/retained/clear", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def clear_retained(
|
||||
body: ClearRetainedRequest,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
user: BrokerAdmin,
|
||||
):
|
||||
"""Clear one retained message: publish a zero-length payload with the
|
||||
retain flag (MQTT's defined mechanism, spec section 36)."""
|
||||
try:
|
||||
explorer.publish_message(body.topic, b"", qos=0, retain=True)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"clear failed: {exc}",
|
||||
) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"RETAINED_CLEARED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="topic",
|
||||
object_id=body.topic,
|
||||
)
|
||||
return None
|
||||
@@ -245,6 +245,71 @@ def client(db_session, monkeypatch):
|
||||
monkeypatch.setattr(broker_router, "_call_control", _fake_call_control)
|
||||
monkeypatch.setattr(system_router, "_call_control", _fake_call_control)
|
||||
|
||||
# The topic explorer talks to the broker via MQTT. Fake the session
|
||||
# registry + publish/retained so the endpoints can be exercised
|
||||
# without a live broker.
|
||||
from app.routers import topics as topics_router
|
||||
from app import explorer as explorer_module
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, id, username, topic_filter):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.topic_filter = topic_filter
|
||||
|
||||
class FakeSessions:
|
||||
def __init__(self):
|
||||
self._sessions = {}
|
||||
self._counter = 0
|
||||
def sweep(self):
|
||||
pass
|
||||
def start(self, username, topic_filter):
|
||||
self._counter += 1
|
||||
sid = f"fake-{self._counter}"
|
||||
self._sessions[sid] = FakeSession(sid, username, topic_filter)
|
||||
return self._sessions[sid]
|
||||
def get(self, session_id):
|
||||
if session_id not in self._sessions:
|
||||
raise KeyError(session_id)
|
||||
return self._sessions[session_id]
|
||||
def stop(self, session_id):
|
||||
self._sessions.pop(session_id, None)
|
||||
def stop_all(self):
|
||||
self._sessions.clear()
|
||||
|
||||
fake_sessions = FakeSessions()
|
||||
monkeypatch.setattr(explorer_module, "sessions", fake_sessions)
|
||||
|
||||
def fake_messages_since(session_id, index):
|
||||
return []
|
||||
|
||||
def fake_list_retained(max_results=200, timeout=3.0):
|
||||
return [
|
||||
{
|
||||
"index": 0,
|
||||
"topic": "site/config/level",
|
||||
"qos": 1,
|
||||
"retain": True,
|
||||
"received_at": 12345.0,
|
||||
"payload_b64": "MTIz",
|
||||
"payload_text": "123",
|
||||
"properties": None,
|
||||
}
|
||||
]
|
||||
|
||||
def fake_publish(topic, payload, qos, retain, timeout=10.0):
|
||||
fake_publish.calls.append((topic, payload, qos, retain))
|
||||
|
||||
fake_publish.calls = []
|
||||
monkeypatch.setattr(explorer_module, "messages_since", fake_messages_since)
|
||||
monkeypatch.setattr(explorer_module, "list_retained", fake_list_retained)
|
||||
monkeypatch.setattr(explorer_module, "publish_message", fake_publish)
|
||||
# The router imports names from the module: patch there too.
|
||||
monkeypatch.setattr(topics_router.explorer, "sessions", fake_sessions)
|
||||
monkeypatch.setattr(topics_router.explorer, "messages_since", fake_messages_since)
|
||||
monkeypatch.setattr(topics_router.explorer, "list_retained", fake_list_retained)
|
||||
monkeypatch.setattr(topics_router.explorer, "publish_message", fake_publish)
|
||||
|
||||
with TestClient(main_module.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.conftest import login
|
||||
|
||||
|
||||
@@ -239,6 +241,113 @@ def test_broker_status_endpoint(admin_client, auditor_client):
|
||||
metrics.reset()
|
||||
|
||||
|
||||
# --- Topic explorer (spec sections 35, 36) ------------------------------------
|
||||
|
||||
|
||||
def test_topic_explorer_rbac(auditor_client):
|
||||
"""Subscribe/stream/retained need OPERATOR+; publish/clear need
|
||||
BROKER_ADMIN (spec section 35: access restricted through
|
||||
management RBAC)."""
|
||||
# Auditor may not even subscribe (read-only role is not OPERATOR).
|
||||
assert (
|
||||
auditor_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#"}).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
auditor_client.get("/api/v1/topics/retained").status_code == 403
|
||||
)
|
||||
assert (
|
||||
auditor_client.post(
|
||||
"/api/v1/topics/publish", json={"topic": "t", "payload_b64": ""}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_topic_explorer_flow(admin_client):
|
||||
"""Operator-level (admin is superuser) subscribe/stream/retained."""
|
||||
sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#"})
|
||||
assert sub.status_code == 200
|
||||
session_id = sub.json()["session_id"]
|
||||
|
||||
# The stream endpoint exists and is SSE. NOTE: consuming the stream
|
||||
# itself would hang (infinite generator by design) — verify the
|
||||
# session's liveness through the subscribe/404 contract instead:
|
||||
# an unknown session must 404, a live one was accepted above.
|
||||
missing = admin_client.get("/api/v1/topics/stream/does-not-exist")
|
||||
assert missing.status_code == 404
|
||||
|
||||
# Retained listing works.
|
||||
retained = admin_client.get("/api/v1/topics/retained")
|
||||
assert retained.status_code == 200
|
||||
assert any(m["topic"] == "site/config/level" for m in retained.json())
|
||||
|
||||
|
||||
def test_topic_explorer_publish_audited(admin_client, db_session):
|
||||
"""Publishes through the test console are audited (spec section 35),
|
||||
and wildcards are rejected."""
|
||||
import base64
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
payload = {"cmd": "restart"}
|
||||
response = admin_client.post(
|
||||
"/api/v1/topics/publish",
|
||||
json={
|
||||
"topic": "site/test/command",
|
||||
"payload_b64": base64.b64encode(json.dumps(payload).encode()).decode(),
|
||||
"qos": 1,
|
||||
"retain": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
events = db_session.scalars(
|
||||
select(AuditEvent).where(AuditEvent.action == "TOPIC_PUBLISHED")
|
||||
).all()
|
||||
assert any(e.object_id == "site/test/command" for e in events)
|
||||
# The audit row records metadata, never the payload content.
|
||||
for e in events:
|
||||
if e.object_id == "site/test/command":
|
||||
assert e.new_value["qos"] == 1
|
||||
assert "payload" not in json.dumps(e.new_value).lower()
|
||||
|
||||
# Wildcards cannot be published to.
|
||||
wildcard = admin_client.post(
|
||||
"/api/v1/topics/publish",
|
||||
json={"topic": "site/+/command", "payload_b64": "", "qos": 0},
|
||||
)
|
||||
assert wildcard.status_code == 400
|
||||
|
||||
# A mis-placed # is rejected on subscribe too.
|
||||
bad = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#/deep"})
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_topic_explorer_clear_retained(admin_client, db_session):
|
||||
"""Clearing a retained message publishes a zero-length retained
|
||||
payload and audits RETAINED_CLEARED (spec section 36)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
cleared = admin_client.post(
|
||||
"/api/v1/topics/retained/clear", json={"topic": "site/config/level"}
|
||||
)
|
||||
assert cleared.status_code == 204
|
||||
events = db_session.scalars(
|
||||
select(AuditEvent).where(AuditEvent.action == "RETAINED_CLEARED")
|
||||
).all()
|
||||
assert any(e.object_id == "site/config/level" for e in events)
|
||||
|
||||
|
||||
def test_topic_explorer_stream_404_for_unknown_session(admin_client):
|
||||
missing = admin_client.get("/api/v1/topics/stream/does-not-exist")
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_role_acl_lifecycle(admin_client):
|
||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||
|
||||
|
||||
@@ -17,4 +17,17 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Server-Sent Events (topic explorer): buffering would hold stream
|
||||
# chunks until the buffer fills — disable it and allow long-lived
|
||||
# connections. The backend also sends X-Accel-Buffering: no.
|
||||
location /api/v1/topics/stream/ {
|
||||
proxy_pass http://mqtt-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -11,6 +11,7 @@ import { BrokerConfigPage } from "./pages/BrokerConfig";
|
||||
import { CertificatesPage } from "./pages/Certificates";
|
||||
import { SystemPage } from "./pages/System";
|
||||
import { StatusPage } from "./pages/Status";
|
||||
import { TopicExplorerPage } from "./pages/TopicExplorer";
|
||||
|
||||
// Navigation structure per spec section 57. Entries with a `to` route are
|
||||
// implemented; entries without one are planned pages, rendered greyed out
|
||||
@@ -52,7 +53,7 @@ const NAV_SECTIONS: {
|
||||
{
|
||||
title: "Tools",
|
||||
items: [
|
||||
{ label: "Topic Explorer", hint: "Test console (post-MVP)" },
|
||||
{ label: "Topic Explorer", to: "/topics" },
|
||||
{ label: "Connection Test", hint: "Connectivity tester (post-MVP)" },
|
||||
{ label: "Client Provisioning", hint: "Provisioning wizard (post-MVP)" },
|
||||
],
|
||||
@@ -254,6 +255,14 @@ export function App() {
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/topics"
|
||||
element={
|
||||
<Protected>
|
||||
<TopicExplorerPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/clients" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -248,4 +248,29 @@ export interface Revision {
|
||||
export interface SystemStatus {
|
||||
broker_alive: boolean;
|
||||
restart_required: boolean;
|
||||
}
|
||||
|
||||
export interface SubscribeResponse {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface ExplorerMessage {
|
||||
index: number;
|
||||
topic: string;
|
||||
qos: number;
|
||||
retain: boolean;
|
||||
received_at: number;
|
||||
payload_b64: string;
|
||||
payload_text: string | null;
|
||||
properties: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface RetainedMessage {
|
||||
index: number;
|
||||
topic: string;
|
||||
qos: number;
|
||||
retain: boolean;
|
||||
received_at: number;
|
||||
payload_b64: string;
|
||||
payload_text: string | null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1174,6 +1174,23 @@ set retained flag
|
||||
inspect MQTT 5 properties
|
||||
```
|
||||
|
||||
## 35.1 Consolidated topic view
|
||||
|
||||
The live message view shall be organized as ONE ROW PER KNOWN TOPIC
|
||||
(rather than one row per received message), so an active topic tree is
|
||||
browsable at a glance. For each row:
|
||||
|
||||
* on receiving a new message, the row shall HIGHLIGHT THE DIFFERENCES
|
||||
between the new message and the previously known message for that
|
||||
topic (payload, QoS, retain flag and MQTT 5 properties that changed)
|
||||
* the row shall GLOW briefly when a new message arrives, so the user
|
||||
notices activity without watching every row
|
||||
|
||||
The full KNOWN HISTORY of each topic shall be browsable: selecting a
|
||||
topic row opens its message history (most recent first), from which any
|
||||
entry can be inspected in detail (payload as text/hex, QoS, retain
|
||||
flag, MQTT 5 properties, arrival time).
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
|
||||
Reference in New Issue
Block a user