/** * PoolPanel -- IP pool management panel for device configuration. * * Displays IP pools from /ip/pool with range editor, next-pool chaining, * used-by DHCP indicator, add/edit/delete dialogs, * and standard apply mode by default. */ import { useState, useCallback, useMemo } from 'react' import { Plus, Pencil, Trash2, Layers } 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 { cn } from '@/lib/utils' import type { ConfigPanelProps } from '@/lib/configPanelTypes' // --------------------------------------------------------------------------- // Entry & form types // --------------------------------------------------------------------------- interface PoolEntry { '.id': string name: string ranges: string 'next-pool': string [key: string]: string } interface DhcpServerEntry { '.id': string name: string 'address-pool': string [key: string]: string } interface PoolForm { name: string ranges: string 'next-pool': string } const EMPTY_FORM: PoolForm = { name: '', ranges: '', 'next-pool': '', } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/ function validateRange(range: string): boolean { const trimmed = range.trim() if (!trimmed) return false const parts = trimmed.split('-') if (parts.length !== 2) return false return IP_REGEX.test(parts[0].trim()) && IP_REGEX.test(parts[1].trim()) } function validatePoolForm(form: PoolForm): Record { const errors: Record = {} if (!form.name) { errors.name = 'Pool name is required' } if (!form.ranges) { errors.ranges = 'At least one range is required' } else { const ranges = form.ranges.split(',') const invalid = ranges.filter((r) => !validateRange(r)) if (invalid.length > 0) { errors.ranges = 'Each range must be in format x.x.x.x-x.x.x.x' } } return errors } // --------------------------------------------------------------------------- // Panel type shorthand // --------------------------------------------------------------------------- type PanelHook = ReturnType // --------------------------------------------------------------------------- // PoolPanel // --------------------------------------------------------------------------- export function PoolPanel({ tenantId, deviceId, active }: ConfigPanelProps) { const { entries, isLoading, error, refetch } = useConfigBrowse( tenantId, deviceId, '/ip/pool', { enabled: active }, ) // Fetch DHCP servers to show used-by indicator const { entries: dhcpEntries } = useConfigBrowse( tenantId, deviceId, '/ip/dhcp-server', { enabled: active }, ) const panel = useConfigPanel(tenantId, deviceId, 'pools') const [previewOpen, setPreviewOpen] = useState(false) const typedEntries = entries as PoolEntry[] const dhcpServers = dhcpEntries as DhcpServerEntry[] // Build a map of pool name → DHCP server names that use it const poolUsedBy = useMemo(() => { const map: Record = {} dhcpServers.forEach((server) => { const pool = server['address-pool'] if (pool) { if (!map[pool]) map[pool] = [] map[pool].push(server.name || server['.id']) } }) return map }, [dhcpServers]) if (isLoading) { return (
Loading IP pools...
) } if (error) { return (
Failed to load IP pools.{' '}
) } return (
{/* Header with SafetyToggle and Apply button */}
{/* Pool table */} e.name).filter(Boolean)} /> {/* Change Preview Modal */} { panel.applyChanges() setPreviewOpen(false) }} isApplying={panel.isApplying} />
) } // --------------------------------------------------------------------------- // Pool Table // --------------------------------------------------------------------------- function PoolTable({ entries, panel, poolUsedBy, existingPools, }: { entries: PoolEntry[] panel: PanelHook poolUsedBy: Record existingPools: string[] }) { const [dialogOpen, setDialogOpen] = useState(false) const [editing, setEditing] = useState(null) const [form, setForm] = useState(EMPTY_FORM) const [errors, setErrors] = useState>({}) const handleAdd = useCallback(() => { setEditing(null) setForm(EMPTY_FORM) setErrors({}) setDialogOpen(true) }, []) const handleEdit = useCallback((entry: PoolEntry) => { setEditing(entry) setForm({ name: entry.name || '', ranges: entry.ranges || '', 'next-pool': entry['next-pool'] || '', }) setErrors({}) setDialogOpen(true) }, []) const handleDelete = useCallback( (entry: PoolEntry) => { panel.addChange({ operation: 'remove', path: '/ip/pool', entryId: entry['.id'], properties: {}, description: `Remove pool "${entry.name}"`, }) }, [panel], ) const handleSave = useCallback(() => { const validationErrors = validatePoolForm(form) if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors) return } const props: Record = { name: form.name, ranges: form.ranges, } if (form['next-pool']) { props['next-pool'] = form['next-pool'] } if (editing) { panel.addChange({ operation: 'set', path: '/ip/pool', entryId: editing['.id'], properties: props, description: `Edit pool "${form.name}" ranges ${form.ranges}`, }) } else { panel.addChange({ operation: 'add', path: '/ip/pool', properties: props, description: `Add pool "${form.name}" with ranges ${form.ranges}`, }) } setDialogOpen(false) }, [form, editing, panel]) return ( <> {/* Table */}
IP Pools ({entries.length})
{entries.length === 0 ? (
No IP pools configured.
) : (
{entries.map((entry) => { const usedBy = poolUsedBy[entry.name] return ( ) })}
Name Ranges Next Pool Actions
{entry.name || '—'} {usedBy && usedBy.length > 0 && (
Used by: {usedBy.join(', ')}
)}
{entry.ranges || '—'} {entry['next-pool'] || '—'}
)}
{/* Add/Edit Dialog */} {editing ? 'Edit Pool' : 'Add Pool'} {editing ? 'Modify the pool properties below.' : 'Enter the pool details. Changes are staged until you apply them.'}
setForm((f) => ({ ...f, name: e.target.value })) } placeholder="dhcp-pool1" className={cn('h-8 text-sm', errors.name && 'border-error')} /> {errors.name && (

{errors.name}

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

{errors.ranges}

)}

Use start-end notation. Comma-separate multiple ranges.

setForm((f) => ({ ...f, 'next-pool': e.target.value })) } placeholder="none" className="h-8 text-sm" />

Leave empty if no chaining needed.

) }