Client Details
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index 126f74c..dd5f6e4 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -229,6 +229,12 @@ export default function DashboardPage() {
ts: new Date(p.time).getTime(),
}));
+ const chartDomain = useMemo<[number, number]>(
+ () => [Date.now() - 24 * 60 * 60 * 1000, Date.now()],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [clientsOverTime]
+ );
+
const { data: topClients = [] } = useQuery({
queryKey: ['top-clients'],
queryFn: () => metricsApi.topClients(8).then((r) => r.data),
@@ -393,7 +399,7 @@ export default function DashboardPage() {
dataKey="ts"
type="number"
scale="time"
- domain={[Date.now() - 24 * 60 * 60 * 1000, Date.now()]}
+ domain={chartDomain}
tick={{ fontSize: 11 }}
tickCount={7}
tickFormatter={(t) => format(new Date(t), 'HH:mm')}
diff --git a/frontend/src/pages/NetworkServicesDHCPPage.tsx b/frontend/src/pages/NetworkServicesDHCPPage.tsx
index 4fe1645..4dec6cc 100644
--- a/frontend/src/pages/NetworkServicesDHCPPage.tsx
+++ b/frontend/src/pages/NetworkServicesDHCPPage.tsx
@@ -346,6 +346,176 @@ function Section({ color, title, count, open, onToggle, action, children }: Sect
// ─── Main page ────────────────────────────────────────────────────────────────
+// ─── ServerTable ─────────────────────────────────────────────────────────────
+
+interface ServerTableProps {
+ servers: NS[];
+ protocol: 'ipv4' | 'ipv6';
+ canWrite: boolean;
+ conflictDevices: { name: unknown }[];
+ onToggle: (id: string, currentDisabled: boolean) => void;
+ onEdit: (existing: NS) => void;
+ onDelete: (id: string) => void;
+}
+
+function ServerTable({ servers, protocol, canWrite, conflictDevices, onToggle, onEdit, onDelete }: ServerTableProps) {
+ if (servers.length === 0) {
+ return
No servers configured.
;
+ }
+ return (
+
+
+
+
+ Name
+ Interface
+ Pool
+ Lease Time
+ Status
+ {canWrite && Actions }
+
+
+
+ {servers.map((s, i) => {
+ const disabled = s['disabled'] === 'true';
+ return (
+
+ {s['name'] || '—'}
+ {s['interface'] || '—'}
+ {s['address-pool'] || '—'}
+ {leaseLabel(s['lease-time'] || '')}
+
+ {canWrite && (
+
+
+
{
+ if (disabled && conflictDevices.length > 0) {
+ const names = conflictDevices.map(d => d.name as string).join(', ');
+ if (!confirm(`Active ${protocol.toUpperCase()} DHCP server(s) already running on: ${names}.\n\nEnabling another DHCP server on the same network may cause IP address conflicts. Continue?`)) return;
+ }
+ onToggle(s['.id'], disabled);
+ }} 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')}>
+
+
+
onEdit(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">
+
+
+
{ if (confirm(`Delete DHCP server "${s['name']}"?`)) onDelete(s['.id']); }} 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">
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
+
+// ─── PoolTable ───────────────────────────────────────────────────────────────
+
+interface PoolTableProps {
+ pools: NS[];
+ protocol: 'ipv4' | 'ipv6';
+ canWrite: boolean;
+ onDelete: (id: string) => void;
+}
+
+function PoolTable({ pools, protocol, canWrite, onDelete }: PoolTableProps) {
+ if (pools.length === 0) return
No pools configured.
;
+ return (
+
+
+
+
+ Name
+ {protocol === 'ipv4' ? 'Ranges' : 'Prefix'}
+ {canWrite && Actions }
+
+
+
+ {pools.map((p, i) => (
+
+ {p['name']}
+ {p['ranges'] || p['prefix'] || '—'}
+ {canWrite && (
+
+ { if (confirm(`Delete pool "${p['name']}"?`)) onDelete(p['.id']); }}
+ 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">
+
+
+
+ )}
+
+ ))}
+
+
+
+ );
+}
+
+// ─── LeaseTable ──────────────────────────────────────────────────────────────
+
+interface LeaseTableProps {
+ leases: NS[];
+ canWrite: boolean;
+ onDelete: (id: string) => void;
+}
+
+function LeaseTable({ leases, canWrite, onDelete }: LeaseTableProps) {
+ if (leases.length === 0) return
No leases.
;
+ return (
+
+
+
+
+ MAC / DUID
+ Address
+ Hostname
+ Type
+ {canWrite && Actions }
+
+
+
+ {leases.map((l, i) => {
+ const isStatic = l['dynamic'] !== 'true';
+ return (
+
+ {l['mac-address'] || l['duid'] || '—'}
+ {l['address'] || '—'}
+ {l['host-name'] || l['comment'] || '—'}
+
+
+ {isStatic ? 'Static' : 'Dynamic'}
+
+
+ {canWrite && (
+
+ {isStatic && (
+ onDelete(l['.id'])}
+ 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">
+
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
+
+// ─── Main Page ────────────────────────────────────────────────────────────────
+
export default function NetworkServicesDHCPPage() {
const canWrite = useCanWrite();
const qc = useQueryClient();
@@ -430,148 +600,6 @@ export default function NetworkServicesDHCPPage() {
return (v6?.enabled ?? 0) > 0;
});
- function ServerTable({ servers, protocol }: { servers: NS[]; protocol: 'ipv4' | 'ipv6' }) {
- if (servers.length === 0) {
- return
No servers configured.
;
- }
- return (
-
-
-
-
- Name
- Interface
- Pool
- Lease Time
- Status
- {canWrite && Actions }
-
-
-
- {servers.map((s, i) => {
- const disabled = s['disabled'] === 'true';
- return (
-
- {s['name'] || '—'}
- {s['interface'] || '—'}
- {s['address-pool'] || '—'}
- {leaseLabel(s['lease-time'] || '')}
-
- {canWrite && (
-
-
-
{
- if (disabled) {
- const conflicts = protocol === 'ipv4' ? conflictDevicesV4 : conflictDevicesV6;
- if (conflicts.length > 0) {
- const names = conflicts.map(d => d.name as string).join(', ');
- if (!confirm(`Active ${protocol.toUpperCase()} DHCP server(s) already running on: ${names}.\n\nEnabling another DHCP server on the same network may cause IP address conflicts. Continue?`)) return;
- }
- }
- 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')}>
-
-
-
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">
-
-
-
{ 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">
-
-
-
-
- )}
-
- );
- })}
-
-
-
- );
- }
-
- function PoolTable({ pools, protocol }: { pools: NS[]; protocol: 'ipv4' | 'ipv6' }) {
- if (pools.length === 0) return
No pools configured.
;
- return (
-
-
-
-
- Name
- {protocol === 'ipv4' ? 'Ranges' : 'Prefix'}
- {canWrite && Actions }
-
-
-
- {pools.map((p, i) => (
-
- {p['name']}
- {p['ranges'] || p['prefix'] || '—'}
- {canWrite && (
-
- { 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">
-
-
-
- )}
-
- ))}
-
-
-
- );
- }
-
- function LeaseTable({ leases, protocol }: { leases: NS[]; protocol: 'ipv4' | 'ipv6' }) {
- if (leases.length === 0) return
No leases.
;
- return (
-
-
-
-
- MAC / DUID
- Address
- Hostname
- Type
- {canWrite && Actions }
-
-
-
- {leases.map((l, i) => {
- const isStatic = l['dynamic'] !== 'true';
- return (
-
- {l['mac-address'] || l['duid'] || '—'}
- {l['address'] || '—'}
- {l['host-name'] || l['comment'] || '—'}
-
-
- {isStatic ? 'Static' : 'Dynamic'}
-
-
- {canWrite && (
-
- {isStatic && (
- 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">
-
-
- )}
-
- )}
-
- );
- })}
-
-
-
- );
- }
-
return (
{/* Modals */}
@@ -656,42 +684,54 @@ export default function NetworkServicesDHCPPage() {
setV4Open(o => !o)}
action={canWrite ? 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"> Add : undefined}>
-
+ toggleServer.mutate({ id, disabled: !cur, protocol: 'ipv4' })}
+ onEdit={(s) => setServerForm({ protocol: 'ipv4', existing: s })}
+ onDelete={(id) => deleteServer.mutate({ id, protocol: 'ipv4' })} />
{/* IPv4 Pools */}
setPoolsV4Open(o => !o)}
action={canWrite ? 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"> Add : undefined}>
-
+ deletePool.mutate({ id, protocol: 'ipv4' })} />
{/* IPv4 Leases */}
setLeasesOpen(o => !o)}
action={canWrite ? 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"> Static : undefined}>
-
+ deleteLease.mutate({ id, protocol: 'ipv4' })} />
{/* IPv6 Servers */}
setV6Open(o => !o)}
action={canWrite ? 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"> Add : undefined}>
-
+ toggleServer.mutate({ id, disabled: !cur, protocol: 'ipv6' })}
+ onEdit={(s) => setServerForm({ protocol: 'ipv6', existing: s })}
+ onDelete={(id) => deleteServer.mutate({ id, protocol: 'ipv6' })} />
{/* IPv6 Pools */}
setPoolsV6Open(o => !o)}
action={canWrite ? 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"> Add : undefined}>
-
+ deletePool.mutate({ id, protocol: 'ipv6' })} />
{/* IPv6 Leases */}
setLeasesV6Open(o => !o)}
action={canWrite ? 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"> Static : undefined}>
-
+ deleteLease.mutate({ id, protocol: 'ipv6' })} />
>
)}
diff --git a/frontend/src/pages/NetworkServicesDNSPage.tsx b/frontend/src/pages/NetworkServicesDNSPage.tsx
index 4a1add7..5cff777 100644
--- a/frontend/src/pages/NetworkServicesDNSPage.tsx
+++ b/frontend/src/pages/NetworkServicesDNSPage.tsx
@@ -156,6 +156,7 @@ export default function NetworkServicesDNSPage() {
// Sync settings form
useEffect(() => {
if (dns?.settings) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setServersInput(dns.settings['servers'] || '');
setAllowRemote(dns.settings['allow-remote-requests'] === 'yes');
setMaxUdpSize(dns.settings['max-udp-packet-size'] || '');
diff --git a/frontend/src/pages/NetworkServicesNTPPage.tsx b/frontend/src/pages/NetworkServicesNTPPage.tsx
index d547e4a..24918ce 100644
--- a/frontend/src/pages/NetworkServicesNTPPage.tsx
+++ b/frontend/src/pages/NetworkServicesNTPPage.tsx
@@ -79,6 +79,7 @@ export default function NetworkServicesNTPPage() {
useEffect(() => {
if (ntp) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setServerEnabled(ntp.server?.['enabled'] === 'yes');
setServerBroadcast(ntp.server?.['broadcast'] === 'yes');
setServerManycast(ntp.server?.['manycast'] === 'yes');
diff --git a/frontend/src/pages/TopologyPage.tsx b/frontend/src/pages/TopologyPage.tsx
index ccd76ec..79b96cf 100644
--- a/frontend/src/pages/TopologyPage.tsx
+++ b/frontend/src/pages/TopologyPage.tsx
@@ -477,7 +477,7 @@ export default function TopologyPage() {
No topology data yet
- Add devices and click "Discover" to map your network topology via LLDP neighbors.
+ Add devices and click "Discover" to map your network topology via LLDP neighbors.
{
if (settings) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setEnabled(!!settings['spectral_scan_enabled']);
setIntervalHours((settings['spectral_scan_interval_hours'] as number) || 24);
}
@@ -752,6 +753,7 @@ function APScanSettings({ canWrite }: { canWrite: boolean }) {
useEffect(() => {
if (settings) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setEnabled(!!settings['ap_scan_enabled']);
setIntervalHours((settings['ap_scan_interval_hours'] as number) || 24);
}
@@ -835,6 +837,7 @@ export default function WirelessSettingsPage() {
// Auto-select first AP
useEffect(() => {
if (aps.length > 0 && selectedApId === null) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedApId(aps[0].id);
}
}, [aps, selectedApId]);