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:
2026-07-09 04:10:43 +00:00
parent 5defc27f63
commit 735f3fcb05
121 changed files with 18347 additions and 0 deletions

View 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>
);
}