145 lines
5.3 KiB
TypeScript
145 lines
5.3 KiB
TypeScript
/**
|
|
* PushProgressPanel -- real-time per-device push status display.
|
|
* Polls the push status API every 3 seconds until all devices
|
|
* reach a terminal state (committed/reverted/failed).
|
|
*/
|
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { CheckCircle, XCircle, AlertTriangle, Loader2, Clock } from 'lucide-react'
|
|
import { templatesApi } from '@/lib/templatesApi'
|
|
import { cn } from '@/lib/utils'
|
|
import { formatDateTime } from '@/lib/utils'
|
|
import { DeviceLink } from '@/components/ui/device-link'
|
|
|
|
interface PushProgressPanelProps {
|
|
tenantId: string
|
|
rolloutId: string
|
|
onClose: () => void
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<string, { icon: typeof Clock; color: string; label: string }> = {
|
|
pending: { icon: Clock, color: 'text-text-muted', label: 'Pending' },
|
|
pushing: { icon: Loader2, color: 'text-info', label: 'Pushing' },
|
|
committed: { icon: CheckCircle, color: 'text-success', label: 'Committed' },
|
|
reverted: { icon: AlertTriangle, color: 'text-warning', label: 'Reverted' },
|
|
failed: { icon: XCircle, color: 'text-error', label: 'Failed' },
|
|
}
|
|
|
|
const TERMINAL_STATUSES = new Set(['committed', 'reverted', 'failed'])
|
|
|
|
export function PushProgressPanel({ tenantId, rolloutId, onClose }: PushProgressPanelProps) {
|
|
const { data } = useQuery({
|
|
queryKey: ['push-status', rolloutId],
|
|
queryFn: () => templatesApi.pushStatus(tenantId, rolloutId),
|
|
refetchInterval: (query) => {
|
|
const jobs = query.state.data?.jobs ?? []
|
|
const allTerminal = jobs.length > 0 && jobs.every((j) => TERMINAL_STATUSES.has(j.status))
|
|
return allTerminal ? false : 3000
|
|
},
|
|
})
|
|
|
|
const jobs = data?.jobs ?? []
|
|
const total = jobs.length
|
|
const committed = jobs.filter((j) => j.status === 'committed').length
|
|
const failed = jobs.filter((j) => j.status === 'failed').length
|
|
const reverted = jobs.filter((j) => j.status === 'reverted').length
|
|
const pending = jobs.filter((j) => j.status === 'pending').length
|
|
const allDone = jobs.length > 0 && jobs.every((j) => TERMINAL_STATUSES.has(j.status))
|
|
const hasFailed = failed > 0 || reverted > 0
|
|
|
|
// Progress percentage
|
|
const completedCount = committed + failed + reverted
|
|
const progressPct = total > 0 ? Math.round((completedCount / total) * 100) : 0
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-text-primary font-medium">Push Progress</div>
|
|
<div className="text-xs text-text-muted">
|
|
{completedCount} / {total} devices
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="h-2 bg-elevated/50 rounded-full overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'h-full transition-all duration-500 rounded-full',
|
|
hasFailed ? 'bg-error' : 'bg-success',
|
|
)}
|
|
style={{ width: `${progressPct}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Status message */}
|
|
{allDone && !hasFailed && (
|
|
<div className="text-xs text-success bg-success/10 rounded-lg px-3 py-2">
|
|
Push complete -- all {committed} devices configured successfully
|
|
</div>
|
|
)}
|
|
|
|
{allDone && hasFailed && pending > 0 && (
|
|
<div className="text-xs text-warning bg-warning/10 rounded-lg px-3 py-2">
|
|
Push paused -- {failed + reverted} device(s) failed/reverted.{' '}
|
|
{pending} device(s) remain pending.
|
|
</div>
|
|
)}
|
|
|
|
{allDone && hasFailed && pending === 0 && (
|
|
<div className="text-xs text-error bg-error/10 rounded-lg px-3 py-2">
|
|
Push complete with errors -- {failed} failed, {reverted} reverted out of {total} devices.
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-device list */}
|
|
<div className="space-y-1">
|
|
{jobs.map((job) => {
|
|
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
|
|
const Icon = config.icon
|
|
|
|
return (
|
|
<div
|
|
key={job.device_id}
|
|
className="flex items-center gap-3 rounded-lg border border-border/50 bg-surface/50 px-3 py-2"
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
'h-4 w-4 flex-shrink-0',
|
|
config.color,
|
|
job.status === 'pushing' && 'animate-spin',
|
|
)}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs text-text-primary truncate"><DeviceLink tenantId={tenantId} deviceId={job.device_id}>{job.hostname}</DeviceLink></div>
|
|
{job.error_message && (
|
|
<div className="text-[10px] text-error truncate">{job.error_message}</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className={cn('text-[10px] px-1.5 py-0.5 rounded', config.color, 'bg-elevated/50')}>
|
|
{config.label}
|
|
</span>
|
|
{job.completed_at && (
|
|
<span className="text-[10px] text-text-muted">{formatDateTime(job.completed_at)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{allDone && (
|
|
<div className="flex justify-end pt-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="text-xs text-text-secondary hover:text-text-primary transition-colors underline"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|