Implemented software update installation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
This commit is contained in:
parent
ea57ba65f3
commit
78dc567a8f
@ -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",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -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': {}
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
140
src/App.jsx
140
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 = () => {
|
||||
<PrintServerProvider>
|
||||
<ApiServerProvider>
|
||||
<MessageProvider>
|
||||
<NotificationProvider>
|
||||
<SpotlightProvider>
|
||||
<ActionsModalProvider>
|
||||
<Routes>
|
||||
<Route path='/applaunch' element={<AuthLaunch />} />
|
||||
<Route
|
||||
path='/dashboard/electron/spotlightcontent'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<ElectronSpotlightContentPage />
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<Navigate
|
||||
to='/dashboard/production/overview'
|
||||
replace
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/callback'
|
||||
element={<AuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/marketplace/callback'
|
||||
element={<MarketplaceAuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/email/notification'
|
||||
element={<EmailNotificationTemplate />}
|
||||
/>
|
||||
<AppUpdateProvider>
|
||||
<NotificationProvider>
|
||||
<SpotlightProvider>
|
||||
<ActionsModalProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path='/applaunch'
|
||||
element={<AuthLaunch />}
|
||||
/>
|
||||
<Route
|
||||
path='/dashboard/electron/spotlightcontent'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<ElectronSpotlightContentPage />
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<Navigate
|
||||
to='/dashboard/production/overview'
|
||||
replace
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/callback'
|
||||
element={<AuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/marketplace/callback'
|
||||
element={<MarketplaceAuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/email/notification'
|
||||
element={<EmailNotificationTemplate />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path='/dashboard'
|
||||
element={
|
||||
<PrivateRoute component={() => <Dashboard />} />
|
||||
}
|
||||
>
|
||||
{ProductionRoutes}
|
||||
{InventoryRoutes}
|
||||
{FinanceRoutes}
|
||||
{SalesRoutes}
|
||||
{ManagementRoutes}
|
||||
{DeveloperRoutes}
|
||||
</Route>
|
||||
<Route
|
||||
path='*'
|
||||
element={
|
||||
<AppError
|
||||
message='Error 404. Page not found.'
|
||||
showRefresh={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ActionsModalProvider>
|
||||
</SpotlightProvider>
|
||||
</NotificationProvider>
|
||||
<Route
|
||||
path='/dashboard'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => <Dashboard />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{ProductionRoutes}
|
||||
{InventoryRoutes}
|
||||
{FinanceRoutes}
|
||||
{SalesRoutes}
|
||||
{ManagementRoutes}
|
||||
{DeveloperRoutes}
|
||||
</Route>
|
||||
<Route
|
||||
path='*'
|
||||
element={
|
||||
<AppError
|
||||
message='Error 404. Page not found.'
|
||||
showRefresh={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ActionsModalProvider>
|
||||
</SpotlightProvider>
|
||||
</NotificationProvider>
|
||||
</AppUpdateProvider>
|
||||
</MessageProvider>
|
||||
</ApiServerProvider>
|
||||
</PrintServerProvider>
|
||||
|
||||
@ -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: <ReloadIcon />,
|
||||
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: <ReloadIcon />,
|
||||
onClick: () => {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if (isElectron) {
|
||||
actions.unshift(
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
icon: <DownloadIcon />,
|
||||
onClick: checkForUpdates
|
||||
},
|
||||
{ type: 'divider' }
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
|
||||
@ -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) => (
|
||||
<Option key={branch} value={branch}>
|
||||
{branch}
|
||||
</Option>
|
||||
)),
|
||||
[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 (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<Flex vertical gap='large'>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.updater ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('updater', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Application Updater
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label='Branch'>
|
||||
<Select
|
||||
value={selectedBranch}
|
||||
onChange={setSelectedBranch}
|
||||
style={{ width: '100%' }}
|
||||
loading={branchLoading}
|
||||
placeholder='Select a branch'
|
||||
>
|
||||
{branchOptions}
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Actions'>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleCheckForUpdates}
|
||||
loading={checking}
|
||||
disabled={!selectedBranch}
|
||||
>
|
||||
Check for Updates
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{currentUpdate ? (
|
||||
<Descriptions bordered column={1} title='Latest Build'>
|
||||
<Descriptions.Item label='Branch'>
|
||||
{currentUpdate.branch || selectedBranch}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Build Number'>
|
||||
{currentUpdate.buildNumber || 'Unknown'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Build Source'>
|
||||
{currentUpdate.buildSource || 'Unknown'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Build Status'>
|
||||
{currentUpdate.buildResult || 'Unknown'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Build Time'>
|
||||
{buildTimestamp}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Build URL'>
|
||||
{currentUpdate.buildUrl ? (
|
||||
<Link href={currentUpdate.buildUrl} target='_blank'>
|
||||
Open Jenkins Build
|
||||
</Link>
|
||||
) : (
|
||||
<Text type='secondary'>No build URL available</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Artifacts'>
|
||||
{Array.isArray(currentUpdate.artifacts) &&
|
||||
currentUpdate.artifacts.length > 0 ? (
|
||||
<Space direction='vertical'>
|
||||
{currentUpdate.artifacts.map((artifact) => (
|
||||
<Link
|
||||
key={artifact.url}
|
||||
href={artifact.url}
|
||||
target='_blank'
|
||||
>
|
||||
{artifact.fileName || artifact.relativePath}
|
||||
</Link>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Text type='secondary'>No artifacts published</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description='No update check has been run yet'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppUpdate
|
||||
@ -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 (
|
||||
<Flex vertical gap='middle'>
|
||||
<Text>
|
||||
Updating Farm Control to version{' '}
|
||||
{update?.version ? `${update.version}` : 'unknown'} build{' '}
|
||||
{update?.buildNumber ? `${update.buildNumber}` : 'unknown'} from branch{' '}
|
||||
{update?.branch ? `${update.branch}` : 'unknown'}...
|
||||
</Text>
|
||||
|
||||
{isError ? (
|
||||
<Alert type='error' showIcon message={message} />
|
||||
) : (
|
||||
<Alert
|
||||
type={isInstalling ? 'success' : 'info'}
|
||||
showIcon
|
||||
icon={isInstalling ? undefined : <LoadingOutlined />}
|
||||
message={
|
||||
isInstalling
|
||||
? 'The app will close while the installer runs, then reopen automatically.'
|
||||
: `Downloading ${artifactName}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isInstalling && (
|
||||
<Progress
|
||||
percent={percent}
|
||||
status={getProgressStatus(phase)}
|
||||
showInfo={phase !== 'preparing'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{downloaded && total && (
|
||||
<Text type='secondary'>
|
||||
{downloaded} of {total} downloaded
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@ -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 (
|
||||
<Flex vertical gap='middle'>
|
||||
<Text>
|
||||
A new Farm Control update is available. Would you like to update now?
|
||||
</Text>
|
||||
<Card styles={{ body: { padding: 12 } }}>
|
||||
<Flex gap={12}>
|
||||
<img
|
||||
src={'/logo512.png'}
|
||||
alt='Farm Control Logo'
|
||||
style={{ width: '70px', height: '70px' }}
|
||||
/>
|
||||
<Flex vertical gap={2} justify='center'>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
{'Farm Control UI'}
|
||||
</Title>
|
||||
<Flex style={{ columnGap: '15px', rowGap: '8px' }}>
|
||||
<Text style={{ margin: 0 }} type='secondary'>
|
||||
Version:{' '}
|
||||
<Text>
|
||||
{update?.version ? `v${update.version}` : 'Unknown'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={{ margin: 0 }} type='secondary'>
|
||||
Build Number:{' '}
|
||||
<Text>
|
||||
{update?.buildNumber ? `b${update.buildNumber}` : 'Unknown'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={{ margin: 0 }} type='secondary'>
|
||||
Branch: <Text>{update?.branch || 'Unknown'}</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
<Flex justify='space-between' gap='small' align='center'>
|
||||
<Flex gap='small'>
|
||||
<Text type='secondary'>Built at:</Text>
|
||||
<TimeDisplay dateTime={update?.builtAt} />
|
||||
</Flex>
|
||||
<Flex gap='small'>
|
||||
<Button onClick={onCancel}>Not Now</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => onUpdate(update)}
|
||||
disabled={!primaryArtifact}
|
||||
>
|
||||
Update Now
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
NewAppUpdate.propTypes = {
|
||||
update: PropTypes.object,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default NewAppUpdate
|
||||
@ -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) => (
|
||||
<Option key={branch} value={branch}>
|
||||
{branch}
|
||||
</Option>
|
||||
)),
|
||||
[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 (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.appearance ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
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 }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Appearance Settings
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='Theme'>
|
||||
<Select
|
||||
value={getCurrentThemeValue()}
|
||||
onChange={handleThemeChange}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value='light'>Light</Option>
|
||||
<Option value='dark'>Dark</Option>
|
||||
<Option value='system'>System</Option>
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='UI Density'>
|
||||
<Select
|
||||
value={isCompact ? 'compact' : 'comfortable'}
|
||||
onChange={handleCompactChange}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value='comfortable'>Comfortable</Option>
|
||||
<Option value='compact'>Compact</Option>
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
}))
|
||||
}
|
||||
|
||||
setThemeMode(draftSettings.theme)
|
||||
setDensityMode(draftSettings.density)
|
||||
setAppSettingsState(nextSettings)
|
||||
setDraftSettings(nextSettings)
|
||||
setIsEditing(false)
|
||||
showSuccess('Settings saved.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<Space size='small'>
|
||||
<ViewButton
|
||||
disabled={settingsLoading}
|
||||
items={viewItems}
|
||||
visibleState={collapseState}
|
||||
updateVisibleState={updateCollapseState}
|
||||
/>
|
||||
</Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleSave}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
formValid={
|
||||
Boolean(draftSettings.theme && draftSettings.density) &&
|
||||
(!isElectron || Boolean(draftSettings.appUpdateBranch))
|
||||
}
|
||||
disabled={settingsLoading || (!isElectron && !userProfile)}
|
||||
loading={saving}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<Spin spinning={settingsLoading} indicator={<LoadingOutlined />}>
|
||||
<Flex vertical gap='large'>
|
||||
<InfoCollapse
|
||||
title='Appearance Settings'
|
||||
icon={<SettingOutlined />}
|
||||
active={collapseState.appearance}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('appearance', expanded)
|
||||
}
|
||||
collapseKey='appearance'
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='Theme'>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={draftSettings.theme}
|
||||
onChange={(value) =>
|
||||
setDraftSettings((previous) => ({
|
||||
...previous,
|
||||
theme: value
|
||||
}))
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value='light'>Light</Option>
|
||||
<Option value='dark'>Dark</Option>
|
||||
<Option value='system'>System</Option>
|
||||
</Select>
|
||||
) : (
|
||||
<Text>{currentThemeValue}</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='UI Density'>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={draftSettings.density}
|
||||
onChange={(value) =>
|
||||
setDraftSettings((previous) => ({
|
||||
...previous,
|
||||
density: value
|
||||
}))
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value='comfortable'>Comfortable</Option>
|
||||
<Option value='compact'>Compact</Option>
|
||||
</Select>
|
||||
) : (
|
||||
<Text>{isCompact ? 'Compact' : 'Comfortable'}</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</InfoCollapse>
|
||||
{isElectron && (
|
||||
<InfoCollapse
|
||||
title='App Update Settings'
|
||||
icon={<SettingOutlined />}
|
||||
active={collapseState.appUpdates}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('appUpdates', expanded)
|
||||
}
|
||||
collapseKey='appUpdates'
|
||||
>
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label='Branch'>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={draftSettings.appUpdateBranch}
|
||||
onChange={(value) =>
|
||||
setDraftSettings((previous) => ({
|
||||
...previous,
|
||||
appUpdateBranch: value
|
||||
}))
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
loading={branchLoading}
|
||||
placeholder='Select a branch'
|
||||
>
|
||||
{branchOptions}
|
||||
</Select>
|
||||
) : (
|
||||
<Text>{currentBranch}</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</InfoCollapse>
|
||||
)}
|
||||
</Flex>
|
||||
</Spin>
|
||||
</div>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
300
src/components/Dashboard/context/AppUpdateContext.jsx
Normal file
300
src/components/Dashboard/context/AppUpdateContext.jsx
Normal file
@ -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 (
|
||||
<AppUpdateContext.Provider value={{ checkForUpdates }}>
|
||||
{children}
|
||||
<Modal
|
||||
open={checking}
|
||||
className='loading-modal'
|
||||
title={false}
|
||||
height={20}
|
||||
style={{ maxWidth: 260, top: '50%', transform: 'translateY(-50%)' }}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
footer={false}
|
||||
>
|
||||
<Space size='middle'>
|
||||
<LoadingOutlined />
|
||||
<Text style={{ margin: 0 }}>Checking for updates...</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
title='Software Update'
|
||||
open={noUpdateOpen}
|
||||
okText='OK'
|
||||
style={{ maxWidth: 430 }}
|
||||
centered
|
||||
maskClosable
|
||||
onOk={() => setNoUpdateOpen(false)}
|
||||
onCancel={() => setNoUpdateOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key='ok'
|
||||
type='primary'
|
||||
onClick={() => setNoUpdateOpen(false)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Text>There are no new software updates available.</Text>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={installingUpdate ? 'Software Update' : 'Software Update Available'}
|
||||
open={updateModalOpen}
|
||||
footer={null}
|
||||
width={650}
|
||||
centered
|
||||
closable={!updateModalBusy}
|
||||
maskClosable={!updateModalBusy}
|
||||
onCancel={updateModalBusy ? undefined : closeUpdateModal}
|
||||
>
|
||||
{installingUpdate ? (
|
||||
<AppUpdateProgress
|
||||
progress={updateProgress}
|
||||
update={installingUpdate}
|
||||
/>
|
||||
) : (
|
||||
<NewAppUpdate
|
||||
update={availableUpdate}
|
||||
onCancel={closeUpdateModal}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</AppUpdateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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={<AppPasswordInfo />}
|
||||
/>,
|
||||
<Route key='settings' path='management/settings' element={<Settings />} />,
|
||||
<Route key='appupdate' path='management/appupdate' element={<AppUpdate />} />,
|
||||
<Route key='about' path='management/about' element={<About />} />,
|
||||
<Route key='auditlogs' path='management/auditlogs' element={<AuditLogs />} />,
|
||||
<Route key='taxrates' path='management/taxrates' element={<TaxRates />} />,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user