Support custom domains. Fix host-record/address resolution.

This commit is contained in:
2026-07-13 00:26:13 +00:00
parent 65c7e56b0a
commit 1e7d93256e
12 changed files with 336 additions and 132 deletions

View File

@@ -104,6 +104,7 @@ export function AppSidebar() {
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
{ to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined },
];
return (

View File

@@ -0,0 +1,160 @@
import { Card, CardContent } from '../ui/card';
import { Button } from '../ui/button';
import { Alert, AlertDescription } from '../ui/alert';
import { Badge } from '../ui/badge';
import { Loader2, CheckCircle, AlertCircle, RefreshCw, Globe } from 'lucide-react';
import { useDdns } from '../../hooks/useDdns';
import { useQuery } from '@tanstack/react-query';
import { ddnsConfigApi } from '../../services/api/settings';
function formatTime(iso?: string): string {
if (!iso) return '--';
const d = new Date(iso);
if (isNaN(d.getTime())) return '--';
return d.toLocaleString();
}
export function DdnsSubsystem() {
const { status, isLoadingStatus, trigger, isTriggering } = useDdns();
const configQuery = useQuery({
queryKey: ['ddns', 'config'],
queryFn: () => ddnsConfigApi.get(),
});
const config = configQuery.data;
const records = status?.records ?? [];
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<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">Dynamic DNS</h2>
<p className="text-muted-foreground">Cloudflare DNS records managed by Wild Central</p>
</div>
</div>
{status && (
<Badge variant={status.enabled ? 'success' : 'secondary'} className="gap-1">
{status.enabled ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{status.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</div>
{/* Global error */}
{status?.lastError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{status.lastError}</AlertDescription>
</Alert>
)}
{/* Status + Actions */}
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm flex-1">
<div>
<span className="text-muted-foreground">Public IP</span>
<div className="font-mono mt-0.5">{status?.currentIP ?? '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Provider</span>
<div className="font-mono mt-0.5">{config?.provider ?? '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Last Checked</span>
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastChecked)}</div>
</div>
<div>
<span className="text-muted-foreground">Last Updated</span>
<div className="font-mono mt-0.5 text-xs">{formatTime(status?.lastUpdated)}</div>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-1 ml-4 shrink-0"
onClick={() => trigger()}
disabled={isTriggering || !status?.enabled}
>
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Sync Now
</Button>
</div>
{config?.intervalMinutes && (
<p className="text-xs text-muted-foreground">
Checks every {config.intervalMinutes} minute{config.intervalMinutes !== 1 ? 's' : ''}
</p>
)}
</CardContent>
</Card>
{/* Records table */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-3">Managed Records</h3>
{isLoadingStatus ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
</div>
) : records.length === 0 ? (
<div className="text-center py-8">
<Globe className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{status?.enabled
? 'No public domains registered. Mark a domain as public to create a DDNS record.'
: 'DDNS is disabled. Enable it in the Domains settings to manage DNS records.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 font-medium text-muted-foreground">Record</th>
<th className="pb-2 font-medium text-muted-foreground">IP</th>
<th className="pb-2 font-medium text-muted-foreground text-right">Status</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.name} className="border-b last:border-0">
<td className="py-2 font-mono text-xs">{r.name}</td>
<td className="py-2 font-mono text-xs">{r.ip ?? '--'}</td>
<td className="py-2 text-right">
{r.ok ? (
<Badge variant="success" className="gap-1 text-xs">
<CheckCircle className="h-3 w-3" />
Synced
</Badge>
) : (
<span className="inline-flex items-center gap-1">
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
Error
</Badge>
{r.error && (
<span className="text-xs text-muted-foreground max-w-[200px] truncate" title={r.error}>
{r.error}
</span>
)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -4,3 +4,4 @@ export { NftablesSubsystem } from './NftablesSubsystem';
export { WireguardSubsystem } from './WireguardSubsystem';
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
export { AutheliaSubsystem } from './AutheliaSubsystem';
export { DdnsSubsystem } from './DdnsSubsystem';

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { DdnsSubsystem } from '../../../components/advanced';
export function DdnsPage() {
return (
<ErrorBoundary>
<DdnsSubsystem />
</ErrorBoundary>
);
}

View File

@@ -4,3 +4,4 @@ export { NftablesPage } from './NftablesPage';
export { WireguardPage } from './WireguardPage';
export { CrowdsecPage } from './CrowdsecPage';
export { AutheliaAdvancedPage } from './AutheliaPage';
export { DdnsPage } from './DdnsPage';

View File

@@ -13,6 +13,7 @@ import {
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
CrowdsecPage as CrowdsecAdvancedPage,
AutheliaAdvancedPage,
DdnsPage,
} from './pages/advanced';
export const routes: RouteObject[] = [
@@ -34,6 +35,7 @@ export const routes: RouteObject[] = [
{ path: 'advanced/wireguard', element: <WireguardPage /> },
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
{ path: 'advanced/ddns', element: <DdnsPage /> },
],
},
{