/** * BulkCommandWizard -- 3-step wizard for executing a RouterOS CLI command * across multiple devices sequentially. * * Step 1: Select Devices (individual, by group, or all online) * Step 2: Enter Command (with client-side blocklist validation) * Step 3: Review & Execute (sequential execution with per-device results) */ import { useState, useCallback, useRef } from 'react' import { useQuery } from '@tanstack/react-query' import { CheckCircle, XCircle, Loader2, ChevronRight, ChevronLeft, Play, AlertTriangle, Search, RotateCcw, MinusCircle, } from 'lucide-react' import { devicesApi, deviceGroupsApi, type DeviceResponse, } from '@/lib/api' import { configEditorApi } from '@/lib/configEditorApi' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Checkbox } from '@/components/ui/checkbox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { cn } from '@/lib/utils' import { TableSkeleton } from '@/components/ui/page-skeleton' import { DeviceLink } from '@/components/ui/device-link' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type DeviceExecStatus = 'pending' | 'running' | 'success' | 'error' | 'skipped' interface DeviceExecResult { deviceId: string hostname: string ipAddress: string status: DeviceExecStatus output?: string error?: string duration?: number } interface BulkCommandWizardProps { tenantId: string } type SelectionMode = 'individual' | 'by-group' | 'all-online' // --------------------------------------------------------------------------- // Client-side blocklist (matches backend DANGEROUS_COMMANDS subset) // --------------------------------------------------------------------------- const BLOCKED_COMMAND_PREFIXES = [ '/system/reset-configuration', '/system/shutdown', '/system/reboot', '/user', '/password', '/certificate', ] function checkCommandBlocked(command: string): string | null { const normalized = command.trim().toLowerCase() for (const blocked of BLOCKED_COMMAND_PREFIXES) { if (normalized.startsWith(blocked)) { return `Command matches dangerous prefix "${blocked}". This operation is blocked for safety.` } } return null } // --------------------------------------------------------------------------- // Step Indicator // --------------------------------------------------------------------------- function StepIndicator({ currentStep }: { currentStep: number }) { const steps = ['Select Devices', 'Enter Command', 'Review & Execute'] return (
{steps.map((label, idx) => { const stepNum = idx + 1 const isActive = stepNum === currentStep const isComplete = stepNum < currentStep return (
{idx > 0 && (
)}
{isComplete ? : stepNum}
) })}
) } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) { const [step, setStep] = useState(1) const [selectedDevices, setSelectedDevices] = useState([]) const [command, setCommand] = useState('') const [results, setResults] = useState([]) const [isExecuting, setIsExecuting] = useState(false) const [showConfirm, setShowConfirm] = useState(false) const abortRef = useRef(false) // Reset wizard to step 1 const resetWizard = useCallback(() => { setStep(1) setSelectedDevices([]) setCommand('') setResults([]) setIsExecuting(false) setShowConfirm(false) abortRef.current = false }, []) // Execute command on all selected devices sequentially const executeAll = useCallback(async () => { setShowConfirm(false) setIsExecuting(true) abortRef.current = false const initialResults: DeviceExecResult[] = selectedDevices.map((d) => ({ deviceId: d.id, hostname: d.hostname, ipAddress: d.ip_address, status: d.status === 'online' ? 'pending' : 'skipped', error: d.status !== 'online' ? 'Device offline' : undefined, })) setResults([...initialResults]) for (let i = 0; i < initialResults.length; i++) { if (abortRef.current) break if (initialResults[i].status === 'skipped') continue // Mark as running initialResults[i].status = 'running' setResults([...initialResults]) const start = performance.now() try { const response = await configEditorApi.execute( tenantId, initialResults[i].deviceId, command, ) const elapsed = Math.round(performance.now() - start) if (response.success) { initialResults[i].status = 'success' initialResults[i].output = response.data ? JSON.stringify(response.data, null, 2) : 'OK' } else { initialResults[i].status = 'error' initialResults[i].error = response.error ?? 'Unknown error' } initialResults[i].duration = elapsed } catch (err: unknown) { const elapsed = Math.round(performance.now() - start) initialResults[i].status = 'error' initialResults[i].error = (err as { response?: { data?: { detail?: string } } })?.response?.data ?.detail ?? (err as Error)?.message ?? 'Execution failed' initialResults[i].duration = elapsed } setResults([...initialResults]) } setIsExecuting(false) }, [selectedDevices, tenantId, command]) // Computed summary const succeeded = results.filter((r) => r.status === 'success').length const failed = results.filter((r) => r.status === 'error').length const skipped = results.filter((r) => r.status === 'skipped').length const blockWarning = command ? checkCommandBlocked(command) : null return (
{/* Step 1: Select Devices */} {step === 1 && ( setStep(2)} /> )} {/* Step 2: Enter Command */} {step === 2 && (

Enter RouterOS Command

Enter a full RouterOS CLI command, e.g., /ip/address/print or /system/resource/print

setCommand(e.target.value)} placeholder="/ip/address/print" className="font-mono text-sm" autoFocus /> {blockWarning && (

{blockWarning}

)}
)} {/* Step 3: Review & Execute */} {step === 3 && (

Review & Execute

Verify the command and target devices before executing.

{/* Command summary */}

Command

{command}
{/* Device list (scrollable) */}

{selectedDevices.length} device {selectedDevices.length !== 1 ? 's' : ''} selected

{selectedDevices.map((d) => (
{d.hostname} {d.ip_address}
))}
{/* Results table (shown after execution starts) */} {results.length > 0 && (
{results.map((r) => ( ))}
Device Status Output Time
{r.hostname}
{r.ipAddress}
{r.status === 'success' && r.output && (
                            {r.output.slice(0, 120)}
                          
)} {r.status === 'error' && ( {r.error} )} {r.status === 'skipped' && ( {r.error} )}
{r.duration != null ? `${(r.duration / 1000).toFixed(1)}s` : ''}
)} {/* Summary banner after completion */} {!isExecuting && results.length > 0 && (
{succeeded} succeeded
{failed > 0 && (
{failed} failed
)} {skipped > 0 && (
{skipped} skipped
)}
)} {/* Actions */}
{results.length === 0 ? ( <> ) : ( )}
)} {/* Confirmation Dialog */} Confirm Execution Execute{' '} {command} {' '} on {selectedDevices.length} device {selectedDevices.length !== 1 ? 's' : ''}?
) } // --------------------------------------------------------------------------- // Status Badge // --------------------------------------------------------------------------- function StatusBadge({ status }: { status: DeviceExecStatus }) { switch (status) { case 'pending': return (
Pending ) case 'running': return ( Running ) case 'success': return ( Success ) case 'error': return ( Error ) case 'skipped': return ( Skipped ) } } // --------------------------------------------------------------------------- // Device Selection Step // --------------------------------------------------------------------------- function DeviceSelectionStep({ tenantId, selectedDevices, onSelectionChange, onNext, }: { tenantId: string selectedDevices: DeviceResponse[] onSelectionChange: (devices: DeviceResponse[]) => void onNext: () => void }) { const [mode, setMode] = useState('individual') const [searchFilter, setSearchFilter] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [selectedGroup, setSelectedGroup] = useState('') const { data: deviceData, isLoading: devicesLoading } = useQuery({ queryKey: ['devices', tenantId, 'bulk-cmd'], queryFn: () => devicesApi.list(tenantId, { page_size: 100 }), }) const { data: groups } = useQuery({ queryKey: ['device-groups', tenantId], queryFn: () => deviceGroupsApi.list(tenantId), }) const devices = deviceData?.items ?? [] const selectedIds = new Set(selectedDevices.map((d) => d.id)) // Filter devices const filteredDevices = devices.filter((d) => { if (statusFilter !== 'all' && d.status !== statusFilter) return false if (searchFilter) { const q = searchFilter.toLowerCase() if ( !d.hostname.toLowerCase().includes(q) && !d.ip_address.toLowerCase().includes(q) ) return false } return true }) const toggleDevice = (device: DeviceResponse) => { if (selectedIds.has(device.id)) { onSelectionChange(selectedDevices.filter((d) => d.id !== device.id)) } else { onSelectionChange([...selectedDevices, device]) } } const selectAllOnline = () => { onSelectionChange(devices.filter((d) => d.status === 'online')) } const selectByGroup = (groupId: string) => { setSelectedGroup(groupId) const group = groups?.find((g) => g.id === groupId) if (!group) return // Select all devices that belong to this group const groupDevices = devices.filter((d) => d.groups?.some((g) => g.id === groupId), ) onSelectionChange(groupDevices) } if (devicesLoading) { return } return (

Select Target Devices

Choose which devices to execute the command on.

{/* Selection mode tabs */}
{( [ { value: 'individual', label: 'Select Individual' }, { value: 'by-group', label: 'By Group' }, { value: 'all-online', label: 'All Online' }, ] as const ).map((opt) => ( ))}
{/* Group selector */} {mode === 'by-group' && groups && ( )} {/* Search + filter (for individual mode) */} {mode === 'individual' && (
setSearchFilter(e.target.value)} placeholder="Filter by hostname or IP..." className="pl-8 h-8 text-xs" />
)} {/* Device list */} {(mode === 'individual' || mode === 'by-group') && (
{filteredDevices.map((d) => ( toggleDevice(d)} > ))} {filteredDevices.length === 0 && ( )}
0 && filteredDevices.every((d) => selectedIds.has(d.id)) } onCheckedChange={(checked) => { if (checked) { const newSet = new Set(selectedIds) filteredDevices.forEach((d) => newSet.add(d.id)) onSelectionChange( devices.filter((d) => newSet.has(d.id)), ) } else { const removeSet = new Set( filteredDevices.map((d) => d.id), ) onSelectionChange( selectedDevices.filter( (d) => !removeSet.has(d.id), ), ) } }} /> Hostname IP Address Status Model
e.stopPropagation()} > toggleDevice(d)} /> {d.hostname} {d.ip_address} {d.model ?? ''}
No devices found
)} {/* All online summary */} {mode === 'all-online' && (
{selectedDevices.length} {' '} online device{selectedDevices.length !== 1 ? 's' : ''} selected
)} {/* Footer */}

{selectedDevices.length} device {selectedDevices.length !== 1 ? 's' : ''} selected

) }