feat: Add Wild Central web app (Central-only UI)
Extract Central pages from the wild-cloud web app into a standalone React app for Wild Central. Includes: - Central overview, DNS, DHCP, Firewall, VPN, Ingress, CrowdSec pages - Simplified sidebar with Central-only navigation - Branding updated to "Wild Central" - All Cloud-specific pages, components, hooks, and API services removed - TypeScript type-check and production build pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
350
web/src/components/DhcpComponent.tsx
Normal file
350
web/src/components/DhcpComponent.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
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 { useConfig } from '../hooks';
|
||||
import { useDhcp } from '../hooks/useDhcp';
|
||||
import { useDnsmasq } from '../hooks/useDnsmasq';
|
||||
import type { DhcpStaticLease } from '../services/api/networking';
|
||||
|
||||
export function DhcpComponent() {
|
||||
const { config: globalConfig, updateConfig, isUpdating } = useConfig();
|
||||
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: globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ?? false,
|
||||
rangeStart: globalConfig?.cloud?.dnsmasq?.dhcp?.rangeStart ?? '',
|
||||
rangeEnd: globalConfig?.cloud?.dnsmasq?.dhcp?.rangeEnd ?? '',
|
||||
leaseTime: globalConfig?.cloud?.dnsmasq?.dhcp?.leaseTime ?? '24h',
|
||||
gateway: globalConfig?.cloud?.dnsmasq?.dhcp?.gateway ?? '',
|
||||
});
|
||||
setEditingDhcpConfig(true);
|
||||
};
|
||||
|
||||
const handleDhcpConfigSave = async () => {
|
||||
if (!globalConfig) return;
|
||||
try {
|
||||
await updateConfig({
|
||||
...globalConfig,
|
||||
cloud: {
|
||||
...globalConfig.cloud,
|
||||
dnsmasq: {
|
||||
...globalConfig.cloud?.dnsmasq,
|
||||
dhcp: {
|
||||
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={globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ? 'success' : 'secondary'} className="gap-1">
|
||||
{globalConfig?.cloud?.dnsmasq?.dhcp?.enabled && <CheckCircle className="h-3 w-3" />}
|
||||
{globalConfig?.cloud?.dnsmasq?.dhcp?.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">
|
||||
{globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Range: </span>
|
||||
<span className="font-mono">
|
||||
{globalConfig.cloud.dnsmasq.dhcp.rangeStart} – {globalConfig.cloud.dnsmasq.dhcp.rangeEnd}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Lease: </span>
|
||||
<span className="font-mono">{globalConfig.cloud.dnsmasq.dhcp.leaseTime || '24h'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{globalConfig.cloud.dnsmasq.dhcp.gateway && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Gateway: </span>
|
||||
<span className="font-mono">{globalConfig.cloud.dnsmasq.dhcp.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user