From e53ff8476188bd8e8fd246987694287a07ff00de Mon Sep 17 00:00:00 2001
From: jtricerolph
Date: Tue, 28 Jul 2026 17:11:50 +0000
Subject: [PATCH] Add MQTT tab (broker capture) and MQTT Clients admin page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AdminMonitor: new "MQTT" tab reuses the existing Shell tab's /deploy/exec
mechanism to run a bounded (8s) mosquitto_sub capture against the broker
LXC via a read-only "mqtt-inspector" identity — a one-shot capture, not a
live stream, since /deploy/exec is request/response. Retained messages
(most device telemetry) show immediately regardless.
AdminSettings: new "MQTT Clients" tab — create/list/revoke broker
identities via the new settings API, matching forecasting's API Keys
show-once-password UX. Removes the need to hand-run mosquitto_ctrl for
every new device or app that needs broker access.
Co-Authored-By: Claude Sonnet 5
---
src/pages/AdminMonitor.tsx | 110 ++++++++++++++++-
src/pages/AdminSettings.tsx | 240 +++++++++++++++++++++++++++++++++++-
2 files changed, 345 insertions(+), 5 deletions(-)
diff --git a/src/pages/AdminMonitor.tsx b/src/pages/AdminMonitor.tsx
index acea8a8..2365b0e 100644
--- a/src/pages/AdminMonitor.tsx
+++ b/src/pages/AdminMonitor.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
-import { ArrowUpCircle, Activity, ScrollText, RefreshCw, TerminalSquare, HardDrive, ChevronDown, ChevronRight, Play } from 'lucide-react'
+import { ArrowUpCircle, Activity, ScrollText, RefreshCw, TerminalSquare, HardDrive, ChevronDown, ChevronRight, Play, Radio } from 'lucide-react'
import { PageShell } from '../components/PageShell'
import type { User } from '../types'
@@ -87,7 +87,7 @@ interface BackupRun {
}
export function AdminMonitor({ user }: { user: User }) {
- const [tab, setTab] = useState<'updates' | 'states' | 'deploys' | 'shell' | 'backups'>('updates')
+ const [tab, setTab] = useState<'updates' | 'states' | 'deploys' | 'shell' | 'backups' | 'mqtt'>('updates')
const [statuses, setStatuses] = useState([])
const [deploys, setDeploys] = useState([])
const [health, setHealth] = useState([])
@@ -103,6 +103,10 @@ export function AdminMonitor({ user }: { user: User }) {
const [backupLoading, setBackupLoading] = useState(false)
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)
async function fetchStatus(force = false) {
setLoading(true)
@@ -163,6 +167,40 @@ 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.
+ 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`)
+ 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', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ host: '10.10.10.104', command: cmd }),
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ setMqttOutput(prev => prev + `Error: ${data.error}\n\n`)
+ } else {
+ const out = [data.stdout, data.stderr].filter(Boolean).join('')
+ setMqttOutput(prev => prev + (out.trim() || '(no messages received)') + '\n\n')
+ }
+ } catch (e: any) {
+ setMqttOutput(prev => prev + `Request failed: ${e.message}\n\n`)
+ } finally {
+ setMqttCapturing(false)
+ }
+ }
+
async function fetchBackupRuns() {
setBackupLoading(true)
try {
@@ -227,6 +265,10 @@ export function AdminMonitor({ user }: { user: User }) {
if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight
}, [shellOutput])
+ useEffect(() => {
+ if (mqttTermRef.current) mqttTermRef.current.scrollTop = mqttTermRef.current.scrollHeight
+ }, [mqttOutput])
+
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
const tabs = [
@@ -235,6 +277,7 @@ export function AdminMonitor({ user }: { user: User }) {
{ key: 'deploys' as const, label: 'Deploy Log', icon: },
{ key: 'backups' as const, label: 'Backups', icon: },
{ key: 'shell' as const, label: 'Shell', icon: },
+ { key: 'mqtt' as const, label: 'MQTT', icon: },
]
return (
@@ -716,6 +759,69 @@ 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)',
+ borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.82rem',
+ color: 'var(--text-dark)', fontFamily: 'monospace',
+ }}
+ />
+
+ {mqttCapturing ? 'Capturing…' : 'Capture 8s'}
+
+ {mqttOutput && (
+ setMqttOutput('')}
+ style={{
+ background: 'transparent', border: '1px solid var(--card-border)',
+ borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.8rem',
+ color: 'var(--text-mid)', cursor: 'pointer',
+ }}
+ >
+ Clear
+
+ )}
+
+
+ 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.
+
+
+ {mqttOutput || Set a topic filter and click Capture… }
+
+
+ )}
+
)
diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx
index 1331590..2efd701 100644
--- a/src/pages/AdminSettings.tsx
+++ b/src/pages/AdminSettings.tsx
@@ -52,7 +52,7 @@ interface AppRow {
}
export function AdminSettings({ user }: { user: User }) {
- const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device'>('integrations')
+ const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device' | 'mqtt-clients'>('integrations')
const [integrations, setIntegrations] = useState([])
const [loading, setLoading] = useState(true)
@@ -71,7 +71,7 @@ export function AdminSettings({ user }: { user: User }) {
- {(['integrations', 'rooms', 'apps', 'device'] as const).map(t => (
+ {(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients'] as const).map(t => (
setTab(t)} style={{
background: 'none', border: 'none', padding: '0.5rem 1rem',
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
@@ -79,7 +79,7 @@ export function AdminSettings({ user }: { user: User }) {
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
marginBottom: '-1px',
}}>
- {t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : t === 'apps' ? 'App Settings' : 'This Device'}
+ {t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : t === 'apps' ? 'App Settings' : t === 'device' ? 'This Device' : 'MQTT Clients'}
))}
@@ -99,6 +99,7 @@ export function AdminSettings({ user }: { user: User }) {
{tab === 'rooms' && }
{tab === 'apps' && }
{tab === 'device' && }
+ {tab === 'mqtt-clients' && }
)
}
@@ -920,6 +921,239 @@ function DeviceTab() {
)
}
+interface MqttClient {
+ id: number
+ name: string
+ username: string
+ rolename: string
+ topic_scope: string
+ can_publish: boolean
+ can_subscribe: boolean
+ active: boolean
+ created_at: string
+ revoked_at: string | null
+}
+
+function MqttClientsTab() {
+ const [clients, setClients] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showForm, setShowForm] = useState(false)
+ const [name, setName] = useState('')
+ const [topicScope, setTopicScope] = useState('')
+ const [canPublish, setCanPublish] = useState(false)
+ const [canSubscribe, setCanSubscribe] = useState(true)
+ const [creating, setCreating] = useState(false)
+ const [error, setError] = useState('')
+ const [created, setCreated] = useState<{ username: string; password: string } | null>(null)
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => { load() }, [])
+
+ async function load() {
+ setLoading(true)
+ const res = await fetch('/settings/api/mqtt-clients', { credentials: 'include' })
+ if (res.ok) setClients(await res.json())
+ setLoading(false)
+ }
+
+ async function create(e: React.FormEvent) {
+ e.preventDefault()
+ setError('')
+ if (!name.trim()) { setError('Name is required'); return }
+ if (!topicScope.trim()) { setError('Topic scope is required'); return }
+ if (!canPublish && !canSubscribe) { setError('Enable at least one of publish or subscribe'); return }
+ setCreating(true)
+ try {
+ const res = await fetch('/settings/api/mqtt-clients', {
+ method: 'POST', credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(), topic_scope: topicScope.trim(),
+ can_publish: canPublish, can_subscribe: canSubscribe,
+ }),
+ })
+ const data = await res.json()
+ if (!res.ok) { setError(data.error || 'Failed to create client'); return }
+ setCreated({ username: data.username, password: data.password })
+ setShowForm(false)
+ setName(''); setTopicScope(''); setCanPublish(false); setCanSubscribe(true)
+ load()
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ async function revoke(c: MqttClient) {
+ if (!confirm(`Revoke MQTT client "${c.name}" (${c.username})? It will lose broker access immediately.`)) return
+ const res = await fetch(`/settings/api/mqtt-clients/${c.id}/revoke`, { method: 'POST', credentials: 'include' })
+ if (res.ok) load()
+ }
+
+ async function removeClient(c: MqttClient) {
+ if (!confirm(`Permanently delete the record for "${c.name}"? This cannot be undone.`)) return
+ const res = await fetch(`/settings/api/mqtt-clients/${c.id}`, { method: 'DELETE', credentials: 'include' })
+ if (res.ok) load()
+ }
+
+ function copyPassword() {
+ if (!created) return
+ navigator.clipboard.writeText(created.password)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+
+
+
+
+ MQTT Broker Clients
+
+
+ Per-device/app broker logins, scoped to a topic filter — no more manually running mosquitto_ctrl.
+
+
+
{ setShowForm(!showForm); setError('') }} style={{
+ background: 'var(--navy)', color: '#fff', border: 'none',
+ borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600, cursor: 'pointer',
+ }}>
+ {showForm ? 'Cancel' : 'New Client'}
+
+
+
+ {showForm && (
+
+ )}
+
+ {created && (
+
+
+ Client created — copy this password now, it won't be shown again
+
+
+
+ Username: {created.username}
+
+
+ {created.password}
+
+ {copied ? <> Copied> : <> Copy>}
+
+
+
+
setCreated(null)} style={{
+ background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '0.78rem',
+ cursor: 'pointer', marginTop: '0.75rem', padding: 0, textDecoration: 'underline',
+ }}>
+ Dismiss
+
+
+ )}
+
+ {loading ? (
+
Loading…
+ ) : clients.length === 0 ? (
+
No MQTT clients yet.
+ ) : (
+
+ {clients.map(c => (
+
+
+
+ {c.name}
+
+ {c.username}
+
+
+
+ {c.topic_scope}
+
+ {[c.can_publish && 'publish', c.can_subscribe && 'subscribe'].filter(Boolean).join(' + ')}
+
+
+
+
+ {c.active ? (
+ revoke(c)} style={{
+ background: 'transparent', color: 'var(--text-mid)',
+ border: '1px solid var(--card-border)', borderRadius: '6px',
+ padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer',
+ }}>
+ Revoke
+
+ ) : (
+ Revoked
+ )}
+ removeClient(c)} style={{
+ background: 'transparent', color: 'var(--danger)', border: 'none',
+ fontSize: '1.1rem', cursor: 'pointer', padding: '0 0.3rem', lineHeight: 1,
+ }} title="Delete record">
+ ×
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
const inputStyle: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',