Implemented about page.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-06-21 13:19:09 +01:00
parent 8901cdbc98
commit 4363f08f50
11 changed files with 426 additions and 65 deletions

6
Jenkinsfile vendored
View File

@ -20,7 +20,7 @@ def deploy() {
stage('Build (Ubuntu)') {
nodejs(nodeJSInstallationName: 'Node23') {
sh 'NODE_ENV=production pnpm build:cloudflare'
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production pnpm build:cloudflare"
}
}
@ -70,9 +70,9 @@ def buildOnLabel(label, buildCommand) {
stage("Build (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh "NODE_ENV=production ${buildCommand}"
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
} else {
bat "set NODE_ENV=production && ${buildCommand}"
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
}
}
}

View File

@ -596,3 +596,9 @@ body {
.ant-table-wrapper .ant-table-filter-column {
align-items: center;
}
span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
width: 50px;
min-width: 0;
height: 20px;
}

0
public/appupdate.js Normal file
View File

View File

@ -206,6 +206,10 @@ export function getWindow() {
return win
}
export function getElectronVersion() {
return process.versions.electron
}
export function setupMainWindowIPC() {
// IPC handler to get window state
ipcMain.handle('window-state', () => {
@ -267,6 +271,8 @@ export function setupMainWindowIPC() {
applyApplicationMenu()
return true
})
ipcMain.handle('electron-version', () => getElectronVersion())
}
export function setupMainWindowAppEvents(app) {

View File

@ -0,0 +1,180 @@
import { useContext, useEffect, useState } from 'react'
import {
Flex,
Typography,
Button,
Dropdown,
Skeleton,
Tag,
Divider
} from 'antd'
import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
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 { useMediaQuery } from 'react-responsive'
const { Title, Text, Link } = Typography
const About = () => {
const [collapseState, updateCollapseState] = useCollapseState('About', {
updater: true
})
const { token } = useContext(AuthContext)
const actions = [
{
label: 'Check for Updates',
icon: <ReloadIcon />,
onClick: () => {
console.log('Check for Updates')
}
}
]
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 })
useEffect(() => {
if (token) {
fetchApiServerVersion().then((version) => {
setApiServerVersion(version)
})
fetchWsServerVersion().then((version) => {
setWsServerVersion(version)
})
}
}, [fetchApiServerVersion, fetchWsServerVersion, token])
useEffect(() => {
if (!isElectron) return
getElectronVersion()
.then((version) => {
setElectronVersion(version || 'unknown')
})
.catch(() => {
setElectronVersion('unknown')
})
}, [getElectronVersion, isElectron])
const [apiServerVersion, setApiServerVersion] = useState(null)
const [wsServerVersion, setWsServerVersion] = useState(null)
const [electronVersion, setElectronVersion] = useState(null)
const apiServerVersionText = apiServerVersion ? (
<Text>
{`v${apiServerVersion.version}-${apiServerVersion.buildNumber}`}
</Text>
) : (
<Skeleton.Input active size='small' className='text-skeleton' />
)
const wsServerVersionText = wsServerVersion ? (
<Text>{`v${wsServerVersion.version}-${wsServerVersion.buildNumber}`}</Text>
) : (
<Skeleton.Input active size='small' className='text-skeleton' />
)
const electronVersionText = electronVersion ? (
<Text>{`v${electronVersion}`}</Text>
) : (
<Skeleton.Input active size='small' className='text-skeleton' />
)
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Flex vertical gap='large'>
<Flex vertical gap='middle' align='start'>
<Dropdown menu={{ items: actions }}>
<Button>Actions</Button>
</Dropdown>
</Flex>
<Flex vertical gap='large'>
<InfoCollapse
title='About Farm Control'
icon={<InfoCircleIcon />}
canCollapse={false}
active={collapseState.purchaseOrderStats}
onToggle={(isActive) =>
updateCollapseState('purchaseOrderStats', isActive)
}
className='no-t-padding-collapse'
collapseKey='purchaseOrderStats'
>
<Flex gap='large'>
<img
src={'/logo512.png'}
alt='Farm Control Logo'
style={{ width: '200px', height: '200px' }}
/>
<Flex vertical gap='small' justify='center'>
<Flex gap='middle' align='center'>
<Title level={2} style={{ margin: 0 }}>
Farm Control
</Title>
{developmentMode && !isMobile && (
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
>
Development
</Tag>
)}
</Flex>
<Text type='secondary'>
3D Printer ERP and Control Software.
</Text>
<Flex style={{ columnGap: '15px', rowGap: '8px' }} wrap='wrap'>
<Text type='secondary'>
User Interface:{' '}
<Text>
v{appVersion}-{buildNumber}
</Text>
</Text>
{isElectron && (
<Text type='secondary'>
Electron: {electronVersionText}
</Text>
)}
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
<Text type='secondary'>
Web Socket: {wsServerVersionText}
</Text>
</Flex>
<Flex gap='middle' align='center'>
{developmentMode && isMobile && (
<Tag
color='yellow'
style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
>
Development
</Tag>
)}
<Link href='https://ci.tombutcher.work/job/farmcontrol'>
Jenkins
</Link>
<Divider type='vertical' style={{ margin: 0 }} />
<Link href='https://github.com/farmcontrol'>GitHub</Link>
</Flex>
</Flex>
</Flex>
</InfoCollapse>
</Flex>
</Flex>
</div>
)
}
export default About

View File

@ -17,7 +17,8 @@ const breadcrumbNameMap = {
info: 'Info',
design: 'Design',
control: 'Control',
preview: 'Preview'
preview: 'Preview',
about: 'About'
}
const mainSections = ['production', 'inventory', 'management', 'developer']

View File

@ -744,7 +744,7 @@ const ApiServerProvider = ({ children }) => {
[offLockEvent]
)
const showError = (error, callback = null) => {
const showError = useCallback((error, callback = null) => {
const code = error.response.data.code || 'UNKNOWN'
if (code == 'UNAUTHORIZED') {
setUnauthenticated()
@ -757,7 +757,7 @@ const ApiServerProvider = ({ children }) => {
setErrorModalContent(content)
setRetryCallback(() => callback)
setShowErrorModal(true)
}
}, [setUnauthenticated])
const handleRetry = () => {
setShowErrorModal(false)
@ -1678,15 +1678,55 @@ const ApiServerProvider = ({ children }) => {
return response.data
}, [])
const fetchAppUpdateBranches = useCallback(async () => {
const fetchApiServerVersion = useCallback(async () => {
try {
const response = await axios.get(`${config.backendUrl}/appupdate/branches`, {
const response = await axios.get(`${config.backendUrl}/server/version`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
})
return Array.isArray(response.data?.branches) ? response.data.branches : []
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
fetchApiServerVersion()
})
return null
}
}, [token, showError])
const fetchWsServerVersion = useCallback(async () => {
try {
if (socketRef.current && socketRef.current.connected) {
return await new Promise((resolve) => {
socketRef.current.emit('getServerVersion', {}, resolve)
})
}
return null
} catch (err) {
console.error(err)
showError(err, () => {
fetchWsServerVersion()
})
return null
}
}, [showError])
const fetchAppUpdateBranches = useCallback(async () => {
try {
const response = await axios.get(
`${config.backendUrl}/appupdate/branches`,
{
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
)
return Array.isArray(response.data?.branches)
? response.data.branches
: []
} catch (err) {
console.error(err)
showError(err, () => {
@ -1694,18 +1734,21 @@ const ApiServerProvider = ({ children }) => {
})
return []
}
}, [token])
}, [token, showError])
const fetchAppUpdateCurrent = useCallback(
async (branch) => {
try {
const response = await axios.get(`${config.backendUrl}/appupdate/current`, {
params: { branch },
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
const response = await axios.get(
`${config.backendUrl}/appupdate/current`,
{
params: { branch },
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
})
)
return response.data
} catch (err) {
console.error(err)
@ -1715,7 +1758,7 @@ const ApiServerProvider = ({ children }) => {
return null
}
},
[token]
[token, showError]
)
const flushFile = async (id) => {
@ -1816,7 +1859,9 @@ const ApiServerProvider = ({ children }) => {
completeAppLaunchSession,
getAppLaunchSession,
fetchAppUpdateBranches,
fetchAppUpdateCurrent
fetchAppUpdateCurrent,
fetchWsServerVersion,
fetchApiServerVersion
}}
>
{contextHolder}

View File

@ -152,6 +152,11 @@ const ElectronProvider = ({ children }) => {
[electronAvailable]
)
const getElectronVersion = useCallback(async () => {
if (!electronAvailable || !ipcRenderer) return null
return await ipcRenderer.invoke('electron-version')
}, [electronAvailable])
return (
<ElectronContext.Provider
value={{
@ -168,7 +173,8 @@ const ElectronProvider = ({ children }) => {
getToken,
setToken,
resizeSpotlightWindow,
setSidebarViewMenu
setSidebarViewMenu,
getElectronVersion
}}
>
{children}

View File

@ -48,6 +48,7 @@ import CourierServiceIcon from './CourierServiceIcon'
import TaxRateIcon from './TaxRateIcon'
import TaxRecordIcon from './TaxRecordIcon'
import AppPasswordIcon from './AppPasswordIcon'
import InfoCircleIcon from './InfoCircleIcon'
const toEmoji = (emoji) => <span aria-hidden>{emoji}</span>
@ -102,6 +103,7 @@ const sidebarIconMap = {
taxRate: <TaxRateIcon />,
taxRecord: <TaxRecordIcon />,
appPassword: <AppPasswordIcon />,
infoCircle: <InfoCircleIcon />,
sessionStorage: toEmoji('🗃️'),
authDebug: toEmoji('🔐'),
apiDebug: toEmoji('🌐')

View File

@ -168,6 +168,12 @@ const managementSidebarItems = [
label: 'Developer',
path: '/dashboard/developer/sessionstorage',
devOnly: true
},
{
key: 'about',
iconKey: 'infoCircle',
label: 'About',
path: '/dashboard/management/about'
}
]

View File

@ -1,55 +1,155 @@
import { lazy } from 'react'
import { Route } from 'react-router-dom'
const Filaments = lazy(() => import('../components/Dashboard/Management/Filaments'))
const FilamentInfo = lazy(() => import('../components/Dashboard/Management/Filaments/FilamentInfo.jsx'))
const FilamentSkus = lazy(() => import('../components/Dashboard/Management/FilamentSkus.jsx'))
const FilamentSkuInfo = lazy(() => import('../components/Dashboard/Management/FilamentSkus/FilamentSkuInfo.jsx'))
const Filaments = lazy(
() => import('../components/Dashboard/Management/Filaments')
)
const FilamentInfo = lazy(
() => import('../components/Dashboard/Management/Filaments/FilamentInfo.jsx')
)
const FilamentSkus = lazy(
() => import('../components/Dashboard/Management/FilamentSkus.jsx')
)
const FilamentSkuInfo = lazy(
() =>
import('../components/Dashboard/Management/FilamentSkus/FilamentSkuInfo.jsx')
)
const Parts = lazy(() => import('../components/Dashboard/Management/Parts.jsx'))
const PartInfo = lazy(() => import('../components/Dashboard/Management/Parts/PartInfo.jsx'))
const PartSkus = lazy(() => import('../components/Dashboard/Management/PartSkus.jsx'))
const PartSkuInfo = lazy(() => import('../components/Dashboard/Management/PartSkus/PartSkuInfo.jsx'))
const Products = lazy(() => import('../components/Dashboard/Management/Products.jsx'))
const ProductInfo = lazy(() => import('../components/Dashboard/Management/Products/ProductInfo.jsx'))
const ProductCategories = lazy(() => import('../components/Dashboard/Management/ProductCategories.jsx'))
const ProductCategoryInfo = lazy(() => import('../components/Dashboard/Management/ProductCategories/ProductCategoryInfo.jsx'))
const ProductSkus = lazy(() => import('../components/Dashboard/Management/ProductSkus.jsx'))
const ProductSkuInfo = lazy(() => import('../components/Dashboard/Management/ProductSkus/ProductSkuInfo.jsx'))
const PartInfo = lazy(
() => import('../components/Dashboard/Management/Parts/PartInfo.jsx')
)
const PartSkus = lazy(
() => import('../components/Dashboard/Management/PartSkus.jsx')
)
const PartSkuInfo = lazy(
() => import('../components/Dashboard/Management/PartSkus/PartSkuInfo.jsx')
)
const Products = lazy(
() => import('../components/Dashboard/Management/Products.jsx')
)
const ProductInfo = lazy(
() => import('../components/Dashboard/Management/Products/ProductInfo.jsx')
)
const ProductCategories = lazy(
() => import('../components/Dashboard/Management/ProductCategories.jsx')
)
const ProductCategoryInfo = lazy(
() =>
import('../components/Dashboard/Management/ProductCategories/ProductCategoryInfo.jsx')
)
const ProductSkus = lazy(
() => import('../components/Dashboard/Management/ProductSkus.jsx')
)
const ProductSkuInfo = lazy(
() =>
import('../components/Dashboard/Management/ProductSkus/ProductSkuInfo.jsx')
)
const Vendors = lazy(() => import('../components/Dashboard/Management/Vendors'))
const VendorInfo = lazy(() => import('../components/Dashboard/Management/Vendors/VendorInfo'))
const Materials = lazy(() => import('../components/Dashboard/Management/Materials'))
const MaterialInfo = lazy(() => import('../components/Dashboard/Management/Materials/MaterialInfo.jsx'))
const Couriers = lazy(() => import('../components/Dashboard/Management/Couriers'))
const CourierInfo = lazy(() => import('../components/Dashboard/Management/Couriers/CourierInfo.jsx'))
const CourierServices = lazy(() => import('../components/Dashboard/Management/CourierServices'))
const CourierServiceInfo = lazy(() => import('../components/Dashboard/Management/CourierServices/CourierServiceInfo.jsx'))
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'))
const NoteTypes = lazy(() => import('../components/Dashboard/Management/NoteTypes.jsx'))
const NoteTypeInfo = lazy(() => import('../components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx'))
const NoteInfo = lazy(() => import('../components/Dashboard/Management/Notes/NoteInfo.jsx'))
const VendorInfo = lazy(
() => import('../components/Dashboard/Management/Vendors/VendorInfo')
)
const Materials = lazy(
() => import('../components/Dashboard/Management/Materials')
)
const MaterialInfo = lazy(
() => import('../components/Dashboard/Management/Materials/MaterialInfo.jsx')
)
const Couriers = lazy(
() => import('../components/Dashboard/Management/Couriers')
)
const CourierInfo = lazy(
() => import('../components/Dashboard/Management/Couriers/CourierInfo.jsx')
)
const CourierServices = lazy(
() => import('../components/Dashboard/Management/CourierServices')
)
const CourierServiceInfo = lazy(
() =>
import('../components/Dashboard/Management/CourierServices/CourierServiceInfo.jsx')
)
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')
)
const NoteTypes = lazy(
() => import('../components/Dashboard/Management/NoteTypes.jsx')
)
const NoteTypeInfo = lazy(
() => import('../components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx')
)
const NoteInfo = lazy(
() => import('../components/Dashboard/Management/Notes/NoteInfo.jsx')
)
const Users = lazy(() => import('../components/Dashboard/Management/Users.jsx'))
const UserInfo = lazy(() => import('../components/Dashboard/Management/Users/UserInfo.jsx'))
const AppPasswords = lazy(() => import('../components/Dashboard/Management/AppPasswords.jsx'))
const AppPasswordInfo = lazy(() => import('../components/Dashboard/Management/AppPasswords/AppPasswordInfo.jsx'))
const UserInfo = lazy(
() => import('../components/Dashboard/Management/Users/UserInfo.jsx')
)
const AppPasswords = lazy(
() => import('../components/Dashboard/Management/AppPasswords.jsx')
)
const AppPasswordInfo = lazy(
() =>
import('../components/Dashboard/Management/AppPasswords/AppPasswordInfo.jsx')
)
const Hosts = lazy(() => import('../components/Dashboard/Management/Hosts.jsx'))
const HostInfo = lazy(() => import('../components/Dashboard/Management/Hosts/HostInfo.jsx'))
const DocumentSizes = lazy(() => import('../components/Dashboard/Management/DocumentSizes.jsx'))
const DocumentSizeInfo = lazy(() => import('../components/Dashboard/Management/DocumentSizes/DocumentSizeInfo.jsx'))
const DocumentTemplates = lazy(() => import('../components/Dashboard/Management/DocumentTemplates.jsx'))
const DocumentTemplateInfo = lazy(() => import('../components/Dashboard/Management/DocumentTemplates/DocumentTemplateInfo.jsx'))
const DocumentPrinters = lazy(() => import('../components/Dashboard/Management/DocumentPrinters.jsx'))
const DocumentPrinterInfo = lazy(() => import('../components/Dashboard/Management/DocumentPrinters/DocumentPrinterInfo.jsx'))
const DocumentJobs = lazy(() => import('../components/Dashboard/Management/DocumentJobs.jsx'))
const DocumentJobInfo = lazy(() => import('../components/Dashboard/Management/DocumentJobs/DocumentJobInfo.jsx'))
const DocumentTemplateDesign = lazy(() => import('../components/Dashboard/Management/DocumentTemplates/DocumentTemplateDesign.jsx'))
const HostInfo = lazy(
() => import('../components/Dashboard/Management/Hosts/HostInfo.jsx')
)
const DocumentSizes = lazy(
() => import('../components/Dashboard/Management/DocumentSizes.jsx')
)
const DocumentSizeInfo = lazy(
() =>
import('../components/Dashboard/Management/DocumentSizes/DocumentSizeInfo.jsx')
)
const DocumentTemplates = lazy(
() => import('../components/Dashboard/Management/DocumentTemplates.jsx')
)
const DocumentTemplateInfo = lazy(
() =>
import('../components/Dashboard/Management/DocumentTemplates/DocumentTemplateInfo.jsx')
)
const DocumentPrinters = lazy(
() => import('../components/Dashboard/Management/DocumentPrinters.jsx')
)
const DocumentPrinterInfo = lazy(
() =>
import('../components/Dashboard/Management/DocumentPrinters/DocumentPrinterInfo.jsx')
)
const DocumentJobs = lazy(
() => import('../components/Dashboard/Management/DocumentJobs.jsx')
)
const DocumentJobInfo = lazy(
() =>
import('../components/Dashboard/Management/DocumentJobs/DocumentJobInfo.jsx')
)
const DocumentTemplateDesign = lazy(
() =>
import('../components/Dashboard/Management/DocumentTemplates/DocumentTemplateDesign.jsx')
)
const Files = lazy(() => import('../components/Dashboard/Management/Files.jsx'))
const FileInfo = lazy(() => import('../components/Dashboard/Management/Files/FileInfo.jsx'))
const TaxRates = lazy(() => import('../components/Dashboard/Management/TaxRates.jsx'))
const TaxRateInfo = lazy(() => import('../components/Dashboard/Management/TaxRates/TaxRateInfo.jsx'))
const TaxRecords = lazy(() => import('../components/Dashboard/Management/TaxRecords.jsx'))
const TaxRecordInfo = lazy(() => import('../components/Dashboard/Management/TaxRecords/TaxRecordInfo.jsx'))
const FileInfo = lazy(
() => import('../components/Dashboard/Management/Files/FileInfo.jsx')
)
const TaxRates = lazy(
() => import('../components/Dashboard/Management/TaxRates.jsx')
)
const TaxRateInfo = lazy(
() => import('../components/Dashboard/Management/TaxRates/TaxRateInfo.jsx')
)
const TaxRecords = lazy(
() => import('../components/Dashboard/Management/TaxRecords.jsx')
)
const TaxRecordInfo = lazy(
() =>
import('../components/Dashboard/Management/TaxRecords/TaxRecordInfo.jsx')
)
const About = lazy(() => import('../components/Dashboard/Management/About.jsx'))
const ManagementRoutes = [
<Route key='filaments' path='management/filaments' element={<Filaments />} />,
@ -58,7 +158,11 @@ const ManagementRoutes = [
path='management/filaments/info'
element={<FilamentInfo />}
/>,
<Route key='filamentskus' path='management/filamentskus' element={<FilamentSkus />} />,
<Route
key='filamentskus'
path='management/filamentskus'
element={<FilamentSkus />}
/>,
<Route
key='filamentskus-info'
path='management/filamentskus/info'
@ -92,7 +196,11 @@ const ManagementRoutes = [
path='management/productcategories/info'
element={<ProductCategoryInfo />}
/>,
<Route key='productskus' path='management/productskus' element={<ProductSkus />} />,
<Route
key='productskus'
path='management/productskus'
element={<ProductSkus />}
/>,
<Route
key='productskus-info'
path='management/productskus/info'
@ -208,6 +316,7 @@ const ManagementRoutes = [
/>,
<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 />} />,
<Route