/**
* UpgradeProgressModal — real-time upgrade progress tracking.
* Supports single-device and mass rollout views.
* Uses SSE firmware-progress events for live updates with polling as fallback.
*/
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Circle,
Clock,
Download,
Upload,
RefreshCw,
Search,
CheckCircle,
XCircle,
PauseCircle,
} from 'lucide-react'
import { firmwareApi } from '@/lib/firmwareApi'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<
string,
{ icon: React.FC<{ className?: string }>; label: string; color: string }
> = {
pending: { icon: Circle, label: 'Pending', color: 'text-text-muted' },
scheduled: { icon: Clock, label: 'Scheduled', color: 'text-info' },
downloading: { icon: Download, label: 'Downloading NPK', color: 'text-info' },
uploading: { icon: Upload, label: 'Uploading to device', color: 'text-info' },
rebooting: { icon: RefreshCw, label: 'Rebooting', color: 'text-warning' },
verifying: { icon: Search, label: 'Verifying version', color: 'text-warning' },
completed: { icon: CheckCircle, label: 'Completed', color: 'text-success' },
failed: { icon: XCircle, label: 'Failed', color: 'text-error' },
paused: { icon: PauseCircle, label: 'Paused', color: 'text-warning' },
}
const STATUS_STEPS = [
'pending',
'downloading',
'uploading',
'rebooting',
'verifying',
'completed',
]
function StatusStep({ step, currentStatus }: { step: string; currentStatus: string }) {
const config = STATUS_CONFIG[step] ?? STATUS_CONFIG.pending
const Icon = config.icon
const currentIndex = STATUS_STEPS.indexOf(currentStatus)
const stepIndex = STATUS_STEPS.indexOf(step)
const isActive = step === currentStatus
const isDone = currentStatus === 'completed' || (stepIndex < currentIndex && currentStatus !== 'failed')
const isFailed = currentStatus === 'failed' && isActive
return (
{config.label}
)
}
function SingleUpgradeProgress({
tenantId,
jobId,
}: {
tenantId: string
jobId: string
}) {
// ── SSE live state ────────────────────────────────────────────────────
const [sseStatus, setSSEStatus] = useState(null)
const [sseMessage, setSSEMessage] = useState(null)
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
job_id?: string
device_id?: string
stage: string
message?: string
}
if (detail.job_id === jobId) {
setSSEStatus(detail.stage)
setSSEMessage(detail.message ?? null)
}
}
window.addEventListener('firmware-progress', handler)
return () => window.removeEventListener('firmware-progress', handler)
}, [jobId])
// ── Polling fallback (slower interval since SSE provides live updates) ─
const { data: job } = useQuery({
queryKey: ['upgrade-job', tenantId, jobId],
queryFn: () => firmwareApi.getUpgradeJob(tenantId, jobId),
refetchInterval: (query) => {
const status = sseStatus ?? query.state.data?.status
if (status === 'completed' || status === 'failed') return false
return 15_000 // Slower poll as backup when SSE is active
},
})
if (!job) return Loading...
// Use SSE status if available, fall back to polled status
const displayStatus = sseStatus ?? job.status
return (
{job.device_hostname ?? job.device_id.slice(0, 8)} — upgrading to{' '}
{job.target_version}
{STATUS_STEPS.map((step) => (
))}
{/* SSE live message */}
{sseMessage && (
{sseMessage}
)}
{job.error_message && (
{job.error_message}
)}
{job.started_at && (
Started: {new Date(job.started_at).toLocaleString()}
{job.completed_at && (
<>
{' — '}Finished: {new Date(job.completed_at).toLocaleString()}
>
)}
)}
)
}
function MassUpgradeProgress({
tenantId,
rolloutGroupId,
onResume,
onAbort,
}: {
tenantId: string
rolloutGroupId: string
onResume?: () => void
onAbort?: () => void
}) {
// ── SSE live message for current device ────────────────────────────────
const [sseMessage, setSSEMessage] = useState(null)
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
job_id?: string
device_id?: string
stage: string
message?: string
}
// Any firmware progress event is relevant to mass upgrade
setSSEMessage(detail.message ?? `Stage: ${detail.stage}`)
}
window.addEventListener('firmware-progress', handler)
return () => window.removeEventListener('firmware-progress', handler)
}, [])
const { data: rollout } = useQuery({
queryKey: ['rollout-status', tenantId, rolloutGroupId],
queryFn: () => firmwareApi.getRolloutStatus(tenantId, rolloutGroupId),
refetchInterval: (query) => {
const data = query.state.data
if (!data) return 5_000
if (data.completed + data.failed >= data.total) return false
if (data.paused > 0) return false
return 15_000 // Slower poll as backup when SSE is active
},
})
if (!rollout) return Loading...
const progressPct =
rollout.total > 0
? Math.round(((rollout.completed + rollout.failed) / rollout.total) * 100)
: 0
return (
{/* Progress bar */}
{rollout.completed}/{rollout.total} devices
{progressPct}%
{/* Summary */}
Completed: {rollout.completed}
{rollout.failed > 0 && Failed: {rollout.failed}}
{rollout.paused > 0 && (
Paused: {rollout.paused}
)}
{rollout.pending > 0 && Pending: {rollout.pending}}
{rollout.current_device && (
Currently upgrading: {rollout.current_device}
)}
{/* SSE live message */}
{sseMessage && (
{sseMessage}
)}
{/* Device list */}
{rollout.jobs.map((job) => {
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
const Icon = config.icon
return (
{job.device_hostname ?? job.device_id.slice(0, 8)}
{config.label}
)
})}
{/* Actions for paused rollout */}
{rollout.paused > 0 && (
{onResume && (
)}
{onAbort && (
)}
)}
)
}
export function UpgradeProgressModal({
open,
onClose,
tenantId,
jobId,
rolloutGroupId,
onResume,
onAbort,
}: {
open: boolean
onClose: () => void
tenantId: string
jobId?: string
rolloutGroupId?: string
onResume?: () => void
onAbort?: () => void
}) {
return (
)
}