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:
parent
e53ff84761
commit
26c16e952d
1 changed files with 214 additions and 38 deletions
|
|
@ -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<string, MqttTreeNode>; 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<string | null>(null)
|
||||
const [mqttTopic, setMqttTopic] = useState('#')
|
||||
const [mqttOutput, setMqttOutput] = useState('')
|
||||
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) {
|
||||
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' && (
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -770,28 +810,44 @@ export function AdminMonitor({ user }: { user: User }) {
|
|||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
<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
|
||||
onClick={captureMqtt}
|
||||
disabled={mqttCapturing}
|
||||
disabled={mqttCapturing || mqttAuto}
|
||||
style={{
|
||||
background: 'var(--gold)', color: 'var(--navy)', border: 'none',
|
||||
borderRadius: '6px', padding: '0.4rem 1rem', fontSize: '0.82rem', fontWeight: 700,
|
||||
opacity: mqttCapturing ? 0.6 : 1,
|
||||
cursor: mqttCapturing ? 'default' : 'pointer',
|
||||
opacity: (mqttCapturing || mqttAuto) ? 0.6 : 1,
|
||||
cursor: (mqttCapturing || mqttAuto) ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{mqttCapturing ? 'Capturing…' : 'Capture 8s'}
|
||||
</button>
|
||||
{mqttOutput && (
|
||||
{mqttMessages.length > 0 && (
|
||||
<button
|
||||
onClick={() => setMqttOutput('')}
|
||||
onClick={() => setMqttMessages([])}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid var(--card-border)',
|
||||
borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.8rem',
|
||||
|
|
@ -802,23 +858,39 @@ export function AdminMonitor({ user }: { user: User }) {
|
|||
</button>
|
||||
)}
|
||||
</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
|
||||
inspector identity, then returns whatever was published — not a live stream. Retained
|
||||
messages (most device telemetry) appear immediately even with no fresh activity.
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{(['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>
|
||||
<pre
|
||||
ref={mqttTermRef}
|
||||
style={{
|
||||
flex: 1, background: '#0f172a', borderRadius: '8px',
|
||||
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>
|
||||
|
||||
{mqttView === 'tree'
|
||||
? <MqttTreeView tree={mqttTree} />
|
||||
: <MqttFeedView messages={mqttMessages} feedRef={mqttFeedRef} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -826,3 +898,107 @@ export function AdminMonitor({ user }: { user: User }) {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue