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 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
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 { 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)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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([])
|
||||||
|
|
|
||||||
|
|
@ -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([])
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue