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 MeshtasticSources from './pages/MeshtasticSources'
import { ToastProvider } from './components/ToastProvider'
import { DirtyProvider } from './context/DirtyContext'
function App() {
return (
<DirtyProvider>
<ToastProvider>
<Layout>
<Routes>
@ -42,6 +44,7 @@ function App() {
</Routes>
</Layout>
</ToastProvider>
</DirtyProvider>
)
}

View file

@ -1,5 +1,6 @@
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 {
LayoutDashboard,
Radio,
@ -91,7 +92,14 @@ function formatUptime(seconds: number): string {
// Renders a single nav <Link>. Items whose path carries a ?section= query are
// 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('?')
? `${pathname}${search}` === item.path
: pathname === item.path
@ -100,6 +108,7 @@ function renderNavItem(item: NavItem, pathname: string, search: string) {
<Link
key={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 ${
isActive
? 'text-white bg-transparent'
@ -127,11 +136,23 @@ function getPageTitle(fullPath: string): string {
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const navigate = useNavigate()
const { dirty, setDirty } = useDirty()
const { connected, lastAlert } = useWebSocket()
const { addToast } = useToast()
const [status, setStatus] = useState<SystemStatus | 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
useEffect(() => {
if (lastAlert) {
@ -182,13 +203,13 @@ export default function Layout({ children }: LayoutProps) {
{/* Navigation */}
<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) => (
<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]">
{group.header}
</div>
{group.items.map((item) => renderNavItem(item, location.pathname, location.search))}
{group.items.map((item) => renderNavItem(item, location.pathname, location.search, handleNavClick))}
</div>
))}
</nav>

View file

@ -82,7 +82,7 @@ export default function RestartBanner() {
setRestarting(true)
setError(null)
try {
const res = await fetch('/api/system/restart', { method: 'POST' })
const res = await fetch('/api/restart', { method: 'POST' })
if (!res.ok && res.status !== 202) {
const body = await res.json().catch(() => ({}))
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 { Link, useSearchParams } from 'react-router-dom'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { useDirty } from '@/context/DirtyContext'
import NodePicker from '@/components/NodePicker'
import ChannelPicker from '@/components/ChannelPicker'
import {
@ -1799,6 +1800,7 @@ function DashboardSection({ data, onChange }: { data: DashboardConfig; onChange:
}
export default function Config() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<FullConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null)
const [activeSection, setActiveSection] = useState<SectionKey>('bot')
@ -1846,6 +1848,11 @@ export default function Config() {
}
}, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveSection = async () => {
if (!config) return
@ -1870,6 +1877,7 @@ export default function Config() {
setSuccess(`${activeSection} saved successfully`)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false)
setDirty(false)
if (result.restart_required) {
setRestartRequired(true)

View file

@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
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 { 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
// connection config (Meshtastic type / serial / tcp) is preserved untouched on
@ -20,6 +21,7 @@ interface ConnectionConfig {
}
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)
@ -54,6 +56,11 @@ export default function MeshCoreConnection() {
}
}, [config, originalConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const upd = (patch: Partial<ConnectionConfig>) =>
setConfig((c) => (c ? { ...c, ...patch } : c))
@ -67,6 +74,7 @@ export default function MeshCoreConnection() {
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([])
@ -108,7 +116,7 @@ export default function MeshCoreConnection() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Transport mode and MeshCore node connection.
MeshCore node connection.
</p>
</div>
<div className="flex items-center gap-2">
@ -151,18 +159,34 @@ export default function MeshCoreConnection() {
{/* Form */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<SelectInput
label="Transport Mode"
value={config.transport ?? 'meshtastic'}
onChange={(v) => upd({ transport: v })}
options={[
{ value: 'meshtastic', label: 'Meshtastic' },
{ value: 'meshcore', label: 'MeshCore' },
{ value: 'both', label: 'Both' },
]}
helper="Which radio transport(s) MeshAI uses"
info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation."
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-slate-300">Enable MeshCore</span>
<p className="text-xs text-slate-600">
Meshtastic is always on; enabling adds MeshCore (Both).
</p>
</div>
<button
type="button"
onClick={() => {
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="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<div className="grid grid-cols-2 gap-4">

View file

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

View file

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

View file

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

View file

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