mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
277 lines
9.6 KiB
TypeScript
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"
|
|
>
|
|
→ 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>
|
|
)
|
|
}
|