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:
484
web/src/components/DnsComponent.tsx
Normal file
484
web/src/components/DnsComponent.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Input, Label } from './ui';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import {
|
||||
Router, Globe, Loader2, AlertCircle, CheckCircle, XCircle,
|
||||
Play, RotateCw, Copy, ChevronDown, ChevronUp, Edit2,
|
||||
RefreshCw, ExternalLink, TestTube2,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { useConfig } from '../hooks';
|
||||
import { useCloudflare } from '../hooks/useCloudflare';
|
||||
import { useDnsmasq } from '../hooks/useDnsmasq';
|
||||
import { useDdns } from '../hooks/useDdns';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
import type { DdnsRecordStatus } from '../services/api/networking';
|
||||
|
||||
export function DnsComponent() {
|
||||
const { config: globalConfig, updateConfig, isUpdating } = useConfig();
|
||||
|
||||
// ── LAN DNS (dnsmasq) ──
|
||||
const {
|
||||
status: dnsStatus,
|
||||
isLoadingStatus: isDnsStatusLoading,
|
||||
config: dnsConfig,
|
||||
fetchConfig: fetchDnsConfig,
|
||||
generateConfig: generateDnsConfig,
|
||||
isGenerating: isDnsGenerating,
|
||||
generateData: dnsGenerateData,
|
||||
restart: restartDns,
|
||||
isRestarting: isDnsRestarting,
|
||||
restartData: dnsRestartData,
|
||||
generateError: dnsGenerateError,
|
||||
restartError: dnsRestartError,
|
||||
} = useDnsmasq();
|
||||
|
||||
const [showDnsAdvanced, setShowDnsAdvanced] = useState(false);
|
||||
const [editedDnsConfig, setEditedDnsConfig] = useState('');
|
||||
const [originalDnsConfig, setOriginalDnsConfig] = useState('');
|
||||
const [copiedDnsIp, setCopiedDnsIp] = useState(false);
|
||||
|
||||
const isDnsRunning = dnsStatus?.status === 'active';
|
||||
const dnsIp = dnsStatus?.ip;
|
||||
|
||||
const handleCopyDnsIp = () => {
|
||||
if (dnsIp) {
|
||||
navigator.clipboard.writeText(dnsIp);
|
||||
setCopiedDnsIp(true);
|
||||
setTimeout(() => setCopiedDnsIp(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDnsAdvanced = async () => {
|
||||
if (!showDnsAdvanced) {
|
||||
const result = await fetchDnsConfig();
|
||||
const content = result.data?.content || dnsConfig?.content || '';
|
||||
setEditedDnsConfig(content);
|
||||
setOriginalDnsConfig(content);
|
||||
}
|
||||
setShowDnsAdvanced(!showDnsAdvanced);
|
||||
};
|
||||
|
||||
const handleSaveDnsConfig = async () => {
|
||||
try {
|
||||
await apiService.writeDnsmasqConfig(editedDnsConfig);
|
||||
fetchDnsConfig();
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndRestartDns = async () => {
|
||||
try {
|
||||
await apiService.writeDnsmasqConfig(editedDnsConfig);
|
||||
await apiService.restartDnsmasq();
|
||||
fetchDnsConfig();
|
||||
} catch (error) {
|
||||
console.error('Failed to save config and restart:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Dynamic DNS ──
|
||||
const { status: ddnsStatus, isLoadingStatus: isDdnsLoading, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
|
||||
const { verification: cfVerification } = useCloudflare();
|
||||
|
||||
const [editingDdnsConfig, setEditingDdnsConfig] = useState(false);
|
||||
const [ddnsConfigForm, setDdnsConfigForm] = useState({ enabled: false, records: '', intervalMinutes: 5 });
|
||||
|
||||
const handleDdnsConfigEdit = () => {
|
||||
setDdnsConfigForm({
|
||||
enabled: globalConfig?.cloud?.ddns?.enabled ?? false,
|
||||
records: (globalConfig?.cloud?.ddns?.records ?? []).join('\n'),
|
||||
intervalMinutes: globalConfig?.cloud?.ddns?.intervalMinutes ?? 5,
|
||||
});
|
||||
setEditingDdnsConfig(true);
|
||||
};
|
||||
|
||||
const handleDdnsConfigSave = async () => {
|
||||
if (!globalConfig) return;
|
||||
const records = ddnsConfigForm.records.split('\n').map(r => r.trim()).filter(Boolean);
|
||||
await updateConfig({
|
||||
...globalConfig,
|
||||
cloud: {
|
||||
...globalConfig.cloud,
|
||||
ddns: {
|
||||
enabled: ddnsConfigForm.enabled,
|
||||
provider: 'cloudflare',
|
||||
records,
|
||||
intervalMinutes: ddnsConfigForm.intervalMinutes,
|
||||
},
|
||||
},
|
||||
});
|
||||
setEditingDdnsConfig(false);
|
||||
};
|
||||
|
||||
// ── DNS Lookup ──
|
||||
const [lookupDomain, setLookupDomain] = useState('');
|
||||
const [lookupTesting, setLookupTesting] = useState<'external' | 'internal' | null>(null);
|
||||
const [lookupResult, setLookupResult] = useState<{ type: string; success: boolean; message: string } | null>(null);
|
||||
|
||||
const handleLookup = async (type: 'external' | 'internal') => {
|
||||
const domain = lookupDomain.trim();
|
||||
if (!domain) return;
|
||||
setLookupTesting(type);
|
||||
setLookupResult(null);
|
||||
try {
|
||||
if (type === 'external') {
|
||||
const response = await fetch(`https://cloudflare-dns.com/dns-query?name=${domain}&type=A`, {
|
||||
headers: { accept: 'application/dns-json' },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.Status === 0 && data.Answer?.length > 0) {
|
||||
const aRecord = data.Answer.find((a: { type: number; data: string }) => a.type === 1);
|
||||
const ip = aRecord ? aRecord.data : data.Answer[data.Answer.length - 1].data;
|
||||
setLookupResult({ type: 'External', success: true, message: `Resolves to ${ip}` });
|
||||
} else {
|
||||
setLookupResult({ type: 'External', success: false, message: 'No public DNS record found' });
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(`/api/v1/network/resolve?domain=${domain}`);
|
||||
const data = await response.json();
|
||||
if (data.success && data.ip) {
|
||||
setLookupResult({ type: 'LAN', success: true, message: `Resolves to ${data.ip}` });
|
||||
} else {
|
||||
setLookupResult({ type: 'LAN', success: false, message: data.error || 'Not resolved on LAN' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setLookupResult({ type: type === 'external' ? 'External' : 'LAN', success: false, message: 'Lookup failed' });
|
||||
} finally {
|
||||
setLookupTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Globe className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">DNS</h2>
|
||||
<p className="text-muted-foreground">LAN name resolution and public DNS record management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── LAN DNS ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Router className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>LAN DNS</CardTitle>
|
||||
</div>
|
||||
<Badge variant={isDnsRunning ? 'success' : 'warning'} className="gap-1">
|
||||
{isDnsStatusLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isDnsRunning ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : null}
|
||||
{isDnsStatusLoading ? 'Checking...' : isDnsRunning ? 'Running' : 'Stopped'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-0.5">Set as primary DNS in your router</div>
|
||||
<code className="text-lg font-mono font-bold">{dnsIp || 'Auto-detecting...'}</code>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyDnsIp} disabled={!dnsIp} className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
{copiedDnsIp ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{globalConfig?.cloud?.router?.ip && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Router: <span className="font-mono">{globalConfig.cloud.router.ip}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(`http://${globalConfig?.cloud?.router?.ip}`, '_blank')}
|
||||
className="gap-1 text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open Admin
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!isDnsRunning ? (
|
||||
<Button onClick={() => generateDnsConfig(true)} disabled={isDnsGenerating} className="gap-2">
|
||||
{isDnsGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
Start DNS
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => restartDns()} disabled={isDnsRestarting} variant="outline" className="gap-2">
|
||||
{isDnsRestarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCw className="h-4 w-4" />}
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleShowDnsAdvanced} variant="outline" className="gap-2">
|
||||
{showDnsAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(dnsGenerateData || dnsRestartData) && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>{dnsGenerateData?.message || dnsRestartData?.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(dnsGenerateError || dnsRestartError) && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{(dnsGenerateError || dnsRestartError)?.toString()}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showDnsAdvanced && (
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<span className="text-sm font-medium">Config file</span>
|
||||
<Textarea
|
||||
value={editedDnsConfig}
|
||||
onChange={(e) => setEditedDnsConfig(e.target.value)}
|
||||
className="font-mono text-xs min-h-[300px]"
|
||||
placeholder="# dnsmasq configuration"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSaveDnsConfig} className="gap-2">
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveAndRestartDns} className="gap-2">
|
||||
Save & Restart
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditedDnsConfig(originalDnsConfig)} disabled={editedDnsConfig === originalDnsConfig} className="gap-2">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Dynamic DNS ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Dynamic DNS</CardTitle>
|
||||
</div>
|
||||
{isDdnsLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : ddnsStatus?.enabled ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{ddnsStatus?.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Public IP</span>
|
||||
<div className="font-mono font-medium mt-0.5">{ddnsStatus.currentIP || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last checked</span>
|
||||
<div className="font-mono mt-0.5">
|
||||
{ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ddnsStatus.lastError && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">{ddnsStatus.lastError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => triggerDdns()}
|
||||
disabled={isDdnsTriggering}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDdnsTriggering ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Check Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={ddnsStatus?.enabled ? 'border-t pt-4' : ''}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium">A Records</span>
|
||||
{!editingDdnsConfig && (
|
||||
<Button variant="outline" size="sm" onClick={handleDdnsConfigEdit} className="gap-1">
|
||||
<Edit2 className="h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editingDdnsConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ddns-enabled"
|
||||
checked={ddnsConfigForm.enabled}
|
||||
onChange={e => setDdnsConfigForm(prev => ({ ...prev, enabled: e.target.checked }))}
|
||||
/>
|
||||
<Label htmlFor="ddns-enabled">Enabled</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Domains (one per line)</Label>
|
||||
<Textarea
|
||||
value={ddnsConfigForm.records}
|
||||
onChange={e => setDdnsConfigForm(prev => ({ ...prev, records: e.target.value }))}
|
||||
placeholder={'cloud.example.com\nexample.org'}
|
||||
className="text-sm h-24 mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Check interval (minutes)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ddnsConfigForm.intervalMinutes}
|
||||
onChange={e => setDdnsConfigForm(prev => ({ ...prev, intervalMinutes: parseInt(e.target.value) || 5 }))}
|
||||
className="h-9 mt-1 w-24"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleDdnsConfigSave} disabled={isUpdating}>
|
||||
{isUpdating ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingDdnsConfig(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(globalConfig?.cloud?.ddns?.records ?? []).length > 0 ? (
|
||||
<>
|
||||
{(globalConfig?.cloud?.ddns?.records ?? []).map(r => {
|
||||
const rs: DdnsRecordStatus | undefined = ddnsStatus?.records?.find(s => s.name === r);
|
||||
return (
|
||||
<div key={r} className="flex items-center gap-2 text-xs">
|
||||
{rs?.ok ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : rs && !rs.ok ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<div className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="font-mono bg-muted px-2 py-0.5 rounded">{r}</span>
|
||||
{rs?.ok && rs.ip && <span className="text-muted-foreground font-mono">{rs.ip}</span>}
|
||||
{rs && !rs.ok && rs.error && (
|
||||
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(globalConfig?.cloud?.ddns?.intervalMinutes ?? 0) > 0 && (
|
||||
<div className="text-muted-foreground text-xs pt-1">
|
||||
Checks every {globalConfig!.cloud!.ddns!.intervalMinutes} min
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No records configured</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cfVerification && !cfVerification.tokenConfigured && (
|
||||
<div className="flex items-center gap-3 rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-700 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Cloudflare API token not configured. DDNS requires a valid token.{' '}
|
||||
<NavLink to="/central/cloudflare" className="underline font-medium">Configure token</NavLink>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── DNS Lookup ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<TestTube2 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>DNS Lookup</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="cloud.example.com"
|
||||
value={lookupDomain}
|
||||
onChange={e => { setLookupDomain(e.target.value); setLookupResult(null); }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleLookup('external')}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleLookup('external')}
|
||||
disabled={!lookupDomain.trim() || !!lookupTesting}
|
||||
className="gap-1 shrink-0"
|
||||
>
|
||||
{lookupTesting === 'external' ? <Loader2 className="h-3 w-3 animate-spin" /> : <Globe className="h-3 w-3" />}
|
||||
External
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleLookup('internal')}
|
||||
disabled={!lookupDomain.trim() || !!lookupTesting}
|
||||
className="gap-1 shrink-0"
|
||||
>
|
||||
{lookupTesting === 'internal' ? <Loader2 className="h-3 w-3 animate-spin" /> : <Router className="h-3 w-3" />}
|
||||
LAN
|
||||
</Button>
|
||||
</div>
|
||||
{lookupResult && (
|
||||
<Alert className={lookupResult.success
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-950'
|
||||
: 'border-amber-500 bg-amber-50 dark:bg-amber-950'
|
||||
}>
|
||||
{lookupResult.success
|
||||
? <CheckCircle className="h-4 w-4 text-green-600" />
|
||||
: <AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
}
|
||||
<AlertDescription className={lookupResult.success
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-amber-800 dark:text-amber-200'
|
||||
}>
|
||||
<span className="font-medium">{lookupResult.type}: </span>
|
||||
{lookupResult.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user