Files
the-other-dude/frontend/src/components/config/BatchConfigPanel.tsx
Jason Staack fee40db073 fix: resolve review issues in DeviceLink rollout
- Fix import casing in AuditLogTable (DeviceLink → device-link)
- Remove DeviceLink from BatchConfigPanel device selector (nested interactive)
- Remove DeviceLink from TemplatePushWizard device selector (nested interactive)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:28:17 -05:00

895 lines
30 KiB
TypeScript

/**
* 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<string, string>
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 (
<div className="flex items-center justify-center gap-2 mb-6">
{steps.map((label, idx) => {
const stepNum = idx + 1
const isActive = stepNum === currentStep
const isComplete = stepNum < currentStep
return (
<div key={label} className="flex items-center gap-2">
{idx > 0 && (
<div
className={cn(
'w-8 h-px',
isComplete ? 'bg-success' : 'bg-border',
)}
/>
)}
<div className="flex items-center gap-2">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium',
isActive && 'bg-accent text-white',
isComplete && 'bg-success text-white',
!isActive && !isComplete && 'bg-elevated text-text-muted',
)}
>
{isComplete ? <CheckCircle className="h-4 w-4" /> : stepNum}
</div>
<span
className={cn(
'text-sm hidden sm:inline',
isActive ? 'text-text-primary font-medium' : 'text-text-muted',
)}
>
{label}
</span>
</div>
</div>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Step 1: Select Devices
// ---------------------------------------------------------------------------
function DeviceSelector({
tenantId,
selectedIds,
onSelectionChange,
}: {
tenantId: string
selectedIds: Set<string>
onSelectionChange: (ids: Set<string>) => 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 <TableSkeleton rows={5} />
if (devices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-text-muted text-sm">No devices found for this tenant.</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-secondary">
{selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={selectAllOnline}>
Select All Online ({onlineDevices.length})
</Button>
{selectedIds.size > 0 && (
<Button variant="outline" size="sm" onClick={deselectAll}>
Deselect All
</Button>
)}
</div>
</div>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/50">
<th className="w-10 px-3 py-2" />
<th className="text-left px-3 py-2 font-medium text-text-secondary">Hostname</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">IP Address</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Status</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Model</th>
</tr>
</thead>
<tbody>
{devices.map((device) => {
const isOnline = device.status === 'online'
const isSelected = selectedIds.has(device.id)
return (
<tr
key={device.id}
className={cn(
'border-b border-border/50 last:border-0 transition-colors',
isSelected && 'bg-accent/5',
isOnline ? 'cursor-pointer hover:bg-elevated/30' : 'opacity-50',
)}
onClick={() => isOnline && toggleDevice(device.id)}
>
<td className="px-3 py-2">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleDevice(device.id)}
disabled={!isOnline}
aria-label={`Select ${device.hostname}`}
/>
</td>
<td className="px-3 py-2 font-medium">
{device.hostname}
</td>
<td className="px-3 py-2 font-mono text-text-secondary">{device.ip_address}</td>
<td className="px-3 py-2">
<span
className={cn(
'inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded',
isOnline
? 'text-success bg-success/10'
: 'text-error bg-error/10',
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', isOnline ? 'bg-success' : 'bg-error')} />
{device.status}
</span>
</td>
<td className="px-3 py-2 text-text-muted">{device.model ?? '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Step 2: Define Change
// ---------------------------------------------------------------------------
function ChangeDefiner({
operationType,
onOperationTypeChange,
formData,
onFormDataChange,
}: {
operationType: OperationType | null
onOperationTypeChange: (op: OperationType) => void
formData: Record<string, string>
onFormDataChange: (data: Record<string, string>) => void
}) {
const setField = (key: string, value: string) => {
onFormDataChange({ ...formData, [key]: value })
}
const field = (key: string, label: string, opts?: { placeholder?: string; type?: string }) => (
<div className="space-y-1">
<Label className="text-xs text-text-secondary">{label}</Label>
<Input
value={formData[key] ?? ''}
onChange={(e) => setField(key, e.target.value)}
placeholder={opts?.placeholder}
type={opts?.type ?? 'text'}
className="h-8 text-sm"
/>
</div>
)
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Operation Type</Label>
<Select value={operationType ?? ''} onValueChange={(v) => onOperationTypeChange(v as OperationType)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select an operation..." />
</SelectTrigger>
<SelectContent>
{OPERATIONS.map((op) => {
const Icon = op.icon
return (
<SelectItem key={op.value} value={op.value}>
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5" />
{op.label}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
{operationType && (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
{operationType === 'add-firewall-rule' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Firewall Rule</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Chain</Label>
<Select value={formData.chain ?? 'input'} onValueChange={(v) => setField('chain', v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input">input</SelectItem>
<SelectItem value="forward">forward</SelectItem>
<SelectItem value="output">output</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Action</Label>
<Select value={formData.action ?? 'accept'} onValueChange={(v) => setField('action', v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="accept">accept</SelectItem>
<SelectItem value="drop">drop</SelectItem>
<SelectItem value="reject">reject</SelectItem>
<SelectItem value="log">log</SelectItem>
</SelectContent>
</Select>
</div>
{field('src-address', 'Source Address', { placeholder: '0.0.0.0/0' })}
{field('dst-address', 'Dest Address', { placeholder: '0.0.0.0/0' })}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Protocol</Label>
<Select value={formData.protocol ?? ''} onValueChange={(v) => setField('protocol', v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="icmp">icmp</SelectItem>
</SelectContent>
</Select>
</div>
{field('dst-port', 'Dest Port', { placeholder: '80,443' })}
{field('comment', 'Comment', { placeholder: 'Batch rule' })}
</div>
</>
)}
{operationType === 'set-dns-servers' && (
<>
<h4 className="text-sm font-medium text-text-secondary">DNS Servers</h4>
{field('servers', 'DNS Servers (comma-separated)', { placeholder: '8.8.8.8,8.8.4.4' })}
<div className="flex items-center gap-2 mt-2">
<Checkbox
checked={formData['allow-remote-requests'] === 'yes'}
onCheckedChange={(checked) =>
setField('allow-remote-requests', checked ? 'yes' : 'no')
}
/>
<Label className="text-xs text-text-secondary">Allow remote requests</Label>
</div>
</>
)}
{operationType === 'add-vlan' && (
<>
<h4 className="text-sm font-medium text-text-secondary">VLAN</h4>
<div className="grid grid-cols-2 gap-3">
{field('name', 'Name', { placeholder: 'vlan100' })}
{field('vlan-id', 'VLAN ID', { placeholder: '100', type: 'number' })}
{field('interface', 'Interface', { placeholder: 'bridge1' })}
</div>
</>
)}
{operationType === 'add-simple-queue' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Simple Queue</h4>
<div className="grid grid-cols-2 gap-3">
{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' })}
</div>
</>
)}
{operationType === 'add-ip-address' && (
<>
<h4 className="text-sm font-medium text-text-secondary">IP Address</h4>
<div className="grid grid-cols-2 gap-3">
{field('address', 'Address (CIDR)', { placeholder: '192.168.1.1/24' })}
{field('interface', 'Interface', { placeholder: 'ether1' })}
{field('comment', 'Comment', { placeholder: 'Batch IP' })}
</div>
</>
)}
{operationType === 'add-static-dns' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Static DNS Entry</h4>
<div className="grid grid-cols-2 gap-3">
{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' })}
</div>
</>
)}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Build batch change from form data
// ---------------------------------------------------------------------------
function buildBatchChange(
operationType: OperationType,
formData: Record<string, string>,
): BatchChange | null {
const clean = (obj: Record<string, string>) => {
const result: Record<string, string> = {}
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 (
<div className="space-y-4">
{/* Change description */}
<div className="rounded-lg border border-border bg-surface p-4">
<h4 className="text-sm font-medium text-text-secondary mb-1">Change to Apply</h4>
<p className="text-sm text-text-primary">{change.description}</p>
<p className="text-xs text-text-muted mt-1 font-mono">
{change.path} {change.operation}{' '}
{Object.entries(change.properties)
.map(([k, v]) => `${k}=${v}`)
.join(' ')}
</p>
</div>
{/* Execute button */}
{!isRunning && !isComplete && (
<Button onClick={onExecute} className="w-full">
<Play className="h-4 w-4 mr-2" />
Execute on {devices.length} device{devices.length !== 1 ? 's' : ''}
</Button>
)}
{/* Summary */}
{isComplete && (
<div className="rounded-lg border border-border bg-surface p-4 flex items-center gap-4">
<div className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">{successCount} succeeded</span>
</div>
{failedCount > 0 && (
<div className="flex items-center gap-2 text-error">
<XCircle className="h-5 w-5" />
<span className="text-sm font-medium">{failedCount} failed</span>
</div>
)}
</div>
)}
{/* Device status table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/50">
<th className="text-left px-3 py-2 font-medium text-text-secondary">Hostname</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">IP Address</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Status</th>
<th className="text-left px-3 py-2 font-medium text-text-secondary">Error</th>
</tr>
</thead>
<tbody>
{execStates.map((state) => (
<tr key={state.deviceId} className="border-b border-border/50 last:border-0">
<td className="px-3 py-2 font-medium">
<DeviceLink tenantId={tenantId} deviceId={state.deviceId}>{state.hostname}</DeviceLink>
</td>
<td className="px-3 py-2 font-mono text-text-secondary">{state.ipAddress}</td>
<td className="px-3 py-2">
<StatusIcon status={state.status} />
</td>
<td className="px-3 py-2 text-xs text-error max-w-xs truncate">
{state.error ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function StatusIcon({ status }: { status: DeviceStatus }) {
switch (status) {
case 'pending':
return (
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
<Clock className="h-3.5 w-3.5" /> Pending
</span>
)
case 'applying':
return (
<span className="inline-flex items-center gap-1 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Applying
</span>
)
case 'success':
return (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle className="h-3.5 w-3.5" /> Success
</span>
)
case 'failed':
return (
<span className="inline-flex items-center gap-1 text-xs text-error">
<XCircle className="h-3.5 w-3.5" /> Failed
</span>
)
}
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function BatchConfigPanel({ tenantId }: BatchConfigPanelProps) {
const [step, setStep] = useState(1)
const [selectedDeviceIds, setSelectedDeviceIds] = useState<Set<string>>(new Set())
const [operationType, setOperationType] = useState<OperationType | null>(null)
const [formData, setFormData] = useState<Record<string, string>>({})
const [batchChange, setBatchChange] = useState<BatchChange | null>(null)
const [execStates, setExecStates] = useState<DeviceExecState[]>([])
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 (
<div className="space-y-4">
<StepIndicator currentStep={step} />
{/* Step content */}
{step === 1 && (
<DeviceSelector
tenantId={tenantId}
selectedIds={selectedDeviceIds}
onSelectionChange={setSelectedDeviceIds}
/>
)}
{step === 2 && (
<ChangeDefiner
operationType={operationType}
onOperationTypeChange={setOperationType}
formData={formData}
onFormDataChange={setFormData}
/>
)}
{step === 3 && batchChange && (
<ExecutionPanel
tenantId={tenantId}
change={batchChange}
devices={selectedDevices}
execStates={execStates}
isRunning={isRunning}
isComplete={isComplete}
onExecute={handleExecute}
/>
)}
{/* Navigation buttons */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div>
{step > 1 && !isRunning && (
<Button variant="outline" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
</div>
<div className="flex gap-2">
{isComplete && (
<Button variant="outline" onClick={handleReset}>
Start New Batch
</Button>
)}
{step < 3 && (
<Button onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</div>
</div>
</div>
)
}