mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
This commit is contained in:
parent
47b56adab5
commit
11bac716d0
8 changed files with 425 additions and 3 deletions
|
|
@ -481,3 +481,26 @@ export async function fetchHotspots(): Promise<HotspotsResponse> {
|
|||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
|
||||
export interface MeshcoreChannels { active: boolean; channels: string[] }
|
||||
export interface TestSendResult { sent: boolean; detail: string }
|
||||
|
||||
export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
|
||||
return fetchJson<MeshcoreChannels>('/api/meshcore/channels')
|
||||
}
|
||||
|
||||
export async function sendTestMessage(body: {
|
||||
transport: 'meshtastic' | 'meshcore'
|
||||
channel: string | number
|
||||
text?: string
|
||||
}): Promise<TestSendResult> {
|
||||
const response = await fetch('/api/mesh/test-send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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 } from '@/lib/api'
|
||||
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
|
||||
|
|
@ -29,6 +29,14 @@ export default function MeshCoreConnection() {
|
|||
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 {
|
||||
|
|
@ -49,6 +57,35 @@ export default function MeshCoreConnection() {
|
|||
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))
|
||||
|
|
@ -190,6 +227,51 @@ export default function MeshCoreConnection() {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
|
||||
import { ConnectionSection, type ConnectionConfig } from './Config'
|
||||
import { ConnectionSection, TextInput, NumberInput, type ConnectionConfig } from './Config'
|
||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
|
||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
|
||||
import { useDirty } from '@/context/DirtyContext'
|
||||
|
||||
export default function MeshtasticConnection() {
|
||||
|
|
@ -15,6 +15,29 @@ export default function MeshtasticConnection() {
|
|||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// 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 {
|
||||
|
|
@ -143,6 +166,41 @@ export default function MeshtasticConnection() {
|
|||
<div className="bg-bg-card border border-border p-6">
|
||||
<ConnectionSection data={config} onChange={setConfig} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue