Initial commit baby!
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
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<string, string>;
|
||||
|
||||
// ─── 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 (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
{disabled ? 'Disabled' : 'Enabled'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} DHCP Server
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="dhcp1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Interface *</label>
|
||||
<input className="input w-full" value={iface} onChange={e => setIface(e.target.value)} placeholder="bridge1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Address Pool</label>
|
||||
{pools.length > 0 ? (
|
||||
<select className="input w-full" value={pool} onChange={e => setPool(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{pools.map(p => <option key={p['.id']} value={p['name']}>{p['name']} ({p['ranges'] || p['prefix'] || ''})</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input w-full" value={pool} onChange={e => setPool(e.target.value)} placeholder="pool-name" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Lease Time</label>
|
||||
<input className="input w-full" value={leaseTime} onChange={e => setLeaseTime(e.target.value)} placeholder="00:10:00" />
|
||||
<p className="mt-0.5 text-xs text-gray-400">Format: HH:MM:SS or e.g. 10m, 1h, 1d</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name || !iface || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 dark:text-slate-400 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Add {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} Address Pool
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="pool1" />
|
||||
</div>
|
||||
{protocol === 'ipv4' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Ranges *</label>
|
||||
<input className="input w-full" value={ranges} onChange={e => setRanges(e.target.value)} placeholder="192.168.1.10-192.168.1.100" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Prefix *</label>
|
||||
<input className="input w-full" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="2001:db8::/64" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Adding…' : 'Add Pool'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Add Static Lease</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">MAC Address *</label>
|
||||
<input className="input w-full font-mono" value={mac} onChange={e => setMac(e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">IP Address *</label>
|
||||
<input className="input w-full font-mono" value={address} onChange={e => setAddress(e.target.value)} placeholder={protocol === 'ipv4' ? '192.168.1.50' : '2001:db8::50'} />
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">DHCP Server</label>
|
||||
<select className="input w-full" value={server} onChange={e => setServer(e.target.value)}>
|
||||
{servers.map(s => <option key={s['.id']} value={s['name']}>{s['name']}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Comment</label>
|
||||
<input className="input w-full" value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!mac || !address || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Adding…' : 'Add Lease'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center border-b border-gray-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 flex-1 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-800/40 transition-colors text-left"
|
||||
>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
<Server className={clsx('w-4 h-4', color)} />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">{title}</h2>
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-slate-500">{count}</span>
|
||||
</button>
|
||||
{action && <div className="px-3">{action}</div>}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesDHCPPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
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 <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No servers configured.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Interface</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Pool</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Lease Time</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s, i) => {
|
||||
const disabled = s['disabled'] === 'true';
|
||||
return (
|
||||
<tr key={s['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{s['name'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{s['interface'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{s['address-pool'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{leaseLabel(s['lease-time'] || '')}</td>
|
||||
<td className="px-4 py-3"><StatusBadge disabled={disabled} /></td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => toggleServer.mutate({ id: s['.id'], disabled: !disabled, protocol })} title={disabled ? 'Enable' : 'Disable'}
|
||||
className={clsx('p-1.5 rounded-lg transition-colors', disabled ? 'text-gray-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20' : 'text-green-600 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20')}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setServerForm({ protocol, existing: s })} title="Edit"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm(`Delete DHCP server "${s['name']}"?`)) deleteServer.mutate({ id: s['.id'], protocol }); }} title="Delete"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolTable({ pools, protocol }: { pools: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (pools.length === 0) return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No pools configured.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">{protocol === 'ipv4' ? 'Ranges' : 'Prefix'}</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pools.map((p, i) => (
|
||||
<tr key={p['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{p['name']}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-300">{p['ranges'] || p['prefix'] || '—'}</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => { if (confirm(`Delete pool "${p['name']}"?`)) deletePool.mutate({ id: p['.id'], protocol }); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaseTable({ leases, protocol }: { leases: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (leases.length === 0) return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No leases.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">MAC / DUID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Address</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Hostname</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Type</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leases.map((l, i) => {
|
||||
const isStatic = l['dynamic'] !== 'true';
|
||||
return (
|
||||
<tr key={l['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-slate-300">{l['mac-address'] || l['duid'] || '—'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-slate-300">{l['address'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{l['host-name'] || l['comment'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full font-medium', isStatic ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500')}>
|
||||
{isStatic ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isStatic && (
|
||||
<button onClick={() => deleteLease.mutate({ id: l['.id'], protocol })}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{serverForm && (
|
||||
<ServerForm
|
||||
protocol={serverForm.protocol}
|
||||
existing={serverForm.existing}
|
||||
pools={serverForm.protocol === 'ipv4' ? (dhcp?.pools_v4 || []) : (dhcp?.pools_v6 || [])}
|
||||
deviceId={deviceId}
|
||||
onClose={() => setServerForm(null)}
|
||||
/>
|
||||
)}
|
||||
{poolForm && <PoolForm protocol={poolForm} deviceId={deviceId} onClose={() => setPoolForm(null)} />}
|
||||
{leaseForm && (
|
||||
<StaticLeaseForm
|
||||
protocol={leaseForm}
|
||||
servers={leaseForm === 'ipv4' ? (dhcp?.ipv4 || []) : (dhcp?.ipv6 || [])}
|
||||
deviceId={deviceId}
|
||||
onClose={() => setLeaseForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">DHCP Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Manage DHCP servers, address pools, and leases</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedDevice && selectedDevice.status !== 'online' && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline. Connection may fail.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhcp && (
|
||||
<>
|
||||
{/* IPv4 Servers */}
|
||||
<Section color="text-blue-500" title="IPv4 DHCP Servers" count={dhcp.ipv4.length}
|
||||
open={v4Open} onToggle={() => setV4Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setServerForm({ protocol: 'ipv4' })} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<ServerTable servers={dhcp.ipv4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv4 Pools */}
|
||||
<Section color="text-cyan-500" title="IPv4 Address Pools" count={dhcp.pools_v4.length}
|
||||
open={poolsV4Open} onToggle={() => setPoolsV4Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setPoolForm('ipv4')} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<PoolTable pools={dhcp.pools_v4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv4 Leases */}
|
||||
<Section color="text-sky-500" title="IPv4 Leases" count={leasesV4.length}
|
||||
open={leasesOpen} onToggle={() => setLeasesOpen(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setLeaseForm('ipv4')} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Static</button> : undefined}>
|
||||
<LeaseTable leases={leasesV4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Servers */}
|
||||
<Section color="text-indigo-500" title="IPv6 DHCP Servers" count={dhcp.ipv6.length}
|
||||
open={v6Open} onToggle={() => setV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setServerForm({ protocol: 'ipv6' })} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<ServerTable servers={dhcp.ipv6} protocol="ipv6" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Pools */}
|
||||
<Section color="text-violet-500" title="IPv6 Address Pools" count={dhcp.pools_v6.length}
|
||||
open={poolsV6Open} onToggle={() => setPoolsV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setPoolForm('ipv6')} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<PoolTable pools={dhcp.pools_v6} protocol="ipv6" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Leases */}
|
||||
<Section color="text-purple-500" title="IPv6 Bindings" count={leasesV6.length}
|
||||
open={leasesV6Open} onToggle={() => setLeasesV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setLeaseForm('ipv6')} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Static</button> : undefined}>
|
||||
<LeaseTable leases={leasesV6} protocol="ipv6" />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user