diff --git a/package.json b/package.json
index 1a13c86..28b78c6 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.9.1",
"@uiw/react-codemirror": "^4.25.1",
+ "@vscode/sudo-prompt": "^9.3.2",
"antd": "^5.27.1",
"antd-style": "^3.7.1",
"axios": "^1.11.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f820c1..e141c01 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
'@uiw/react-codemirror':
specifier: ^4.25.1
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.3)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.12)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@vscode/sudo-prompt':
+ specifier: ^9.3.2
+ version: 9.3.2
antd:
specifier: ^5.27.1
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2125,6 +2128,9 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@vscode/sudo-prompt@9.3.2':
+ resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==}
+
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
@@ -8306,6 +8312,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vscode/sudo-prompt@9.3.2': {}
+
'@xmldom/xmldom@0.8.11': {}
'@zeit/schemas@2.36.0': {}
diff --git a/public/appupdate.js b/public/appupdate.js
index e69de29..b2a5dbe 100644
--- a/public/appupdate.js
+++ b/public/appupdate.js
@@ -0,0 +1,398 @@
+import { ipcMain } from 'electron'
+import { createWriteStream, promises as fs } from 'fs'
+import { spawn } from 'child_process'
+import { createRequire } from 'module'
+import http from 'http'
+import https from 'https'
+import os from 'os'
+import path from 'path'
+import process from 'process'
+
+const require = createRequire(import.meta.url)
+const sudo = require('@vscode/sudo-prompt')
+
+const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
+const SUPPORTED_TARGETS = {
+ darwin: {
+ extension: '.pkg',
+ osMatchers: ['darwin', 'mac', 'macos', 'osx']
+ },
+ win32: {
+ extension: '.msi',
+ osMatchers: ['win32', 'win', 'windows']
+ }
+}
+
+let runningUpdate = null
+
+const getArtifactName = (artifact) =>
+ String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
+
+const normalizeArch = (arch) => {
+ if (arch === 'x64' || arch === 'amd64') return 'x64'
+ if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
+ return arch
+}
+
+const artifactMatchesPlatform = (artifact, target, platform, arch) => {
+ const name = getArtifactName(artifact).toLowerCase()
+ const normalizedArch = normalizeArch(arch)
+ const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
+ const artifactPlatform = String(
+ artifact?.platform || artifact?.os || artifact?.target || ''
+ ).toLowerCase()
+
+ if (!name.endsWith(target.extension)) return false
+ if (!artifact?.url) return false
+
+ const matchesArch =
+ artifactArch === normalizedArch ||
+ name.includes(`-${normalizedArch}`) ||
+ name.includes(`_${normalizedArch}`) ||
+ name.includes(`.${normalizedArch}.`) ||
+ name.includes(normalizedArch)
+
+ const matchesOs =
+ !artifactPlatform ||
+ target.osMatchers.includes(artifactPlatform) ||
+ target.osMatchers.some((matcher) => name.includes(matcher)) ||
+ (platform === 'darwin' && name.includes('mac')) ||
+ (platform === 'win32' && name.includes('win'))
+
+ return matchesArch && matchesOs
+}
+
+const selectUpdateArtifact = (
+ update,
+ platform = process.platform,
+ arch = process.arch
+) => {
+ const target = SUPPORTED_TARGETS[platform]
+ if (!target) {
+ throw new Error(`App updates are not supported on ${platform}.`)
+ }
+
+ const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
+ const matchingArtifact = artifacts.find((artifact) =>
+ artifactMatchesPlatform(artifact, target, platform, arch)
+ )
+ const fallbackArtifact = artifacts.find((artifact) => {
+ const name = getArtifactName(artifact).toLowerCase()
+ return artifact?.url && name.endsWith(target.extension)
+ })
+
+ if (!matchingArtifact && !fallbackArtifact) {
+ throw new Error(
+ `No ${target.extension} update artifact found for ${platform}/${arch}.`
+ )
+ }
+
+ return matchingArtifact || fallbackArtifact
+}
+
+const sendProgress = (webContents, payload) => {
+ if (!webContents || webContents.isDestroyed()) return
+ webContents.send(UPDATE_PROGRESS_CHANNEL, {
+ timestamp: new Date().toISOString(),
+ ...payload
+ })
+}
+
+const getDownloadUrl = (url, redirectCount = 0) =>
+ new Promise((resolve, reject) => {
+ if (redirectCount > 5) {
+ reject(new Error('Too many redirects while downloading update.'))
+ return
+ }
+
+ const parsedUrl = new URL(url)
+ const client = parsedUrl.protocol === 'https:' ? https : http
+ const request = client.get(parsedUrl, (response) => {
+ const location = response.headers.location
+
+ if (response.statusCode >= 300 && response.statusCode < 400 && location) {
+ response.resume()
+ resolve(
+ getDownloadUrl(
+ new URL(location, parsedUrl).toString(),
+ redirectCount + 1
+ )
+ )
+ return
+ }
+
+ resolve({ response, url: parsedUrl.toString() })
+ })
+
+ request.on('error', reject)
+ })
+
+const downloadArtifact = async (artifact, destinationPath, webContents) => {
+ const { response } = await getDownloadUrl(artifact.url)
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ response.resume()
+ throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
+ }
+
+ const totalBytes =
+ Number.parseInt(response.headers['content-length'], 10) || 0
+ let downloadedBytes = 0
+
+ await new Promise((resolve, reject) => {
+ const output = createWriteStream(destinationPath)
+
+ response.on('data', (chunk) => {
+ downloadedBytes += chunk.length
+ const percent = totalBytes
+ ? Math.round((downloadedBytes / totalBytes) * 100)
+ : null
+
+ sendProgress(webContents, {
+ phase: 'downloading',
+ percent,
+ downloadedBytes,
+ totalBytes,
+ message: totalBytes
+ ? `Downloading update (${percent}%)`
+ : 'Downloading update'
+ })
+ })
+
+ response.on('error', reject)
+ output.on('error', reject)
+ output.on('finish', resolve)
+ response.pipe(output)
+ })
+}
+
+const getMacAppPath = (app) => {
+ const executablePath = app.getPath('exe')
+ const appPathIndex = executablePath.indexOf('.app/')
+
+ if (appPathIndex === -1) return null
+ return executablePath.slice(0, appPathIndex + 4)
+}
+
+const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
+
+const getInstallErrorMessage = (error, output = '') => {
+ const combined = `${output}\n${error?.message || ''}`.trim()
+
+ if (
+ /cancel/i.test(combined) ||
+ /did not grant permission/i.test(combined) ||
+ /user canceled/i.test(combined)
+ ) {
+ return 'Update installation was cancelled.'
+ }
+
+ if (/incorrect/i.test(combined)) {
+ return 'The administrator password was incorrect.'
+ }
+
+ return combined || 'Failed to install update.'
+}
+
+const buildMacInstallScript = (app, installerPath) => {
+ const appPath = getMacAppPath(app)
+ const relaunchCommand = appPath
+ ? `open ${quoteShellArg(appPath)}`
+ : `open ${quoteShellArg(app.getPath('exe'))}`
+
+ return `sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
+ installerPath
+ )} -target / && ${relaunchCommand}`
+}
+
+const buildWindowsInstallCommand = (app, installerPath) => ({
+ command: 'cmd.exe',
+ args: [
+ '/d',
+ '/s',
+ '/c',
+ `timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart && start "" "${app.getPath(
+ 'exe'
+ )}"`
+ ]
+})
+
+const launchMacInstaller = (app, installerPath, webContents) => {
+ const installScript = buildMacInstallScript(app, installerPath)
+ const promptName = app.getName() || 'Farm Control'
+
+ console.log('[app-update] launching macOS installer:', {
+ installerPath,
+ installScript,
+ promptName
+ })
+
+ sendProgress(webContents, {
+ phase: 'installing',
+ percent: 100,
+ message:
+ 'Installing update. Enter your Mac password when prompted, then Farm Control will restart automatically.'
+ })
+
+ app.focus({ steal: true })
+ app.dock?.show()
+
+ return new Promise((resolve, reject) => {
+ sudo.exec(installScript, { name: promptName }, (error, stdout, stderr) => {
+ const output = `${stdout || ''}${stderr || ''}`
+
+ if (stdout) console.log('[app-update] installer stdout:', stdout)
+ if (stderr) console.error('[app-update] installer stderr:', stderr)
+
+ if (error) {
+ console.error('[app-update] installer error:', error)
+ const message = getInstallErrorMessage(error, output)
+ sendProgress(webContents, {
+ phase: 'error',
+ percent: null,
+ message
+ })
+ reject(new Error(message))
+ return
+ }
+
+ console.log('[app-update] installer completed successfully')
+ resolve()
+ })
+ })
+}
+
+const launchWindowsInstaller = (app, installerPath, webContents) => {
+ const { command, args } = buildWindowsInstallCommand(app, installerPath)
+
+ console.log('[app-update] launching installer:', {
+ installerPath,
+ command,
+ args,
+ shellCommand: args.join(' '),
+ platform: process.platform
+ })
+
+ sendProgress(webContents, {
+ phase: 'installing',
+ percent: 100,
+ message: 'Installing update. Farm Control will restart automatically.'
+ })
+
+ let installerOutput = ''
+
+ const installerProcess = spawn(command, args, {
+ detached: true,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true
+ })
+
+ installerProcess.stdout?.on('data', (data) => {
+ const text = data.toString()
+ installerOutput += text
+ console.log('[app-update] installer stdout:', text)
+ })
+
+ installerProcess.stderr?.on('data', (data) => {
+ const text = data.toString()
+ installerOutput += text
+ console.error('[app-update] installer stderr:', text)
+ })
+
+ installerProcess.on('spawn', () => {
+ console.log('[app-update] installer spawned, pid:', installerProcess.pid)
+ })
+
+ installerProcess.on('error', (error) => {
+ console.error('[app-update] installer spawn error:', error)
+ sendProgress(webContents, {
+ phase: 'error',
+ percent: null,
+ message: error?.message || 'Failed to start update installer.'
+ })
+ })
+
+ installerProcess.on('exit', (code, signal) => {
+ console.log('[app-update] installer exited:', {
+ code,
+ signal,
+ output: installerOutput
+ })
+
+ if (code !== 0) {
+ sendProgress(webContents, {
+ phase: 'error',
+ percent: null,
+ message: getInstallErrorMessage(null, installerOutput)
+ })
+ }
+ })
+
+ installerProcess.unref()
+}
+
+const launchInstallerAndQuit = async (app, installerPath, webContents) => {
+ if (process.platform === 'darwin') {
+ await launchMacInstaller(app, installerPath, webContents)
+ return
+ }
+
+ if (process.platform === 'win32') {
+ launchWindowsInstaller(app, installerPath, webContents)
+ return
+ }
+
+ throw new Error(`App updates are not supported on ${process.platform}.`)
+}
+
+const runAppUpdate = async (app, update, webContents) => {
+ const artifact = selectUpdateArtifact(update)
+ const tempDirectory = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'farmcontrol-update-')
+ )
+ const artifactName = path.basename(getArtifactName(artifact))
+ const installerPath = path.join(tempDirectory, artifactName)
+
+ sendProgress(webContents, {
+ phase: 'preparing',
+ percent: 0,
+ artifact,
+ message: 'Preparing update download'
+ })
+
+ await downloadArtifact(artifact, installerPath, webContents)
+
+ sendProgress(webContents, {
+ phase: 'downloaded',
+ percent: 100,
+ downloadedBytes: null,
+ totalBytes: null,
+ artifact,
+ message: 'Update downloaded'
+ })
+
+ await launchInstallerAndQuit(app, installerPath, webContents)
+}
+
+export function setupAppUpdateIPC(app) {
+ ipcMain.handle('app-update-start', async (event, update) => {
+ if (runningUpdate) return runningUpdate
+
+ const webContents = event.sender
+ runningUpdate = runAppUpdate(app, update, webContents)
+ .then(() => ({ ok: true }))
+ .catch((error) => {
+ sendProgress(webContents, {
+ phase: 'error',
+ percent: null,
+ message: error?.message || 'Failed to update app.'
+ })
+ throw error
+ })
+ .finally(() => {
+ runningUpdate = null
+ })
+
+ return runningUpdate
+ })
+}
diff --git a/public/electron.js b/public/electron.js
index 0384c5b..504be18 100644
--- a/public/electron.js
+++ b/public/electron.js
@@ -1,5 +1,7 @@
import { app, ipcMain, shell, globalShortcut, safeStorage } from 'electron'
import Store from 'electron-store'
+import { Buffer } from 'buffer'
+import process from 'process'
import {
registerGlobalShortcuts,
setupSpotlightIPC
@@ -12,12 +14,17 @@ import {
setupSingleInstanceLock,
handleDeepLinkFromArgv
} from './mainWindow.js'
+import { setupAppUpdateIPC } from './appupdate.js'
// --- Auth session storage (main process) ---
const authStore = new Store({
name: 'auth-session'
})
const AUTH_SESSION_KEY = 'authSession'
+const appSettingsStore = new Store({
+ name: 'settings'
+})
+const APP_SETTINGS_KEY = 'appSettings'
const serializeAuthSession = (session) => {
const sessionJson = JSON.stringify(session)
@@ -76,6 +83,7 @@ if (gotTheLock) {
registerGlobalShortcuts()
setupSpotlightIPC()
setupMainWindowIPC()
+ setupAppUpdateIPC(app)
setupMainWindowAppEvents(app)
setupDevAuthServer()
handleDeepLinkFromArgv()
@@ -124,6 +132,27 @@ ipcMain.handle('auth-session-clear', async () => {
}
})
+ipcMain.handle('app-settings-get', async () => {
+ try {
+ const settings = appSettingsStore.get(APP_SETTINGS_KEY)
+ return settings && typeof settings === 'object' ? settings : {}
+ } catch (e) {
+ console.warn('[app-settings] Failed to read settings.', e?.message || e)
+ return {}
+ }
+})
+
+ipcMain.handle('app-settings-set', async (event, settings) => {
+ try {
+ if (!settings || typeof settings !== 'object') return false
+ appSettingsStore.set(APP_SETTINGS_KEY, settings)
+ return true
+ } catch (e) {
+ console.warn('[app-settings] Failed to write settings.', e?.message || e)
+ return false
+ }
+})
+
// IPC handler for opening external URLs
ipcMain.handle('open-external-url', (event, url) => {
shell.openExternal(url)
diff --git a/src/App.jsx b/src/App.jsx
index c0784b2..f25235c 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -28,6 +28,7 @@ import { ApiServerProvider } from './components/Dashboard/context/ApiServerConte
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
+import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
import AuthCallback from './components/App/AuthCallback.jsx'
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
@@ -76,73 +77,80 @@ const AppContent = () => {
-
-
-
-
- } />
- (
-
- )}
- />
- }
- />
- (
-
- )}
- />
- }
- />
- }
- />
- }
- />
- }
- />
+
+
+
+
+
+ }
+ />
+ (
+
+ )}
+ />
+ }
+ />
+ (
+
+ )}
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
- } />
- }
- >
- {ProductionRoutes}
- {InventoryRoutes}
- {FinanceRoutes}
- {SalesRoutes}
- {ManagementRoutes}
- {DeveloperRoutes}
-
-
- }
- />
-
-
-
-
+ }
+ />
+ }
+ >
+ {ProductionRoutes}
+ {InventoryRoutes}
+ {FinanceRoutes}
+ {SalesRoutes}
+ {ManagementRoutes}
+ {DeveloperRoutes}
+
+
+ }
+ />
+
+
+
+
+
diff --git a/src/components/Dashboard/Management/About.jsx b/src/components/Dashboard/Management/About.jsx
index 723710c..fe1d8d6 100644
--- a/src/components/Dashboard/Management/About.jsx
+++ b/src/components/Dashboard/Management/About.jsx
@@ -12,11 +12,13 @@ import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
+import DownloadIcon from '../../Icons/DownloadIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import { version as appVersion } from '../../../../package.json'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
+import { AppUpdateContext } from '../context/AppUpdateContext'
import { useMediaQuery } from 'react-responsive'
const { Title, Text, Link } = Typography
@@ -25,25 +27,37 @@ const About = () => {
updater: true
})
const { token } = useContext(AuthContext)
- const actions = [
- {
- label: 'Check for Updates',
- icon: ,
- onClick: () => {
- console.log('Check for Updates')
- }
- }
- ]
+ const { fetchApiServerVersion, fetchWsServerVersion } =
+ useContext(ApiServerContext)
+ const { isElectron, getElectronVersion } = useContext(ElectronContext)
+ const { checkForUpdates } = useContext(AppUpdateContext)
+ const isMobile = useMediaQuery({ maxWidth: 768 })
const buildNumber = import.meta.env.VITE_BUILD_NUMBER
? 'b' + import.meta.env.VITE_BUILD_NUMBER
: 'dev'
const developmentMode = import.meta.env.MODE === 'development'
- const { fetchApiServerVersion, fetchWsServerVersion } =
- useContext(ApiServerContext)
- const { isElectron, getElectronVersion } = useContext(ElectronContext)
- const isMobile = useMediaQuery({ maxWidth: 768 })
+ const actions = [
+ {
+ label: 'Reload Window',
+ icon: ,
+ onClick: () => {
+ window.location.reload()
+ }
+ }
+ ]
+
+ if (isElectron) {
+ actions.unshift(
+ {
+ label: 'Check for Updates',
+ icon: ,
+ onClick: checkForUpdates
+ },
+ { type: 'divider' }
+ )
+ }
useEffect(() => {
if (token) {
diff --git a/src/components/Dashboard/Management/AppUpdate.jsx b/src/components/Dashboard/Management/AppUpdate.jsx
deleted file mode 100644
index b9fb4ca..0000000
--- a/src/components/Dashboard/Management/AppUpdate.jsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { useContext, useEffect, useMemo, useState } from 'react'
-import {
- Button,
- Collapse,
- Descriptions,
- Empty,
- Flex,
- Select,
- Space,
- Typography
-} from 'antd'
-import { CaretLeftOutlined } from '@ant-design/icons'
-import { ApiServerContext } from '../context/ApiServerContext'
-import useCollapseState from '../hooks/useCollapseState'
-
-const { Title, Text, Link } = Typography
-const { Option } = Select
-
-const AppUpdate = () => {
- const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
- useContext(ApiServerContext)
- const [collapseState, updateCollapseState] = useCollapseState('AppUpdate', {
- updater: true
- })
- const [branches, setBranches] = useState([])
- const [selectedBranch, setSelectedBranch] = useState(undefined)
- const [branchLoading, setBranchLoading] = useState(false)
- const [checking, setChecking] = useState(false)
- const [currentUpdate, setCurrentUpdate] = useState(null)
-
- useEffect(() => {
- const loadBranches = async () => {
- setBranchLoading(true)
- const availableBranches = await fetchAppUpdateBranches()
- setBranches(availableBranches)
-
- if (availableBranches.length > 0) {
- setSelectedBranch((previous) =>
- previous && availableBranches.includes(previous)
- ? previous
- : availableBranches[0]
- )
- }
- setBranchLoading(false)
- }
-
- loadBranches()
- }, [fetchAppUpdateBranches])
-
- const branchOptions = useMemo(
- () =>
- branches.map((branch) => (
-
- )),
- [branches]
- )
-
- const handleCheckForUpdates = async () => {
- if (!selectedBranch) return
- setChecking(true)
- const updateData = await fetchAppUpdateCurrent(selectedBranch)
- setCurrentUpdate(updateData)
- setChecking(false)
- }
-
- const buildTimestamp = currentUpdate?.buildTimestamp
- ? new Date(currentUpdate.buildTimestamp).toLocaleString()
- : 'Unknown'
-
- return (
-
-
- updateCollapseState('updater', keys.length > 0)}
- expandIcon={({ isActive }) => (
-
- )}
- className='no-h-padding-collapse'
- >
-
-
- Application Updater
-
-
- }
- key='1'
- >
-
-
-
-
-
-
-
-
-
-
- {currentUpdate ? (
-
-
- {currentUpdate.branch || selectedBranch}
-
-
- {currentUpdate.buildNumber || 'Unknown'}
-
-
- {currentUpdate.buildSource || 'Unknown'}
-
-
- {currentUpdate.buildResult || 'Unknown'}
-
-
- {buildTimestamp}
-
-
- {currentUpdate.buildUrl ? (
-
- Open Jenkins Build
-
- ) : (
- No build URL available
- )}
-
-
- {Array.isArray(currentUpdate.artifacts) &&
- currentUpdate.artifacts.length > 0 ? (
-
- {currentUpdate.artifacts.map((artifact) => (
-
- {artifact.fileName || artifact.relativePath}
-
- ))}
-
- ) : (
- No artifacts published
- )}
-
-
- ) : (
-
- )}
-
-
-
-
-
- )
-}
-
-export default AppUpdate
diff --git a/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx
new file mode 100644
index 0000000..35263eb
--- /dev/null
+++ b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx
@@ -0,0 +1,97 @@
+import PropTypes from 'prop-types'
+import { Alert, Flex, Progress, Typography } from 'antd'
+import { LoadingOutlined } from '@ant-design/icons'
+
+const { Text } = Typography
+
+const formatBytes = (bytes) => {
+ if (!Number.isFinite(bytes) || bytes <= 0) return null
+
+ const units = ['B', 'KB', 'MB', 'GB']
+ let value = bytes
+ let unitIndex = 0
+
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024
+ unitIndex += 1
+ }
+
+ return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${
+ units[unitIndex]
+ }`
+}
+
+const getProgressStatus = (phase) => {
+ if (phase === 'error') return 'exception'
+ if (phase === 'downloaded' || phase === 'installing') return 'success'
+ return 'active'
+}
+
+const AppUpdateProgress = ({ progress, update }) => {
+ const phase = progress?.phase || 'preparing'
+ const percent =
+ typeof progress?.percent === 'number' ? Math.min(progress.percent, 100) : 0
+ const downloaded = formatBytes(progress?.downloadedBytes)
+ const total = formatBytes(progress?.totalBytes)
+ const artifactName =
+ progress?.artifact?.fileName ||
+ progress?.artifact?.relativePath ||
+ 'Selected installer'
+ const message = progress?.message || 'Preparing update'
+ const isInstalling = phase === 'installing'
+ const isError = phase === 'error'
+
+ return (
+
+
+ Updating Farm Control to version{' '}
+ {update?.version ? `${update.version}` : 'unknown'} build{' '}
+ {update?.buildNumber ? `${update.buildNumber}` : 'unknown'} from branch{' '}
+ {update?.branch ? `${update.branch}` : 'unknown'}...
+
+
+ {isError ? (
+
+ ) : (
+ }
+ message={
+ isInstalling
+ ? 'The app will close while the installer runs, then reopen automatically.'
+ : `Downloading ${artifactName}`
+ }
+ />
+ )}
+
+ {!isInstalling && (
+
+ )}
+
+ {downloaded && total && (
+
+ {downloaded} of {total} downloaded
+
+ )}
+
+ )
+}
+
+AppUpdateProgress.propTypes = {
+ progress: PropTypes.shape({
+ phase: PropTypes.string,
+ percent: PropTypes.number,
+ downloadedBytes: PropTypes.number,
+ totalBytes: PropTypes.number,
+ message: PropTypes.string,
+ artifact: PropTypes.object
+ }),
+ update: PropTypes.object
+}
+
+export default AppUpdateProgress
diff --git a/src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx b/src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx
new file mode 100644
index 0000000..9548d88
--- /dev/null
+++ b/src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types'
+import { Button, Flex, Typography, Card } from 'antd'
+import TimeDisplay from '../../common/TimeDisplay'
+
+const { Text, Title } = Typography
+
+const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
+ const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
+ const primaryArtifact = artifacts.find((artifact) => artifact.url)
+
+ return (
+
+
+ A new Farm Control update is available. Would you like to update now?
+
+
+
+
+
+
+ {'Farm Control UI'}
+
+
+
+ Version:{' '}
+
+ {update?.version ? `v${update.version}` : 'Unknown'}
+
+
+
+ Build Number:{' '}
+
+ {update?.buildNumber ? `b${update.buildNumber}` : 'Unknown'}
+
+
+
+ Branch: {update?.branch || 'Unknown'}
+
+
+
+
+
+
+
+
+ Built at:
+
+
+
+
+
+
+
+
+ )
+}
+
+NewAppUpdate.propTypes = {
+ update: PropTypes.object,
+ onCancel: PropTypes.func.isRequired,
+ onUpdate: PropTypes.func.isRequired
+}
+
+export default NewAppUpdate
diff --git a/src/components/Dashboard/Management/Settings.jsx b/src/components/Dashboard/Management/Settings.jsx
index 315905c..db9a01f 100644
--- a/src/components/Dashboard/Management/Settings.jsx
+++ b/src/components/Dashboard/Management/Settings.jsx
@@ -1,121 +1,309 @@
-import { Select, Typography, Descriptions, Collapse, Flex } from 'antd'
-import { CaretLeftOutlined } from '@ant-design/icons'
+import { useContext, useEffect, useMemo, useState } from 'react'
+import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd'
+import { LoadingOutlined, SettingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext'
+import { ApiServerContext } from '../context/ApiServerContext'
+import { ElectronContext } from '../context/ElectronContext'
+import { AuthContext } from '../context/AuthContext'
+import { useMessageContext } from '../context/MessageContext'
import useCollapseState from '../hooks/useCollapseState'
+import InfoCollapse from '../common/InfoCollapse'
+import ViewButton from '../common/ViewButton'
+import EditButtons from '../common/EditButtons'
-const { Title } = Typography
+const { Text } = Typography
const { Option } = Select
+const DEFAULT_UPDATE_BRANCH = 'main'
const Settings = () => {
- const {
- isDarkMode,
- toggleTheme,
- isCompact,
- toggleCompact,
- isSystem,
- toggleSystem
- } = useThemeContext()
+ const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
+ useThemeContext()
+ const { fetchAppUpdateBranches } = useContext(ApiServerContext)
+ const { isElectron, getAppSettings, setAppSettings } =
+ useContext(ElectronContext)
+ const { userProfile, setUserProfile } = useContext(AuthContext)
+ const { showSuccess, showError } = useMessageContext()
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
- appearance: true
+ appearance: true,
+ appUpdates: true
})
+ const [isEditing, setIsEditing] = useState(false)
+ const [settingsLoading, setSettingsLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [appSettings, setAppSettingsState] = useState({})
+ const [draftSettings, setDraftSettings] = useState({})
+ const [branches, setBranches] = useState([])
+ const [branchLoading, setBranchLoading] = useState(false)
- const handleThemeChange = (value) => {
- if (value === 'system') {
- toggleSystem()
- } else {
- if (isSystem) {
- toggleSystem()
- }
- if (value === 'dark' && !isDarkMode) {
- toggleTheme()
- } else if (value === 'light' && isDarkMode) {
- toggleTheme()
- }
+ useEffect(() => {
+ const loadSettings = async () => {
+ setSettingsLoading(true)
+ const storedSettings = isElectron
+ ? await getAppSettings()
+ : userProfile?.settings || {}
+ setAppSettingsState(storedSettings || {})
+ setDraftSettings(storedSettings || {})
+ setSettingsLoading(false)
}
- }
- const handleCompactChange = (value) => {
- if (value === 'compact' && !isCompact) {
- toggleCompact()
- } else if (value === 'comfortable' && isCompact) {
- toggleCompact()
+ loadSettings()
+ }, [getAppSettings, isElectron, userProfile?.settings])
+
+ useEffect(() => {
+ if (settingsLoading || isEditing) return
+ if (appSettings.theme) setThemeMode(appSettings.theme)
+ if (appSettings.density) setDensityMode(appSettings.density)
+ }, [
+ appSettings.density,
+ appSettings.theme,
+ isEditing,
+ setDensityMode,
+ setThemeMode,
+ settingsLoading
+ ])
+
+ useEffect(() => {
+ if (!isElectron) {
+ setBranches([])
+ setBranchLoading(false)
+ return
}
- }
+
+ const loadBranches = async () => {
+ setBranchLoading(true)
+ const availableBranches = await fetchAppUpdateBranches()
+ setBranches(availableBranches)
+
+ setDraftSettings((previous) => {
+ if (previous.appUpdateBranch) return previous
+
+ const defaultBranch = availableBranches.includes(DEFAULT_UPDATE_BRANCH)
+ ? DEFAULT_UPDATE_BRANCH
+ : availableBranches[0]
+
+ return defaultBranch
+ ? { ...previous, appUpdateBranch: defaultBranch }
+ : previous
+ })
+
+ setBranchLoading(false)
+ }
+
+ loadBranches()
+ }, [fetchAppUpdateBranches, isElectron])
+
+ const branchOptions = useMemo(
+ () =>
+ branches.map((branch) => (
+
+ )),
+ [branches]
+ )
+ const viewItems = [
+ { key: 'appearance', label: 'Appearance Settings' },
+ ...(isElectron ? [{ key: 'appUpdates', label: 'App Update Settings' }] : [])
+ ]
const getCurrentThemeValue = () => {
if (isSystem) return 'system'
return isDarkMode ? 'dark' : 'light'
}
- return (
-
-
-
- updateCollapseState('appearance', keys.length > 0)
+ const currentThemeValue = getCurrentThemeValue()
+ const currentDensityValue = isCompact ? 'compact' : 'comfortable'
+ const currentBranch =
+ appSettings.appUpdateBranch ||
+ (branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
+ branches[0] ||
+ 'Not configured'
+
+ const startEditing = () => {
+ setDraftSettings({
+ ...appSettings,
+ appUpdateBranch:
+ currentBranch === 'Not configured' ? undefined : currentBranch,
+ theme: currentThemeValue,
+ density: currentDensityValue
+ })
+ setIsEditing(true)
+ }
+
+ const cancelEditing = () => {
+ setDraftSettings(appSettings)
+ setIsEditing(false)
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+
+ try {
+ const nextSettings = {
+ ...appSettings,
+ theme: draftSettings.theme,
+ density: draftSettings.density,
+ ...(isElectron
+ ? { appUpdateBranch: draftSettings.appUpdateBranch }
+ : {})
+ }
+ const saved = isElectron
+ ? await setAppSettings(nextSettings)
+ : Boolean(userProfile)
+
+ if (!saved) {
+ showError('Unable to save settings.')
+ return
+ }
+
+ if (!isElectron) {
+ setUserProfile((previous) => ({
+ ...previous,
+ settings: {
+ ...(previous?.settings || {}),
+ theme: draftSettings.theme,
+ density: draftSettings.density
}
- expandIcon={({ isActive }) => (
-
- )}
- className='no-h-padding-collapse'
- >
-
-
- Appearance Settings
-
-
- }
- key='1'
- >
-
-
-
-
-
-
-
-
-
-
+ }))
+ }
+
+ setThemeMode(draftSettings.theme)
+ setDensityMode(draftSettings.density)
+ setAppSettingsState(nextSettings)
+ setDraftSettings(nextSettings)
+ setIsEditing(false)
+ showSuccess('Settings saved.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
-
+
+ }>
+
+ }
+ active={collapseState.appearance}
+ onToggle={(expanded) =>
+ updateCollapseState('appearance', expanded)
+ }
+ collapseKey='appearance'
+ >
+
+
+ {isEditing ? (
+
+ ) : (
+ {currentThemeValue}
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+ {isCompact ? 'Compact' : 'Comfortable'}
+ )}
+
+
+
+ {isElectron && (
+ }
+ active={collapseState.appUpdates}
+ onToggle={(expanded) =>
+ updateCollapseState('appUpdates', expanded)
+ }
+ collapseKey='appUpdates'
+ >
+
+
+ {isEditing ? (
+
+ ) : (
+ {currentBranch}
+ )}
+
+
+
+ )}
+
+
+
+
)
}
diff --git a/src/components/Dashboard/context/AppUpdateContext.jsx b/src/components/Dashboard/context/AppUpdateContext.jsx
new file mode 100644
index 0000000..bd84155
--- /dev/null
+++ b/src/components/Dashboard/context/AppUpdateContext.jsx
@@ -0,0 +1,300 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState
+} from 'react'
+import PropTypes from 'prop-types'
+import { Button, Modal, Space, Typography } from 'antd'
+import { LoadingOutlined } from '@ant-design/icons'
+import { version as appVersion } from '../../../../package.json'
+import { ApiServerContext } from './ApiServerContext'
+import { AuthContext } from './AuthContext'
+import { ElectronContext } from './ElectronContext'
+import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
+import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
+
+const { Text } = Typography
+
+const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
+const DEFAULT_UPDATE_BRANCH = 'main'
+const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
+
+const AppUpdateContext = createContext()
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const compareVersionNumbers = (leftVersion, rightVersion) => {
+ const leftParts = String(leftVersion || '0.0.0')
+ .split('.')
+ .map((part) => Number.parseInt(part, 10) || 0)
+ const rightParts = String(rightVersion || '0.0.0')
+ .split('.')
+ .map((part) => Number.parseInt(part, 10) || 0)
+ const length = Math.max(leftParts.length, rightParts.length)
+
+ for (let index = 0; index < length; index += 1) {
+ const leftPart = leftParts[index] || 0
+ const rightPart = rightParts[index] || 0
+
+ if (leftPart > rightPart) return 1
+ if (leftPart < rightPart) return -1
+ }
+
+ return 0
+}
+
+const normalizeBuildNumber = (buildNumber) => {
+ const parsedBuildNumber = Number.parseInt(buildNumber, 10)
+ return Number.isNaN(parsedBuildNumber) ? 0 : parsedBuildNumber
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const isAppUpdateAvailable = (update, currentVersion, currentBuild) => {
+ if (!update) return false
+
+ const versionComparison = compareVersionNumbers(
+ update.version,
+ currentVersion
+ )
+
+ if (versionComparison > 0) return true
+ if (versionComparison < 0) return false
+
+ return (
+ normalizeBuildNumber(update.buildNumber) >
+ normalizeBuildNumber(currentBuild)
+ )
+}
+
+export const AppUpdateProvider = ({ children }) => {
+ const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
+ useContext(ApiServerContext)
+ const { token } = useContext(AuthContext)
+ const { isElectron, getAppSettings, startAppUpdate, onAppUpdateProgress } =
+ useContext(ElectronContext)
+ const [checking, setChecking] = useState(false)
+ const [noUpdateOpen, setNoUpdateOpen] = useState(false)
+ const [availableUpdate, setAvailableUpdate] = useState(null)
+ const [installingUpdate, setInstallingUpdate] = useState(null)
+ const [updateProgress, setUpdateProgress] = useState(null)
+ const runningCheckRef = useRef(null)
+ const updateCheckDependenciesRef = useRef({})
+
+ updateCheckDependenciesRef.current = {
+ fetchAppUpdateBranches,
+ fetchAppUpdateCurrent,
+ getAppSettings,
+ isElectron,
+ token
+ }
+
+ const checkForAvailableUpdate = useCallback(async () => {
+ const {
+ fetchAppUpdateBranches,
+ fetchAppUpdateCurrent,
+ getAppSettings,
+ isElectron,
+ token
+ } = updateCheckDependenciesRef.current
+
+ if (!isElectron || !token) return null
+ if (runningCheckRef.current) return runningCheckRef.current
+
+ const checkPromise = (async () => {
+ const [branches, appSettings] = await Promise.all([
+ fetchAppUpdateBranches(),
+ getAppSettings()
+ ])
+ const configuredBranch = appSettings?.appUpdateBranch
+ const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
+ ? DEFAULT_UPDATE_BRANCH
+ : branches[0]
+ const selectedBranch = branches.includes(configuredBranch)
+ ? configuredBranch
+ : defaultBranch
+
+ if (!selectedBranch) return null
+
+ const update = await fetchAppUpdateCurrent(selectedBranch)
+
+ return isAppUpdateAvailable(update, appVersion, CURRENT_BUILD_NUMBER)
+ ? update
+ : null
+ })()
+
+ runningCheckRef.current = checkPromise
+
+ try {
+ const update = await checkPromise
+ return update
+ } finally {
+ runningCheckRef.current = null
+ }
+ }, [])
+
+ const showUpdateIfAvailable = useCallback(async () => {
+ const update = await checkForAvailableUpdate()
+ if (update) {
+ setNoUpdateOpen(false)
+ setAvailableUpdate(update)
+ }
+ return update
+ }, [checkForAvailableUpdate])
+
+ const checkForUpdates = useCallback(async () => {
+ if (!isElectron) return null
+
+ setChecking(true)
+
+ try {
+ const update = await showUpdateIfAvailable()
+ if (!update) {
+ setNoUpdateOpen(true)
+ }
+ return update
+ } finally {
+ setChecking(false)
+ }
+ }, [isElectron, showUpdateIfAvailable])
+
+ useEffect(() => {
+ if (!isElectron) return undefined
+
+ showUpdateIfAvailable()
+
+ const interval = window.setInterval(() => {
+ showUpdateIfAvailable()
+ }, UPDATE_CHECK_INTERVAL_MS)
+
+ return () => {
+ window.clearInterval(interval)
+ }
+ }, [isElectron, showUpdateIfAvailable])
+
+ useEffect(() => {
+ if (!isElectron || !onAppUpdateProgress) return undefined
+
+ return onAppUpdateProgress((progress) => {
+ setUpdateProgress(progress)
+ })
+ }, [isElectron, onAppUpdateProgress])
+
+ const closeUpdateModal = () => {
+ setAvailableUpdate(null)
+ setInstallingUpdate(null)
+ setUpdateProgress(null)
+ }
+
+ const handleUpdate = async (update) => {
+ setNoUpdateOpen(false)
+ setAvailableUpdate(null)
+ setInstallingUpdate(update)
+ setUpdateProgress({
+ phase: 'preparing',
+ percent: 0,
+ message: 'Preparing update'
+ })
+
+ try {
+ const result = await startAppUpdate(update)
+
+ if (!result) {
+ throw new Error('App updates are only available in the desktop app.')
+ }
+ } catch (error) {
+ setUpdateProgress({
+ phase: 'error',
+ percent: null,
+ message: error?.message || 'Failed to start the app update.'
+ })
+ }
+ }
+
+ const updateModalOpen = Boolean(availableUpdate || installingUpdate)
+ const updateModalBusy =
+ Boolean(installingUpdate) && updateProgress?.phase !== 'error'
+
+ return (
+
+ {children}
+
+
+
+ Checking for updates...
+
+
+ setNoUpdateOpen(false)}
+ onCancel={() => setNoUpdateOpen(false)}
+ footer={[
+
+ ]}
+ >
+ There are no new software updates available.
+
+
+ {installingUpdate ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+AppUpdateProvider.propTypes = {
+ children: PropTypes.node.isRequired
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useAppUpdateContext = () => {
+ const context = useContext(AppUpdateContext)
+ if (!context) {
+ throw new Error(
+ 'useAppUpdateContext must be used within an AppUpdateProvider'
+ )
+ }
+ return context
+}
+
+export { AppUpdateContext }
diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx
index 31e7ff7..7ef3132 100644
--- a/src/components/Dashboard/context/ElectronContext.jsx
+++ b/src/components/Dashboard/context/ElectronContext.jsx
@@ -120,6 +120,46 @@ const ElectronProvider = ({ children }) => {
return await ipcRenderer.invoke('auth-session-clear')
}
+ const getAppSettings = useCallback(async () => {
+ if (!electronAvailable || !ipcRenderer) return {}
+ return await ipcRenderer.invoke('app-settings-get')
+ }, [electronAvailable])
+
+ const setAppSettings = useCallback(
+ async (settings) => {
+ if (!electronAvailable || !ipcRenderer) return false
+ return await ipcRenderer.invoke('app-settings-set', settings)
+ },
+ [electronAvailable]
+ )
+
+ const startAppUpdate = useCallback(
+ async (update) => {
+ if (!electronAvailable || !ipcRenderer) return false
+ return await ipcRenderer.invoke('app-update-start', update)
+ },
+ [electronAvailable]
+ )
+
+ const onAppUpdateProgress = useCallback(
+ (handler) => {
+ if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
+ return () => {}
+ }
+
+ const progressHandler = (event, progress) => {
+ handler(progress)
+ }
+
+ ipcRenderer.on('app-update-progress', progressHandler)
+
+ return () => {
+ ipcRenderer.removeListener('app-update-progress', progressHandler)
+ }
+ },
+ [electronAvailable]
+ )
+
// Backwards-compatible helpers
const getToken = async () => {
const session = await getAuthSession()
@@ -170,6 +210,10 @@ const ElectronProvider = ({ children }) => {
getAuthSession,
setAuthSession,
clearAuthSession,
+ getAppSettings,
+ setAppSettings,
+ startAppUpdate,
+ onAppUpdateProgress,
getToken,
setToken,
resizeSpotlightWindow,
diff --git a/src/components/Dashboard/context/ThemeContext.jsx b/src/components/Dashboard/context/ThemeContext.jsx
index 94b0f36..7fac467 100644
--- a/src/components/Dashboard/context/ThemeContext.jsx
+++ b/src/components/Dashboard/context/ThemeContext.jsx
@@ -1,4 +1,10 @@
-import { createContext, useContext, useState, useEffect } from 'react'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ useEffect
+} from 'react'
import { theme } from 'antd'
import PropTypes from 'prop-types'
@@ -86,6 +92,21 @@ export const ThemeProvider = ({ children }) => {
setIsCompact(!isCompact)
}
+ const setThemeMode = useCallback((value) => {
+ if (value === 'system') {
+ setIsSystem(true)
+ setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
+ return
+ }
+
+ setIsSystem(false)
+ setIsDarkMode(value === 'dark')
+ }, [])
+
+ const setDensityMode = useCallback((value) => {
+ setIsCompact(value === 'compact')
+ }, [])
+
const getThemeAlgorithm = () => {
var baseAlgorithm
if (isDarkMode == true) {
@@ -142,6 +163,8 @@ export const ThemeProvider = ({ children }) => {
toggleCompact,
isSystem,
toggleSystem,
+ setThemeMode,
+ setDensityMode,
getColors,
themeConfig
}}
diff --git a/src/database/sidebars/management.js b/src/database/sidebars/management.js
index 0b53c3c..a877649 100644
--- a/src/database/sidebars/management.js
+++ b/src/database/sidebars/management.js
@@ -143,12 +143,6 @@ const managementSidebarItems = [
label: 'Settings',
path: '/dashboard/management/settings'
},
- {
- key: 'appUpdate',
- iconKey: 'settings',
- label: 'App Update',
- path: '/dashboard/management/appupdate'
- },
{
key: 'files',
iconKey: 'file',
diff --git a/src/routes/ManagementRoutes.jsx b/src/routes/ManagementRoutes.jsx
index e7c348a..043760c 100644
--- a/src/routes/ManagementRoutes.jsx
+++ b/src/routes/ManagementRoutes.jsx
@@ -70,9 +70,6 @@ const CourierServiceInfo = lazy(
const Settings = lazy(
() => import('../components/Dashboard/Management/Settings')
)
-const AppUpdate = lazy(
- () => import('../components/Dashboard/Management/AppUpdate')
-)
const AuditLogs = lazy(
() => import('../components/Dashboard/Management/AuditLogs.jsx')
)
@@ -315,7 +312,6 @@ const ManagementRoutes = [
element={}
/>,
} />,
- } />,
} />,
} />,
} />,