fix(dashboard): restart URL, Enable-MeshCore toggle, unsaved-changes guard (#12)

* fix(dashboard): restart URL, Enable-MeshCore toggle, unsaved-changes guard

- RestartBanner POSTs /api/restart (was /api/system/restart -> 405)
- MeshCore Connection: replace transport-mode dropdown with an
  Enable MeshCore toggle (on=both, off=meshtastic)
- Guard unsaved edits: confirm before navigating away with pending
  changes (config pages no longer silently discard edits)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dashboard): drop stale "Transport mode" wording on MeshCore Connection header

---------

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:
malice 2026-07-02 23:41:56 -06:00 committed by GitHub
commit b260dcbae0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 153 additions and 19 deletions

View file

@ -17,9 +17,11 @@ import MeshCoreCompanion from './pages/MeshCoreCompanion'
import MeshtasticConnection from './pages/MeshtasticConnection' import MeshtasticConnection from './pages/MeshtasticConnection'
import MeshtasticSources from './pages/MeshtasticSources' import MeshtasticSources from './pages/MeshtasticSources'
import { ToastProvider } from './components/ToastProvider' import { ToastProvider } from './components/ToastProvider'
import { DirtyProvider } from './context/DirtyContext'
function App() { function App() {
return ( return (
<DirtyProvider>
<ToastProvider> <ToastProvider>
<Layout> <Layout>
<Routes> <Routes>
@ -42,6 +44,7 @@ function App() {
</Routes> </Routes>
</Layout> </Layout>
</ToastProvider> </ToastProvider>
</DirtyProvider>
) )
} }

View file

@ -1,5 +1,6 @@
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useDirty } from '@/context/DirtyContext'
import { import {
LayoutDashboard, LayoutDashboard,
Radio, Radio,
@ -91,7 +92,14 @@ function formatUptime(seconds: number): string {
// Renders a single nav <Link>. Items whose path carries a ?section= query are // Renders a single nav <Link>. Items whose path carries a ?section= query are
// matched against pathname+search so only the matching deep-link highlights. // matched against pathname+search so only the matching deep-link highlights.
function renderNavItem(item: NavItem, pathname: string, search: string) { // onNavClick: called with the target path; return true to allow navigation,
// false to block it (caller shows confirm dialog).
function renderNavItem(
item: NavItem,
pathname: string,
search: string,
onNavClick: (path: string, e: React.MouseEvent) => void,
) {
const isActive = item.path.includes('?') const isActive = item.path.includes('?')
? `${pathname}${search}` === item.path ? `${pathname}${search}` === item.path
: pathname === item.path : pathname === item.path
@ -100,6 +108,7 @@ function renderNavItem(item: NavItem, pathname: string, search: string) {
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={(e) => onNavClick(item.path, e)}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${ className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive isActive
? 'text-white bg-transparent' ? 'text-white bg-transparent'
@ -127,11 +136,23 @@ function getPageTitle(fullPath: string): string {
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const { dirty, setDirty } = useDirty()
const { connected, lastAlert } = useWebSocket() const { connected, lastAlert } = useWebSocket()
const { addToast } = useToast() const { addToast } = useToast()
const [status, setStatus] = useState<SystemStatus | null>(null) const [status, setStatus] = useState<SystemStatus | null>(null)
const [lastAlertId, setLastAlertId] = useState<string | null>(null) const [lastAlertId, setLastAlertId] = useState<string | null>(null)
const handleNavClick = (path: string, e: React.MouseEvent) => {
if (dirty) {
e.preventDefault()
if (window.confirm('You have unsaved changes. Discard them?')) {
setDirty(false)
navigate(path)
}
}
}
// Trigger toast on new alerts // Trigger toast on new alerts
useEffect(() => { useEffect(() => {
if (lastAlert) { if (lastAlert) {
@ -182,13 +203,13 @@ export default function Layout({ children }: LayoutProps) {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 py-4"> <nav className="flex-1 py-4">
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search))} {topNavItems.map((item) => renderNavItem(item, location.pathname, location.search, handleNavClick))}
{navGroups.map((group) => ( {navGroups.map((group) => (
<div key={group.header} className="mt-4"> <div key={group.header} className="mt-4">
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]"> <div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">
{group.header} {group.header}
</div> </div>
{group.items.map((item) => renderNavItem(item, location.pathname, location.search))} {group.items.map((item) => renderNavItem(item, location.pathname, location.search, handleNavClick))}
</div> </div>
))} ))}
</nav> </nav>

View file

@ -82,7 +82,7 @@ export default function RestartBanner() {
setRestarting(true) setRestarting(true)
setError(null) setError(null)
try { try {
const res = await fetch('/api/system/restart', { method: 'POST' }) const res = await fetch('/api/restart', { method: 'POST' })
if (!res.ok && res.status !== 202) { if (!res.ok && res.status !== 202) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
throw new Error(body.detail || `HTTP ${res.status}`) throw new Error(body.detail || `HTTP ${res.status}`)

View file

@ -0,0 +1,46 @@
// DirtyContext — tracks whether any config page has unsaved changes.
//
// Usage:
// - Wrap the app (or BrowserRouter) with <DirtyProvider>.
// - In any config page: import { useDirty } from '@/context/DirtyContext'
// then call setDirty(hasChanges) in a useEffect, and setDirty(false) in
// cleanup / on save.
// - In Layout.tsx nav links: check dirty before navigation and confirm.
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface DirtyContextValue {
dirty: boolean
setDirty: (v: boolean) => void
}
const DirtyContext = createContext<DirtyContextValue>({
dirty: false,
setDirty: () => {},
})
export function DirtyProvider({ children }: { children: ReactNode }) {
const [dirty, setDirty] = useState(false)
// Warn on tab close / refresh while dirty.
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [dirty])
return (
<DirtyContext.Provider value={{ dirty, setDirty }}>
{children}
</DirtyContext.Provider>
)
}
export function useDirty() {
return useContext(DirtyContext)
}

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { useDirty } from '@/context/DirtyContext'
import NodePicker from '@/components/NodePicker' import NodePicker from '@/components/NodePicker'
import ChannelPicker from '@/components/ChannelPicker' import ChannelPicker from '@/components/ChannelPicker'
import { import {
@ -1799,6 +1800,7 @@ function DashboardSection({ data, onChange }: { data: DashboardConfig; onChange:
} }
export default function Config() { export default function Config() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<FullConfig | null>(null) const [config, setConfig] = useState<FullConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null)
const [activeSection, setActiveSection] = useState<SectionKey>('bot') const [activeSection, setActiveSection] = useState<SectionKey>('bot')
@ -1846,6 +1848,11 @@ export default function Config() {
} }
}, [config, originalConfig]) }, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveSection = async () => { const saveSection = async () => {
if (!config) return if (!config) return
@ -1870,6 +1877,7 @@ export default function Config() {
setSuccess(`${activeSection} saved successfully`) setSuccess(`${activeSection} saved successfully`)
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false) setHasChanges(false)
setDirty(false)
if (result.restart_required) { if (result.restart_required) {
setRestartRequired(true) setRestartRequired(true)

View file

@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { TextInput, NumberInput, SelectInput } from './Config' import { TextInput, NumberInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
// Only the fields this page edits are typed explicitly; the rest of the // Only the fields this page edits are typed explicitly; the rest of the
// connection config (Meshtastic type / serial / tcp) is preserved untouched on // connection config (Meshtastic type / serial / tcp) is preserved untouched on
@ -20,6 +21,7 @@ interface ConnectionConfig {
} }
export default function MeshCoreConnection() { export default function MeshCoreConnection() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null) const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -54,6 +56,11 @@ export default function MeshCoreConnection() {
} }
}, [config, originalConfig]) }, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const upd = (patch: Partial<ConnectionConfig>) => const upd = (patch: Partial<ConnectionConfig>) =>
setConfig((c) => (c ? { ...c, ...patch } : c)) setConfig((c) => (c ? { ...c, ...patch } : c))
@ -67,6 +74,7 @@ export default function MeshCoreConnection() {
const result = await apiUpdateConfig('connection', config) const result = await apiUpdateConfig('connection', config)
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false) setHasChanges(false)
setDirty(false)
setSuccess('MeshCore connection saved successfully') setSuccess('MeshCore connection saved successfully')
if (result.restart_required) { if (result.restart_required) {
notifyRestartRequired([]) notifyRestartRequired([])
@ -108,7 +116,7 @@ export default function MeshCoreConnection() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Transport mode and MeshCore node connection. MeshCore node connection.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -151,18 +159,34 @@ export default function MeshCoreConnection() {
{/* Form */} {/* Form */}
<div className="bg-bg-card border border-border p-6 space-y-4"> <div className="bg-bg-card border border-border p-6 space-y-4">
<SelectInput <div className="flex items-center justify-between py-2">
label="Transport Mode" <div>
value={config.transport ?? 'meshtastic'} <span className="text-sm text-slate-300">Enable MeshCore</span>
onChange={(v) => upd({ transport: v })} <p className="text-xs text-slate-600">
options={[ Meshtastic is always on; enabling adds MeshCore (Both).
{ value: 'meshtastic', label: 'Meshtastic' }, </p>
{ value: 'meshcore', label: 'MeshCore' }, </div>
{ value: 'both', label: 'Both' }, <button
]} type="button"
helper="Which radio transport(s) MeshAI uses" onClick={() => {
info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation." const checked = !(config.transport === 'both' || config.transport === 'meshcore')
/> upd({ transport: checked ? 'both' : 'meshtastic' })
}}
className={`relative w-11 h-6 rounded-full transition-colors ${
config.transport === 'both' || config.transport === 'meshcore'
? 'bg-accent'
: 'bg-[#1e2a3a]'
}`}
>
<span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
config.transport === 'both' || config.transport === 'meshcore'
? 'translate-x-5'
: ''
}`}
/>
</button>
</div>
<div className="pt-2 border-t border-[#1e2a3a] space-y-4"> <div className="pt-2 border-t border-[#1e2a3a] space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div> <div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useDirty } from '@/context/DirtyContext'
import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink } from 'lucide-react'
import { import {
SeverityChannelMatrix, SeverityChannelMatrix,
@ -40,6 +41,7 @@ function mergeMeshcoreFields(
} }
export default function MeshCoreRouting() { export default function MeshCoreRouting() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<NotificationsConfig | null>(null) const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -75,6 +77,11 @@ export default function MeshCoreRouting() {
} }
}, [config, originalConfig]) }, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const upd = (fam: string, patch: Partial<NotificationToggle>) => { const upd = (fam: string, patch: Partial<NotificationToggle>) => {
if (!config) return if (!config) return
const toggles = config.toggles || {} const toggles = config.toggles || {}
@ -118,6 +125,7 @@ export default function MeshCoreRouting() {
setConfig(merged) setConfig(merged)
setOriginalConfig(JSON.parse(JSON.stringify(merged))) setOriginalConfig(JSON.parse(JSON.stringify(merged)))
setHasChanges(false) setHasChanges(false)
setDirty(false)
setSuccess('MeshCore routing saved successfully') setSuccess('MeshCore routing saved successfully')
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
} catch (err) { } catch (err) {

View file

@ -3,8 +3,10 @@ import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { ConnectionSection, type ConnectionConfig } from './Config' import { ConnectionSection, type ConnectionConfig } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
export default function MeshtasticConnection() { export default function MeshtasticConnection() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null) const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -39,6 +41,11 @@ export default function MeshtasticConnection() {
} }
}, [config, originalConfig]) }, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveConfig = async () => { const saveConfig = async () => {
if (!config) return if (!config) return
setSaving(true) setSaving(true)
@ -49,6 +56,7 @@ export default function MeshtasticConnection() {
const result = await apiUpdateConfig('connection', config) const result = await apiUpdateConfig('connection', config)
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false) setHasChanges(false)
setDirty(false)
setSuccess('Meshtastic connection saved successfully') setSuccess('Meshtastic connection saved successfully')
if (result.restart_required) { if (result.restart_required) {
notifyRestartRequired([]) notifyRestartRequired([])

View file

@ -8,8 +8,10 @@ import {
} from './Config' } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
export default function MeshtasticSources() { export default function MeshtasticSources() {
const { setDirty } = useDirty()
const [meshmonitor, setMeshmonitor] = useState<MeshMonitorConfig | null>(null) const [meshmonitor, setMeshmonitor] = useState<MeshMonitorConfig | null>(null)
const [originalMeshmonitor, setOriginalMeshmonitor] = useState<MeshMonitorConfig | null>(null) const [originalMeshmonitor, setOriginalMeshmonitor] = useState<MeshMonitorConfig | null>(null)
const [meshSources, setMeshSources] = useState<MeshSourceConfig[] | null>(null) const [meshSources, setMeshSources] = useState<MeshSourceConfig[] | null>(null)
@ -54,6 +56,11 @@ export default function MeshtasticSources() {
} }
}, [meshmonitor, originalMeshmonitor, meshSources, originalMeshSources]) }, [meshmonitor, originalMeshmonitor, meshSources, originalMeshSources])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveConfig = async () => { const saveConfig = async () => {
if (!meshmonitor || !meshSources) return if (!meshmonitor || !meshSources) return
setSaving(true) setSaving(true)
@ -67,6 +74,7 @@ export default function MeshtasticSources() {
setOriginalMeshmonitor(JSON.parse(JSON.stringify(meshmonitor))) setOriginalMeshmonitor(JSON.parse(JSON.stringify(meshmonitor)))
setOriginalMeshSources(JSON.parse(JSON.stringify(meshSources))) setOriginalMeshSources(JSON.parse(JSON.stringify(meshSources)))
setHasChanges(false) setHasChanges(false)
setDirty(false)
setSuccess('Meshtastic sources saved successfully') setSuccess('Meshtastic sources saved successfully')
if (mmResult.restart_required || msResult.restart_required) { if (mmResult.restart_required || msResult.restart_required) {
notifyRestartRequired([]) notifyRestartRequired([])

View file

@ -9,6 +9,7 @@ import {
import ChannelPicker from '@/components/ChannelPicker' import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker' import NodePicker from '@/components/NodePicker'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
// Types // Types
interface NotificationRuleConfig { interface NotificationRuleConfig {
@ -1762,6 +1763,7 @@ function MasterToggles({ toggles, onChange }: {
export default function Notifications() { export default function Notifications() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<NotificationsConfig | null>(null) const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
const [categories, setCategories] = useState<AlertCategory[]>([]) const [categories, setCategories] = useState<AlertCategory[]>([])
@ -1811,6 +1813,11 @@ export default function Notifications() {
} }
}, [config, originalConfig]) }, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveConfig = async () => { const saveConfig = async () => {
if (!config) return if (!config) return
@ -1834,6 +1841,7 @@ export default function Notifications() {
setSuccess('Notifications config saved successfully') setSuccess('Notifications config saved successfully')
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false) setHasChanges(false)
setDirty(false)
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Save failed') setError(err instanceof Error ? err.message : 'Save failed')