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', + }} + /> + + {mqttOutput && ( + + )} +
+

+ 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 => ( ))}
@@ -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. +

+
+ +
+ + {showForm && ( +
+ {error && ( +
+ {error} +
+ )} + + +
+ + +
+
+ +
+
+ )} + + {created && ( +
+

+ Client created — copy this password now, it won't be shown again +

+
+
+ Username: {created.username} +
+
+ {created.password} + +
+
+ +
+ )} + + {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 ? ( + + ) : ( + Revoked + )} + +
+
+ ))} +
+ )} +
+ ) +} + const inputStyle: React.CSSProperties = { background: 'var(--body-bg)', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',