/** * InterfacesPanel -- Guided interface configuration for RouterOS devices. * * Sub-tabs: Interfaces, IP Addresses, VLANs, Bridges * Each tab provides browse data + add/edit/remove forms. * All changes flow through useConfigPanel -> ChangePreviewModal. */ import { useState, useMemo, useCallback } from 'react' import { Network, Globe, Layers, GitBranch, Plus, MoreHorizontal, Pencil, Trash2, ToggleLeft, ToggleRight, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { SafetyToggle } from '@/components/config/SafetyToggle' import { ChangePreviewModal } from '@/components/config/ChangePreviewModal' import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel' import type { ConfigPanelProps, ConfigChange } from '@/lib/configPanelTypes' import { cn } from '@/lib/utils' // --------------------------------------------------------------------------- // Sub-tab definitions // --------------------------------------------------------------------------- type SubTab = 'interfaces' | 'ip-addresses' | 'vlans' | 'bridges' const SUB_TABS: { key: SubTab; label: string; icon: React.ElementType }[] = [ { key: 'interfaces', label: 'Interfaces', icon: Network }, { key: 'ip-addresses', label: 'IP Addresses', icon: Globe }, { key: 'vlans', label: 'VLANs', icon: Layers }, { key: 'bridges', label: 'Bridges', icon: GitBranch }, ] // --------------------------------------------------------------------------- // Type badge color map // --------------------------------------------------------------------------- const TYPE_COLORS: Record = { ether: '#3B82F6', bridge: '#8B5CF6', vlan: '#F59E0B', bonding: '#10B981', pppoe: '#EF4444', l2tp: '#EC4899', ovpn: '#06B6D4', wlan: '#84CC16', } // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- const CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/ function isValidCidr(value: string): boolean { if (!CIDR_REGEX.test(value)) return false const [ip, mask] = value.split('/') const parts = ip.split('.').map(Number) if (parts.some((p) => p < 0 || p > 255)) return false const maskNum = Number(mask) return maskNum >= 0 && maskNum <= 32 } function isValidVlanId(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 4094 } // --------------------------------------------------------------------------- // InterfacesPanel // --------------------------------------------------------------------------- export function InterfacesPanel({ tenantId, deviceId, active }: ConfigPanelProps) { const [subTab, setSubTab] = useState('interfaces') const [previewOpen, setPreviewOpen] = useState(false) // Shared config panel hook const panel = useConfigPanel(tenantId, deviceId, 'interfaces') // Browse data const interfaces = useConfigBrowse(tenantId, deviceId, '/interface', { enabled: active }) const ipAddresses = useConfigBrowse(tenantId, deviceId, '/ip/address', { enabled: active }) const vlans = useConfigBrowse(tenantId, deviceId, '/interface/vlan', { enabled: active }) const bridges = useConfigBrowse(tenantId, deviceId, '/interface/bridge', { enabled: active }) const bridgePorts = useConfigBrowse(tenantId, deviceId, '/interface/bridge/port', { enabled: active, }) // Interface name list for select fields const interfaceNames = useMemo( () => interfaces.entries.map((e) => e.name).filter(Boolean), [interfaces.entries], ) const bridgeNames = useMemo( () => bridges.entries.map((e) => e.name).filter(Boolean), [bridges.entries], ) const refetchAll = useCallback(() => { interfaces.refetch() ipAddresses.refetch() vlans.refetch() bridges.refetch() bridgePorts.refetch() }, [interfaces, ipAddresses, vlans, bridges, bridgePorts]) const handleApplyConfirm = useCallback(() => { panel.applyChanges() setPreviewOpen(false) // Refetch after a short delay to allow the device to process setTimeout(refetchAll, 1500) }, [panel, refetchAll]) return (
{/* Header: safety toggle + apply button */}
{/* Sub-tab buttons */}
{SUB_TABS.map(({ key, label, icon: Icon }) => ( ))}
{/* Sub-tab content */} {subTab === 'interfaces' && ( )} {subTab === 'ip-addresses' && ( )} {subTab === 'vlans' && ( )} {subTab === 'bridges' && ( )} {/* Change preview modal */}
) } // --------------------------------------------------------------------------- // Loading skeleton // --------------------------------------------------------------------------- function TableSkeleton({ rows = 5 }: { rows?: number }) { return (
{Array.from({ length: rows }).map((_, i) => (
))}
) } // --------------------------------------------------------------------------- // Interfaces Tab (read-only list) // --------------------------------------------------------------------------- function InterfacesTable({ entries, isLoading, }: { entries: Record[] isLoading: boolean }) { if (isLoading) return if (entries.length === 0) { return (
No interfaces found on this device.
) } return (
{entries.map((entry, i) => { const isRunning = entry.running === 'true' const isDisabled = entry.disabled === 'true' const ifType = entry.type || 'unknown' return ( ) })}
Name Type Status MAC MTU
{entry.name || '---'} {ifType} {isDisabled ? 'disabled' : isRunning ? 'running' : 'down'} {entry['mac-address'] || '---'} {entry.mtu || '---'}
) } // --------------------------------------------------------------------------- // IP Addresses Tab // --------------------------------------------------------------------------- interface IpAddressesTabProps { entries: Record[] isLoading: boolean interfaceNames: string[] addChange: (c: ConfigChange) => void } function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAddressesTabProps) { const [dialogOpen, setDialogOpen] = useState(false) const [editEntry, setEditEntry] = useState | null>(null) const openAdd = () => { setEditEntry(null) setDialogOpen(true) } const openEdit = (entry: Record) => { setEditEntry(entry) setDialogOpen(true) } const handleRemove = (entry: Record) => { addChange({ operation: 'remove', path: '/ip/address', entryId: entry['.id'], properties: {}, description: `Remove IP ${entry.address} from ${entry.interface}`, }) } const handleToggle = (entry: Record) => { const newDisabled = entry.disabled === 'true' ? 'no' : 'yes' addChange({ operation: 'set', path: '/ip/address', entryId: entry['.id'], properties: { disabled: newDisabled }, description: `${newDisabled === 'yes' ? 'Disable' : 'Enable'} IP ${entry.address} on ${entry.interface}`, }) } if (isLoading) return return (
{entries.length === 0 ? (
No IP addresses configured.
) : (
{entries.map((entry, i) => { const isDisabled = entry.disabled === 'true' return ( ) })}
Address Network Interface Status Actions
{entry.address || '---'} {entry.network || '---'} {entry.interface || '---'} {isDisabled ? 'disabled' : 'active'}
)}
) } // --------------------------------------------------------------------------- // IP Address Dialog // --------------------------------------------------------------------------- function IpAddressDialog({ open, onOpenChange, entry, interfaceNames, addChange, }: { open: boolean onOpenChange: (open: boolean) => void entry: Record | null interfaceNames: string[] addChange: (c: ConfigChange) => void }) { const isEdit = !!entry const [address, setAddress] = useState('') const [iface, setIface] = useState('') const [disabled, setDisabled] = useState(false) const [error, setError] = useState('') // Reset form when dialog opens const handleOpenChange = (val: boolean) => { if (val) { setAddress(entry?.address || '') setIface(entry?.interface || '') setDisabled(entry?.disabled === 'true') setError('') } onOpenChange(val) } const handleSubmit = () => { if (!isValidCidr(address)) { setError('Invalid CIDR format. Use format like 192.168.1.1/24') return } if (!iface) { setError('Please select an interface') return } setError('') const properties: Record = { address, interface: iface, disabled: disabled ? 'yes' : 'no', } if (isEdit) { addChange({ operation: 'set', path: '/ip/address', entryId: entry['.id'], properties, description: `Update IP ${address} on ${iface}`, }) } else { addChange({ operation: 'add', path: '/ip/address', properties, description: `Add IP ${address} to ${iface}`, }) } onOpenChange(false) } return ( {isEdit ? 'Edit IP Address' : 'Add IP Address'} {isEdit ? 'Modify the IP address configuration.' : 'Add a new IP address to an interface.'}
setAddress(e.target.value)} />
setDisabled(v === true)} />
{error &&

{error}

}
) } // --------------------------------------------------------------------------- // VLANs Tab // --------------------------------------------------------------------------- interface VlansTabProps { entries: Record[] isLoading: boolean interfaceNames: string[] addChange: (c: ConfigChange) => void } function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabProps) { const [dialogOpen, setDialogOpen] = useState(false) const [editEntry, setEditEntry] = useState | null>(null) const openAdd = () => { setEditEntry(null) setDialogOpen(true) } const openEdit = (entry: Record) => { setEditEntry(entry) setDialogOpen(true) } const handleRemove = (entry: Record) => { addChange({ operation: 'remove', path: '/interface/vlan', entryId: entry['.id'], properties: {}, description: `Remove VLAN ${entry.name} (ID: ${entry['vlan-id']})`, }) } const handleToggle = (entry: Record) => { const newDisabled = entry.disabled === 'true' ? 'no' : 'yes' addChange({ operation: 'set', path: '/interface/vlan', entryId: entry['.id'], properties: { disabled: newDisabled }, description: `${newDisabled === 'yes' ? 'Disable' : 'Enable'} VLAN ${entry.name}`, }) } if (isLoading) return return (
{entries.length === 0 ? (
No VLANs configured.
) : (
{entries.map((entry, i) => { const isDisabled = entry.disabled === 'true' return ( ) })}
Name VLAN ID Interface Status Actions
{entry.name || '---'} {entry['vlan-id'] || '---'} {entry.interface || '---'} {isDisabled ? 'disabled' : 'active'}
)}
) } // --------------------------------------------------------------------------- // VLAN Dialog // --------------------------------------------------------------------------- function VlanDialog({ open, onOpenChange, entry, interfaceNames, addChange, }: { open: boolean onOpenChange: (open: boolean) => void entry: Record | null interfaceNames: string[] addChange: (c: ConfigChange) => void }) { const isEdit = !!entry const [name, setName] = useState('') const [vlanId, setVlanId] = useState('') const [iface, setIface] = useState('') const [disabled, setDisabled] = useState(false) const [error, setError] = useState('') const handleOpenChange = (val: boolean) => { if (val) { setName(entry?.name || '') setVlanId(entry?.['vlan-id'] || '') setIface(entry?.interface || '') setDisabled(entry?.disabled === 'true') setError('') } onOpenChange(val) } const handleSubmit = () => { if (!name.trim()) { setError('VLAN name is required') return } const vid = Number(vlanId) if (!isValidVlanId(vid)) { setError('VLAN ID must be between 1 and 4094') return } if (!iface) { setError('Please select a parent interface') return } setError('') const properties: Record = { name: name.trim(), 'vlan-id': String(vid), interface: iface, disabled: disabled ? 'yes' : 'no', } if (isEdit) { addChange({ operation: 'set', path: '/interface/vlan', entryId: entry['.id'], properties, description: `Update VLAN ${name} (ID: ${vid}) on ${iface}`, }) } else { addChange({ operation: 'add', path: '/interface/vlan', properties, description: `Add VLAN ${name} (ID: ${vid}) on ${iface}`, }) } onOpenChange(false) } return ( {isEdit ? 'Edit VLAN' : 'Add VLAN'} {isEdit ? 'Modify the VLAN configuration.' : 'Create a new VLAN interface.'}
setName(e.target.value)} />
setVlanId(e.target.value)} />
setDisabled(v === true)} />
{error &&

{error}

}
) } // --------------------------------------------------------------------------- // Bridges Tab // --------------------------------------------------------------------------- interface BridgesTabProps { bridges: Record[] bridgePorts: Record[] isLoading: boolean interfaceNames: string[] bridgeNames: string[] addChange: (c: ConfigChange) => void } function BridgesTab({ bridges, bridgePorts, isLoading, interfaceNames, bridgeNames, addChange, }: BridgesTabProps) { const [bridgeDialogOpen, setBridgeDialogOpen] = useState(false) const [portDialogOpen, setPortDialogOpen] = useState(false) const [editBridge, setEditBridge] = useState | null>(null) const [editPort, setEditPort] = useState | null>(null) const openAddBridge = () => { setEditBridge(null) setBridgeDialogOpen(true) } const openEditBridge = (entry: Record) => { setEditBridge(entry) setBridgeDialogOpen(true) } const handleRemoveBridge = (entry: Record) => { addChange({ operation: 'remove', path: '/interface/bridge', entryId: entry['.id'], properties: {}, description: `Remove bridge ${entry.name}`, }) } const handleToggleBridge = (entry: Record) => { const newDisabled = entry.disabled === 'true' ? 'no' : 'yes' addChange({ operation: 'set', path: '/interface/bridge', entryId: entry['.id'], properties: { disabled: newDisabled }, description: `${newDisabled === 'yes' ? 'Disable' : 'Enable'} bridge ${entry.name}`, }) } const openAddPort = () => { setEditPort(null) setPortDialogOpen(true) } const openEditPort = (entry: Record) => { setEditPort(entry) setPortDialogOpen(true) } const handleRemovePort = (entry: Record) => { addChange({ operation: 'remove', path: '/interface/bridge/port', entryId: entry['.id'], properties: {}, description: `Remove ${entry.interface} from bridge ${entry.bridge}`, }) } if (isLoading) return return (
{/* Bridges section */}

Bridges

{bridges.length === 0 ? (
No bridges configured.
) : (
{bridges.map((entry, i) => { const isDisabled = entry.disabled === 'true' return ( ) })}
Name Protocol Mode Status Actions
{entry.name || '---'} {entry['protocol-mode'] || '---'} {isDisabled ? 'disabled' : 'active'}
)}
{/* Bridge Ports section */}

Bridge Ports

{bridgePorts.length === 0 ? (
No bridge ports configured.
) : (
{bridgePorts.map((entry, i) => ( ))}
Interface Bridge PVID Actions
{entry.interface || '---'} {entry.bridge || '---'} {entry.pvid || '---'} openEditPort(entry)}> Edit handleRemovePort(entry)} className="text-error focus:text-error" > Remove
)}
{/* Dialogs */}
) } // --------------------------------------------------------------------------- // Bridge Dialog // --------------------------------------------------------------------------- function BridgeDialog({ open, onOpenChange, entry, addChange, }: { open: boolean onOpenChange: (open: boolean) => void entry: Record | null addChange: (c: ConfigChange) => void }) { const isEdit = !!entry const [name, setName] = useState('') const [protocolMode, setProtocolMode] = useState('rstp') const [error, setError] = useState('') const handleOpenChange = (val: boolean) => { if (val) { setName(entry?.name || '') setProtocolMode(entry?.['protocol-mode'] || 'rstp') setError('') } onOpenChange(val) } const handleSubmit = () => { if (!name.trim()) { setError('Bridge name is required') return } setError('') const properties: Record = { name: name.trim(), 'protocol-mode': protocolMode, } if (isEdit) { addChange({ operation: 'set', path: '/interface/bridge', entryId: entry['.id'], properties, description: `Update bridge ${name} (${protocolMode})`, }) } else { addChange({ operation: 'add', path: '/interface/bridge', properties, description: `Add bridge ${name} (${protocolMode})`, }) } onOpenChange(false) } return ( {isEdit ? 'Edit Bridge' : 'Add Bridge'} {isEdit ? 'Modify the bridge configuration.' : 'Create a new bridge interface.'}
setName(e.target.value)} />
{error &&

{error}

}
) } // --------------------------------------------------------------------------- // Bridge Port Dialog // --------------------------------------------------------------------------- function BridgePortDialog({ open, onOpenChange, entry, interfaceNames, bridgeNames, addChange, }: { open: boolean onOpenChange: (open: boolean) => void entry: Record | null interfaceNames: string[] bridgeNames: string[] addChange: (c: ConfigChange) => void }) { const isEdit = !!entry const [iface, setIface] = useState('') const [bridge, setBridge] = useState('') const [pvid, setPvid] = useState('1') const [error, setError] = useState('') const handleOpenChange = (val: boolean) => { if (val) { setIface(entry?.interface || '') setBridge(entry?.bridge || '') setPvid(entry?.pvid || '1') setError('') } onOpenChange(val) } const handleSubmit = () => { if (!iface) { setError('Please select an interface') return } if (!bridge) { setError('Please select a bridge') return } setError('') const properties: Record = { interface: iface, bridge, pvid, } if (isEdit) { addChange({ operation: 'set', path: '/interface/bridge/port', entryId: entry['.id'], properties, description: `Update bridge port ${iface} on ${bridge} (PVID: ${pvid})`, }) } else { addChange({ operation: 'add', path: '/interface/bridge/port', properties, description: `Add ${iface} to bridge ${bridge} (PVID: ${pvid})`, }) } onOpenChange(false) } return ( {isEdit ? 'Edit Bridge Port' : 'Add Bridge Port'} {isEdit ? 'Modify the bridge port assignment.' : 'Assign an interface to a bridge.'}
setPvid(e.target.value)} />
{error &&

{error}

}
) } // --------------------------------------------------------------------------- // Shared Entry Actions Dropdown // --------------------------------------------------------------------------- function EntryActions({ entry, onEdit, onRemove, onToggle, }: { entry: Record onEdit: (entry: Record) => void onRemove: (entry: Record) => void onToggle: (entry: Record) => void }) { const isDisabled = entry.disabled === 'true' return ( onEdit(entry)}> Edit onToggle(entry)}> {isDisabled ? ( <> Enable ) : ( <> Disable )} onRemove(entry)} className="text-error focus:text-error" > Delete ) }