import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Server, RefreshCw, AlertTriangle, Power, Plus, Pencil, Trash2, ChevronDown, ChevronRight, X, Check, } from 'lucide-react'; import clsx from 'clsx'; import { networkServicesApi, devicesApi } from '../services/api'; import { useCanWrite } from '../hooks/useCanWrite'; type NS = Record; // ─── helpers ───────────────────────────────────────────────────────────────── function leaseLabel(secs: string): string { const n = parseInt(secs); if (!n || isNaN(n)) return secs || '—'; if (n < 3600) return `${Math.round(n / 60)} min`; if (n < 86400) return `${Math.round(n / 3600)} hr`; return `${Math.round(n / 86400)} d`; } function StatusBadge({ disabled }: { disabled: boolean }) { return ( {disabled ? 'Disabled' : 'Enabled'} ); } // ─── Server form modal ──────────────────────────────────────────────────────── interface ServerFormProps { protocol: 'ipv4' | 'ipv6'; existing?: NS; pools: NS[]; deviceId: number; onClose: () => void; } function ServerForm({ protocol, existing, pools, deviceId, onClose }: ServerFormProps) { const qc = useQueryClient(); const [name, setName] = useState(existing?.['name'] || ''); const [iface, setIface] = useState(existing?.['interface'] || ''); const [pool, setPool] = useState(existing?.['address-pool'] || ''); const [leaseTime, setLeaseTime] = useState(existing?.['lease-time'] || '00:10:00'); const save = useMutation({ mutationFn: () => { const body: NS & { protocol: 'ipv4' | 'ipv6' } = { protocol, name, interface: iface, 'address-pool': pool, 'lease-time': leaseTime, }; return existing?.['.id'] ? networkServicesApi.updateDhcpServer(deviceId, existing['.id'], body) : networkServicesApi.addDhcpServer(deviceId, body); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }); onClose(); }, }); return (

{existing ? 'Edit' : 'Add'} {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} DHCP Server

setName(e.target.value)} placeholder="dhcp1" />
setIface(e.target.value)} placeholder="bridge1" />
{pools.length > 0 ? ( ) : ( setPool(e.target.value)} placeholder="pool-name" /> )}
setLeaseTime(e.target.value)} placeholder="00:10:00" />

Format: HH:MM:SS or e.g. 10m, 1h, 1d

{save.isError && {(save.error as Error).message}}
); } // ─── Pool form modal ────────────────────────────────────────────────────────── interface PoolFormProps { protocol: 'ipv4' | 'ipv6'; deviceId: number; onClose: () => void; } function PoolForm({ protocol, deviceId, onClose }: PoolFormProps) { const qc = useQueryClient(); const [name, setName] = useState(''); const [ranges, setRanges] = useState(''); const [prefix, setPrefix] = useState(''); const save = useMutation({ mutationFn: () => { const params: NS & { protocol: 'ipv4' | 'ipv6' } = { protocol, name }; if (protocol === 'ipv4') params['ranges'] = ranges; else params['prefix'] = prefix; return networkServicesApi.addDhcpPool(deviceId, params); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }); onClose(); }, }); return (

Add {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} Address Pool

setName(e.target.value)} placeholder="pool1" />
{protocol === 'ipv4' ? (
setRanges(e.target.value)} placeholder="192.168.1.10-192.168.1.100" />
) : (
setPrefix(e.target.value)} placeholder="2001:db8::/64" />
)}
{save.isError && {(save.error as Error).message}}
); } // ─── Static Lease form modal ────────────────────────────────────────────────── interface StaticLeaseFormProps { protocol: 'ipv4' | 'ipv6'; servers: NS[]; deviceId: number; onClose: () => void; } function StaticLeaseForm({ protocol, servers, deviceId, onClose }: StaticLeaseFormProps) { const qc = useQueryClient(); const [mac, setMac] = useState(''); const [address, setAddress] = useState(''); const [server, setServer] = useState(servers[0]?.['name'] || ''); const [comment, setComment] = useState(''); const save = useMutation({ mutationFn: () => { const body: NS & { protocol: 'ipv4' | 'ipv6' } = { protocol, 'mac-address': mac, address, comment, ...(protocol === 'ipv4' ? { server } : { server }), }; return networkServicesApi.addStaticLease(deviceId, body); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-leases', deviceId, protocol] }); onClose(); }, }); return (

Add Static Lease

setMac(e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" />
setAddress(e.target.value)} placeholder={protocol === 'ipv4' ? '192.168.1.50' : '2001:db8::50'} />
{servers.length > 0 && (
)}
setComment(e.target.value)} placeholder="Optional" />
{save.isError && {(save.error as Error).message}}
); } // ─── Section component ──────────────────────────────────────────────────────── interface SectionProps { color: string; title: string; count: number; open: boolean; onToggle: () => void; action?: React.ReactNode; children: React.ReactNode; } function Section({ color, title, count, open, onToggle, action, children }: SectionProps) { return (
{action &&
{action}
}
{open && children}
); } // ─── Main page ──────────────────────────────────────────────────────────────── export default function NetworkServicesDHCPPage() { const canWrite = useCanWrite(); const qc = useQueryClient(); const [selectedDeviceId, setSelectedDeviceId] = useState(''); const [v4Open, setV4Open] = useState(true); const [v6Open, setV6Open] = useState(true); const [poolsV4Open, setPoolsV4Open] = useState(false); const [poolsV6Open, setPoolsV6Open] = useState(false); const [leasesOpen, setLeasesOpen] = useState(false); const [leasesV6Open, setLeasesV6Open] = useState(false); // Modals const [serverForm, setServerForm] = useState<{ protocol: 'ipv4' | 'ipv6'; existing?: NS } | null>(null); const [poolForm, setPoolForm] = useState<'ipv4' | 'ipv6' | null>(null); const [leaseForm, setLeaseForm] = useState<'ipv4' | 'ipv6' | null>(null); const { data: devices = [] } = useQuery({ queryKey: ['devices'], queryFn: () => devicesApi.list().then(r => r.data), staleTime: 60_000, }); const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0; const selectedDevice = devices.find(d => d.id === deviceId); const { data: dhcp, isLoading, refetch, isFetching, error } = useQuery({ queryKey: ['ns-dhcp', deviceId], queryFn: () => networkServicesApi.getDhcp(deviceId).then(r => r.data), enabled: deviceId > 0, }); const { data: leasesV4 = [] } = useQuery({ queryKey: ['ns-leases', deviceId, 'ipv4'], queryFn: () => networkServicesApi.getLeases(deviceId, 'ipv4').then(r => r.data), enabled: deviceId > 0 && leasesOpen, }); const { data: leasesV6 = [] } = useQuery({ queryKey: ['ns-leases', deviceId, 'ipv6'], queryFn: () => networkServicesApi.getLeases(deviceId, 'ipv6').then(r => r.data), enabled: deviceId > 0 && leasesV6Open, }); const deleteServer = useMutation({ mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) => networkServicesApi.deleteDhcpServer(deviceId, id, protocol), onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }), }); const toggleServer = useMutation({ mutationFn: ({ id, disabled, protocol }: { id: string; disabled: boolean; protocol: 'ipv4' | 'ipv6' }) => networkServicesApi.toggleDhcpServer(deviceId, id, disabled, protocol), onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }), }); const deletePool = useMutation({ mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) => networkServicesApi.deleteDhcpPool(deviceId, id, protocol), onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }), }); const deleteLease = useMutation({ mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) => networkServicesApi.deleteStaticLease(deviceId, id, protocol), onSuccess: (_d, vars) => qc.invalidateQueries({ queryKey: ['ns-leases', deviceId, vars.protocol] }), }); function ServerTable({ servers, protocol }: { servers: NS[]; protocol: 'ipv4' | 'ipv6' }) { if (servers.length === 0) { return
No servers configured.
; } return (
{canWrite && } {servers.map((s, i) => { const disabled = s['disabled'] === 'true'; return ( {canWrite && ( )} ); })}
Name Interface Pool Lease Time StatusActions
{s['name'] || '—'} {s['interface'] || '—'} {s['address-pool'] || '—'} {leaseLabel(s['lease-time'] || '')}
); } function PoolTable({ pools, protocol }: { pools: NS[]; protocol: 'ipv4' | 'ipv6' }) { if (pools.length === 0) return
No pools configured.
; return (
{canWrite && } {pools.map((p, i) => ( {canWrite && ( )} ))}
Name {protocol === 'ipv4' ? 'Ranges' : 'Prefix'}Actions
{p['name']} {p['ranges'] || p['prefix'] || '—'}
); } function LeaseTable({ leases, protocol }: { leases: NS[]; protocol: 'ipv4' | 'ipv6' }) { if (leases.length === 0) return
No leases.
; return (
{canWrite && } {leases.map((l, i) => { const isStatic = l['dynamic'] !== 'true'; return ( {canWrite && ( )} ); })}
MAC / DUID Address Hostname TypeActions
{l['mac-address'] || l['duid'] || '—'} {l['address'] || '—'} {l['host-name'] || l['comment'] || '—'} {isStatic ? 'Static' : 'Dynamic'} {isStatic && ( )}
); } return (
{/* Modals */} {serverForm && ( setServerForm(null)} /> )} {poolForm && setPoolForm(null)} />} {leaseForm && ( setLeaseForm(null)} /> )} {/* Header */}

DHCP Server

Manage DHCP servers, address pools, and leases

{deviceId > 0 && ( )}
{/* Device selector */}
{selectedDevice && selectedDevice.status !== 'online' && (
This device is currently offline. Connection may fail.
)}
{deviceId === 0 &&
Select a device above.
} {deviceId > 0 && isLoading &&
Loading…
} {deviceId > 0 && error && (
Failed: {(error as Error).message}
)} {dhcp && ( <> {/* IPv4 Servers */}
setV4Open(o => !o)} action={canWrite ? : undefined}>
{/* IPv4 Pools */}
setPoolsV4Open(o => !o)} action={canWrite ? : undefined}>
{/* IPv4 Leases */}
setLeasesOpen(o => !o)} action={canWrite ? : undefined}>
{/* IPv6 Servers */}
setV6Open(o => !o)} action={canWrite ? : undefined}>
{/* IPv6 Pools */}
setPoolsV6Open(o => !o)} action={canWrite ? : undefined}>
{/* IPv6 Leases */}
setLeasesV6Open(o => !o)} action={canWrite ? : undefined}>
)}
); }