Files
the-other-dude/frontend/src/components/certificates/BulkDeployDialog.tsx
Jason Staack c455fe4ed5 feat(ui): sweep remaining components for Deep Space consistency
Replace old design tokens and hardcoded colors across 29 files:
- bg-primary/text-primary-foreground -> bg-accent/text-white
- text-muted-foreground -> text-text-muted
- text-destructive/bg-destructive -> text-error/bg-error
- bg-muted -> bg-elevated (background usage)
- Hardcoded green/red/yellow/emerald/amber/slate -> semantic tokens
- Remove shadow-md/lg from cards, tooltips, topology nodes
- rounded-xl -> rounded-lg on cards/panels
- focus:ring-1 focus:ring-ring -> focus:border-accent on inputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:00:36 -05:00

328 lines
11 KiB
TypeScript

/**
* BulkDeployDialog -- Multi-device certificate deployment dialog.
*
* Shows a checkbox list of devices without deployed certs, with Select All / Deselect All.
* On deploy, calls bulkDeploy API and shows progress + results summary.
*/
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Layers,
Loader2,
CheckCircle,
XCircle,
Check,
} from 'lucide-react'
import { certificatesApi } from '@/lib/certificatesApi'
import { devicesApi, type DeviceResponse } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
type BulkStep = 'select' | 'deploying' | 'results'
interface BulkResult {
success: number
failed: number
errors: Array<{ device_id: string; error: string }>
}
interface BulkDeployDialogProps {
open: boolean
onClose: () => void
tenantId: string
}
export function BulkDeployDialog({
open,
onClose,
tenantId,
}: BulkDeployDialogProps) {
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [step, setStep] = useState<BulkStep>('select')
const [result, setResult] = useState<BulkResult | null>(null)
// Fetch devices
const { data: deviceList = [] } = useQuery({
queryKey: ['devices-for-cert', tenantId],
queryFn: async () => {
const result = await devicesApi.list(tenantId)
return (result as { items?: DeviceResponse[] }).items ?? (result as DeviceResponse[])
},
enabled: !!tenantId && open,
})
// Fetch existing device certs to filter
const { data: existingCerts = [] } = useQuery({
queryKey: ['deviceCerts', tenantId],
queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId),
enabled: !!tenantId && open,
})
const deployedDeviceIds = new Set(
existingCerts
.filter((c) => c.status === 'deployed' || c.status === 'deploying')
.map((c) => c.device_id),
)
const availableDevices = (deviceList as DeviceResponse[]).filter(
(d) => !deployedDeviceIds.has(d.id),
)
const toggleDevice = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const selectAll = () => {
setSelected(new Set(availableDevices.map((d) => d.id)))
}
const deselectAll = () => {
setSelected(new Set())
}
const handleDeploy = async () => {
if (selected.size === 0) return
setStep('deploying')
try {
const responses = await certificatesApi.bulkDeploy(Array.from(selected), tenantId)
const succeeded = responses.filter((r) => r.success).length
const failed = responses.filter((r) => !r.success)
const bulkResult: BulkResult = {
success: succeeded,
failed: failed.length,
errors: failed.map((f) => ({
device_id: f.device_id,
error: f.error ?? 'Unknown error',
})),
}
setResult(bulkResult)
setStep('results')
void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] })
if (failed.length === 0) {
toast({ title: `${succeeded} certificate(s) deployed successfully` })
} else {
toast({
title: `${succeeded} deployed, ${failed.length} failed`,
variant: 'destructive',
})
}
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
setResult({
success: 0,
failed: selected.size,
errors: [
{
device_id: 'bulk',
error: err?.response?.data?.detail || 'Bulk deployment failed',
},
],
})
setStep('results')
toast({ title: 'Bulk deployment failed', variant: 'destructive' })
}
}
const handleClose = () => {
onClose()
setSelected(new Set())
setStep('select')
setResult(null)
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Bulk Certificate Deployment</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{step === 'select' && (
<>
<p className="text-sm text-text-secondary">
Select devices to sign and deploy TLS certificates in batch.
</p>
{availableDevices.length === 0 ? (
<div className="rounded-lg border border-border bg-elevated/50 p-4 text-center">
<CheckCircle className="h-6 w-6 text-success mx-auto mb-2" />
<p className="text-sm font-medium text-text-primary">
All devices have certificates
</p>
<p className="text-xs text-text-muted mt-1">
Every device already has a deployed certificate.
</p>
</div>
) : (
<>
{/* Select All / Deselect All */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">
{selected.size} of {availableDevices.length} selected
</span>
<div className="flex gap-2">
<button
className="text-xs text-accent hover:underline"
onClick={selectAll}
>
Select All
</button>
<button
className="text-xs text-text-muted hover:underline"
onClick={deselectAll}
>
Deselect All
</button>
</div>
</div>
{/* Device list */}
<div className="max-h-64 overflow-y-auto rounded-lg border border-border divide-y divide-border">
{availableDevices.map((d: DeviceResponse) => (
<label
key={d.id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-elevated/30 cursor-pointer transition-colors"
>
<Checkbox
checked={selected.has(d.id)}
onCheckedChange={() => toggleDevice(d.id)}
/>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-text-primary block truncate">
{d.hostname}
</span>
<span className="text-xs text-text-muted">
{d.ip_address}
</span>
</div>
<span
className={cn(
'text-[10px] uppercase px-1.5 py-0.5 rounded',
d.status === 'online'
? 'bg-success/10 text-success'
: 'bg-text-muted/10 text-text-muted',
)}
>
{d.status}
</span>
</label>
))}
</div>
<Button
className="w-full"
disabled={selected.size === 0}
onClick={handleDeploy}
>
<Layers className="h-4 w-4 mr-2" />
Deploy to {selected.size} device
{selected.size !== 1 ? 's' : ''}
</Button>
</>
)}
</>
)}
{step === 'deploying' && (
<div className="py-8 text-center space-y-3">
<Loader2 className="h-8 w-8 text-accent mx-auto animate-spin" />
<p className="text-sm font-medium text-text-primary">
Deploying certificates...
</p>
<p className="text-xs text-text-muted">
Signing and deploying to {selected.size} device
{selected.size !== 1 ? 's' : ''}. This may take a moment.
</p>
</div>
)}
{step === 'results' && result && (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-success/30 bg-success/5 p-4 text-center">
<CheckCircle className="h-6 w-6 text-success mx-auto mb-1" />
<p className="text-2xl font-bold text-success">
{result.success}
</p>
<p className="text-xs text-text-muted">Succeeded</p>
</div>
<div
className={cn(
'rounded-lg border p-4 text-center',
result.failed > 0
? 'border-error/30 bg-error/5'
: 'border-border bg-surface',
)}
>
<XCircle
className={cn(
'h-6 w-6 mx-auto mb-1',
result.failed > 0 ? 'text-error' : 'text-text-muted',
)}
/>
<p
className={cn(
'text-2xl font-bold',
result.failed > 0 ? 'text-error' : 'text-text-muted',
)}
>
{result.failed}
</p>
<p className="text-xs text-text-muted">Failed</p>
</div>
</div>
{/* Error details */}
{result.errors.length > 0 && (
<div className="rounded-lg border border-error/30 bg-error/5 p-3 space-y-2">
<p className="text-xs font-medium text-error">
Failed deployments:
</p>
{result.errors.map((err, i) => (
<div
key={i}
className="text-xs text-text-secondary flex items-start gap-2"
>
<XCircle className="h-3 w-3 text-error mt-0.5 flex-shrink-0" />
<span>{err.error}</span>
</div>
))}
</div>
)}
<Button className="w-full" onClick={handleClose}>
<Check className="h-4 w-4 mr-2" />
Done
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}