Tom Butcher 3caa62d675
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor AppUpdateProgress for improved stage tracking and status management
- Introduced a new mechanism to track the active update stage using an index, allowing for better handling of out-of-order progress events.
- Simplified status determination functions for download, install, and restart stages, enhancing clarity and maintainability.
- Updated the rendering logic to ensure accurate display of progress and status messages, improving user experience during updates.
- Adjusted modal width in AppUpdateContext for better layout consistency.
2026-08-09 14:36:19 +01:00

287 lines
7.8 KiB
JavaScript

import PropTypes from 'prop-types'
import { useEffect, useState } from 'react'
import { Button, Flex, Modal, Typography, theme, Divider } from 'antd'
import CloudIcon from '../../../Icons/CloudIcon'
import HostIcon from '../../../Icons/HostIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import HProgress from '../../common/HProgress'
import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
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 STAGE_CONFIG = {
download: {
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'
}
},
restart: {
icon: ReloadIcon,
labels: {
pending: 'Restart',
active: 'Restarting...',
complete: 'Restarted',
error: 'Restart 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 STAGE_INDEX = { download: 0, install: 1, restart: 2 }
const isCompletionMessage = (message) => {
const normalized = String(message || '').toLowerCase()
return (
normalized.includes('complete') ||
normalized.includes('successful') ||
normalized.includes('restarting')
)
}
// Maps a progress event to the stage that should currently be active.
// Returns null when the event carries no stage information (e.g. errors),
// so the previously reached stage is kept.
const getStageIndexFromProgress = (phase, percent, message) => {
if (phase === 'restarting') return STAGE_INDEX.restart
if (phase === 'installing') {
return percent >= 100 || isCompletionMessage(message)
? STAGE_INDEX.restart
: STAGE_INDEX.install
}
if (phase === 'downloaded') return STAGE_INDEX.install
if (phase === 'preparing' || phase === 'downloading') {
return phase === 'downloading' && percent >= 100
? STAGE_INDEX.install
: STAGE_INDEX.download
}
return null
}
const getStageStatus = (stageIndex, activeIndex, isError) => {
if (stageIndex < activeIndex) return 'complete'
if (stageIndex > activeIndex) return 'pending'
return isError ? 'error' : 'active'
}
const getProgressStatus = (stageStatus) => {
if (stageStatus === 'error') return 'exception'
if (stageStatus === 'complete') return 'success'
return 'active'
}
const UpdateStage = ({ stage, status, percent, detail }) => {
const { token } = theme.useToken()
const config = STAGE_CONFIG[stage]
const StageIcon = config.icon
const resolvedPercent =
typeof percent === 'number' ? Math.min(percent, 100) : undefined
const color = getStageColor(status, token)
const showProgress = status === 'active' && stage !== 'restart'
const StatusIcon =
status === 'complete'
? CheckCircleIcon
: status === 'error'
? XMarkCircleIcon
: StageIcon
return (
<Flex align='start' gap='middle' style={{ width: '100%' }}>
<StatusIcon style={{ fontSize: 22, color, flexShrink: 0 }} />
<Flex align='start' gap='24px' style={{ flex: 1, minWidth: 0 }}>
<Text style={{ flexShrink: 0 }}>{config.labels[status]}</Text>
{showProgress && (
<Flex vertical gap={2} style={{ flex: 1, minWidth: 0 }}>
<HProgress
percent={resolvedPercent}
status={getProgressStatus(status)}
showInfo={typeof resolvedPercent === 'number'}
style={{ flex: 1, margin: 0 }}
/>
{detail && (
<Text type='secondary' ellipsis style={{ minWidth: 0 }}>
{detail}
</Text>
)}
</Flex>
)}
</Flex>
</Flex>
)
}
UpdateStage.propTypes = {
stage: PropTypes.oneOf(['download', 'install', 'restart']).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 percent =
typeof progress?.percent === 'number'
? Math.min(progress.percent, 100)
: null
const downloaded = formatBytes(progress?.downloadedBytes)
const total = formatBytes(progress?.totalBytes)
const message = progress?.message || 'Preparing update'
const isError = phase === 'error'
const [errorModalOpen, setErrorModalOpen] = useState(true)
// Track the furthest stage reached so out-of-order or skipped progress
// events can never move the steps backwards.
const [activeStageIndex, setActiveStageIndex] = useState(STAGE_INDEX.download)
useEffect(() => {
const stageIndex = getStageIndexFromProgress(phase, percent, message)
if (stageIndex !== null) {
setActiveStageIndex((previous) => Math.max(previous, stageIndex))
}
}, [phase, percent, message])
const downloadStatus = getStageStatus(
STAGE_INDEX.download,
activeStageIndex,
isError
)
const installStatus = getStageStatus(
STAGE_INDEX.install,
activeStageIndex,
isError
)
const restartStatus = getStageStatus(
STAGE_INDEX.restart,
activeStageIndex,
isError
)
const downloadPercent =
downloadStatus === 'active' && phase === 'downloading' ? percent : 0
const installPercent =
installStatus === 'active' && phase === 'installing' ? percent : null
const downloadDetail =
downloadStatus === 'active' && downloaded && total
? `${downloaded} of ${total}`
: null
const installDetail =
installStatus === 'active' && phase === 'installing' ? message : null
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>
<Divider style={{ margin: '4px 0' }} />
<Flex vertical gap='middle'>
<UpdateStage
stage='download'
status={downloadStatus}
percent={downloadPercent}
detail={downloadDetail}
/>
<UpdateStage
stage='install'
status={installStatus}
percent={installPercent}
detail={installDetail}
/>
<UpdateStage stage='restart' status={restartStatus} />
</Flex>
<Modal
title='Update Failed'
open={isError && errorModalOpen == true}
centered
closable={false}
maskClosable={false}
onCancel={() => {
setErrorModalOpen(false)
onClose()
}}
footer={[
<Button
key='close'
onClick={() => {
setErrorModalOpen(false)
onClose()
}}
>
Close
</Button>
]}
>
<Text>{message}</Text>
</Modal>
</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,
onClose: PropTypes.func
}
export default AppUpdateProgress