feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
449
frontend/src/components/firmware/FirmwarePage.tsx
Normal file
449
frontend/src/components/firmware/FirmwarePage.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* FirmwarePage — firmware dashboard with version groups, upgrade buttons,
|
||||
* channel preferences, and upgrade progress tracking.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Building2,
|
||||
Download,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
firmwareApi,
|
||||
type FirmwareOverview,
|
||||
type DeviceFirmwareStatus,
|
||||
type FirmwareVersionGroup,
|
||||
} from '@/lib/firmwareApi'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { UpgradeProgressModal } from './UpgradeProgressModal'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className={cn('text-2xl font-bold', color)}>{value}</div>
|
||||
<div className="text-xs text-text-muted mt-1">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upgrade confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UpgradeDialog({
|
||||
open,
|
||||
onClose,
|
||||
tenantId,
|
||||
devices,
|
||||
targetVersion,
|
||||
channel,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
tenantId: string
|
||||
devices: DeviceFirmwareStatus[]
|
||||
targetVersion: string
|
||||
channel: string
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const isMass = devices.length > 1
|
||||
|
||||
// Check for major version change
|
||||
const hasMajorChange = devices.some((d) => {
|
||||
if (!d.routeros_version) return false
|
||||
const currentMajor = d.routeros_version.split('.')[0]
|
||||
const targetMajor = targetVersion.split('.')[0]
|
||||
return currentMajor !== targetMajor
|
||||
})
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isMass) {
|
||||
return firmwareApi.startMassUpgrade(tenantId, {
|
||||
device_ids: devices.map((d) => d.id),
|
||||
target_version: targetVersion,
|
||||
channel,
|
||||
confirmed_major_upgrade: hasMajorChange ? confirmed : false,
|
||||
})
|
||||
} else {
|
||||
return firmwareApi.startUpgrade(tenantId, {
|
||||
device_id: devices[0].id,
|
||||
target_version: targetVersion,
|
||||
architecture: devices[0].architecture ?? '',
|
||||
channel,
|
||||
confirmed_major_upgrade: hasMajorChange ? confirmed : false,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['firmware-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['upgrade-jobs'] })
|
||||
toast({ title: isMass ? 'Mass upgrade started' : 'Upgrade started' })
|
||||
onClose()
|
||||
|
||||
// Open progress modal
|
||||
if ('rollout_group_id' in data) {
|
||||
setProgressRolloutId(data.rollout_group_id)
|
||||
} else if ('job_id' in data) {
|
||||
setProgressJobId(data.job_id)
|
||||
}
|
||||
},
|
||||
onError: () =>
|
||||
toast({ title: 'Failed to start upgrade', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const [progressJobId, setProgressJobId] = useState<string | null>(null)
|
||||
const [progressRolloutId, setProgressRolloutId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isMass ? 'Mass Firmware Upgrade' : 'Firmware Upgrade'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isMass ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Upgrade <span className="text-text-primary font-medium">{devices.length}</span>{' '}
|
||||
devices to RouterOS{' '}
|
||||
<span className="font-mono text-text-primary">{targetVersion}</span>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Upgrade{' '}
|
||||
<span className="text-text-primary font-medium">{devices[0].hostname}</span>{' '}
|
||||
from{' '}
|
||||
<span className="font-mono text-text-secondary">
|
||||
{devices[0].routeros_version ?? 'unknown'}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="font-mono text-text-primary">{targetVersion}</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isMass && (
|
||||
<div className="rounded border border-info/40 bg-info/10 p-3 text-xs text-info">
|
||||
Devices will be upgraded one at a time (sequential rollout). If any
|
||||
device fails, the rollout will pause automatically.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMajorChange && (
|
||||
<div className="rounded border border-error/40 bg-error/10 p-3 space-y-2">
|
||||
<p className="text-xs text-error font-medium">
|
||||
WARNING: Major version upgrade detected
|
||||
</p>
|
||||
<p className="text-xs text-error/80">
|
||||
Upgrading across major versions (e.g., RouterOS 6 to 7) may cause
|
||||
breaking changes. Ensure you have reviewed the MikroTik migration
|
||||
guide before proceeding.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox
|
||||
checked={confirmed}
|
||||
onCheckedChange={(v) => setConfirmed(!!v)}
|
||||
/>
|
||||
<span className="text-error">I understand the risks</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded border border-border bg-surface p-3 text-xs text-text-secondary">
|
||||
A mandatory config backup will be taken before upgrading each device.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => upgradeMutation.mutate()}
|
||||
disabled={
|
||||
upgradeMutation.isPending || (hasMajorChange && !confirmed)
|
||||
}
|
||||
>
|
||||
{upgradeMutation.isPending
|
||||
? 'Starting...'
|
||||
: isMass
|
||||
? `Upgrade ${devices.length} devices`
|
||||
: 'Upgrade'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{progressJobId && (
|
||||
<UpgradeProgressModal
|
||||
open={!!progressJobId}
|
||||
onClose={() => setProgressJobId(null)}
|
||||
tenantId={tenantId}
|
||||
jobId={progressJobId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{progressRolloutId && (
|
||||
<UpgradeProgressModal
|
||||
open={!!progressRolloutId}
|
||||
onClose={() => setProgressRolloutId(null)}
|
||||
tenantId={tenantId}
|
||||
rolloutGroupId={progressRolloutId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version group card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VersionGroupCard({
|
||||
group,
|
||||
tenantId,
|
||||
canUpgrade,
|
||||
}: {
|
||||
group: FirmwareVersionGroup
|
||||
tenantId: string
|
||||
canUpgrade: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [upgradeTarget, setUpgradeTarget] = useState<DeviceFirmwareStatus[] | null>(null)
|
||||
|
||||
// Determine the target version for outdated devices
|
||||
const firstOutdated = group.devices.find((d) => !d.is_up_to_date && d.latest_version)
|
||||
const latestVersion = firstOutdated?.latest_version ?? ''
|
||||
const channel = firstOutdated?.channel ?? 'stable'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-surface transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
|
||||
<span className="font-mono text-sm text-text-primary">
|
||||
{group.version === 'unknown' ? 'Unknown' : `v${group.version}`}
|
||||
</span>
|
||||
|
||||
{group.is_latest ? (
|
||||
<span className="text-[10px] bg-success/20 text-success border border-success/40 rounded px-1.5 py-0.5">
|
||||
latest
|
||||
</span>
|
||||
) : group.version === 'unknown' ? (
|
||||
<span className="text-[10px] bg-elevated text-text-muted border border-border rounded px-1.5 py-0.5">
|
||||
unknown
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] bg-warning/20 text-warning border border-warning/40 rounded px-1.5 py-0.5">
|
||||
outdated
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-text-muted ml-auto">
|
||||
{group.count} device{group.count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
{canUpgrade && !group.is_latest && group.version !== 'unknown' && latestVersion && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs ml-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setUpgradeTarget(group.devices.filter((d) => !d.is_up_to_date))
|
||||
}}
|
||||
>
|
||||
Upgrade All to {latestVersion}
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center gap-3 px-4 py-1.5 text-[10px] text-text-muted font-medium uppercase">
|
||||
<span className="flex-1">Hostname</span>
|
||||
<span className="w-28">Model</span>
|
||||
<span className="w-24">Architecture</span>
|
||||
<span className="w-24">Serial</span>
|
||||
<span className="w-20">Channel</span>
|
||||
<span className="w-20">Status</span>
|
||||
<span className="w-20" />
|
||||
</div>
|
||||
{group.devices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center gap-3 px-4 py-2 border-t border-border/50 text-xs"
|
||||
>
|
||||
<span className="flex-1 text-text-secondary truncate">{device.hostname}</span>
|
||||
<span className="w-28 text-text-muted truncate">
|
||||
{device.model ?? '—'}
|
||||
</span>
|
||||
<span className="w-24 text-text-muted font-mono">
|
||||
{device.architecture ?? '—'}
|
||||
</span>
|
||||
<span className="w-24 text-text-muted font-mono">
|
||||
{device.serial_number || '—'}
|
||||
</span>
|
||||
<span className="w-20 text-text-muted">{device.channel}</span>
|
||||
<span className="w-20">
|
||||
{device.is_up_to_date ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-success" />
|
||||
) : device.routeros_version ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
|
||||
) : (
|
||||
<HelpCircle className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</span>
|
||||
<span className="w-20 text-right">
|
||||
{canUpgrade && !device.is_up_to_date && device.latest_version && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2"
|
||||
onClick={() => setUpgradeTarget([device])}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{upgradeTarget && latestVersion && (
|
||||
<UpgradeDialog
|
||||
open={!!upgradeTarget}
|
||||
onClose={() => setUpgradeTarget(null)}
|
||||
tenantId={tenantId}
|
||||
devices={upgradeTarget}
|
||||
targetVersion={latestVersion}
|
||||
channel={channel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FirmwarePage() {
|
||||
const { user } = useAuth()
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
queryKey: ['firmware-overview', tenantId],
|
||||
queryFn: () => firmwareApi.getFirmwareOverview(tenantId),
|
||||
enabled: !!tenantId,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const summary = overview?.summary ?? { total: 0, up_to_date: 0, outdated: 0, unknown: 0 }
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className="h-5 w-5 text-text-secondary" />
|
||||
<h1 className="text-lg font-semibold">Firmware</h1>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{!tenantId ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Building2 className="h-10 w-10 text-text-muted mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view firmware status.
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="Total Devices" value={summary.total} color="text-text-primary" />
|
||||
<StatCard label="Up to Date" value={summary.up_to_date} color="text-success" />
|
||||
<StatCard label="Outdated" value={summary.outdated} color="text-warning" />
|
||||
<StatCard label="Unknown" value={summary.unknown} color="text-text-muted" />
|
||||
</div>
|
||||
|
||||
{/* Version groups */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-text-secondary mb-3">Version Groups</h2>
|
||||
{overview?.version_groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Download}
|
||||
title="All firmware up to date"
|
||||
description="All devices are running the latest firmware version."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{overview?.version_groups.map((group) => (
|
||||
<VersionGroupCard
|
||||
key={group.version}
|
||||
group={group}
|
||||
tenantId={tenantId}
|
||||
canUpgrade={canWrite(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
340
frontend/src/components/firmware/UpgradeProgressModal.tsx
Normal file
340
frontend/src/components/firmware/UpgradeProgressModal.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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,
|
||||
type FirmwareUpgradeJob,
|
||||
} 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isDone ? 'text-success' : isActive ? config.color : isFailed ? 'text-error' : 'text-text-muted',
|
||||
isActive && !isFailed && 'animate-pulse',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isDone ? 'text-success' : isActive ? 'text-text-primary' : 'text-text-muted',
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleUpgradeProgress({
|
||||
tenantId,
|
||||
jobId,
|
||||
}: {
|
||||
tenantId: string
|
||||
jobId: string
|
||||
}) {
|
||||
// ── SSE live state ────────────────────────────────────────────────────
|
||||
const [sseStatus, setSSEStatus] = useState<string | null>(null)
|
||||
const [sseMessage, setSSEMessage] = useState<string | null>(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 <div className="text-sm text-text-muted py-4">Loading...</div>
|
||||
|
||||
// Use SSE status if available, fall back to polled status
|
||||
const displayStatus = sseStatus ?? job.status
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-text-secondary">
|
||||
{job.device_hostname ?? job.device_id.slice(0, 8)} — upgrading to{' '}
|
||||
<span className="text-text-primary font-mono">{job.target_version}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{STATUS_STEPS.map((step) => (
|
||||
<StatusStep key={step} step={step} currentStatus={displayStatus} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SSE live message */}
|
||||
{sseMessage && (
|
||||
<div className="text-xs text-info animate-pulse">
|
||||
{sseMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<div className="rounded border border-error/40 bg-error/10 p-3 text-xs text-error">
|
||||
{job.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.started_at && (
|
||||
<div className="text-xs text-text-muted">
|
||||
Started: {new Date(job.started_at).toLocaleString()}
|
||||
{job.completed_at && (
|
||||
<>
|
||||
{' — '}Finished: {new Date(job.completed_at).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MassUpgradeProgress({
|
||||
tenantId,
|
||||
rolloutGroupId,
|
||||
onResume,
|
||||
onAbort,
|
||||
}: {
|
||||
tenantId: string
|
||||
rolloutGroupId: string
|
||||
onResume?: () => void
|
||||
onAbort?: () => void
|
||||
}) {
|
||||
// ── SSE live message for current device ────────────────────────────────
|
||||
const [sseMessage, setSSEMessage] = useState<string | null>(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 <div className="text-sm text-text-muted py-4">Loading...</div>
|
||||
|
||||
const progressPct =
|
||||
rollout.total > 0
|
||||
? Math.round(((rollout.completed + rollout.failed) / rollout.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
||||
<span>
|
||||
{rollout.completed}/{rollout.total} devices
|
||||
</span>
|
||||
<span>{progressPct}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-elevated overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-success transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="text-success">Completed: {rollout.completed}</span>
|
||||
{rollout.failed > 0 && <span className="text-error">Failed: {rollout.failed}</span>}
|
||||
{rollout.paused > 0 && (
|
||||
<span className="text-warning">Paused: {rollout.paused}</span>
|
||||
)}
|
||||
{rollout.pending > 0 && <span className="text-text-muted">Pending: {rollout.pending}</span>}
|
||||
</div>
|
||||
|
||||
{rollout.current_device && (
|
||||
<div className="text-xs text-info">
|
||||
Currently upgrading: {rollout.current_device}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSE live message */}
|
||||
{sseMessage && (
|
||||
<div className="text-xs text-info animate-pulse">
|
||||
{sseMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device list */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden max-h-48 overflow-y-auto">
|
||||
{rollout.jobs.map((job) => {
|
||||
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 border-b border-border/50 last:border-0 text-xs"
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', config.color)} />
|
||||
<span className="text-text-secondary flex-1">
|
||||
{job.device_hostname ?? job.device_id.slice(0, 8)}
|
||||
</span>
|
||||
<span className={cn('text-[10px]', config.color)}>{config.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions for paused rollout */}
|
||||
{rollout.paused > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{onResume && (
|
||||
<Button size="sm" variant="outline" onClick={onResume}>
|
||||
Resume Rollout
|
||||
</Button>
|
||||
)}
|
||||
{onAbort && (
|
||||
<Button size="sm" variant="destructive" onClick={onAbort}>
|
||||
Abort Remaining
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{rolloutGroupId ? 'Mass Upgrade Progress' : 'Upgrade Progress'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{jobId && <SingleUpgradeProgress tenantId={tenantId} jobId={jobId} />}
|
||||
{rolloutGroupId && (
|
||||
<MassUpgradeProgress
|
||||
tenantId={tenantId}
|
||||
rolloutGroupId={rolloutGroupId}
|
||||
onResume={onResume}
|
||||
onAbort={onAbort}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user