Enhance AppUpdateProgress component with detailed download and installation stages; introduce modal for error handling and improve progress status management.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-06-21 19:08:18 +01:00
parent 71fe6e3462
commit 8a0bc22124
2 changed files with 168 additions and 40 deletions

View File

@ -1,6 +1,12 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Alert, Flex, Progress, Typography } from 'antd' import { useState } from 'react'
import { LoadingOutlined } from '@ant-design/icons' import { Button, Flex, Modal, Progress, Typography, theme } from 'antd'
import CloudIcon from '../../../Icons/CloudIcon'
import HostIcon from '../../../Icons/HostIcon'
import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
const { Text } = Typography const { Text } = Typography
@ -21,27 +27,137 @@ const formatBytes = (bytes) => {
}` }`
} }
const getProgressStatus = (phase) => { const STAGE_CONFIG = {
if (phase === 'error') return 'exception' download: {
if (phase === 'downloaded' || phase === 'installing') return 'success' icon: CloudIcon,
labels: {
pending: 'Download',
active: 'Downloading',
complete: 'Downloaded',
error: 'Download failed'
}
},
install: {
icon: HostIcon,
labels: {
pending: 'Install',
active: 'Installing',
complete: 'Installed',
error: 'Install failed'
}
}
}
const getStageColor = (status, token) => {
if (status === 'complete') return token.colorSuccess
if (status === 'active') return token.colorPrimary
if (status === 'error') return token.colorError
return token.colorTextQuaternary
}
const getDownloadStageStatus = (phase, isError) => {
if (isError && ['preparing', 'downloading'].includes(phase)) return 'error'
if (['downloaded', 'installing'].includes(phase)) return 'complete'
if (['preparing', 'downloading'].includes(phase)) return 'active'
return 'pending'
}
const getInstallStageStatus = (phase, isError, message) => {
if (isError && ['downloaded', 'installing'].includes(phase)) return 'error'
if (
phase === 'installing' &&
String(message || '')
.toLowerCase()
.includes('complete')
) {
return 'complete'
}
if (phase === 'installing') return 'active'
return 'pending'
}
const getProgressStatus = (stageStatus) => {
if (stageStatus === 'error') return 'exception'
if (stageStatus === 'complete') return 'success'
return 'active' return 'active'
} }
const AppUpdateProgress = ({ progress, update }) => { const UpdateStage = ({ stage, status, percent, detail }) => {
const { token } = theme.useToken()
const config = STAGE_CONFIG[stage]
const StageIcon = config.icon
const color = getStageColor(status, token)
const showProgress = status === 'active'
const resolvedPercent =
typeof percent === 'number' ? Math.min(percent, 100) : undefined
const StatusIcon =
status === 'complete'
? CheckCircleIcon
: status === 'error'
? XMarkCircleIcon
: StageIcon
return (
<Flex vertical gap={4}>
<Flex align='center' gap='middle'>
<StatusIcon style={{ fontSize: 20, color, flexShrink: 0 }} />
<Flex align='center' gap='small' style={{ flex: 1, minWidth: 0 }}>
<Text style={{ flexShrink: 0, minWidth: 96 }}>
{config.labels[status]}
</Text>
{showProgress && (
<Progress
percent={resolvedPercent}
status={getProgressStatus(status)}
showInfo={typeof resolvedPercent === 'number'}
style={{ flex: 1, margin: 0 }}
/>
)}
</Flex>
</Flex>
{detail && (
<Text type='secondary' style={{ marginLeft: 36 }}>
{detail}
</Text>
)}
</Flex>
)
}
UpdateStage.propTypes = {
stage: PropTypes.oneOf(['download', 'install']).isRequired,
status: PropTypes.oneOf(['pending', 'active', 'complete', 'error'])
.isRequired,
percent: PropTypes.number,
detail: PropTypes.string
}
const AppUpdateProgress = ({ progress, update, onClose }) => {
const phase = progress?.phase || 'preparing' const phase = progress?.phase || 'preparing'
const percent = const percent =
typeof progress?.percent === 'number' ? Math.min(progress.percent, 100) : 0 typeof progress?.percent === 'number'
? Math.min(progress.percent, 100)
: null
const downloaded = formatBytes(progress?.downloadedBytes) const downloaded = formatBytes(progress?.downloadedBytes)
const total = formatBytes(progress?.totalBytes) const total = formatBytes(progress?.totalBytes)
const artifactName =
progress?.artifact?.fileName ||
progress?.artifact?.relativePath ||
'Selected installer'
const message = progress?.message || 'Preparing update' const message = progress?.message || 'Preparing update'
const isInstalling = phase === 'installing'
const isError = phase === 'error' const isError = phase === 'error'
const showProgress =
!isError && (!isInstalling || typeof progress?.percent === 'number') const [errorModalOpen, setErrorModalOpen] = useState(true)
const downloadStatus = getDownloadStageStatus(phase, isError)
const installStatus = getInstallStageStatus(phase, isError, message)
const downloadPercent =
downloadStatus === 'active' ? (phase === 'preparing' ? 0 : percent) : null
const installPercent = installStatus === 'active' ? percent : null
const downloadDetail =
downloadStatus === 'active' && downloaded && total
? `${downloaded} of ${total}`
: null
return ( return (
<Flex vertical gap='middle'> <Flex vertical gap='middle'>
@ -52,34 +168,44 @@ const AppUpdateProgress = ({ progress, update }) => {
{update?.branch ? `${update.branch}` : 'unknown'}... {update?.branch ? `${update.branch}` : 'unknown'}...
</Text> </Text>
{isError ? ( <Flex vertical gap='middle'>
<Alert type='error' showIcon message={message} /> <UpdateStage
) : ( stage='download'
<Alert status={downloadStatus}
type={isInstalling ? 'success' : 'info'} percent={downloadPercent}
showIcon detail={downloadDetail}
icon={isInstalling ? undefined : <LoadingOutlined />}
message={
isInstalling
? message
: `Downloading ${artifactName}`
}
/> />
)} <UpdateStage
stage='install'
{showProgress && ( status={installStatus}
<Progress percent={installPercent}
percent={percent}
status={getProgressStatus(phase)}
showInfo={phase !== 'preparing'}
/> />
)} </Flex>
{downloaded && total && ( <Modal
<Text type='secondary'> title='Update Failed'
{downloaded} of {total} downloaded open={isError && errorModalOpen == true}
</Text> centered
)} closable={false}
maskClosable={false}
onCancel={() => {
setErrorModalOpen(false)
onClose()
}}
footer={[
<Button
key='close'
onClick={() => {
setErrorModalOpen(false)
onClose()
}}
>
Close
</Button>
]}
>
<Text>{message}</Text>
</Modal>
</Flex> </Flex>
) )
} }
@ -93,7 +219,8 @@ AppUpdateProgress.propTypes = {
message: PropTypes.string, message: PropTypes.string,
artifact: PropTypes.object artifact: PropTypes.object
}), }),
update: PropTypes.object update: PropTypes.object,
onClose: PropTypes.func
} }
export default AppUpdateProgress export default AppUpdateProgress

View File

@ -269,6 +269,7 @@ export const AppUpdateProvider = ({ children }) => {
<AppUpdateProgress <AppUpdateProgress
progress={updateProgress} progress={updateProgress}
update={installingUpdate} update={installingUpdate}
onClose={closeUpdateModal}
/> />
) : ( ) : (
<NewAppUpdate <NewAppUpdate