From b260dcbae080a0898a4af31c96aec9b621964a2f Mon Sep 17 00:00:00 2001 From: malice Date: Thu, 2 Jul 2026 23:41:56 -0600 Subject: [PATCH] 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) * docs(dashboard): drop stale "Transport mode" wording on MeshCore Connection header --------- Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/App.tsx | 3 ++ .../src/components/Layout.tsx | 29 +++++++++-- .../src/components/RestartBanner.tsx | 2 +- .../src/context/DirtyContext.tsx | 46 ++++++++++++++++ work/dashboard-frontend/src/pages/Config.tsx | 8 +++ .../src/pages/MeshCoreConnection.tsx | 52 ++++++++++++++----- .../src/pages/MeshCoreRouting.tsx | 8 +++ .../src/pages/MeshtasticConnection.tsx | 8 +++ .../src/pages/MeshtasticSources.tsx | 8 +++ .../src/pages/Notifications.tsx | 8 +++ 10 files changed, 153 insertions(+), 19 deletions(-) create mode 100644 work/dashboard-frontend/src/context/DirtyContext.tsx diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx index 026d499..500d4e4 100644 --- a/work/dashboard-frontend/src/App.tsx +++ b/work/dashboard-frontend/src/App.tsx @@ -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 ( + @@ -42,6 +44,7 @@ function App() { + ) } diff --git a/work/dashboard-frontend/src/components/Layout.tsx b/work/dashboard-frontend/src/components/Layout.tsx index 2434c8c..46bc2d5 100644 --- a/work/dashboard-frontend/src/components/Layout.tsx +++ b/work/dashboard-frontend/src/components/Layout.tsx @@ -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 . 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) { 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(null) const [lastAlertId, setLastAlertId] = useState(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 */} diff --git a/work/dashboard-frontend/src/components/RestartBanner.tsx b/work/dashboard-frontend/src/components/RestartBanner.tsx index db1c2e9..ff53ce9 100644 --- a/work/dashboard-frontend/src/components/RestartBanner.tsx +++ b/work/dashboard-frontend/src/components/RestartBanner.tsx @@ -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}`) diff --git a/work/dashboard-frontend/src/context/DirtyContext.tsx b/work/dashboard-frontend/src/context/DirtyContext.tsx new file mode 100644 index 0000000..f09972a --- /dev/null +++ b/work/dashboard-frontend/src/context/DirtyContext.tsx @@ -0,0 +1,46 @@ +// DirtyContext — tracks whether any config page has unsaved changes. +// +// Usage: +// - Wrap the app (or BrowserRouter) with . +// - 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({ + 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 ( + + {children} + + ) +} + +export function useDirty() { + return useContext(DirtyContext) +} diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index 83b2f8d..13d67f5 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -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(null) const [originalConfig, setOriginalConfig] = useState(null) const [activeSection, setActiveSection] = useState('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) diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx index 63414c7..e957f37 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx @@ -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(null) const [originalConfig, setOriginalConfig] = useState(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) => 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() {

- Transport mode and MeshCore node connection. + MeshCore node connection.

@@ -151,18 +159,34 @@ export default function MeshCoreConnection() { {/* Form */}
- 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." - /> +
+
+ Enable MeshCore +

+ Meshtastic is always on; enabling adds MeshCore (Both). +

+
+ +
MeshCore Connection
diff --git a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx index 95874a1..1709997 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx @@ -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(null) const [originalConfig, setOriginalConfig] = useState(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) => { 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) { diff --git a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx index f52e8ab..3e54582 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx @@ -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(null) const [originalConfig, setOriginalConfig] = useState(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([]) diff --git a/work/dashboard-frontend/src/pages/MeshtasticSources.tsx b/work/dashboard-frontend/src/pages/MeshtasticSources.tsx index 3583a49..d0e69d4 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticSources.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticSources.tsx @@ -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(null) const [originalMeshmonitor, setOriginalMeshmonitor] = useState(null) const [meshSources, setMeshSources] = useState(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([]) diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index 7bdab66..5cf9195 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -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(null) const [originalConfig, setOriginalConfig] = useState(null) const [categories, setCategories] = useState([]) @@ -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')