MQTT tab: topic tree + feed views, auto-refresh

Replaces the flat scrolling capture log with a structured message buffer
(mqttMessages) rendered two ways: a collapsible topic tree showing each
topic's latest value (MQTT-Explorer-ish), and a chronological feed. An
"Auto-refresh" toggle repeats the existing 8s /deploy/exec capture every
~9s and merges results in, rather than requiring a manual click each time.

Still not a genuine live stream (no WebSocket listener on the broker) —
just polling dressed up to feel closer to one, without touching the
broker or NPM config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 17:38:04 +00:00
parent e53ff84761
commit 26c16e952d

View file

@ -26,6 +26,9 @@ function relativeTime(iso: string | null | undefined): string | null {
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' })
} }
interface MqttMessage { topic: string; payload: string; ts: number }
interface MqttTreeNode { children: Record<string, MqttTreeNode>; value?: string; ts?: number }
interface StatRange { total: number; used: number } interface StatRange { total: number; used: number }
interface HealthStatus { interface HealthStatus {
@ -104,9 +107,12 @@ export function AdminMonitor({ user }: { user: User }) {
const [backupTriggering, setBackupTriggering] = useState(false) const [backupTriggering, setBackupTriggering] = useState(false)
const [expandedRun, setExpandedRun] = useState<string | null>(null) const [expandedRun, setExpandedRun] = useState<string | null>(null)
const [mqttTopic, setMqttTopic] = useState('#') const [mqttTopic, setMqttTopic] = useState('#')
const [mqttOutput, setMqttOutput] = useState('')
const [mqttCapturing, setMqttCapturing] = useState(false) const [mqttCapturing, setMqttCapturing] = useState(false)
const mqttTermRef = useRef<HTMLPreElement>(null) const [mqttAuto, setMqttAuto] = useState(false)
const [mqttView, setMqttView] = useState<'tree' | 'feed'>('tree')
const [mqttMessages, setMqttMessages] = useState<MqttMessage[]>([])
const [mqttError, setMqttError] = useState('')
const mqttFeedRef = useRef<HTMLDivElement>(null)
async function fetchStatus(force = false) { async function fetchStatus(force = false) {
setLoading(true) setLoading(true)
@ -169,16 +175,19 @@ export function AdminMonitor({ user }: { user: User }) {
const MQTT_TOPIC_RE = /^[a-zA-Z0-9/_+#-]+$/ const MQTT_TOPIC_RE = /^[a-zA-Z0-9/_+#-]+$/
// One-shot capture (not a live stream — /deploy/exec is request/response), // Not a genuine live stream — /deploy/exec is request/response, so this
// same mechanism as the Shell tab: SSH into the broker LXC and run a bounded // repeats a bounded 8s mosquitto_sub capture (same mechanism as the Shell
// mosquitto_sub. Uses a read-only "mqtt-inspector" dynsec identity whose // tab: SSH into the broker LXC) and merges whatever came back into
// credentials live only in a file on the broker LXC itself // mqttMessages, which the tree/feed views render from. With "Auto-refresh"
// (/opt/mqtt-broker/inspector-credentials.env) — never sent to the browser. // 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() { async function captureMqtt() {
const topic = mqttTopic.trim() || '#' const topic = mqttTopic.trim() || '#'
if (!MQTT_TOPIC_RE.test(topic) || mqttCapturing) return if (!MQTT_TOPIC_RE.test(topic) || mqttCapturing) return
setMqttCapturing(true) setMqttCapturing(true)
setMqttOutput(prev => prev + `[mqtt-broker] capturing 8s on "${topic}"…\n`) setMqttError('')
try { 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 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', { const res = await fetch('/deploy/exec', {
@ -189,18 +198,39 @@ export function AdminMonitor({ user }: { user: User }) {
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
setMqttOutput(prev => prev + `Error: ${data.error}\n\n`) setMqttError(data.error || 'Capture failed')
} else { } else {
const out = [data.stdout, data.stderr].filter(Boolean).join('') const now = Date.now()
setMqttOutput(prev => prev + (out.trim() || '(no messages received)') + '\n\n') 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) { } catch (e: any) {
setMqttOutput(prev => prev + `Request failed: ${e.message}\n\n`) setMqttError(e.message || 'Request failed')
} finally { } finally {
setMqttCapturing(false) 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() { async function fetchBackupRuns() {
setBackupLoading(true) setBackupLoading(true)
try { try {
@ -266,8 +296,18 @@ export function AdminMonitor({ user }: { user: User }) {
}, [shellOutput]) }, [shellOutput])
useEffect(() => { useEffect(() => {
if (mqttTermRef.current) mqttTermRef.current.scrollTop = mqttTermRef.current.scrollHeight if (mqttFeedRef.current) mqttFeedRef.current.scrollTop = mqttFeedRef.current.scrollHeight
}, [mqttOutput]) }, [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 const updatesAvailable = statuses.filter(s => s.updateAvailable).length
@ -762,7 +802,7 @@ export function AdminMonitor({ user }: { user: User }) {
{/* MQTT tab */} {/* MQTT tab */}
{tab === 'mqtt' && ( {tab === 'mqtt' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '1.5rem 2rem' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '1.5rem 2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem', flexShrink: 0, flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.78rem', color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>Topic filter</span> <span style={{ fontSize: '0.78rem', color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>Topic filter</span>
<input <input
type="text" type="text"
@ -770,28 +810,44 @@ export function AdminMonitor({ user }: { user: User }) {
onChange={e => setMqttTopic(e.target.value)} onChange={e => setMqttTopic(e.target.value)}
onKeyDown={e => e.key === 'Enter' && captureMqtt()} onKeyDown={e => e.key === 'Enter' && captureMqtt()}
placeholder="e.g. utilities/water-softener/# or #" placeholder="e.g. utilities/water-softener/# or #"
disabled={mqttCapturing}
style={{ 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', borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.82rem',
color: 'var(--text-dark)', fontFamily: 'monospace', color: 'var(--text-dark)', fontFamily: 'monospace',
}} }}
/> />
<button
onClick={() => setMqttAuto(a => !a)}
style={{
background: mqttAuto ? '#16a34a' : 'var(--card-bg)',
color: mqttAuto ? '#fff' : 'var(--text-dark)',
border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.4rem 0.85rem', fontSize: '0.82rem', fontWeight: 600,
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem',
}}
>
<span style={{
width: 7, height: 7, borderRadius: '50%',
background: mqttAuto ? '#fff' : 'var(--text-mid)',
boxShadow: mqttAuto ? '0 0 5px #ffffffaa' : undefined,
}} />
{mqttAuto ? 'Auto-refresh on' : 'Auto-refresh off'}
</button>
<button <button
onClick={captureMqtt} onClick={captureMqtt}
disabled={mqttCapturing} disabled={mqttCapturing || mqttAuto}
style={{ style={{
background: 'var(--gold)', color: 'var(--navy)', border: 'none', background: 'var(--gold)', color: 'var(--navy)', border: 'none',
borderRadius: '6px', padding: '0.4rem 1rem', fontSize: '0.82rem', fontWeight: 700, borderRadius: '6px', padding: '0.4rem 1rem', fontSize: '0.82rem', fontWeight: 700,
opacity: mqttCapturing ? 0.6 : 1, opacity: (mqttCapturing || mqttAuto) ? 0.6 : 1,
cursor: mqttCapturing ? 'default' : 'pointer', cursor: (mqttCapturing || mqttAuto) ? 'default' : 'pointer',
}} }}
> >
{mqttCapturing ? 'Capturing…' : 'Capture 8s'} {mqttCapturing ? 'Capturing…' : 'Capture 8s'}
</button> </button>
{mqttOutput && ( {mqttMessages.length > 0 && (
<button <button
onClick={() => setMqttOutput('')} onClick={() => setMqttMessages([])}
style={{ style={{
background: 'transparent', border: '1px solid var(--card-border)', background: 'transparent', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.8rem', borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.8rem',
@ -802,23 +858,39 @@ export function AdminMonitor({ user }: { user: User }) {
</button> </button>
)} )}
</div> </div>
<p style={{ fontSize: '0.72rem', color: 'var(--text-mid)', marginBottom: '0.75rem', flexShrink: 0 }}>
Each capture listens for 8 seconds against the shared broker (LXC 104) using a read-only <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem', flexShrink: 0 }}>
inspector identity, then returns whatever was published not a live stream. Retained <div style={{ display: 'flex', gap: '0.25rem' }}>
messages (most device telemetry) appear immediately even with no fresh activity. {(['tree', 'feed'] as const).map(v => (
<button key={v} onClick={() => setMqttView(v)} style={{
background: mqttView === v ? 'var(--navy)' : 'transparent',
color: mqttView === v ? '#fff' : 'var(--text-mid)',
border: 'none', borderRadius: '5px', padding: '0.3rem 0.75rem',
fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize',
}}>
{v}
</button>
))}
</div>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>
{mqttMessages.length} message{mqttMessages.length !== 1 ? 's' : ''} buffered
</span>
</div>
{mqttError && (
<div style={{ fontSize: '0.78rem', color: 'var(--danger)', marginBottom: '0.5rem', flexShrink: 0 }}>{mqttError}</div>
)}
<p style={{ fontSize: '0.7rem', color: 'var(--text-mid)', marginBottom: '0.6rem', flexShrink: 0 }}>
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.
</p> </p>
<pre
ref={mqttTermRef} {mqttView === 'tree'
style={{ ? <MqttTreeView tree={mqttTree} />
flex: 1, background: '#0f172a', borderRadius: '8px', : <MqttFeedView messages={mqttMessages} feedRef={mqttFeedRef} />
padding: '1rem', fontSize: '0.75rem', color: '#94a3b8', }
overflowY: 'auto', margin: 0, fontFamily: 'monospace',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
border: '1px solid #1e293b',
}}
>
{mqttOutput || <span style={{ color: '#475569' }}>Set a topic filter and click Capture</span>}
</pre>
</div> </div>
)} )}
@ -826,3 +898,107 @@ export function AdminMonitor({ user }: { user: User }) {
</PageShell> </PageShell>
) )
} }
// 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<Set<string>>(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 (
<div key={path}>
<div
onClick={() => 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
? <ChevronRight size={12} strokeWidth={2} style={{ color: 'var(--text-mid)', flexShrink: 0 }} />
: <ChevronDown size={12} strokeWidth={2} style={{ color: 'var(--text-mid)', flexShrink: 0 }} />)
: <span style={{ width: 12, flexShrink: 0 }} />
}
<span style={{ fontFamily: 'monospace', color: 'var(--text-dark)', fontWeight: hasChildren ? 600 : 400 }}>
{name}
</span>
{node.value !== undefined && (
<>
<span style={{
flex: 1, textAlign: 'right', fontFamily: 'monospace', fontSize: '0.78rem',
color: '#0369a1', wordBreak: 'break-all',
}}>
{node.value || <em style={{ color: 'var(--text-mid)' }}>(empty)</em>}
</span>
<span style={{ fontSize: '0.68rem', color: 'var(--text-mid)', flexShrink: 0, minWidth: '52px', textAlign: 'right' }}>
{node.ts ? relativeTime(new Date(node.ts).toISOString()) : ''}
</span>
</>
)}
</div>
{hasChildren && !isCollapsed && Object.entries(node.children)
.sort(([a], [b]) => a.localeCompare(b))
.map(([seg, child]) => renderNode(seg, child, `${path}/${seg}`, depth + 1))
}
</div>
)
}
const entries = Object.entries(tree.children).sort(([a], [b]) => a.localeCompare(b))
if (entries.length === 0) {
return (
<p style={{ color: 'var(--text-mid)', fontSize: '0.85rem', padding: '1rem' }}>
No messages yet click Capture or enable Auto-refresh.
</p>
)
}
return (
<div style={{
flex: 1, overflowY: 'auto', background: 'var(--card-bg)',
border: '1px solid var(--card-border)', borderRadius: '8px',
}}>
{entries.map(([seg, child]) => renderNode(seg, child, seg, 0))}
</div>
)
}
// 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<HTMLDivElement> }) {
return (
<div ref={feedRef} style={{
flex: 1, overflowY: 'auto', background: '#0f172a', borderRadius: '8px',
border: '1px solid #1e293b', fontFamily: 'monospace', fontSize: '0.75rem',
}}>
{messages.length === 0 ? (
<div style={{ padding: '1rem', color: '#475569' }}>
No messages yet click Capture or enable Auto-refresh.
</div>
) : (
messages.map((m, i) => (
<div key={i} style={{
padding: '0.3rem 0.85rem', borderBottom: '1px solid #1e293b',
display: 'flex', gap: '0.6rem',
}}>
<span style={{ color: '#64748b', flexShrink: 0 }}>{new Date(m.ts).toLocaleTimeString()}</span>
<span style={{ color: '#7dd3fc', flexShrink: 0 }}>{m.topic}</span>
<span style={{ color: '#94a3b8', wordBreak: 'break-all' }}>{m.payload}</span>
</div>
))
)}
</div>
)
}