/** * ArpPanel -- ARP table management panel for device configuration. * * Displays ARP entries from /ip/arp with filter tabs (All/Dynamic/Static), * add static ARP, delete entries, flush dynamic ARP cache action, * and standard apply mode by default. */ import { useState, useCallback, useMemo } from 'react' import { Plus, Trash2, RefreshCw, Network } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } from '@/components/ui/dialog' import { SafetyToggle } from './SafetyToggle' import { ChangePreviewModal } from './ChangePreviewModal' import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel' import { configEditorApi } from '@/lib/configEditorApi' import { useMutation } from '@tanstack/react-query' import { toast } from 'sonner' import { cn } from '@/lib/utils' import type { ConfigPanelProps } from '@/lib/configPanelTypes' // --------------------------------------------------------------------------- // Filter types // --------------------------------------------------------------------------- type FilterTab = 'all' | 'dynamic' | 'static' const FILTER_TABS: { key: FilterTab; label: string }[] = [ { key: 'all', label: 'All' }, { key: 'dynamic', label: 'Dynamic' }, { key: 'static', label: 'Static' }, ] // --------------------------------------------------------------------------- // Entry & form types // --------------------------------------------------------------------------- interface ArpEntry { '.id': string address: string 'mac-address': string interface: string dynamic: string complete: string disabled: string [key: string]: string } interface ArpForm { address: string 'mac-address': string interface: string } const EMPTY_FORM: ArpForm = { address: '', 'mac-address': '', interface: '', } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/ const MAC_REGEX = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/ function validateArpForm(form: ArpForm): Record { const errors: Record = {} if (!form.address) { errors.address = 'IP address is required' } else if (!IP_REGEX.test(form.address)) { errors.address = 'Must be valid IP (e.g. 192.168.1.1)' } if (!form['mac-address']) { errors['mac-address'] = 'MAC address is required' } else if (!MAC_REGEX.test(form['mac-address'])) { errors['mac-address'] = 'Must be valid MAC (e.g. AA:BB:CC:DD:EE:FF)' } if (!form.interface) { errors.interface = 'Interface is required' } return errors } // --------------------------------------------------------------------------- // Panel type shorthand // --------------------------------------------------------------------------- type PanelHook = ReturnType // --------------------------------------------------------------------------- // ArpPanel // --------------------------------------------------------------------------- export function ArpPanel({ tenantId, deviceId, active }: ConfigPanelProps) { const { entries, isLoading, error, refetch } = useConfigBrowse( tenantId, deviceId, '/ip/arp', { enabled: active }, ) const panel = useConfigPanel(tenantId, deviceId, 'arp') const [previewOpen, setPreviewOpen] = useState(false) const [filterTab, setFilterTab] = useState('all') const typedEntries = entries as ArpEntry[] const filteredEntries = useMemo(() => { switch (filterTab) { case 'dynamic': return typedEntries.filter((e) => e.dynamic === 'true') case 'static': return typedEntries.filter((e) => e.dynamic !== 'true') default: return typedEntries } }, [typedEntries, filterTab]) // Flush dynamic ARP cache const flushMutation = useMutation({ mutationFn: () => configEditorApi.execute( tenantId, deviceId, '/ip arp remove [find dynamic=yes]', ), onSuccess: () => { toast.success('ARP cache flushed') refetch() }, onError: (err: Error) => { toast.error('Failed to flush ARP cache', { description: err.message }) }, }) const handleFlush = useCallback(() => { if (confirm('Flush all dynamic ARP entries? This will clear the ARP cache.')) { flushMutation.mutate() } }, [flushMutation]) if (isLoading) { return (
Loading ARP table...
) } if (error) { return (
Failed to load ARP table.{' '}
) } return (
{/* Header with SafetyToggle and Apply button */}
{/* Filter tabs + Flush button */}
{FILTER_TABS.map((tab) => ( ))}
{/* ARP table */} {/* Change Preview Modal */} { panel.applyChanges() setPreviewOpen(false) }} isApplying={panel.isApplying} />
) } // --------------------------------------------------------------------------- // ARP Table // --------------------------------------------------------------------------- function ArpTable({ entries, panel, }: { entries: ArpEntry[] panel: PanelHook }) { const [dialogOpen, setDialogOpen] = useState(false) const [form, setForm] = useState(EMPTY_FORM) const [errors, setErrors] = useState>({}) const handleAdd = useCallback(() => { setForm(EMPTY_FORM) setErrors({}) setDialogOpen(true) }, []) const handleDelete = useCallback( (entry: ArpEntry) => { panel.addChange({ operation: 'remove', path: '/ip/arp', entryId: entry['.id'], properties: {}, description: `Remove ARP entry ${entry.address} (${entry['mac-address']})`, }) }, [panel], ) const handleSave = useCallback(() => { const validationErrors = validateArpForm(form) if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors) return } panel.addChange({ operation: 'add', path: '/ip/arp', properties: { address: form.address, 'mac-address': form['mac-address'], interface: form.interface, }, description: `Add static ARP ${form.address} → ${form['mac-address']} on ${form.interface}`, }) setDialogOpen(false) }, [form, panel]) return ( <> {/* Table */}
ARP Table ({entries.length})
{entries.length === 0 ? (
No ARP entries found.
) : (
{entries.map((entry) => ( ))}
IP Address MAC Address Interface Type Complete Actions
{entry.address || '—'} {entry['mac-address'] || '—'} {entry.interface || '—'} {entry.dynamic === 'true' ? ( dynamic ) : ( static )} {entry.complete === 'true' ? ( ) : ( )}
{entry.dynamic !== 'true' && ( )}
)}
{/* Add Static ARP Dialog */} Add Static ARP Entry Create a static ARP mapping. Changes are staged until you apply them.
setForm((f) => ({ ...f, address: e.target.value })) } placeholder="192.168.1.100" className={cn('h-8 text-sm font-mono', errors.address && 'border-error')} /> {errors.address && (

{errors.address}

)}
setForm((f) => ({ ...f, 'mac-address': e.target.value })) } placeholder="AA:BB:CC:DD:EE:FF" className={cn('h-8 text-sm font-mono', errors['mac-address'] && 'border-error')} /> {errors['mac-address'] && (

{errors['mac-address']}

)}
setForm((f) => ({ ...f, interface: e.target.value })) } placeholder="ether1" className={cn('h-8 text-sm', errors.interface && 'border-error')} /> {errors.interface && (

{errors.interface}

)}
) }