feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
886
frontend/src/components/config/BatchConfigPanel.tsx
Normal file
886
frontend/src/components/config/BatchConfigPanel.tsx
Normal file
@@ -0,0 +1,886 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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({
|
||||
change,
|
||||
devices,
|
||||
execStates,
|
||||
isRunning,
|
||||
isComplete,
|
||||
onExecute,
|
||||
}: {
|
||||
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">{state.hostname}</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
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user