mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
de1e58aa71
commit
b260dcbae0
10 changed files with 153 additions and 19 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
46
work/dashboard-frontend/src/context/DirtyContext.tsx
Normal file
46
work/dashboard-frontend/src/context/DirtyContext.tsx
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue