diff --git a/src/pages/AdminMonitor.tsx b/src/pages/AdminMonitor.tsx index 2365b0e..b8c9db2 100644 --- a/src/pages/AdminMonitor.tsx +++ b/src/pages/AdminMonitor.tsx @@ -26,6 +26,9 @@ function relativeTime(iso: string | null | undefined): string | null { return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) } +interface MqttMessage { topic: string; payload: string; ts: number } +interface MqttTreeNode { children: Record; value?: string; ts?: number } + interface StatRange { total: number; used: number } interface HealthStatus { @@ -104,9 +107,12 @@ export function AdminMonitor({ user }: { user: User }) { const [backupTriggering, setBackupTriggering] = useState(false) const [expandedRun, setExpandedRun] = useState(null) const [mqttTopic, setMqttTopic] = useState('#') - const [mqttOutput, setMqttOutput] = useState('') const [mqttCapturing, setMqttCapturing] = useState(false) - const mqttTermRef = useRef(null) + const [mqttAuto, setMqttAuto] = useState(false) + const [mqttView, setMqttView] = useState<'tree' | 'feed'>('tree') + const [mqttMessages, setMqttMessages] = useState([]) + const [mqttError, setMqttError] = useState('') + const mqttFeedRef = useRef(null) async function fetchStatus(force = false) { setLoading(true) @@ -169,16 +175,19 @@ export function AdminMonitor({ user }: { user: User }) { const MQTT_TOPIC_RE = /^[a-zA-Z0-9/_+#-]+$/ - // One-shot capture (not a live stream — /deploy/exec is request/response), - // same mechanism as the Shell tab: SSH into the broker LXC and run a bounded - // mosquitto_sub. Uses a read-only "mqtt-inspector" dynsec identity whose - // credentials live only in a file on the broker LXC itself - // (/opt/mqtt-broker/inspector-credentials.env) — never sent to the browser. + // Not a genuine live stream — /deploy/exec is request/response, so this + // repeats a bounded 8s mosquitto_sub capture (same mechanism as the Shell + // tab: SSH into the broker LXC) and merges whatever came back into + // mqttMessages, which the tree/feed views render from. With "Auto-refresh" + // on it re-runs every ~9s, giving an MQTT-Explorer-ish feel without a + // WebSocket listener on the broker. Uses a read-only "mqtt-inspector" + // dynsec identity whose credentials live only in a file on the broker LXC + // itself (/opt/mqtt-broker/inspector-credentials.env) — never sent here. async function captureMqtt() { const topic = mqttTopic.trim() || '#' if (!MQTT_TOPIC_RE.test(topic) || mqttCapturing) return setMqttCapturing(true) - setMqttOutput(prev => prev + `[mqtt-broker] capturing 8s on "${topic}"…\n`) + setMqttError('') try { const cmd = `source /opt/mqtt-broker/inspector-credentials.env && timeout 8 docker run --rm --network container:hotel-manage-mqtt-broker eclipse-mosquitto:2 mosquitto_sub -h 127.0.0.1 -p 1883 -u "$MQTT_INSPECTOR_USER" -P "$MQTT_INSPECTOR_PASS" -t '${topic}' -v` const res = await fetch('/deploy/exec', { @@ -189,18 +198,39 @@ export function AdminMonitor({ user }: { user: User }) { }) const data = await res.json() if (!res.ok) { - setMqttOutput(prev => prev + `Error: ${data.error}\n\n`) + setMqttError(data.error || 'Capture failed') } else { - const out = [data.stdout, data.stderr].filter(Boolean).join('') - setMqttOutput(prev => prev + (out.trim() || '(no messages received)') + '\n\n') + const now = Date.now() + const lines = (data.stdout || '').split('\n').map((l: string) => l.trim()).filter(Boolean) + const parsed: MqttMessage[] = lines.map((line: string) => { + const sp = line.indexOf(' ') + return sp === -1 + ? { topic: line, payload: '', ts: now } + : { topic: line.slice(0, sp), payload: line.slice(sp + 1), ts: now } + }) + if (parsed.length) setMqttMessages(prev => [...prev, ...parsed].slice(-1000)) } } catch (e: any) { - setMqttOutput(prev => prev + `Request failed: ${e.message}\n\n`) + setMqttError(e.message || 'Request failed') } finally { setMqttCapturing(false) } } + // Latest value per topic, built from the flat message log — this is what + // the tree view renders (an MQTT Explorer "state" view, not a raw log). + const mqttTree = (() => { + const root: MqttTreeNode = { children: {} } + for (const m of mqttMessages) { + let node = root + for (const seg of m.topic.split('/').filter(Boolean)) { + node = node.children[seg] ??= { children: {} } + } + if (!node.ts || m.ts >= node.ts) { node.value = m.payload; node.ts = m.ts } + } + return root + })() + async function fetchBackupRuns() { setBackupLoading(true) try { @@ -266,8 +296,18 @@ export function AdminMonitor({ user }: { user: User }) { }, [shellOutput]) useEffect(() => { - if (mqttTermRef.current) mqttTermRef.current.scrollTop = mqttTermRef.current.scrollHeight - }, [mqttOutput]) + if (mqttFeedRef.current) mqttFeedRef.current.scrollTop = mqttFeedRef.current.scrollHeight + }, [mqttMessages]) + + // Auto-refresh: re-capture every 9s (8s capture + a short gap) while this + // tab is open and the toggle is on. + useEffect(() => { + if (!mqttAuto || tab !== 'mqtt') return + captureMqtt() + const id = setInterval(captureMqtt, 9_000) + return () => clearInterval(id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mqttAuto, tab, mqttTopic]) const updatesAvailable = statuses.filter(s => s.updateAvailable).length @@ -762,7 +802,7 @@ export function AdminMonitor({ user }: { user: User }) { {/* MQTT tab */} {tab === 'mqtt' && (
-
+
Topic filter setMqttTopic(e.target.value)} onKeyDown={e => e.key === 'Enter' && captureMqtt()} placeholder="e.g. utilities/water-softener/# or #" - disabled={mqttCapturing} style={{ - flex: 1, background: 'var(--card-bg)', border: '1px solid var(--card-border)', + flex: 1, minWidth: '200px', background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.82rem', color: 'var(--text-dark)', fontFamily: 'monospace', }} /> + - {mqttOutput && ( + {mqttMessages.length > 0 && ( )}
-

- Each capture listens for 8 seconds against the shared broker (LXC 104) using a read-only - inspector identity, then returns whatever was published — not a live stream. Retained - messages (most device telemetry) appear immediately even with no fresh activity. + +

+
+ {(['tree', 'feed'] as const).map(v => ( + + ))} +
+ + {mqttMessages.length} message{mqttMessages.length !== 1 ? 's' : ''} buffered + +
+ + {mqttError && ( +
{mqttError}
+ )} + +

+ Not a true live stream — each refresh is an 8s capture against the broker (LXC 104), repeated + automatically when Auto-refresh is on. Tree shows each topic's latest known value; retained + messages (most device telemetry) appear immediately even on the first capture.

-
-              {mqttOutput || Set a topic filter and click Capture…}
-            
+ + {mqttView === 'tree' + ? + : + }
)} @@ -826,3 +898,107 @@ export function AdminMonitor({ user }: { user: User }) { ) } + +// MQTT Explorer-style topic tree — collapsible by segment, shows each leaf's +// latest known value + how long ago it was last seen. +function MqttTreeView({ tree }: { tree: MqttTreeNode }) { + const [collapsed, setCollapsed] = useState>(new Set()) + + function toggle(path: string) { + setCollapsed(prev => { + const next = new Set(prev) + next.has(path) ? next.delete(path) : next.add(path) + return next + }) + } + + function renderNode(name: string, node: MqttTreeNode, path: string, depth: number) { + const hasChildren = Object.keys(node.children).length > 0 + const isCollapsed = collapsed.has(path) + return ( +
+
hasChildren && toggle(path)} + style={{ + display: 'flex', alignItems: 'center', gap: '0.4rem', + padding: '0.3rem 0.5rem', paddingLeft: `${0.5 + depth * 1.1}rem`, + fontSize: '0.8rem', cursor: hasChildren ? 'pointer' : 'default', + borderBottom: '1px solid var(--card-border)', + }} + > + {hasChildren + ? (isCollapsed + ? + : ) + : + } + + {name} + + {node.value !== undefined && ( + <> + + {node.value || (empty)} + + + {node.ts ? relativeTime(new Date(node.ts).toISOString()) : ''} + + + )} +
+ {hasChildren && !isCollapsed && Object.entries(node.children) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([seg, child]) => renderNode(seg, child, `${path}/${seg}`, depth + 1)) + } +
+ ) + } + + const entries = Object.entries(tree.children).sort(([a], [b]) => a.localeCompare(b)) + if (entries.length === 0) { + return ( +

+ No messages yet — click Capture or enable Auto-refresh. +

+ ) + } + return ( +
+ {entries.map(([seg, child]) => renderNode(seg, child, seg, 0))} +
+ ) +} + +// Chronological raw feed — every message seen this session, oldest first +// (auto-scrolls to the newest at the bottom). +function MqttFeedView({ messages, feedRef }: { messages: MqttMessage[]; feedRef: React.RefObject }) { + return ( +
+ {messages.length === 0 ? ( +
+ No messages yet — click Capture or enable Auto-refresh. +
+ ) : ( + messages.map((m, i) => ( +
+ {new Date(m.ts).toLocaleTimeString()} + {m.topic} + {m.payload} +
+ )) + )} +
+ ) +}