meshai/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
malice 11bac716d0
feat(dashboard): send-test-message + MeshCore channel list (#14)
Add POST /api/mesh/test-send (fire a labeled test broadcast on a chosen
mesh+channel via the live transport) and GET /api/meshcore/channels
(surface the companion's enumerated channel names). "Send test message"
cards on both Connection pages, with the MeshCore one listing real
channels. Lets the operator confirm a mesh's send path on demand.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:06:31 -06:00

277 lines
9.6 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { TextInput, NumberInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
// Only the fields this page edits are typed explicitly; the rest of the
// connection config (Meshtastic type / serial / tcp) is preserved untouched on
// save via object spread.
interface ConnectionConfig {
type?: string
serial_port?: string
tcp_host?: string
tcp_port?: number
meshcore_host?: string
meshcore_port?: number
[key: string]: unknown
}
export default function MeshCoreConnection() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
// Test send state
const [channelsActive, setChannelsActive] = useState(false)
const [channels, setChannels] = useState<string[]>([])
const [selectedChannel, setSelectedChannel] = useState('')
const [testText, setTestText] = useState('')
const [testSending, setTestSending] = useState(false)
const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null)
const fetchConfig = useCallback(async () => {
setLoading(true)
try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig
setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data)))
setHasChanges(false)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
document.title = 'MeshCore Connection - MeshAI'
fetchConfig()
}, [fetchConfig])
useEffect(() => {
getMeshcoreChannels()
.then((res) => {
setChannelsActive(res.active)
setChannels(res.channels)
if (res.channels.length > 0) setSelectedChannel(res.channels[0])
})
.catch(() => {
setChannelsActive(false)
})
}, [])
const handleTestSend = async () => {
setTestSending(true)
setTestResult(null)
try {
const result = await sendTestMessage({
transport: 'meshcore',
channel: selectedChannel,
text: testText.trim() || undefined,
})
setTestResult(result)
} catch (err) {
setTestResult({ sent: false, detail: err instanceof Error ? err.message : 'Send failed' })
} finally {
setTestSending(false)
}
}
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
}
}, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const upd = (patch: Partial<ConnectionConfig>) =>
setConfig((c) => (c ? { ...c, ...patch } : c))
const saveConfig = async () => {
if (!config) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole connection object so Meshtastic fields are preserved.
const result = await apiUpdateConfig('connection', config)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false)
setDirty(false)
setSuccess('MeshCore connection saved successfully')
if (result.restart_required) {
notifyRestartRequired([])
}
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)))
setHasChanges(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading MeshCore connection...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load connection config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
MeshCore node connection.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Form */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<p className="text-xs text-slate-500">
Set the host and port to enable MeshCore; leave host blank to disable.
Meshtastic is always active.
</p>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={config.meshcore_host ?? ''}
onChange={(v) => upd({ meshcore_host: v })}
placeholder="192.168.1.100"
helper="IP or hostname — leave blank to disable MeshCore"
info="MeshCore is active when this field is non-empty."
/>
<NumberInput
label="MeshCore Port"
value={config.meshcore_port ?? 5525}
onChange={(v) => upd({ meshcore_port: v })}
min={1}
max={65535}
helper="MeshCore TCP port (default 5525)"
/>
</div>
<div className="pt-2">
<Link
to="/meshtastic/connection"
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
>
&rarr; Meshtastic connection
</Link>
</div>
</div>
{/* Send test message card */}
<div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}>
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
{!channelsActive ? (
<p className="text-sm text-slate-500">MeshCore not connected</p>
) : (
<>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Channel</label>
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{channels.map((ch) => (
<option key={ch} value={ch}>{ch}</option>
))}
</select>
</div>
<TextInput
label="Message (optional)"
value={testText}
onChange={setTestText}
placeholder={`\u{1F9EA} MeshAI test — ${new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}`}
/>
<button
onClick={handleTestSend}
disabled={testSending}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white text-sm transition-colors"
>
{testSending ? 'Sending...' : 'Send test'}
</button>
{testResult && (
testResult.sent ? (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />{testResult.detail}
</div>
) : (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{testResult.detail}</div>
)
)}
</>
)}
</div>
</div>
)
}