Files
wild-central/web/src/components/DhcpComponent.tsx
Paul Payne 79f38d2750 Replace state blob with resource-oriented API endpoints
Split /api/v1/state into dedicated resource endpoints:
- /api/v1/operator, /api/v1/central-domain
- /api/v1/ddns/config, /api/v1/dns/settings
- /api/v1/dhcp/config (moved from /dnsmasq/dhcp/)
- /api/v1/nftables/config, /api/v1/haproxy/routes

Each page now talks to its own resource instead of deep-merging
a blob. Removes useConfig hook, CentralState type, and legacy
config methods.
2026-07-10 22:45:36 +00:00

348 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import { Wifi, Loader2, Edit2, Check, X, Trash2, Plus, RotateCw, CheckCircle, AlertCircle } from 'lucide-react';
import { Badge } from './ui/badge';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDhcp } from '../hooks/useDhcp';
import { useDnsmasq } from '../hooks/useDnsmasq';
import { dhcpConfigApi, type DHCPConfig } from '../services/api/settings';
import type { DhcpStaticLease } from '../services/api/networking';
export function DhcpComponent() {
const queryClient = useQueryClient();
const { data: dhcpConfig } = useQuery({ queryKey: ['dhcp', 'config'], queryFn: () => dhcpConfigApi.get() });
const dhcpMutation = useMutation({
mutationFn: (data: Partial<DHCPConfig>) => dhcpConfigApi.update(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dhcp', 'config'] }),
});
const isUpdating = dhcpMutation.isPending;
const { leases, isLoadingLeases, refetchLeases, addStatic, isAddingStatic, deleteStatic } = useDhcp();
const { generateConfig: generateDnsConfig } = useDnsmasq();
const [editingDhcpConfig, setEditingDhcpConfig] = useState(false);
const [dhcpConfigForm, setDhcpConfigForm] = useState({ enabled: false, rangeStart: '', rangeEnd: '', leaseTime: '24h', gateway: '' });
const [showAddLease, setShowAddLease] = useState(false);
const [newLease, setNewLease] = useState<DhcpStaticLease>({ mac: '', ip: '', hostname: '' });
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const showSuccess = (msg: string) => {
setSuccessMessage(msg);
setErrorMessage(null);
setTimeout(() => setSuccessMessage(null), 5000);
};
const showError = (msg: string) => {
setErrorMessage(msg);
setSuccessMessage(null);
setTimeout(() => setErrorMessage(null), 8000);
};
const handleDhcpConfigEdit = () => {
setDhcpConfigForm({
enabled: dhcpConfig?.enabled ?? false,
rangeStart: dhcpConfig?.rangeStart ?? '',
rangeEnd: dhcpConfig?.rangeEnd ?? '',
leaseTime: dhcpConfig?.leaseTime ?? '24h',
gateway: dhcpConfig?.gateway ?? '',
});
setEditingDhcpConfig(true);
};
const handleDhcpConfigSave = async () => {
try {
await dhcpMutation.mutateAsync({
enabled: dhcpConfigForm.enabled,
rangeStart: dhcpConfigForm.rangeStart,
rangeEnd: dhcpConfigForm.rangeEnd,
leaseTime: dhcpConfigForm.leaseTime,
gateway: dhcpConfigForm.gateway || undefined,
});
setEditingDhcpConfig(false);
if (dhcpConfigForm.enabled) {
generateDnsConfig(true);
}
showSuccess(dhcpConfigForm.enabled ? 'DHCP configuration saved and applied' : 'DHCP disabled');
} catch (e) {
showError(`Failed to save DHCP config: ${e instanceof Error ? e.message : String(e)}`);
}
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Wifi className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DHCP</h2>
<p className="text-muted-foreground">Assigns IP addresses to devices on your local network</p>
</div>
</div>
{successMessage && (
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-700 dark:text-green-300">{successMessage}</AlertDescription>
</Alert>
)}
{errorMessage && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Configuration</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={dhcpConfig?.enabled ? 'success' : 'secondary'} className="gap-1">
{dhcpConfig?.enabled && <CheckCircle className="h-3 w-3" />}
{dhcpConfig?.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{!editingDhcpConfig && (
<Button variant="outline" size="sm" onClick={handleDhcpConfigEdit} className="gap-1 h-7 text-xs">
<Edit2 className="h-3 w-3" />
Configure
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{editingDhcpConfig ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dhcp-enabled"
checked={dhcpConfigForm.enabled}
onChange={e => setDhcpConfigForm(prev => ({ ...prev, enabled: e.target.checked }))}
/>
<Label htmlFor="dhcp-enabled">Enable DHCP server</Label>
</div>
{dhcpConfigForm.enabled && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="dhcp-range-start">Range Start</Label>
<Input
id="dhcp-range-start"
value={dhcpConfigForm.rangeStart}
onChange={e => setDhcpConfigForm(prev => ({ ...prev, rangeStart: e.target.value }))}
placeholder="192.168.8.100"
className="mt-1 font-mono text-sm"
/>
</div>
<div>
<Label htmlFor="dhcp-range-end">Range End</Label>
<Input
id="dhcp-range-end"
value={dhcpConfigForm.rangeEnd}
onChange={e => setDhcpConfigForm(prev => ({ ...prev, rangeEnd: e.target.value }))}
placeholder="192.168.8.200"
className="mt-1 font-mono text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="dhcp-lease-time">Lease Time</Label>
<Input
id="dhcp-lease-time"
value={dhcpConfigForm.leaseTime}
onChange={e => setDhcpConfigForm(prev => ({ ...prev, leaseTime: e.target.value }))}
placeholder="24h"
className="mt-1 font-mono text-sm"
/>
</div>
<div>
<Label htmlFor="dhcp-gateway">Gateway (optional)</Label>
<Input
id="dhcp-gateway"
value={dhcpConfigForm.gateway}
onChange={e => setDhcpConfigForm(prev => ({ ...prev, gateway: e.target.value }))}
placeholder="192.168.8.1"
className="mt-1 font-mono text-sm"
/>
</div>
</div>
</>
)}
<div className="flex gap-2">
<Button size="sm" onClick={handleDhcpConfigSave} disabled={isUpdating}>
{isUpdating ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => setEditingDhcpConfig(false)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-2 text-sm">
{dhcpConfig?.enabled ? (
<>
<div className="flex items-center gap-4">
<div>
<span className="text-muted-foreground">Range: </span>
<span className="font-mono">
{dhcpConfig!.rangeStart} – {dhcpConfig!.rangeEnd}
</span>
</div>
<div>
<span className="text-muted-foreground">Lease: </span>
<span className="font-mono">{dhcpConfig!.leaseTime || '24h'}</span>
</div>
</div>
{dhcpConfig!.gateway && (
<div>
<span className="text-muted-foreground">Gateway: </span>
<span className="font-mono">{dhcpConfig!.gateway}</span>
</div>
)}
</>
) : (
<p className="text-muted-foreground text-xs">DHCP is disabled. Enable it to assign IPs to LAN devices.</p>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Leases</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => refetchLeases()} className="gap-1">
<RotateCw className="h-3 w-3" />
Refresh
</Button>
<Button size="sm" onClick={() => setShowAddLease(!showAddLease)} className="gap-1">
<Plus className="h-3 w-3" />
Static
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{showAddLease && (
<Card className="p-3 border-dashed">
<div className="grid grid-cols-3 gap-2 mb-2">
<div>
<Label htmlFor="lease-mac" className="text-xs">MAC Address</Label>
<Input
id="lease-mac"
value={newLease.mac}
onChange={(e) => setNewLease(l => ({ ...l, mac: e.target.value }))}
placeholder="aa:bb:cc:dd:ee:ff"
className="mt-1 font-mono text-xs"
/>
</div>
<div>
<Label htmlFor="lease-ip" className="text-xs">IP Address</Label>
<Input
id="lease-ip"
value={newLease.ip}
onChange={(e) => setNewLease(l => ({ ...l, ip: e.target.value }))}
placeholder="192.168.8.10"
className="mt-1 font-mono text-xs"
/>
</div>
<div>
<Label htmlFor="lease-hostname" className="text-xs">Hostname (optional)</Label>
<Input
id="lease-hostname"
value={newLease.hostname ?? ''}
onChange={(e) => setNewLease(l => ({ ...l, hostname: e.target.value }))}
placeholder="node1"
className="mt-1 text-xs"
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAddLease(false)}>
<X className="h-3 w-3 mr-1" />Cancel
</Button>
<Button
size="sm"
disabled={!newLease.mac || !newLease.ip || isAddingStatic}
onClick={() => {
addStatic(newLease, {
onSuccess: () => {
showSuccess(`Static lease added for ${newLease.ip}`);
setShowAddLease(false);
setNewLease({ mac: '', ip: '', hostname: '' });
},
onError: (e: Error) => {
showError(`Failed to add lease: ${e.message}`);
},
});
}}
>
{isAddingStatic ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Check className="h-3 w-3 mr-1" />}
Add
</Button>
</div>
</Card>
)}
{isLoadingLeases ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : leases.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No active leases. DHCP must be enabled in config.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left pb-1">MAC</th>
<th className="text-left pb-1">IP</th>
<th className="text-left pb-1">Hostname</th>
<th className="text-left pb-1">Expires</th>
<th className="pb-1" />
</tr>
</thead>
<tbody>
{leases.map((lease) => (
<tr key={lease.mac} className="border-b last:border-0">
<td className="py-1 font-mono">{lease.mac}</td>
<td className="py-1 font-mono">{lease.ip}</td>
<td className="py-1">{lease.hostname || '—'}</td>
<td className="py-1 text-muted-foreground">
{lease.expiry ? new Date(lease.expiry).toLocaleString() : '—'}
</td>
<td className="py-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
onClick={() => deleteStatic(lease.mac, {
onSuccess: () => showSuccess(`Lease for ${lease.mac} deleted`),
onError: (e: Error) => showError(`Failed to delete lease: ${e.message}`),
})}
>
<Trash2 className="h-3 w-3" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}