Add MQTT tab (broker capture) and MQTT Clients admin page
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 <noreply@anthropic.com>
This commit is contained in:
parent
dd49d00a84
commit
e53ff84761
2 changed files with 345 additions and 5 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { PageShell } from '../components/PageShell'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ interface BackupRun {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminMonitor({ user }: { user: User }) {
|
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<AppStatus[]>([])
|
const [statuses, setStatuses] = useState<AppStatus[]>([])
|
||||||
const [deploys, setDeploys] = useState<DeployEntry[]>([])
|
const [deploys, setDeploys] = useState<DeployEntry[]>([])
|
||||||
const [health, setHealth] = useState<HealthStatus[]>([])
|
const [health, setHealth] = useState<HealthStatus[]>([])
|
||||||
|
|
@ -103,6 +103,10 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
const [backupLoading, setBackupLoading] = useState(false)
|
const [backupLoading, setBackupLoading] = useState(false)
|
||||||
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 [mqttOutput, setMqttOutput] = useState('')
|
||||||
|
const [mqttCapturing, setMqttCapturing] = useState(false)
|
||||||
|
const mqttTermRef = useRef<HTMLPreElement>(null)
|
||||||
|
|
||||||
async function fetchStatus(force = false) {
|
async function fetchStatus(force = false) {
|
||||||
setLoading(true)
|
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() {
|
async function fetchBackupRuns() {
|
||||||
setBackupLoading(true)
|
setBackupLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -227,6 +265,10 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight
|
if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight
|
||||||
}, [shellOutput])
|
}, [shellOutput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mqttTermRef.current) mqttTermRef.current.scrollTop = mqttTermRef.current.scrollHeight
|
||||||
|
}, [mqttOutput])
|
||||||
|
|
||||||
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
|
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
|
@ -235,6 +277,7 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
{ key: 'deploys' as const, label: 'Deploy Log', icon: <ScrollText size={14} strokeWidth={1.75} /> },
|
{ key: 'deploys' as const, label: 'Deploy Log', icon: <ScrollText size={14} strokeWidth={1.75} /> },
|
||||||
{ key: 'backups' as const, label: 'Backups', icon: <HardDrive size={14} strokeWidth={1.75} /> },
|
{ key: 'backups' as const, label: 'Backups', icon: <HardDrive size={14} strokeWidth={1.75} /> },
|
||||||
{ key: 'shell' as const, label: 'Shell', icon: <TerminalSquare size={14} strokeWidth={1.75} /> },
|
{ key: 'shell' as const, label: 'Shell', icon: <TerminalSquare size={14} strokeWidth={1.75} /> },
|
||||||
|
{ key: 'mqtt' as const, label: 'MQTT', icon: <Radio size={14} strokeWidth={1.75} /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -716,6 +759,69 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>Topic filter</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mqttTopic}
|
||||||
|
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)',
|
||||||
|
borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.82rem',
|
||||||
|
color: 'var(--text-dark)', fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={captureMqtt}
|
||||||
|
disabled={mqttCapturing}
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mqttCapturing ? 'Capturing…' : 'Capture 8s'}
|
||||||
|
</button>
|
||||||
|
{mqttOutput && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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
|
||||||
|
</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.
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ interface AppRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSettings({ user }: { user: User }) {
|
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<Integration[]>([])
|
const [integrations, setIntegrations] = useState<Integration[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ export function AdminSettings({ user }: { user: User }) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
|
||||||
{(['integrations', 'rooms', 'apps', 'device'] as const).map(t => (
|
{(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients'] as const).map(t => (
|
||||||
<button key={t} onClick={() => setTab(t)} style={{
|
<button key={t} onClick={() => setTab(t)} style={{
|
||||||
background: 'none', border: 'none', padding: '0.5rem 1rem',
|
background: 'none', border: 'none', padding: '0.5rem 1rem',
|
||||||
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
|
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',
|
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
|
||||||
marginBottom: '-1px',
|
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'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,6 +99,7 @@ export function AdminSettings({ user }: { user: User }) {
|
||||||
{tab === 'rooms' && <RoomsTab />}
|
{tab === 'rooms' && <RoomsTab />}
|
||||||
{tab === 'apps' && <AppsTab />}
|
{tab === 'apps' && <AppsTab />}
|
||||||
{tab === 'device' && <DeviceTab />}
|
{tab === 'device' && <DeviceTab />}
|
||||||
|
{tab === 'mqtt-clients' && <MqttClientsTab />}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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<MqttClient[]>([])
|
||||||
|
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 (
|
||||||
|
<div style={{ maxWidth: '680px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.15rem' }}>
|
||||||
|
MQTT Broker Clients
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
|
||||||
|
Per-device/app broker logins, scoped to a topic filter — no more manually running mosquitto_ctrl.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { 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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={create} style={{
|
||||||
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
|
||||||
|
padding: '1.25rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '0.85rem',
|
||||||
|
}}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ fontSize: '0.82rem', color: '#dc2626', display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
<XCircle size={14} strokeWidth={1.75} /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>Name</span>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Kitchen Flash Reader" style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>Topic scope</span>
|
||||||
|
<input value={topicScope} onChange={e => setTopicScope(e.target.value)}
|
||||||
|
placeholder="e.g. utilities/kitchen-meter/# or shellies/#" style={{ ...inputStyle, fontFamily: 'monospace' }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '1.5rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
|
||||||
|
<input type="checkbox" checked={canPublish} onChange={e => setCanPublish(e.target.checked)} /> Can publish
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
|
||||||
|
<input type="checkbox" checked={canSubscribe} onChange={e => setCanSubscribe(e.target.checked)} /> Can subscribe
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={creating} style={{
|
||||||
|
background: 'var(--gold)', color: 'var(--navy)', border: 'none',
|
||||||
|
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 700,
|
||||||
|
cursor: creating ? 'wait' : 'pointer', opacity: creating ? 0.6 : 1,
|
||||||
|
}}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{created && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
|
||||||
|
padding: '1.25rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)',
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.5rem' }}>
|
||||||
|
Client created — copy this password now, it won't be shown again
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
|
||||||
|
Username: <code style={{ background: 'var(--surface-2)', padding: '0.1em 0.4em', borderRadius: 3 }}>{created.username}</code>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<code style={{
|
||||||
|
flex: 1, fontSize: '0.8rem', color: 'var(--text-dark)', background: 'var(--body-bg)',
|
||||||
|
border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.55rem 0.75rem',
|
||||||
|
wordBreak: 'break-all', fontFamily: 'monospace',
|
||||||
|
}}>{created.password}</code>
|
||||||
|
<button onClick={copyPassword} style={{
|
||||||
|
background: copied ? '#16a34a' : 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||||
|
borderRadius: '5px', padding: '0.4rem 0.7rem', fontSize: '0.78rem', fontWeight: 600,
|
||||||
|
cursor: 'pointer', color: copied ? '#fff' : 'var(--text-dark)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.3rem', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{copied ? <><Check size={12} strokeWidth={2} /> Copied</> : <><Copy size={12} strokeWidth={1.75} /> Copy</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setCreated(null)} style={{
|
||||||
|
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '0.78rem',
|
||||||
|
cursor: 'pointer', marginTop: '0.75rem', padding: 0, textDecoration: 'underline',
|
||||||
|
}}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: 'var(--text-mid)' }}>Loading…</p>
|
||||||
|
) : clients.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-mid)', fontSize: '0.88rem' }}>No MQTT clients yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
|
{clients.map(c => (
|
||||||
|
<div key={c.id} style={{
|
||||||
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
||||||
|
borderLeft: `3px solid ${c.active ? '#16a34a' : 'var(--card-border)'}`,
|
||||||
|
borderRadius: 'var(--radius)', padding: '0.85rem 1rem',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)', marginBottom: '0.2rem' }}>
|
||||||
|
{c.name}
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontFamily: 'monospace', fontSize: '0.78rem', color: 'var(--text-mid)', fontWeight: 400 }}>
|
||||||
|
{c.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
|
||||||
|
{c.topic_scope}
|
||||||
|
<span style={{ marginLeft: '0.6rem', fontFamily: 'sans-serif' }}>
|
||||||
|
{[c.can_publish && 'publish', c.can_subscribe && 'subscribe'].filter(Boolean).join(' + ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0, display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{c.active ? (
|
||||||
|
<button onClick={() => 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
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)' }}>Revoked</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => removeClient(c)} style={{
|
||||||
|
background: 'transparent', color: 'var(--danger)', border: 'none',
|
||||||
|
fontSize: '1.1rem', cursor: 'pointer', padding: '0 0.3rem', lineHeight: 1,
|
||||||
|
}} title="Delete record">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||||
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue