/** * BatchConfigPanel -- Multi-device batch configuration wizard. * * Three-step workflow: * 1. Select target devices (online only) * 2. Define the configuration change * 3. Review and execute sequentially with per-device status */ import { useState, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { CheckCircle, XCircle, Clock, Loader2, ChevronRight, ChevronLeft, Play, Wifi, Shield, Network, Globe, Server, Gauge, } from 'lucide-react' import { devicesApi, type DeviceResponse } from '@/lib/api' import { configEditorApi } from '@/lib/configEditorApi' 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { toast } from '@/components/ui/toast' import { cn } from '@/lib/utils' import { TableSkeleton } from '@/components/ui/page-skeleton' import { DeviceLink } from '@/components/ui/device-link' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type OperationType = | 'add-firewall-rule' | 'set-dns-servers' | 'add-vlan' | 'add-simple-queue' | 'add-ip-address' | 'add-static-dns' interface BatchChange { operationType: OperationType path: string operation: 'add' | 'set' properties: Record description: string } type DeviceStatus = 'pending' | 'applying' | 'success' | 'failed' interface DeviceExecState { deviceId: string hostname: string ipAddress: string status: DeviceStatus error?: string } interface BatchConfigPanelProps { tenantId: string } // --------------------------------------------------------------------------- // Operation definitions // --------------------------------------------------------------------------- const OPERATIONS: { value: OperationType; label: string; icon: React.FC<{ className?: string }> }[] = [ { value: 'add-firewall-rule', label: 'Add Firewall Rule', icon: Shield }, { value: 'set-dns-servers', label: 'Set DNS Servers', icon: Globe }, { value: 'add-vlan', label: 'Add VLAN', icon: Network }, { value: 'add-simple-queue', label: 'Add Simple Queue', icon: Gauge }, { value: 'add-ip-address', label: 'Add IP Address', icon: Server }, { value: 'add-static-dns', label: 'Add Static DNS Entry', icon: Wifi }, ] // --------------------------------------------------------------------------- // Step Indicator // --------------------------------------------------------------------------- function StepIndicator({ currentStep }: { currentStep: number }) { const steps = ['Select Devices', 'Define Change', 'Review & Execute'] return (
{steps.map((label, idx) => { const stepNum = idx + 1 const isActive = stepNum === currentStep const isComplete = stepNum < currentStep return (
{idx > 0 && (
)}
{isComplete ? : stepNum}
) })}
) } // --------------------------------------------------------------------------- // Step 1: Select Devices // --------------------------------------------------------------------------- function DeviceSelector({ tenantId, selectedIds, onSelectionChange, }: { tenantId: string selectedIds: Set onSelectionChange: (ids: Set) => void }) { const { data, isLoading } = useQuery({ queryKey: ['devices', tenantId, 'batch-list'], queryFn: () => devicesApi.list(tenantId, { page_size: 500 }), enabled: !!tenantId, }) const devices = data?.items ?? [] const onlineDevices = devices.filter((d) => d.status === 'online') const toggleDevice = (id: string) => { const next = new Set(selectedIds) if (next.has(id)) { next.delete(id) } else { next.add(id) } onSelectionChange(next) } const selectAllOnline = () => { onSelectionChange(new Set(onlineDevices.map((d) => d.id))) } const deselectAll = () => { onSelectionChange(new Set()) } if (isLoading) return if (devices.length === 0) { return (

No devices found for this tenant.

) } return (
{selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} selected
{selectedIds.size > 0 && ( )}
{devices.map((device) => { const isOnline = device.status === 'online' const isSelected = selectedIds.has(device.id) return ( isOnline && toggleDevice(device.id)} > ) })}
Hostname IP Address Status Model
toggleDevice(device.id)} disabled={!isOnline} aria-label={`Select ${device.hostname}`} /> {device.hostname} {device.ip_address} {device.status} {device.model ?? '-'}
) } // --------------------------------------------------------------------------- // Step 2: Define Change // --------------------------------------------------------------------------- function ChangeDefiner({ operationType, onOperationTypeChange, formData, onFormDataChange, }: { operationType: OperationType | null onOperationTypeChange: (op: OperationType) => void formData: Record onFormDataChange: (data: Record) => void }) { const setField = (key: string, value: string) => { onFormDataChange({ ...formData, [key]: value }) } const field = (key: string, label: string, opts?: { placeholder?: string; type?: string }) => (
setField(key, e.target.value)} placeholder={opts?.placeholder} type={opts?.type ?? 'text'} className="h-8 text-sm" />
) return (
{operationType && (
{operationType === 'add-firewall-rule' && ( <>

Firewall Rule

{field('src-address', 'Source Address', { placeholder: '0.0.0.0/0' })} {field('dst-address', 'Dest Address', { placeholder: '0.0.0.0/0' })}
{field('dst-port', 'Dest Port', { placeholder: '80,443' })} {field('comment', 'Comment', { placeholder: 'Batch rule' })}
)} {operationType === 'set-dns-servers' && ( <>

DNS Servers

{field('servers', 'DNS Servers (comma-separated)', { placeholder: '8.8.8.8,8.8.4.4' })}
setField('allow-remote-requests', checked ? 'yes' : 'no') } />
)} {operationType === 'add-vlan' && ( <>

VLAN

{field('name', 'Name', { placeholder: 'vlan100' })} {field('vlan-id', 'VLAN ID', { placeholder: '100', type: 'number' })} {field('interface', 'Interface', { placeholder: 'bridge1' })}
)} {operationType === 'add-simple-queue' && ( <>

Simple Queue

{field('name', 'Name', { placeholder: 'queue1' })} {field('target', 'Target', { placeholder: '192.168.1.0/24' })} {field('max-limit', 'Max Limit (upload/download)', { placeholder: '10M/10M' })} {field('comment', 'Comment', { placeholder: 'Batch queue' })}
)} {operationType === 'add-ip-address' && ( <>

IP Address

{field('address', 'Address (CIDR)', { placeholder: '192.168.1.1/24' })} {field('interface', 'Interface', { placeholder: 'ether1' })} {field('comment', 'Comment', { placeholder: 'Batch IP' })}
)} {operationType === 'add-static-dns' && ( <>

Static DNS Entry

{field('name', 'Name', { placeholder: 'router.local' })} {field('address', 'Address', { placeholder: '192.168.1.1' })} {field('type', 'Type', { placeholder: 'A' })} {field('comment', 'Comment', { placeholder: 'Batch DNS' })}
)}
)}
) } // --------------------------------------------------------------------------- // Build batch change from form data // --------------------------------------------------------------------------- function buildBatchChange( operationType: OperationType, formData: Record, ): BatchChange | null { const clean = (obj: Record) => { const result: Record = {} for (const [k, v] of Object.entries(obj)) { if (v && v.trim()) result[k] = v.trim() } return result } switch (operationType) { case 'add-firewall-rule': { const props = clean({ chain: formData.chain || 'input', action: formData.action || 'accept', ...(formData['src-address'] ? { 'src-address': formData['src-address'] } : {}), ...(formData['dst-address'] ? { 'dst-address': formData['dst-address'] } : {}), ...(formData.protocol ? { protocol: formData.protocol } : {}), ...(formData['dst-port'] ? { 'dst-port': formData['dst-port'] } : {}), ...(formData.comment ? { comment: formData.comment } : {}), }) return { operationType, path: '/ip/firewall/filter', operation: 'add', properties: props, description: `Add firewall ${props.chain}/${props.action} rule`, } } case 'set-dns-servers': { if (!formData.servers?.trim()) { toast({ title: 'DNS servers required', variant: 'destructive' }) return null } return { operationType, path: '/ip/dns', operation: 'set', properties: clean({ servers: formData.servers, 'allow-remote-requests': formData['allow-remote-requests'] || 'no', }), description: `Set DNS servers to ${formData.servers}`, } } case 'add-vlan': { const vlanId = formData['vlan-id']?.trim() const iface = formData.interface?.trim() if (!vlanId || !iface) { toast({ title: 'VLAN ID and interface are required', variant: 'destructive' }) return null } return { operationType, path: '/interface/vlan', operation: 'add', properties: clean({ name: formData.name || `vlan${vlanId}`, 'vlan-id': vlanId, interface: iface, }), description: `Add VLAN ${vlanId} on ${iface}`, } } case 'add-simple-queue': { const target = formData.target?.trim() if (!target) { toast({ title: 'Queue target is required', variant: 'destructive' }) return null } return { operationType, path: '/queue/simple', operation: 'add', properties: clean({ name: formData.name || 'batch-queue', target, ...(formData['max-limit'] ? { 'max-limit': formData['max-limit'] } : {}), ...(formData.comment ? { comment: formData.comment } : {}), }), description: `Add simple queue for ${target}`, } } case 'add-ip-address': { const address = formData.address?.trim() const iface = formData.interface?.trim() if (!address || !iface) { toast({ title: 'Address and interface are required', variant: 'destructive' }) return null } return { operationType, path: '/ip/address', operation: 'add', properties: clean({ address, interface: iface, ...(formData.comment ? { comment: formData.comment } : {}), }), description: `Add IP ${address} on ${iface}`, } } case 'add-static-dns': { const name = formData.name?.trim() const addr = formData.address?.trim() if (!name || !addr) { toast({ title: 'Name and address are required', variant: 'destructive' }) return null } return { operationType, path: '/ip/dns/static', operation: 'add', properties: clean({ name, address: addr, ...(formData.type ? { type: formData.type } : {}), ...(formData.comment ? { comment: formData.comment } : {}), }), description: `Add static DNS ${name} -> ${addr}`, } } } } // --------------------------------------------------------------------------- // Step 3: Review & Execute // --------------------------------------------------------------------------- function ExecutionPanel({ tenantId, change, devices, execStates, isRunning, isComplete, onExecute, }: { tenantId: string change: BatchChange devices: DeviceResponse[] execStates: DeviceExecState[] isRunning: boolean isComplete: boolean onExecute: () => void }) { const successCount = execStates.filter((s) => s.status === 'success').length const failedCount = execStates.filter((s) => s.status === 'failed').length return (
{/* Change description */}

Change to Apply

{change.description}

{change.path} {change.operation}{' '} {Object.entries(change.properties) .map(([k, v]) => `${k}=${v}`) .join(' ')}

{/* Execute button */} {!isRunning && !isComplete && ( )} {/* Summary */} {isComplete && (
{successCount} succeeded
{failedCount > 0 && (
{failedCount} failed
)}
)} {/* Device status table */}
{execStates.map((state) => ( ))}
Hostname IP Address Status Error
{state.hostname} {state.ipAddress} {state.error ?? ''}
) } function StatusIcon({ status }: { status: DeviceStatus }) { switch (status) { case 'pending': return ( Pending ) case 'applying': return ( Applying ) case 'success': return ( Success ) case 'failed': return ( Failed ) } } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- export function BatchConfigPanel({ tenantId }: BatchConfigPanelProps) { const [step, setStep] = useState(1) const [selectedDeviceIds, setSelectedDeviceIds] = useState>(new Set()) const [operationType, setOperationType] = useState(null) const [formData, setFormData] = useState>({}) const [batchChange, setBatchChange] = useState(null) const [execStates, setExecStates] = useState([]) const [isRunning, setIsRunning] = useState(false) const [isComplete, setIsComplete] = useState(false) // Load all devices for the execution step const { data: deviceData } = useQuery({ queryKey: ['devices', tenantId, 'batch-list'], queryFn: () => devicesApi.list(tenantId, { page_size: 500 }), enabled: !!tenantId, }) const allDevices = deviceData?.items ?? [] const selectedDevices = allDevices.filter((d) => selectedDeviceIds.has(d.id)) const handleNext = () => { if (step === 1) { if (selectedDeviceIds.size === 0) { toast({ title: 'Select at least one device', variant: 'destructive' }) return } setStep(2) } else if (step === 2) { if (!operationType) { toast({ title: 'Select an operation type', variant: 'destructive' }) return } const change = buildBatchChange(operationType, formData) if (!change) return setBatchChange(change) // Initialize exec states setExecStates( selectedDevices.map((d) => ({ deviceId: d.id, hostname: d.hostname, ipAddress: d.ip_address, status: 'pending' as DeviceStatus, })), ) setStep(3) } } const handleBack = () => { if (step === 2) setStep(1) if (step === 3 && !isRunning) { setStep(2) setBatchChange(null) setExecStates([]) setIsComplete(false) } } const handleExecute = useCallback(async () => { if (!batchChange || isRunning) return setIsRunning(true) for (let i = 0; i < selectedDevices.length; i++) { const device = selectedDevices[i] // Set to applying setExecStates((prev) => prev.map((s) => s.deviceId === device.id ? { ...s, status: 'applying' as DeviceStatus } : s, ), ) try { if (batchChange.operation === 'add') { await configEditorApi.addEntry( tenantId, device.id, batchChange.path, batchChange.properties, ) } else { // set operation (e.g., DNS servers) await configEditorApi.setEntry( tenantId, device.id, batchChange.path, '', batchChange.properties, ) } setExecStates((prev) => prev.map((s) => s.deviceId === device.id ? { ...s, status: 'success' as DeviceStatus } : s, ), ) } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Unknown error' setExecStates((prev) => prev.map((s) => s.deviceId === device.id ? { ...s, status: 'failed' as DeviceStatus, error: errorMsg } : s, ), ) } } setIsRunning(false) setIsComplete(true) toast({ title: 'Batch execution complete' }) }, [batchChange, isRunning, selectedDevices, tenantId]) const handleReset = () => { setStep(1) setSelectedDeviceIds(new Set()) setOperationType(null) setFormData({}) setBatchChange(null) setExecStates([]) setIsRunning(false) setIsComplete(false) } return (
{/* Step content */} {step === 1 && ( )} {step === 2 && ( )} {step === 3 && batchChange && ( )} {/* Navigation buttons */}
{step > 1 && !isRunning && ( )}
{isComplete && ( )} {step < 3 && ( )}
) }