/** * AddressPanel -- IP address management panel for device configuration. * * Displays addresses from /ip/address with interface selector dropdown, * CIDR validation with network auto-calculation, add/edit/delete dialogs, * and safe apply mode by default. */ import { useState, useCallback, useMemo } from 'react' import { Plus, Pencil, Trash2, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { SafetyToggle } from './SafetyToggle' import { ChangePreviewModal } from './ChangePreviewModal' import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel' import { cn } from '@/lib/utils' import type { ConfigPanelProps } from '@/lib/configPanelTypes' // --------------------------------------------------------------------------- // Entry & form types // --------------------------------------------------------------------------- interface AddressEntry { '.id': string address: string network: string broadcast: string interface: string 'actual-interface': string disabled: string dynamic: string [key: string]: string } interface AddressForm { address: string interface: string } const EMPTY_FORM: AddressForm = { address: '', interface: '', } // --------------------------------------------------------------------------- // Validation & helpers // --------------------------------------------------------------------------- const CIDR_REGEX = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/ function validateAddressForm(form: AddressForm): Record { const errors: Record = {} if (!form.address) { errors.address = 'Address is required' } else if (!CIDR_REGEX.test(form.address)) { errors.address = 'Must be valid CIDR (e.g. 192.168.1.1/24)' } if (!form.interface) { errors.interface = 'Interface is required' } return errors } /** * Calculate network address from CIDR notation. * e.g. "192.168.1.100/24" → "192.168.1.0/24" */ function calculateNetwork(cidr: string): string | null { if (!CIDR_REGEX.test(cidr)) return null const [ipStr, maskStr] = cidr.split('/') const mask = parseInt(maskStr, 10) if (mask < 0 || mask > 32) return null const octets = ipStr.split('.').map(Number) if (octets.some((o) => o < 0 || o > 255)) return null const ip = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0 const maskBits = mask === 0 ? 0 : (~0 << (32 - mask)) >>> 0 const network = (ip & maskBits) >>> 0 return [ (network >>> 24) & 0xff, (network >>> 16) & 0xff, (network >>> 8) & 0xff, network & 0xff, ].join('.') + '/' + mask } // --------------------------------------------------------------------------- // Panel type shorthand // --------------------------------------------------------------------------- type PanelHook = ReturnType // --------------------------------------------------------------------------- // AddressPanel // --------------------------------------------------------------------------- export function AddressPanel({ tenantId, deviceId, active }: ConfigPanelProps) { const { entries, isLoading, error, refetch } = useConfigBrowse( tenantId, deviceId, '/ip/address', { enabled: active }, ) const panel = useConfigPanel(tenantId, deviceId, 'addresses') const [previewOpen, setPreviewOpen] = useState(false) const typedEntries = entries as AddressEntry[] // Collect unique interface names for the selector dropdown const interfaceNames = useMemo(() => { const names = new Set() typedEntries.forEach((e) => { if (e.interface) names.add(e.interface) if (e['actual-interface']) names.add(e['actual-interface']) }) return Array.from(names).sort() }, [typedEntries]) if (isLoading) { return (
Loading IP addresses...
) } if (error) { return (
Failed to load IP addresses.{' '}
) } return (
{/* Header with SafetyToggle and Apply button */}
{/* Address table */} {/* Change Preview Modal */} { panel.applyChanges() setPreviewOpen(false) }} isApplying={panel.isApplying} />
) } // --------------------------------------------------------------------------- // Address Table // --------------------------------------------------------------------------- function AddressTable({ entries, panel, interfaceNames, }: { entries: AddressEntry[] panel: PanelHook interfaceNames: string[] }) { const [dialogOpen, setDialogOpen] = useState(false) const [editing, setEditing] = useState(null) const [form, setForm] = useState(EMPTY_FORM) const [errors, setErrors] = useState>({}) const [customInterface, setCustomInterface] = useState(false) const calculatedNetwork = useMemo( () => calculateNetwork(form.address), [form.address], ) const handleAdd = useCallback(() => { setEditing(null) setForm(EMPTY_FORM) setErrors({}) setCustomInterface(false) setDialogOpen(true) }, []) const handleEdit = useCallback((entry: AddressEntry) => { setEditing(entry) setForm({ address: entry.address || '', interface: entry.interface || '', }) setErrors({}) setCustomInterface(false) setDialogOpen(true) }, []) const handleDelete = useCallback( (entry: AddressEntry) => { panel.addChange({ operation: 'remove', path: '/ip/address', entryId: entry['.id'], properties: {}, description: `Remove address ${entry.address} from ${entry.interface || 'unknown'}`, }) }, [panel], ) const handleSave = useCallback(() => { const validationErrors = validateAddressForm(form) if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors) return } const props: Record = { address: form.address, interface: form.interface, } if (editing) { panel.addChange({ operation: 'set', path: '/ip/address', entryId: editing['.id'], properties: props, description: `Edit address ${form.address} on ${form.interface}`, }) } else { panel.addChange({ operation: 'add', path: '/ip/address', properties: props, description: `Add address ${form.address} to ${form.interface}`, }) } setDialogOpen(false) }, [form, editing, panel]) return ( <> {/* Table */}
IP Addresses ({entries.length})
{entries.length === 0 ? (
No IP addresses found.
) : (
{entries.map((entry) => ( ))}
Address Network Broadcast Interface Status Actions
{entry.address || '—'} {entry.network || '—'} {entry.broadcast || '—'} {entry['actual-interface'] || entry.interface || '—'}
{entry.dynamic !== 'true' && ( <> )}
)}
{/* Add/Edit Dialog */} {editing ? 'Edit Address' : 'Add Address'} {editing ? 'Modify the address properties below.' : 'Enter the address details. Changes are staged until you apply them.'}
setForm((f) => ({ ...f, address: e.target.value })) } placeholder="192.168.1.1/24" className={cn('h-8 text-sm font-mono', errors.address && 'border-error')} /> {errors.address && (

{errors.address}

)} {calculatedNetwork && (

Network: {calculatedNetwork}

)}
{!customInterface && interfaceNames.length > 0 ? (
) : (
setForm((f) => ({ ...f, interface: e.target.value })) } placeholder="ether1" className={cn('h-8 text-sm flex-1', errors.interface && 'border-error')} /> {interfaceNames.length > 0 && ( )}
)} {errors.interface && (

{errors.interface}

)}
) } // --------------------------------------------------------------------------- // Status Badge // --------------------------------------------------------------------------- function AddressStatusBadge({ entry }: { entry: AddressEntry }) { if (entry.disabled === 'true') { return ( disabled ) } if (entry.dynamic === 'true') { return ( dynamic ) } return ( active ) }