Files
the-other-dude/frontend/src/components/templates/TemplatePushWizard.tsx
2026-03-14 22:50:50 -05:00

437 lines
16 KiB
TypeScript

/**
* TemplatePushWizard -- multi-step dialog for pushing a template to devices.
* Steps: Target Selection -> Variable Input -> Preview -> Confirm & Push -> Progress
*/
import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Loader2, AlertTriangle, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import {
templatesApi,
type TemplateResponse,
} from '@/lib/templatesApi'
import { deviceGroupsApi, metricsApi } from '@/lib/api'
import { PushProgressPanel } from './PushProgressPanel'
interface TemplatePushWizardProps {
open: boolean
onClose: () => void
tenantId: string
template: TemplateResponse
}
type WizardStep = 'targets' | 'variables' | 'preview' | 'confirm' | 'progress'
export function TemplatePushWizard({ open, onClose, tenantId, template }: TemplatePushWizardProps) {
const [step, setStep] = useState<WizardStep>('targets')
const [selectedDeviceIds, setSelectedDeviceIds] = useState<Set<string>>(new Set())
const [variables, setVariables] = useState<Record<string, string>>(() => {
const defaults: Record<string, string> = {}
for (const v of template.variables) {
if (v.default) defaults[v.name] = v.default
}
return defaults
})
const [previewDevice, setPreviewDevice] = useState<string | null>(null)
const [previews, setPreviews] = useState<Record<string, string>>({})
const [rolloutId, setRolloutId] = useState<string | null>(null)
// Fetch devices
const { data: devices } = useQuery({
queryKey: ['fleet-devices', tenantId],
queryFn: () => metricsApi.fleetSummary(tenantId),
enabled: open,
})
// Fetch groups
const { data: groups } = useQuery({
queryKey: ['device-groups', tenantId],
queryFn: () => deviceGroupsApi.list(tenantId),
enabled: open,
})
// Preview mutation
const previewMutation = useMutation({
mutationFn: ({ deviceId }: { deviceId: string }) =>
templatesApi.preview(tenantId, template.id, deviceId, variables),
onSuccess: (data, { deviceId }) => {
setPreviews((prev) => ({ ...prev, [deviceId]: data.rendered }))
},
})
// Push mutation
const pushMutation = useMutation({
mutationFn: () =>
templatesApi.push(tenantId, template.id, Array.from(selectedDeviceIds), variables),
onSuccess: (data) => {
setRolloutId(data.rollout_id)
setStep('progress')
},
})
const selectedDevices = devices?.filter((d) => selectedDeviceIds.has(d.id)) ?? []
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleGroupSelect = (_groupId: string) => {
// For now, just select all online devices. In a real implementation,
// we'd load group members from the API. Here we select all devices
// as a simplified approach.
if (devices) {
const onlineIds = new Set(devices.filter((d) => d.status === 'online').map((d) => d.id))
setSelectedDeviceIds(onlineIds)
}
}
const toggleDevice = (deviceId: string) => {
setSelectedDeviceIds((prev) => {
const next = new Set(prev)
if (next.has(deviceId)) {
next.delete(deviceId)
} else {
next.add(deviceId)
}
return next
})
}
const goToPreview = () => {
setStep('preview')
// Trigger preview for first selected device
if (selectedDevices.length > 0) {
const firstId = selectedDevices[0].id
setPreviewDevice(firstId)
if (!previews[firstId]) {
previewMutation.mutate({ deviceId: firstId })
}
}
}
const selectPreviewDevice = (deviceId: string) => {
setPreviewDevice(deviceId)
if (!previews[deviceId]) {
previewMutation.mutate({ deviceId })
}
}
const handleClose = () => {
setStep('targets')
setSelectedDeviceIds(new Set())
setVariables(() => {
const defaults: Record<string, string> = {}
for (const v of template.variables) {
if (v.default) defaults[v.name] = v.default
}
return defaults
})
setPreviews({})
setRolloutId(null)
onClose()
}
const userVars = template.variables.filter((v) => v.name !== 'device')
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-surface border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm flex items-center gap-2">
Push Template: {template.name}
{step !== 'progress' && (
<span className="text-[10px] text-text-muted font-normal">
Step {['targets', 'variables', 'preview', 'confirm'].indexOf(step) + 1} of 4
</span>
)}
</DialogTitle>
</DialogHeader>
{/* Step 1: Target Selection */}
{step === 'targets' && (
<div className="space-y-4">
<div className="text-xs text-text-secondary">Select devices to push the template to.</div>
{groups && groups.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Select Group</Label>
<Select onValueChange={handleGroupSelect}>
<SelectTrigger className="bg-elevated/50 border-border text-xs">
<SelectValue placeholder="Select a device group..." />
</SelectTrigger>
<SelectContent>
{groups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">
Devices ({selectedDeviceIds.size} selected)
</Label>
<div className="max-h-60 overflow-y-auto rounded-lg border border-border divide-y divide-white/5">
{devices?.map((device) => (
<label
key={device.id}
className="flex items-center gap-3 px-3 py-2 hover:bg-surface cursor-pointer"
>
<Checkbox
checked={selectedDeviceIds.has(device.id)}
onCheckedChange={() => toggleDevice(device.id)}
/>
<div className="flex-1 min-w-0">
<div className="text-xs text-text-primary truncate">{device.hostname}</div>
<div className="text-[10px] text-text-muted">{device.ip_address}</div>
</div>
<div
className={cn(
'h-2 w-2 rounded-full flex-shrink-0',
device.status === 'online' ? 'bg-success' : 'bg-error',
)}
/>
</label>
)) ?? (
<div className="px-3 py-6 text-xs text-text-muted text-center">Loading devices...</div>
)}
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
className="text-xs gap-1"
disabled={selectedDeviceIds.size === 0}
onClick={() => setStep(userVars.length > 0 ? 'variables' : 'preview')}
>
Next
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
{/* Step 2: Variable Input */}
{step === 'variables' && (
<div className="space-y-4">
<div className="text-xs text-text-secondary">
Provide values for template variables. Built-in device variables are auto-populated per device.
</div>
<div className="text-[10px] text-text-muted bg-surface rounded px-3 py-2">
Auto-populated: {'{{ device.hostname }}'}, {'{{ device.ip }}'}, {'{{ device.model }}'}
</div>
<div className="space-y-3">
{userVars.map((v) => (
<div key={v.name} className="space-y-1">
<Label className="text-xs text-text-secondary">
{v.name}
{v.description && (
<span className="ml-2 text-text-muted">-- {v.description}</span>
)}
</Label>
{v.type === 'boolean' ? (
<div className="flex items-center gap-2">
<Checkbox
checked={variables[v.name] === 'true'}
onCheckedChange={(c) =>
setVariables((prev) => ({ ...prev, [v.name]: c ? 'true' : 'false' }))
}
/>
<span className="text-xs text-text-secondary">{variables[v.name] ?? 'false'}</span>
</div>
) : (
<Input
value={variables[v.name] ?? ''}
onChange={(e) =>
setVariables((prev) => ({ ...prev, [v.name]: e.target.value }))
}
placeholder={
v.type === 'ip'
? '192.168.1.1'
: v.type === 'subnet'
? '192.168.1.0/24'
: v.type === 'integer'
? '0'
: v.default ?? ''
}
type={v.type === 'integer' ? 'number' : 'text'}
className="h-8 text-xs bg-elevated/50 border-border font-mono"
/>
)}
<div className="text-[10px] text-text-muted">
type: {v.type}
{v.default && ` | default: ${v.default}`}
</div>
</div>
))}
</div>
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
className="text-xs gap-1"
onClick={() => setStep('targets')}
>
<ChevronLeft className="h-3 w-3" />
Back
</Button>
<Button size="sm" className="text-xs gap-1" onClick={goToPreview}>
Next
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<div className="space-y-4">
<div className="text-xs text-text-secondary">
Preview the rendered template for each device.
</div>
<div className="flex gap-2 flex-wrap">
{selectedDevices.map((d) => (
<button
key={d.id}
onClick={() => selectPreviewDevice(d.id)}
className={cn(
'text-xs px-2 py-1 rounded transition-colors',
previewDevice === d.id
? 'bg-elevated text-text-primary'
: 'bg-surface text-text-secondary hover:text-text-secondary',
)}
>
{d.hostname}
</button>
))}
</div>
{previewDevice && previews[previewDevice] && (
<pre className="text-xs font-mono bg-background border border-border rounded-lg p-3 text-success overflow-x-auto max-h-64 whitespace-pre-wrap">
{previews[previewDevice]}
</pre>
)}
{previewDevice && !previews[previewDevice] && previewMutation.isPending && (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading preview...
</div>
)}
{previewMutation.isError && (
<div className="text-xs text-error bg-error/10 rounded px-3 py-2">
Preview failed: {previewMutation.error instanceof Error ? previewMutation.error.message : 'Unknown error'}
</div>
)}
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
className="text-xs gap-1"
onClick={() => setStep(userVars.length > 0 ? 'variables' : 'targets')}
>
<ChevronLeft className="h-3 w-3" />
Back
</Button>
<Button size="sm" className="text-xs gap-1" onClick={() => setStep('confirm')}>
Next
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
{/* Step 4: Confirm & Push */}
{step === 'confirm' && (
<div className="space-y-4">
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 flex gap-2">
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="text-xs text-warning leading-relaxed">
This will push configuration to <strong>{selectedDeviceIds.size}</strong> device(s).
Each device will be backed up before changes are applied. If a device becomes
unreachable after push, it will automatically revert.
</div>
</div>
<div className="space-y-2">
<div className="text-xs text-text-secondary">Template: {template.name}</div>
<div className="text-xs text-text-secondary">
Devices: {selectedDevices.map((d) => d.hostname).join(', ')}
</div>
{Object.entries(variables).length > 0 && (
<div className="text-xs text-text-secondary">
Variables:{' '}
{Object.entries(variables)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}
</div>
)}
</div>
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
className="text-xs gap-1"
onClick={() => setStep('preview')}
>
<ChevronLeft className="h-3 w-3" />
Back
</Button>
<Button
size="sm"
className="text-xs gap-1"
onClick={() => pushMutation.mutate()}
disabled={pushMutation.isPending}
>
{pushMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
Push to {selectedDeviceIds.size} Device(s)
</Button>
</div>
{pushMutation.isError && (
<div className="text-xs text-error bg-error/10 rounded px-3 py-2">
Push failed: {pushMutation.error instanceof Error ? pushMutation.error.message : 'Unknown error'}
</div>
)}
</div>
)}
{/* Step 5: Progress */}
{step === 'progress' && rolloutId && (
<PushProgressPanel tenantId={tenantId} rolloutId={rolloutId} onClose={handleClose} />
)}
</DialogContent>
</Dialog>
)
}