meshai/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx
malice 6e1831b606
refactor(channels): move channel info onto the connection pages + expose MeshCore channel key (#104)
Remove the standalone Channels page and its nav entry (avoids menu bloat)
and relocate the channel listings onto the existing connection pages:
MeshCore channels (name, on-air hash, and the channel key/PSK with a copy
button) on the MeshCore companion page, and Meshtastic channels (index,
name, role) on the Meshtastic connection page. The MeshCore transport now
retains the channel_secret it already fetches at enumeration and
/api/meshcore/channels/detail returns it as `key` (hex PSK) so operators
can provision companion radios. known_channels() and /api/meshcore/channels
are unchanged.

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

369 lines
14 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react'
import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfig } from './Config'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage, getChannels, type MeshtasticChannel } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
// Only the fields the "Bot behavior" section edits are typed explicitly; the
// rest of each section (max_age, max_context_items, MQTT fields, bot name/owner,
// etc.) is preserved untouched on save via object spread.
interface ContextCfg {
enabled?: boolean
observe_channels?: number[]
ignore_nodes?: string[]
[key: string]: unknown
}
interface BotCfg {
respond_to_dms?: boolean
[key: string]: unknown
}
export default function MeshtasticConnection() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
// Bot behavior: passive-context (section `context`) + respond-to-DMs (section `bot`)
const [context, setContext] = useState<ContextCfg | null>(null)
const [originalContext, setOriginalContext] = useState<ContextCfg | null>(null)
const [bot, setBot] = useState<BotCfg | null>(null)
const [originalBot, setOriginalBot] = useState<BotCfg | 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)
// Read-only channel listing (index ↔ name ↔ role), fetched from the radio.
const [channels, setChannels] = useState<MeshtasticChannel[]>([])
// Test send state
const [testChannel, setTestChannel] = useState(0)
const [testText, setTestText] = useState('')
const [testSending, setTestSending] = useState(false)
const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null)
const handleTestSend = async () => {
setTestSending(true)
setTestResult(null)
try {
const result = await sendTestMessage({
transport: 'meshtastic',
channel: testChannel,
text: testText.trim() || undefined,
})
setTestResult(result)
} catch (err) {
setTestResult({ sent: false, detail: err instanceof Error ? err.message : 'Send failed' })
} finally {
setTestSending(false)
}
}
const fetchConfig = useCallback(async () => {
setLoading(true)
try {
const [conn, ctx, botData] = await Promise.all([
apiFetchConfig('connection') as Promise<ConnectionConfig>,
apiFetchConfig('context') as Promise<ContextCfg>,
apiFetchConfig('bot') as Promise<BotCfg>,
])
setConfig(conn)
setOriginalConfig(JSON.parse(JSON.stringify(conn)))
setContext(ctx)
setOriginalContext(JSON.parse(JSON.stringify(ctx)))
setBot(botData)
setOriginalBot(JSON.parse(JSON.stringify(botData)))
setHasChanges(false)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
document.title = 'Meshtastic Connection - MeshAI'
fetchConfig()
getChannels()
.then(setChannels)
.catch(() => setChannels([]))
}, [fetchConfig])
useEffect(() => {
if (config && originalConfig && context && originalContext && bot && originalBot) {
const changed =
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
JSON.stringify(context) !== JSON.stringify(originalContext) ||
JSON.stringify(bot) !== JSON.stringify(originalBot)
setHasChanges(changed)
}
}, [config, originalConfig, context, originalContext, bot, originalBot])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveConfig = async () => {
if (!config || !context || !bot) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole objects so sibling fields (MeshCore connection fields,
// context max_age/max_context_items, bot name/owner) aren't clobbered.
const results = await Promise.all([
apiUpdateConfig('connection', config),
apiUpdateConfig('context', context),
apiUpdateConfig('bot', bot),
])
setOriginalConfig(JSON.parse(JSON.stringify(config)))
setOriginalContext(JSON.parse(JSON.stringify(context)))
setOriginalBot(JSON.parse(JSON.stringify(bot)))
setHasChanges(false)
setDirty(false)
setSuccess('Meshtastic connection saved successfully')
if (results.some((r) => r.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)))
if (originalContext) setContext(JSON.parse(JSON.stringify(originalContext)))
if (originalBot) setBot(JSON.parse(JSON.stringify(originalBot)))
setHasChanges(false)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading Meshtastic 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">
Connection to your Meshtastic radio (serial or TCP).
</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">
<ConnectionSection data={config} onChange={setConfig} />
</div>
{/* Advanced — reconnect & packet tuning */}
<div className="bg-bg-card border border-border p-6">
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
Advanced Reconnect &amp; Packet Tuning
</summary>
<div className="mt-4 space-y-4 pl-6 border-l border-[#1e2a3a]">
<Toggle
label="Auto-reconnect"
checked={config.reconnect ?? true}
onChange={(v) => setConfig({ ...config, reconnect: v })}
helper="Automatically reconnect if the mesh link drops"
/>
<NumberInput
label="Reconnect Initial Delay (s)"
value={config.reconnect_initial_delay ?? 2}
onChange={(v) => setConfig({ ...config, reconnect_initial_delay: v })}
min={0}
step={0.5}
helper="Backoff delay before the first reconnect attempt"
/>
<NumberInput
label="Reconnect Max Delay (s)"
value={config.reconnect_max_delay ?? 60}
onChange={(v) => setConfig({ ...config, reconnect_max_delay: v })}
min={0}
helper="Ceiling for exponential reconnect backoff"
/>
<NumberInput
label="Reconnect Health Interval (s)"
value={config.reconnect_health_interval ?? 30}
onChange={(v) => setConfig({ ...config, reconnect_health_interval: v })}
min={1}
helper="How often the socket-probe watchdog checks link health"
/>
<NumberInput
label="Mesh Max Chars"
value={config.mesh_max_chars ?? 140}
onChange={(v) => setConfig({ ...config, mesh_max_chars: v })}
min={1}
helper="Per-packet character budget for the transport"
/>
</div>
</details>
</div>
{/* Bot behavior card */}
{context && bot && (
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
<Toggle
label="Enable Passive Context"
checked={!!context.enabled}
onChange={(v) => setContext({ ...context, enabled: v })}
helper="Listen to channel traffic for context"
info="When enabled, the bot monitors mesh channels and includes recent messages in its context. This lets the bot reference things other people said on the channel."
/>
<ChannelPicker
label="Observe Channels"
value={context.observe_channels ?? []}
onChange={(v) => setContext({ ...context, observe_channels: v })}
helper="Channels to monitor (empty = all)"
info="Meshtastic channels to listen on. Leave empty to monitor all channels."
mode="multi"
/>
<NodePicker
label="Ignore Nodes"
value={context.ignore_nodes ?? []}
onChange={(v) => setContext({ ...context, ignore_nodes: v })}
helper="Nodes to exclude from context"
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
/>
<Toggle
label="Answer direct messages"
checked={!!bot.respond_to_dms}
onChange={(v) => setBot({ ...bot, respond_to_dms: v })}
helper="When on, MeshAI replies to Meshtastic direct messages using the LLM. Applies to Meshtastic only."
/>
</div>
)}
{/* Channels card — read-only Index · Name · Role */}
<div className="bg-bg-card border border-border">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-slate-200">Channels</h3>
<p className="text-xs text-[#555] mt-1">
Channels configured on the radio. Meshtastic routes by channel index. Read-only.
</p>
</div>
{channels.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-[#161616] border-b border-border">
<tr>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Index</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Role</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{channels.map((ch) => (
<tr key={ch.index} className="hover:bg-bg-hover">
<td className="px-3 py-2 font-mono text-xs">{ch.index}</td>
<td className="px-3 py-2">{ch.name}</td>
<td className="px-3 py-2 text-xs text-[#999]">{ch.role}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="px-4 py-3 text-sm text-[#777]">Node offline channels unavailable</div>
)}
</div>
{/* Send test message card */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
<NumberInput
label="Channel Index"
value={testChannel}
onChange={setTestChannel}
min={0}
max={7}
helper="Meshtastic channel number (0 = primary)"
/>
<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>
)
}