mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
Fix event-loop starvation, MeshCore stability, config-page hardening
- mesh_data_store.py / env/store.py: make refresh() async, offload blocking polls via asyncio.to_thread/gather so 7 lockstep sources no longer starve the shared event loop. - main.py: gather pollers concurrently + set_default_executor thread pool. - Dockerfile / docker-compose.yml: healthcheck now curls the dashboard for a real liveness signal instead of a process-exists check. - transport/meshcore_transport.py: MeshCore keepalive loop (get_time() every 120s), reconnect re-arm (_post_reconnect_setup_async from _on_connect_event), and MC channel-name normalization (_resolve_mc_channel_idx strips a leading #). - dashboard-frontend: MeshCoreConnection.tsx config-page hardening, new ErrorBoundary component, wired into App.tsx. - tests: fix ~40 call sites broken by refresh() becoming async ( test_generic_http.py, test_store_received_delta.py, test_store_wzdx_persist.py) by wrapping with asyncio.run(), matching this suite's existing convention for calling async code from sync test functions. Verified: all 40 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c281c96e2
commit
c5aa0e1f42
12 changed files with 462 additions and 129 deletions
|
|
@ -18,12 +18,14 @@ import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
|||
import Coverage from './pages/Coverage'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { DirtyProvider } from './context/DirtyContext'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DirtyProvider>
|
||||
<ToastProvider>
|
||||
<Layout>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
{/* Core routes */}
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
|
|
@ -66,6 +68,7 @@ function App() {
|
|||
<Route path="/meshcore/companion" element={<Navigate to="/meshcore/contacts" replace />} />
|
||||
<Route path="/meshcore/danger-zones" element={<MeshCoreDangerZones />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Layout>
|
||||
</ToastProvider>
|
||||
</DirtyProvider>
|
||||
|
|
|
|||
49
work/dashboard-frontend/src/components/ErrorBoundary.tsx
Normal file
49
work/dashboard-frontend/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// App-wide render-error safety net. Wraps <Routes> in App.tsx so an
|
||||
// unhandled error thrown while rendering any page degrades to a recoverable
|
||||
// "Something went wrong" card instead of a blank white screen.
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('MeshAI dashboard render error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="bg-bg-card border border-red-500/20 rounded p-6 max-w-md w-full text-center space-y-3">
|
||||
<AlertTriangle className="mx-auto text-red-400" size={28} />
|
||||
<div className="text-slate-200 font-medium">Something went wrong</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{this.state.error?.message ?? 'An unexpected error occurred while rendering this page.'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-accent hover:bg-accent/80 rounded text-white text-sm transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy } from 'lucide-react'
|
||||
import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy, X } from 'lucide-react'
|
||||
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
|
||||
import SerialPortPicker from '@/components/SerialPortPicker'
|
||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||
|
|
@ -58,6 +58,9 @@ export default function MeshCoreConnection() {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
// Set when one or both config fetches fail; the form still renders (using
|
||||
// safe defaults for whatever didn't load) instead of blanking the page.
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
// Test send state
|
||||
const [channelsActive, setChannelsActive] = useState(false)
|
||||
|
|
@ -81,22 +84,41 @@ export default function MeshCoreConnection() {
|
|||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [data, mcCtx] = await Promise.all([
|
||||
apiFetchConfig('connection') as Promise<ConnectionConfig>,
|
||||
apiFetchConfig('meshcore_context') as Promise<MeshcoreContextCfg>,
|
||||
])
|
||||
setConfig(data)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(data)))
|
||||
setMcContext(mcCtx)
|
||||
setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx)))
|
||||
setHasChanges(false)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// Promise.allSettled so one section failing to load can't blank the
|
||||
// whole page — each section is set independently from its own result,
|
||||
// and any failure(s) are surfaced as a dismissible banner alongside the
|
||||
// still-usable form (see the `!config` dead-end this replaces).
|
||||
const [connResult, mcResult] = await Promise.allSettled([
|
||||
apiFetchConfig('connection') as Promise<ConnectionConfig>,
|
||||
apiFetchConfig('meshcore_context') as Promise<MeshcoreContextCfg>,
|
||||
])
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
if (connResult.status === 'fulfilled') {
|
||||
setConfig(connResult.value)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(connResult.value)))
|
||||
} else {
|
||||
errors.push(`connection (${connResult.reason instanceof Error ? connResult.reason.message : String(connResult.reason)})`)
|
||||
// Fall back to an empty object (never null) so the form below always
|
||||
// has something to render safe defaults from, and so the dirty-check
|
||||
// diff below still has a baseline to compare edits against.
|
||||
setConfig((c) => c ?? {})
|
||||
setOriginalConfig((c) => c ?? {})
|
||||
}
|
||||
|
||||
if (mcResult.status === 'fulfilled') {
|
||||
setMcContext(mcResult.value)
|
||||
setOriginalMcContext(JSON.parse(JSON.stringify(mcResult.value)))
|
||||
} else {
|
||||
errors.push(`bot behavior (${mcResult.reason instanceof Error ? mcResult.reason.message : String(mcResult.reason)})`)
|
||||
// Leave mcContext null — the "Bot behavior" card only renders when it
|
||||
// is present, so a null value just hides that card cleanly.
|
||||
}
|
||||
|
||||
setLoadError(errors.length ? `Couldn't load ${errors.join(' and ')}. Showing defaults — edits below are still safe to make and save.` : null)
|
||||
setHasChanges(false)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -207,10 +229,13 @@ export default function MeshCoreConnection() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (config && originalConfig && mcContext && originalMcContext) {
|
||||
// mcContext may be null (its fetch failed) — don't let that block
|
||||
// detecting changes to the connection fields, which always load or
|
||||
// fall back to an empty-object baseline in fetchConfig.
|
||||
if (config && originalConfig) {
|
||||
const changed =
|
||||
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
|
||||
JSON.stringify(mcContext) !== JSON.stringify(originalMcContext)
|
||||
(!!mcContext && !!originalMcContext && JSON.stringify(mcContext) !== JSON.stringify(originalMcContext))
|
||||
setHasChanges(changed)
|
||||
}
|
||||
}, [config, originalConfig, mcContext, originalMcContext])
|
||||
|
|
@ -221,22 +246,24 @@ export default function MeshCoreConnection() {
|
|||
}, [hasChanges, setDirty])
|
||||
|
||||
const upd = (patch: Partial<ConnectionConfig>) =>
|
||||
setConfig((c) => (c ? { ...c, ...patch } : c))
|
||||
// Build off an empty object rather than bailing when config is still
|
||||
// null (e.g. mid-retry) — edits should never be silently dropped.
|
||||
setConfig((c) => ({ ...(c ?? {}), ...patch }))
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config || !mcContext) return
|
||||
if (!config) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
// PUT the whole objects so sibling fields (Meshtastic connection fields,
|
||||
// any other meshcore_context keys) are preserved.
|
||||
const results = await Promise.all([
|
||||
apiUpdateConfig('connection', config),
|
||||
apiUpdateConfig('meshcore_context', mcContext),
|
||||
])
|
||||
// any other meshcore_context keys) are preserved. Only PUT
|
||||
// meshcore_context if it actually loaded — its fetch may have failed.
|
||||
const puts: Promise<{ restart_required?: boolean }>[] = [apiUpdateConfig('connection', config)]
|
||||
if (mcContext) puts.push(apiUpdateConfig('meshcore_context', mcContext))
|
||||
const results = await Promise.all(puts)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
||||
setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
|
||||
if (mcContext) setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
|
||||
setHasChanges(false)
|
||||
setDirty(false)
|
||||
setSuccess('MeshCore connection saved successfully')
|
||||
|
|
@ -276,13 +303,10 @@ export default function MeshCoreConnection() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-400">Failed to load connection config</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Always render the form below, even if the fetch failed — `cfg` supplies
|
||||
// safe defaults so the connection-type/host/port fields and Save/Discard
|
||||
// stay usable. The failure itself is surfaced by the loadError banner.
|
||||
const cfg: ConnectionConfig = config ?? {}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
|
|
@ -320,6 +344,30 @@ export default function MeshCoreConnection() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load-error banner — dismissible, with its own Retry so the page
|
||||
never dead-ends when a config section fails to fetch. The form
|
||||
below stays fully editable regardless. */}
|
||||
{loadError && (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20 flex items-start gap-3">
|
||||
<div className="flex-1">{loadError}</div>
|
||||
<button
|
||||
onClick={fetchConfig}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-red-500/20 hover:bg-red-500/30 rounded text-red-300 text-xs shrink-0"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLoadError(null)}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss load error"
|
||||
className="text-red-400 hover:text-red-200 shrink-0"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status messages */}
|
||||
{error && (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
|
||||
|
|
@ -341,7 +389,7 @@ export default function MeshCoreConnection() {
|
|||
</p>
|
||||
<SelectInput
|
||||
label="Connection Type"
|
||||
value={config.meshcore_conn_type ?? 'tcp'}
|
||||
value={cfg.meshcore_conn_type ?? 'tcp'}
|
||||
onChange={(v) => upd({ meshcore_conn_type: v })}
|
||||
options={[
|
||||
{ value: 'tcp', label: 'TCP (companion)' },
|
||||
|
|
@ -350,11 +398,11 @@ export default function MeshCoreConnection() {
|
|||
]}
|
||||
helper="TCP for a companion frame server, Serial for a USB node, BLE for Bluetooth"
|
||||
/>
|
||||
{(config.meshcore_conn_type ?? 'tcp') === 'tcp' && (
|
||||
{(cfg.meshcore_conn_type ?? 'tcp') === 'tcp' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TextInput
|
||||
label="MeshCore Host"
|
||||
value={config.meshcore_host ?? ''}
|
||||
value={cfg.meshcore_host ?? ''}
|
||||
onChange={(v) => upd({ meshcore_host: v })}
|
||||
placeholder="192.168.1.100"
|
||||
helper="IP or hostname of the companion frame server"
|
||||
|
|
@ -362,7 +410,7 @@ export default function MeshCoreConnection() {
|
|||
/>
|
||||
<NumberInput
|
||||
label="MeshCore Port"
|
||||
value={config.meshcore_port ?? 5525}
|
||||
value={cfg.meshcore_port ?? 5525}
|
||||
onChange={(v) => upd({ meshcore_port: v })}
|
||||
min={1}
|
||||
max={65535}
|
||||
|
|
@ -370,27 +418,27 @@ export default function MeshCoreConnection() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{(config.meshcore_conn_type ?? 'tcp') === 'serial' && (
|
||||
{(cfg.meshcore_conn_type ?? 'tcp') === 'serial' && (
|
||||
<>
|
||||
<SerialPortPicker
|
||||
label="MeshCore Serial Port"
|
||||
value={config.meshcore_serial_port ?? ''}
|
||||
value={cfg.meshcore_serial_port ?? ''}
|
||||
onChange={(v) => upd({ meshcore_serial_port: v })}
|
||||
helper="USB-attached MeshCore node — Detect fills a stable by-id path"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Baud Rate"
|
||||
value={config.meshcore_baud ?? 115200}
|
||||
value={cfg.meshcore_baud ?? 115200}
|
||||
onChange={(v) => upd({ meshcore_baud: v })}
|
||||
min={1200}
|
||||
helper="Serial baud rate (default 115200)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(config.meshcore_conn_type ?? 'tcp') === 'ble' && (
|
||||
{(cfg.meshcore_conn_type ?? 'tcp') === 'ble' && (
|
||||
<TextInput
|
||||
label="BLE Address"
|
||||
value={config.meshcore_ble_address ?? ''}
|
||||
value={cfg.meshcore_ble_address ?? ''}
|
||||
onChange={(v) => upd({ meshcore_ble_address: v })}
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
helper="Leave blank to scan/pair the first available device"
|
||||
|
|
@ -406,7 +454,7 @@ export default function MeshCoreConnection() {
|
|||
</div>
|
||||
<Toggle
|
||||
label="Auto-add contacts (AIDA adds any node it hears — required to DM anyone)"
|
||||
checked={config.meshcore_auto_add_contacts ?? true}
|
||||
checked={cfg.meshcore_auto_add_contacts ?? true}
|
||||
onChange={(v) => upd({ meshcore_auto_add_contacts: v })}
|
||||
helper="Enables firmware CMD 58 (set_autoadd_config) at connect so AIDA automatically adds every node it hears an advert from as a contact, enabling DM send/decrypt without manual contact exchange"
|
||||
/>
|
||||
|
|
@ -418,13 +466,13 @@ export default function MeshCoreConnection() {
|
|||
<div className="mt-4 space-y-4 pl-6 border-l border-[#1e2a3a]">
|
||||
<Toggle
|
||||
label="Auto-reconnect (MeshCore)"
|
||||
checked={config.meshcore_auto_reconnect ?? true}
|
||||
checked={cfg.meshcore_auto_reconnect ?? true}
|
||||
onChange={(v) => upd({ meshcore_auto_reconnect: v })}
|
||||
helper="Automatically reconnect to the MeshCore companion if the link drops"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max Reconnect Attempts"
|
||||
value={config.meshcore_max_reconnect_attempts ?? 5}
|
||||
value={cfg.meshcore_max_reconnect_attempts ?? 5}
|
||||
onChange={(v) => upd({ meshcore_max_reconnect_attempts: v })}
|
||||
min={0}
|
||||
helper="Maximum reconnect attempts before giving up (0 = unlimited)"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue