Files
wild-central/web/src/components/DnsComponent.tsx
Paul Payne 735f3fcb05 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>
2026-07-09 04:10:43 +00:00

485 lines
20 KiB
TypeScript

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