/** * 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 ( !v && onClose()}> {rolloutGroupId ? 'Mass Upgrade Progress' : 'Upgrade Progress'} {jobId && } {rolloutGroupId && ( )}
) }