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.
348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|