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>
485 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|