feat: UI refactor — Dashboard + Services, 9 items → 6

Major restructure of Wild Central's web UI:

New pages:
- Dashboard: infrastructure health (Cloudflare token, DDNS sync,
  daemon status, gateway router guidance, cert warnings)
- Services: every registered service with expandable rows showing
  DNS, proxy, TLS, DDNS status. Custom TCP routes section.
  Advanced HAProxy management collapsed at bottom.

Sidebar restructured into two groups:
- Services: Dashboard, Services
- Central: Firewall, VPN, CrowdSec, DHCP

Deleted 7 pages + 7 components (2501 lines removed):
- CentralPage, CloudflarePage, DdnsPage, DnsPage, LanDnsPage,
  CertificatesPage, IngressProxyPage
- CentralComponent, CloudflareComponent, DdnsComponent,
  DnsComponent, LanDnsComponent, IngressProxyComponent,
  DnsmasqSection

All hooks and API services kept unchanged — reused in new components.
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 22:36:54 +00:00
parent c18217944f
commit 9ce9999d5c
21 changed files with 1017 additions and 2501 deletions

View File

@@ -1,5 +1,5 @@
import { NavLink } from 'react-router';
import { Sun, Moon, Monitor, Shield, ShieldCheck, Lock, Network, ShieldAlert, Router, Cloud, Wifi, Server } from 'lucide-react';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning } from 'lucide-react';
import {
Sidebar,
SidebarContent,
@@ -52,14 +52,14 @@ export function AppSidebar() {
}
};
const servicesItems = [
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/central/services', icon: Globe, label: 'Services' },
];
const centralItems = [
{ to: '/central', icon: Server, label: 'Overview', end: true },
{ to: '/central/cloudflare', icon: Cloud, label: 'Cloudflare' },
{ to: '/central/dns', icon: Router, label: 'DNS' },
{ to: '/central/ingress', icon: Network, label: 'Ingress Proxy' },
{ to: '/central/firewall', icon: Shield, label: 'Firewall' },
{ to: '/central/vpn', icon: Lock, label: 'VPN' },
{ to: '/central/certificates', icon: ShieldCheck, label: 'Certificates' },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP' },
];
@@ -69,7 +69,7 @@ export function AppSidebar() {
<SidebarHeader>
<div className="flex items-center justify-center pb-2">
<div className="p-1 bg-primary/10 rounded-lg">
<Server className="h-6 w-6 text-primary" />
<CloudLightning className="h-6 w-6 text-primary" />
</div>
<div className="overflow-hidden transition-all duration-200 ease-linear ml-2 max-w-[200px] opacity-100 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:opacity-0">
<h2 className="text-lg font-bold text-foreground whitespace-nowrap">Wild Central</h2>
@@ -80,13 +80,13 @@ export function AppSidebar() {
<SidebarContent>
{state === 'collapsed' ? (
<SidebarMenu>
{centralItems.map(({ to, icon: Icon, label, end }) => (
<SidebarMenuItem key={to}>
<NavLink to={to} end={end}>
{[...servicesItems, ...centralItems].map((item) => (
<SidebarMenuItem key={item.to}>
<NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive} tooltip={label}>
<Icon className="h-4 w-4" />
<span>{label}</span>
<SidebarMenuButton isActive={isActive} tooltip={item.label}>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</SidebarMenuButton>
)}
</NavLink>
@@ -94,25 +94,47 @@ export function AppSidebar() {
))}
</SidebarMenu>
) : (
<SidebarGroup>
<SidebarGroupLabel>Central</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{centralItems.map(({ to, icon: Icon, label, end }) => (
<SidebarMenuItem key={to}>
<NavLink to={to} end={end}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive}>
<Icon className="h-4 w-4" />
<span className="truncate">{label}</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<>
<SidebarGroup>
<SidebarGroupLabel>Services</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{servicesItems.map(({ to, icon: Icon, label, end }) => (
<SidebarMenuItem key={to}>
<NavLink to={to} end={end}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive}>
<Icon className="h-4 w-4" />
<span className="truncate">{label}</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Central</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{centralItems.map(({ to, icon: Icon, label }) => (
<SidebarMenuItem key={to}>
<NavLink to={to}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive}>
<Icon className="h-4 w-4" />
<span className="truncate">{label}</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)}
</SidebarContent>

View File

@@ -1,219 +0,0 @@
import { useState, useEffect } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import { HardDrive, Settings, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Mail, Edit2, Check, X, RotateCw } from 'lucide-react';
import { Badge } from './ui/badge';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useConfig } from '../hooks';
import { usePageHelp } from '../hooks/usePageHelp';
export function CentralComponent() {
const { data: centralStatus, isLoading: statusLoading, error: statusError, restart: restartDaemon, isRestarting: isDaemonRestarting } = useCentralStatus();
const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig();
const [editingOperator, setEditingOperator] = useState(false);
const [operatorEmail, setOperatorEmail] = useState('');
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);
};
useEffect(() => {
if (globalConfig?.operator?.email) {
setOperatorEmail(globalConfig.operator.email);
}
}, [globalConfig]);
const handleOperatorSave = async () => {
if (!globalConfig || !operatorEmail) return;
try {
await updateGlobalConfig({
...globalConfig,
operator: { ...globalConfig.operator, email: operatorEmail },
});
setEditingOperator(false);
showSuccess('Operator email saved');
} catch (err) {
showError(`Failed to save operator: ${err instanceof Error ? err.message : String(err)}`);
}
};
const handleOperatorCancel = () => {
setOperatorEmail(globalConfig?.operator?.email || '');
setEditingOperator(false);
};
usePageHelp({
title: 'What is the Central Service?',
description: (
<>
<p className="mb-3 leading-relaxed">
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
</p>
<p className="text-sm">
This service handles configuration management, service discovery, and provides the web interface you're using right now.
</p>
</>
),
icon: <BookOpen className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
color: 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20',
actions: (
<Button
variant="outline"
size="sm"
className="text-blue-700 border-blue-300 hover:bg-blue-100 dark:text-blue-300 dark:border-blue-700 dark:hover:bg-blue-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about service orchestration
</Button>
),
});
if (statusError) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Central Status</h3>
<p className="text-muted-foreground mb-4">
{(statusError as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Wild Central</h2>
<p className="text-muted-foreground">Manage your Wild Central server</p>
</div>
{centralStatus && (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
{centralStatus.status === 'running' ? 'Running' : centralStatus.status}
</Badge>
)}
</div>
{centralStatus?.pendingRestart && (
<div className="flex items-center justify-between 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">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>Configuration changed restart Wild Central for changes to take effect.</span>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 border-amber-400 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30"
onClick={restartDaemon}
disabled={isDaemonRestarting}
>
{isDaemonRestarting ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <RotateCw className="h-3 w-3 mr-1" />}
{isDaemonRestarting ? 'Restarting...' : 'Restart Now'}
</Button>
</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>
)}
{statusLoading ? (
<Card className="p-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</Card>
) : (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-blue-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Version</div>
<div className="font-medium font-mono">{centralStatus?.version || 'Unknown'}</div>
</div>
</div>
<div className="flex items-start gap-3">
<HardDrive className="h-5 w-5 text-indigo-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Data Directory</div>
<div className="font-medium font-mono text-sm break-all">
{centralStatus?.dataDir || '/var/lib/wild-central'}
</div>
</div>
</div>
</Card>
)}
<Card className="p-4 border-l-4 border-l-amber-500">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-amber-500" />
<div className="text-sm text-muted-foreground">Operator Email</div>
</div>
{!editingOperator && (
<Button variant="ghost" size="sm" onClick={() => setEditingOperator(true)} disabled={isUpdating}>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
{editingOperator ? (
<div className="space-y-3">
<div>
<Label htmlFor="operator-email">Email</Label>
<Input
id="operator-email"
type="email"
value={operatorEmail}
onChange={(e) => setOperatorEmail(e.target.value)}
placeholder="email@example.com"
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleOperatorCancel} disabled={isUpdating}>
<X className="h-4 w-4 mr-1" />Cancel
</Button>
<Button size="sm" onClick={handleOperatorSave} disabled={isUpdating || !operatorEmail}>
{isUpdating ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Check className="h-4 w-4 mr-1" />}
Save
</Button>
</div>
</div>
) : (
<div className="font-medium font-mono text-sm ml-7">
{globalConfig?.operator?.email || (
<span className="text-muted-foreground italic">Not configured</span>
)}
</div>
)}
</Card>
</div>
);
}

View File

@@ -1,507 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import {
Cloud, CheckCircle, XCircle, AlertCircle, Loader2, Edit2,
RefreshCw, ExternalLink, BookOpen, Globe, Key, Shield, Check, X, KeyRound,
} from 'lucide-react';
import { Badge } from './ui/badge';
import { useCloudflare } from '../hooks/useCloudflare';
import { useConfig } from '../hooks';
import { useDdns } from '../hooks/useDdns';
import { useCert } from '../hooks/useCert';
import { secretsApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp';
export function CloudflareComponent() {
const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare();
const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig();
const { status: ddnsStatus } = useDdns();
const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError, renew: renewCerts, isRenewing } = useCert();
const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({
queryKey: ['globalSecrets'],
queryFn: () => secretsApi.get(),
});
const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalSecrets'] });
queryClient.invalidateQueries({ queryKey: ['cloudflare', 'verify'] });
setEditingToken(false);
setTokenValue('');
showSuccess('Cloudflare API token saved');
},
onError: (err) => {
showError(String(err));
},
});
const cfTokenIsSet = !!(globalSecrets as Record<string, unknown> | undefined)
&& !!(globalSecrets as { cloudflare?: { apiToken?: string } })?.cloudflare?.apiToken;
const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState('');
const [editingDomain, setEditingDomain] = useState(false);
const [domainInput, setDomainInput] = useState('');
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);
};
usePageHelp({
title: 'Why Cloudflare?',
description: (
<>
<p className="mb-3 leading-relaxed">
Wild Cloud uses Cloudflare to manage DNS records and provision TLS certificates for your domains.
A Cloudflare API token enables three key features: <strong>Dynamic DNS</strong> keeps your domain
pointed at your changing public IP, <strong>TLS certificates</strong> are provisioned via
Let's Encrypt DNS-01 challenges, and <strong>ExternalDNS</strong> automatically creates DNS records
when you deploy apps.
</p>
<p className="text-sm">
Create an API token in your Cloudflare dashboard with <strong>Zone:DNS:Edit</strong> and{' '}
<strong>Zone:Zone:Read</strong> permissions, scoped to your domain's zone.
</p>
</>
),
icon: <BookOpen className="h-6 w-6 text-orange-600 dark:text-orange-400" />,
color: 'bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20',
actions: (
<a
href="https://developers.cloudflare.com/fundamentals/api/get-started/create-token/"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="text-orange-700 border-orange-300 hover:bg-orange-100 dark:text-orange-300 dark:border-orange-700 dark:hover:bg-orange-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Create a Cloudflare API token
</Button>
</a>
),
});
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Cloudflare</h2>
<p className="text-muted-foreground">Manage your Cloudflare integration for DNS and TLS</p>
</div>
{verification?.tokenValid && (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Connected
</Badge>
)}
</div>
{/* Alerts */}
{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>
)}
{/* API Token Card */}
<Card className="p-4 border-l-4 border-l-orange-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-orange-500" />
<div className="font-medium">API Token</div>
</div>
{!editingToken && (
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
<Edit2 className="h-3 w-3" />
{cfTokenIsSet ? 'Update' : 'Set'}
</Button>
)}
</div>
{!editingToken && (
cfTokenIsSet
? <p className="text-sm text-green-600 dark:text-green-400 ml-7">Configured</p>
: <p className="text-sm text-muted-foreground ml-7">Not set required for DNS and TLS features</p>
)}
{editingToken && (
<div className="space-y-2 ml-7">
<Input
type="password"
placeholder="Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
disabled={updateSecretsMutation.isPending || !tokenValue}
>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
</div>
)}
</Card>
{/* Central Domain & TLS Card */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-green-500" />
<div className="font-medium">Central Domain</div>
</div>
<div className="flex items-center gap-2">
{certStatus?.certs?.some(c => c.cert.exists) && (
<Badge variant="success" className="gap-1">
<Shield className="h-3 w-3" />
TLS
</Badge>
)}
{!editingDomain && (
<Button variant="ghost" size="sm" onClick={() => {
setDomainInput(globalConfig?.cloud?.central?.domain || '');
setEditingDomain(true);
}} disabled={isUpdating}>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
{editingDomain ? (
<div className="space-y-3 ml-7">
<div>
<Label htmlFor="central-domain">Domain</Label>
<Input
id="central-domain"
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="central.example.com"
className="mt-1 font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
Must resolve to this server (via internal DNS or VPN).
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditingDomain(false)} disabled={isUpdating}>
<X className="h-4 w-4 mr-1" />Cancel
</Button>
<Button size="sm" onClick={async () => {
if (!globalConfig) return;
try {
await updateGlobalConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
central: { domain: domainInput },
},
});
setEditingDomain(false);
showSuccess('Central domain saved');
} catch (err) {
showError(`Failed to save domain: ${err instanceof Error ? err.message : String(err)}`);
}
}} disabled={isUpdating}>
{isUpdating ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Check className="h-4 w-4 mr-1" />}
Save
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="font-mono text-sm ml-7">
{globalConfig?.cloud?.central?.domain ? (
<a
href={`https://${globalConfig.cloud.central.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{globalConfig.cloud.central.domain}
</a>
) : (
<span className="text-muted-foreground italic">Not configured</span>
)}
</div>
{globalConfig?.cloud?.central?.domain && (() => {
const centralCert = certStatus?.certs?.find(c => c.domain === globalConfig?.cloud?.central?.domain);
return (
<div className="ml-7 space-y-2">
{certLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : centralCert?.cert.exists ? (
<div className="text-sm space-y-1">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<Shield className="h-4 w-4" />
<span>Valid TLS certificate</span>
</div>
<div className="text-xs text-muted-foreground">
Expires in {centralCert.cert.daysLeft} days
{centralCert.cert.issuerCN && <> &middot; {centralCert.cert.issuerCN}</>}
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 text-sm">
<KeyRound className="h-4 w-4" />
<span>No TLS certificate</span>
</div>
<Button
size="sm"
onClick={async () => {
try { await provisionCert(centralCert?.domain); } catch { /* error shown below */ }
}}
disabled={isProvisioning}
className="gap-1"
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3" />}
{isProvisioning ? 'Provisioning...' : 'Provision TLS Certificate'}
</Button>
{provisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(provisionError as Error).message}</AlertDescription>
</Alert>
)}
</div>
)}
</div>
);
})()}
</div>
)}
</Card>
{/* TLS Certificates Card */}
{certStatus?.certs && certStatus.certs.length > 0 && (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">TLS Certificates</div>
</div>
</div>
<div className="space-y-3 ml-7">
{certStatus.certs.map((entry) => (
<div key={entry.domain} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="font-mono">{entry.domain}</span>
<Badge variant="outline" className="text-xs">{entry.source}</Badge>
</div>
<div className="flex items-center gap-2">
{entry.cert.exists ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Valid ({entry.cert.daysLeft}d)
</Badge>
) : (
<>
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Missing
</Badge>
{certStatus.canProvision && (
<Button
variant="outline"
size="sm"
onClick={async () => {
try { await provisionCert(entry.domain); } catch { /* error shown in alerts */ }
}}
disabled={isProvisioning}
className="gap-1 h-7 text-xs"
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3" />}
Provision
</Button>
)}
</>
)}
</div>
</div>
))}
{provisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(provisionError as Error).message}</AlertDescription>
</Alert>
)}
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try { await renewCerts(); } catch { /* error shown in alerts */ }
}}
disabled={isRenewing}
className="gap-1"
>
{isRenewing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{isRenewing ? 'Renewing...' : 'Renew All'}
</Button>
</div>
</div>
</Card>
)}
{/* Verification Checklist Card */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">Token Verification</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => reverify()}
disabled={isVerifying}
className="gap-1"
>
{isVerifying ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Verify
</Button>
</div>
{isVerifying && !verification ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying token...
</div>
) : (
<div className="space-y-2 ml-7">
{/* Token configured */}
<div className="flex items-center gap-2 text-sm">
{verification?.tokenConfigured
? <CheckCircle className="h-4 w-4 text-green-500" />
: <XCircle className="h-4 w-4 text-muted-foreground" />}
<span>Token configured</span>
</div>
{/* Token valid */}
{verification?.tokenConfigured && (
<div className="flex items-center gap-2 text-sm">
{verification?.tokenValid
? <CheckCircle className="h-4 w-4 text-green-500" />
: <XCircle className="h-4 w-4 text-red-500" />}
<span>Token valid{verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}</span>
</div>
)}
{/* Accessible zones */}
{verification?.tokenValid && verification.zones && verification.zones.length > 0 && (
<div className="flex items-start gap-2 text-sm">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5" />
<div>
<span>Accessible zones: </span>
<span className="inline-flex flex-wrap gap-1 mt-1">
{verification.zones.map((zone) => (
<span key={zone.id} className="font-mono bg-muted px-2 py-0.5 rounded text-xs">
{zone.name}
</span>
))}
</span>
</div>
</div>
)}
{/* Error */}
{verification?.error && (
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="h-4 w-4" />
<span>{verification.error}</span>
</div>
)}
</div>
)}
</Card>
{/* Feature Status Card */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-green-500" />
<div className="font-medium">Features Using This Token</div>
</div>
</div>
<CardContent className="p-0 ml-7">
<div className="space-y-3">
{/* DDNS */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-muted-foreground" />
<span>Dynamic DNS</span>
</div>
{ddnsStatus?.enabled
? <Badge variant="success" className="text-xs">Active</Badge>
: <Badge variant="secondary" className="text-xs">Disabled</Badge>}
</div>
{/* TLS Certificates */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<span>TLS Certificates</span>
</div>
{(() => {
const certs = certStatus?.certs;
const allExist = certs && certs.length > 0 && certs.every(c => c.cert.exists);
const someExist = certs && certs.some(c => c.cert.exists);
if (allExist) {
return <Badge variant="success" className="text-xs">All valid</Badge>;
} else if (someExist) {
return <Badge variant="warning" className="text-xs">Partial</Badge>;
} else if (certStatus?.configured) {
return <Badge variant="secondary" className="text-xs">Not provisioned</Badge>;
} else {
return <Badge variant="secondary" className="text-xs">Not configured</Badge>;
}
})()}
</div>
{/* ExternalDNS note */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span>ExternalDNS</span>
</div>
<span className="text-xs text-muted-foreground">Per-instance</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,432 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
RefreshCw, BookOpen, Globe, Shield, Router, Server, RotateCw, Key,
} from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useCert } from '../hooks/useCert';
import { useConfig } from '../hooks';
import { secretsApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp';
import type { DdnsRecordStatus } from '../services/api/networking';
export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { status: certStatus } = useCert();
const { config: globalConfig } = useConfig();
const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({
queryKey: ['globalSecrets'],
queryFn: () => secretsApi.get(),
});
const cfTokenIsSet = !!(globalSecrets as Record<string, unknown> | undefined)
&& !!(globalSecrets as { cloudflare?: { apiToken?: string } })?.cloudflare?.apiToken;
const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState('');
const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalSecrets'] });
queryClient.invalidateQueries({ queryKey: ['cloudflare', 'verify'] });
setEditingToken(false);
setTokenValue('');
},
});
// Warning conditions
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
const certExpiring = certStatus?.certs?.some(c => c.cert.exists && (c.cert.daysLeft ?? 999) < 14);
const certMissing = certStatus?.certs?.some(c => !c.cert.exists);
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
const hasWarnings = cfTokenMissing || cfTokenInvalid || certExpiring || certMissing || ddnsFailed;
usePageHelp({
title: 'What is the Dashboard?',
description: (
<>
<p className="mb-3 leading-relaxed">
The Dashboard shows the health of Wild Central's infrastructure — the services and
integrations that must be working for your cloud to serve traffic properly.
</p>
<p className="text-sm">
Green indicators mean everything is healthy. Yellow or red indicators need attention.
Resolve issues from top to bottom, as later services often depend on earlier ones.
</p>
</>
),
icon: <BookOpen className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
color: 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20',
});
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutDashboard className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Dashboard</h2>
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p>
</div>
</div>
{/* Warnings */}
{hasWarnings && (
<div className="space-y-2">
{cfTokenMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Cloudflare API token is not configured. DNS and TLS features are unavailable.</AlertDescription>
</Alert>
)}
{cfTokenInvalid && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
</Alert>
)}
{certMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>One or more TLS certificates are missing. Services may not be reachable over HTTPS.</AlertDescription>
</Alert>
)}
{certExpiring && !certMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>A TLS certificate expires within 14 days. Consider renewing soon.</AlertDescription>
</Alert>
)}
{ddnsFailed && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>DDNS sync error: {ddnsStatus.lastError}</AlertDescription>
</Alert>
)}
</div>
)}
{/* 1. Cloudflare */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-500" />
<div className="font-medium">Cloudflare</div>
</div>
{cfLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : verification?.tokenValid ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Connected
</Badge>
) : verification?.tokenConfigured ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Invalid
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
</div>
{cfLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Checking token...
</div>
) : verification?.tokenValid ? (
<div className="space-y-2 ml-7">
{verification.zones && verification.zones.length > 0 && (
<div className="flex items-start gap-2 text-sm">
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<span className="text-muted-foreground">Zones: </span>
<span className="inline-flex flex-wrap gap-1">
{verification.zones.map((zone) => (
<span key={zone.id} className="font-mono bg-muted px-2 py-0.5 rounded text-xs">
{zone.name}
</span>
))}
</span>
</div>
</div>
)}
</div>
) : (
<div className="ml-7">
{!editingToken ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{verification?.tokenConfigured
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
: 'An API token is required for DNS and TLS features'}
</p>
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
<Key className="h-3 w-3" />
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
</Button>
</div>
) : (
<div className="space-y-2">
<Input
type="password"
placeholder="Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
disabled={updateSecretsMutation.isPending || !tokenValue}
>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
{updateSecretsMutation.isError && (
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
)}
</div>
)}
</div>
)}
</Card>
{/* 2. Dynamic DNS */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-green-500" />
<div className="font-medium">Dynamic DNS</div>
</div>
<div className="flex items-center gap-2">
{ddnsLoading ? (
<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>
</div>
{ddnsStatus?.enabled ? (
<div className="space-y-3 ml-7">
<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>
{/* Record list */}
{globalConfig?.cloud?.ddns?.records?.length ? (
<div className="space-y-1">
{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>
);
})}
</div>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => triggerDdns()}
disabled={isTriggering}
className="gap-1"
>
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Check Now
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground ml-7">
DDNS is not enabled. Configure it in the DDNS settings to keep DNS records in sync with your public IP.
</p>
)}
</Card>
{/* 3. Daemon Status */}
<Card className="p-4 border-l-4 border-l-purple-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-purple-500" />
<div className="font-medium">Daemon Status</div>
</div>
{statusLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : centralStatus ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Running
</Badge>
) : (
<Badge variant="destructive">Offline</Badge>
)}
</div>
{statusLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Loading status...
</div>
) : centralStatus ? (
<div className="space-y-3 ml-7">
{/* Service badges */}
<div className="flex flex-wrap gap-2">
<ServiceBadge name="dnsmasq" />
<ServiceBadge name="haproxy" />
<ServiceBadge name="wireguard" />
<ServiceBadge name="crowdsec" />
</div>
{/* Version and uptime */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Version</span>
<div className="font-mono font-medium mt-0.5">{centralStatus.version || 'Unknown'}</div>
</div>
<div>
<span className="text-muted-foreground">Uptime</span>
<div className="font-mono mt-0.5">{centralStatus.uptime || '--'}</div>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={restartDaemon}
disabled={isRestarting}
className="gap-1"
>
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
{isRestarting ? 'Restarting...' : 'Restart Daemon'}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground ml-7">Cannot reach the Wild Central daemon.</p>
)}
</Card>
{/* 4. TLS Certificates */}
{certStatus?.certs && certStatus.certs.length > 0 && (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">TLS Certificates</div>
</div>
{(() => {
const allExist = certStatus.certs!.every(c => c.cert.exists);
const someExist = certStatus.certs!.some(c => c.cert.exists);
if (allExist) {
return <Badge variant="success" className="gap-1"><CheckCircle className="h-3 w-3" />All valid</Badge>;
} else if (someExist) {
return <Badge variant="warning" className="gap-1"><AlertCircle className="h-3 w-3" />Partial</Badge>;
} else {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Missing</Badge>;
}
})()}
</div>
<div className="space-y-2 ml-7">
{certStatus.certs!.map((entry) => (
<div key={entry.domain} className="flex items-center justify-between text-sm">
<span className="font-mono">{entry.domain}</span>
{entry.cert.exists ? (
<span className="text-xs text-green-600 dark:text-green-400">
Valid ({entry.cert.daysLeft}d)
</span>
) : (
<span className="text-xs text-red-500">Missing</span>
)}
</div>
))}
</div>
</Card>
)}
{/* 5. Gateway Router */}
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
<div className="flex items-center gap-2 mb-3">
<Router className="h-5 w-5 text-amber-500" />
<div className="font-medium">Gateway Router</div>
</div>
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
<p>Ensure your router is configured for Wild Central to work correctly:</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>
Forward DNS to Wild Central
{globalConfig?.cloud?.dnsmasq?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
{globalConfig.cloud.dnsmasq.ip}
</span>
)}
</li>
<li>Forward ports <span className="font-mono">443</span>, <span className="font-mono">80</span>, <span className="font-mono">51820</span> to Wild Central</li>
<li>Set Wild Central as the primary DNS server for your LAN</li>
</ul>
</div>
</Card>
</div>
);
}
/** Inline badge showing a service name with a colored dot. Status is informational only. */
function ServiceBadge({ name }: { name: string }) {
// These are presentational — the daemon is running if we can reach the API,
// and individual service health comes from their own endpoints.
return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
<span className="h-2 w-2 rounded-full bg-green-500" />
{name}
</span>
);
}

View File

@@ -1,280 +0,0 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { Globe, Loader2, AlertCircle, CheckCircle, XCircle, RefreshCw, Edit2 } from 'lucide-react';
import { Badge } from './ui/badge';
import { useConfig } from '../hooks';
import { useDdns } from '../hooks/useDdns';
import { secretsApi } from '../services/api';
import type { DdnsRecordStatus } from '../services/api/networking';
export function DdnsComponent() {
const { config: globalConfig, updateConfig, isUpdating } = useConfig();
const { status: ddnsStatus, isLoadingStatus: isDdnsLoading, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({
queryKey: ['globalSecrets'],
queryFn: () => secretsApi.get(),
});
const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalSecrets'] });
setEditingToken(false);
setTokenValue('');
},
});
const cfTokenIsSet = !!(globalSecrets as Record<string, unknown> | undefined)
&& !!(globalSecrets as { ddns?: { cloudflare?: { apiToken?: string } } })?.ddns?.cloudflare?.apiToken;
const [editingDdnsConfig, setEditingDdnsConfig] = useState(false);
const [ddnsConfigForm, setDdnsConfigForm] = useState({ enabled: false, records: '', intervalMinutes: 5 });
const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState('');
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);
};
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">Dynamic DNS</h2>
<p className="text-muted-foreground">Keeps your Cloudflare A records pointed at your current public IP</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Status</CardTitle>
{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>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Configuration</CardTitle>
{!editingDdnsConfig && (
<Button variant="outline" size="sm" onClick={handleDdnsConfigEdit} className="gap-1">
<Edit2 className="h-3 w-3" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
{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>A Records (one per line)</Label>
<Textarea
value={ddnsConfigForm.records}
onChange={e => setDdnsConfigForm(prev => ({ ...prev, records: e.target.value }))}
placeholder={'cloud.example.com\ncloud2.example.com'}
className="text-sm h-20 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-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Status:</span>
<span>{globalConfig?.cloud?.ddns?.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
{globalConfig?.cloud?.ddns?.records?.length ? (
<div>
<span className="text-muted-foreground">Records:</span>
<div className="mt-1 space-y-1">
{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" />
) : null}
<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>
);
})}
</div>
</div>
) : (
<div className="text-muted-foreground text-xs">No A records configured</div>
)}
{(globalConfig?.cloud?.ddns?.intervalMinutes ?? 0) > 0 && (
<div className="text-muted-foreground text-xs">
Checks every {globalConfig!.cloud!.ddns!.intervalMinutes} min
</div>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Cloudflare API Token</CardTitle>
{!editingToken && (
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
<Edit2 className="h-3 w-3" />
{cfTokenIsSet ? 'Update' : 'Set'}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{cfTokenIsSet ? (
<p className="text-sm text-green-600 dark:text-green-400">Configured</p>
) : (
<p className="text-sm text-muted-foreground">Not set required for DDNS to work</p>
)}
{editingToken && (
<div className="mt-3 space-y-2">
<Input
type="password"
placeholder="Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateSecretsMutation.mutate({ ddns: { cloudflare: { apiToken: tokenValue } } })}
disabled={updateSecretsMutation.isPending || !tokenValue}
>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { setEditingToken(false); setTokenValue(''); }}
>
Cancel
</Button>
</div>
{updateSecretsMutation.isError && (
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,484 +0,0 @@
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>
);
}

View File

@@ -1,86 +0,0 @@
import { Settings, RotateCcw, AlertCircle } from 'lucide-react';
import { useDnsmasq, useMessages } from '../hooks';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const DnsmasqSection = () => {
const {
config: dnsmasqConfig,
generateConfig,
isGenerating,
generateError,
restart,
isRestarting,
restartError,
restartData
} = useDnsmasq();
const { messages, setMessage } = useMessages();
// Handle success/error messaging
if (generateError) {
setMessage('dnsmasq', `Failed to generate dnsmasq config: ${generateError.message}`, 'error');
} else if (dnsmasqConfig) {
setMessage('dnsmasq', 'Dnsmasq config generated successfully', 'success');
}
if (restartError) {
setMessage('dnsmasq', `Failed to restart dnsmasq: ${restartError.message}`, 'error');
} else if (restartData) {
setMessage('dnsmasq', `Dnsmasq restart: ${restartData.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>DNS/DHCP Management</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => generateConfig(false)} disabled={isGenerating} variant="outline">
<Settings className="mr-2 h-4 w-4" />
{isGenerating ? 'Generating...' : 'Generate Dnsmasq Config'}
</Button>
<Button onClick={() => restart()} disabled={isRestarting} variant="outline">
<RotateCcw className={`mr-2 h-4 w-4 ${isRestarting ? 'animate-spin' : ''}`} />
{isRestarting ? 'Restarting...' : 'Restart Dnsmasq'}
</Button>
</div>
{generateError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Generation Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{generateError.message}</p>
</div>
</div>
)}
{restartError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Restart Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{restartError.message}</p>
</div>
</div>
)}
{restartData && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Dnsmasq restart: {restartData.status}
</p>
</div>
)}
<Message message={messages.dnsmasq} />
{dnsmasqConfig && (
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{dnsmasqConfig.content || JSON.stringify(dnsmasqConfig, null, 2)}
</pre>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,348 +0,0 @@
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 { Textarea } from './ui/textarea';
import { Network, Loader2, AlertCircle, CheckCircle, Play, RotateCw, Plus, Trash2, X, Check, Activity, ChevronDown, ChevronUp, Globe, Server } from 'lucide-react';
import { Badge } from './ui/badge';
import { useConfig } from '../hooks';
import { useHaproxy } from '../hooks/useHaproxy';
import { useServices } from '../hooks/useServices';
import type { HAProxyCustomRoute } from '../types';
import { haproxyApi } from '../services/api/networking';
export function IngressProxyComponent() {
const { config: globalConfig, updateConfig, isUpdating } = useConfig();
const {
status: haproxyStatus,
isLoadingStatus: isHaproxyLoading,
stats: haproxyStats,
isLoadingStats: isHaproxyStatsLoading,
generate: generateHaproxy,
isGenerating: isHaproxyGenerating,
generateData: haproxyGenerateData,
generateError: haproxyGenerateError,
restart: restartHaproxy,
isRestarting: isHaproxyRestarting,
} = useHaproxy();
const { services, isLoading: isServicesLoading } = useServices();
const [showAddCustomRoute, setShowAddCustomRoute] = useState(false);
const [newCustomRoute, setNewCustomRoute] = useState<HAProxyCustomRoute>({ name: '', port: 0, backend: '' });
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [advancedConfig, setAdvancedConfig] = useState('');
const [loadingAdvanced, setLoadingAdvanced] = useState(false);
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 handleShowAdvanced = async () => {
if (!showAdvanced) {
setLoadingAdvanced(true);
try {
const result = await haproxyApi.getConfig();
setAdvancedConfig(result.content || '');
} catch {
setAdvancedConfig('# Could not load HAProxy config');
}
setLoadingAdvanced(false);
}
setShowAdvanced(!showAdvanced);
};
const handleAddCustomRoute = async () => {
if (!globalConfig || !newCustomRoute.name || !newCustomRoute.port || !newCustomRoute.backend) return;
try {
const existing = globalConfig.cloud?.haproxy?.customRoutes ?? [];
await updateConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
haproxy: {
...globalConfig.cloud?.haproxy,
customRoutes: [...existing, newCustomRoute],
},
},
});
setNewCustomRoute({ name: '', port: 0, backend: '' });
setShowAddCustomRoute(false);
generateHaproxy();
showSuccess(`Custom route "${newCustomRoute.name}" added`);
} catch (e) {
showError(`Failed to add route: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleDeleteCustomRoute = async (name: string) => {
if (!globalConfig) return;
try {
const updated = (globalConfig.cloud?.haproxy?.customRoutes ?? []).filter(r => r.name !== name);
await updateConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
haproxy: {
...globalConfig.cloud?.haproxy,
customRoutes: updated,
},
},
});
generateHaproxy();
showSuccess(`Custom route "${name}" removed`);
} catch (e) {
showError(`Failed to delete route: ${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">
<Network className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Ingress Proxy</h2>
<p className="text-muted-foreground">Routes external traffic to your Wild Cloud instances by domain name (HAProxy)</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>Registered Services</CardTitle>
{isHaproxyLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Badge variant={haproxyStatus?.status === 'active' ? 'success' : 'warning'} className="gap-1">
{haproxyStatus?.status === 'active' && <CheckCircle className="h-3 w-3" />}
{haproxyStatus?.status === 'active' ? 'Active' : haproxyStatus?.status ?? 'Stopped'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{isServicesLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading services...
</div>
) : services.length > 0 ? (
<div className="border rounded-lg divide-y text-sm">
{services.map(s => (
<div key={s.domain} className="px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{s.reach === 'public' ? (
<Globe className="h-3.5 w-3.5 text-blue-500 shrink-0" />
) : (
<Server className="h-3.5 w-3.5 text-green-500 shrink-0" />
)}
<span className="font-mono font-medium">{s.domain}</span>
{s.subdomains && (
<Badge variant="outline" className="text-xs h-4 px-1">*.{s.domain}</Badge>
)}
</div>
<span className="font-mono text-xs text-muted-foreground">{s.backend.address}</span>
</div>
<div className="mt-1 ml-5 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>source: <span className="text-foreground">{s.source}</span></span>
<span>reach: <span className="text-foreground">{s.reach}</span></span>
<span>type: <span className="text-foreground">{s.backend.type}</span></span>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No services registered click Generate &amp; Apply to configure.
</p>
)}
{haproxyGenerateData && (
<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">
{haproxyGenerateData.message}
</AlertDescription>
</Alert>
)}
{haproxyGenerateError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{(haproxyGenerateError as Error).message}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button onClick={() => generateHaproxy()} disabled={isHaproxyGenerating} className="gap-2">
{isHaproxyGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Generate & Apply
</Button>
<Button variant="outline" onClick={() => restartHaproxy()} disabled={isHaproxyRestarting} className="gap-2">
{isHaproxyRestarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCw className="h-4 w-4" />}
Restart
</Button>
<Button variant="outline" onClick={handleShowAdvanced} disabled={loadingAdvanced} className="gap-2">
{loadingAdvanced ? <Loader2 className="h-4 w-4 animate-spin" /> : showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
Advanced
</Button>
</div>
{showAdvanced && (
<div className="border-t pt-4 space-y-3">
<span className="text-sm font-medium">Generated config file</span>
<Textarea
value={advancedConfig}
readOnly
className="font-mono text-xs min-h-[300px]"
placeholder="# haproxy configuration"
/>
</div>
)}
</CardContent>
</Card>
{(haproxyStats.length > 0 || isHaproxyStatsLoading) && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle>Backend Status</CardTitle>
</div>
</CardHeader>
<CardContent>
{isHaproxyStatsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading
</div>
) : (
<div className="border rounded-lg divide-y text-sm">
{haproxyStats.map(s => (
<div key={s.name} className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<Badge variant={s.status === 'UP' ? 'default' : 'secondary'} className="text-xs h-4">
{s.status}
</Badge>
<span className="font-mono text-xs">{s.name.replace(/^be_https?_/, '')}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span><span className="font-medium text-foreground">{s.activeConns}</span> active</span>
<span><span className="font-medium text-foreground">{s.rate}</span> /s <span className="text-muted-foreground/60">(peak {s.peakRate})</span></span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Custom TCP Routes</CardTitle>
<Button size="sm" variant="outline" onClick={() => setShowAddCustomRoute(!showAddCustomRoute)} className="gap-1">
<Plus className="h-3 w-3" />
Add
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{showAddCustomRoute && (
<Card className="p-3 border-dashed">
<div className="grid grid-cols-3 gap-2 mb-2">
<div>
<Label className="text-xs">Name</Label>
<Input
value={newCustomRoute.name}
onChange={e => setNewCustomRoute(r => ({ ...r, name: e.target.value }))}
placeholder="e.g. civil_ssh"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<Label className="text-xs">Listen Port</Label>
<Input
type="number"
value={newCustomRoute.port || ''}
onChange={e => setNewCustomRoute(r => ({ ...r, port: parseInt(e.target.value) || 0 }))}
placeholder="2222"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<Label className="text-xs">Backend (host:port)</Label>
<Input
value={newCustomRoute.backend}
onChange={e => setNewCustomRoute(r => ({ ...r, backend: e.target.value }))}
placeholder="192.168.8.10:22"
className="mt-1 text-xs font-mono"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setShowAddCustomRoute(false)}>
<X className="h-3 w-3 mr-1" />Cancel
</Button>
<Button
size="sm"
onClick={handleAddCustomRoute}
disabled={!newCustomRoute.name || !newCustomRoute.port || !newCustomRoute.backend || isUpdating}
>
{isUpdating ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Check className="h-3 w-3 mr-1" />}
Add
</Button>
</div>
</Card>
)}
{(globalConfig?.cloud?.haproxy?.customRoutes ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No custom routes configured.</p>
) : (
<div className="border rounded-lg divide-y text-xs">
{(globalConfig?.cloud?.haproxy?.customRoutes ?? []).map(r => (
<div key={r.name} className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-3">
<span className="font-mono font-medium">{r.name}</span>
<span className="text-muted-foreground font-mono">:{r.port} {r.backend}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
onClick={() => handleDeleteCustomRoute(r.name)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,259 +0,0 @@
import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Textarea } from './ui/textarea';
import { Alert, AlertDescription } from './ui/alert';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Router, CheckCircle, ExternalLink, Loader2, AlertCircle, Play, RotateCw, Copy, ChevronDown, ChevronUp, Edit } from 'lucide-react';
import { Badge } from './ui/badge';
import { useConfig } from '../hooks';
import { useDnsmasq } from '../hooks/useDnsmasq';
import { apiService } from '../services/api-legacy';
export function LanDnsComponent() {
const { config: globalConfig } = useConfig();
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 [showDnsEditDialog, setShowDnsEditDialog] = useState(false);
const [editedDnsConfig, setEditedDnsConfig] = 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 = () => {
if (!showDnsAdvanced && !dnsConfig) {
fetchDnsConfig();
}
setShowDnsAdvanced(!showDnsAdvanced);
};
const handleEditDnsConfig = () => {
if (dnsConfig?.content) {
setEditedDnsConfig(dnsConfig.content);
} else if (dnsGenerateData?.config || dnsGenerateData?.content) {
setEditedDnsConfig(dnsGenerateData.config || dnsGenerateData.content || '');
} else {
setEditedDnsConfig('');
}
setShowDnsEditDialog(true);
};
const handleSaveDnsConfig = async () => {
try {
await apiService.writeDnsmasqConfig(editedDnsConfig);
setShowDnsEditDialog(false);
fetchDnsConfig();
} catch (error) {
console.error('Failed to save config:', error);
}
};
const handleSaveAndRestartDns = async () => {
try {
await apiService.writeDnsmasqConfig(editedDnsConfig);
await apiService.restartDnsmasq();
setShowDnsEditDialog(false);
fetchDnsConfig();
} catch (error) {
console.error('Failed to save config and restart:', error);
}
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Router className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">LAN DNS</h2>
<p className="text-muted-foreground">Resolves Wild Cloud domains on your local network via dnsmasq</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>DNS Service</CardTitle>
<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-4 bg-muted/30 rounded-lg">
<div>
<div className="text-sm text-muted-foreground">
Set this as the primary DNS server in your router
</div>
<code className="text-xl font-mono font-bold mt-1 inline-block">
{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>
)}
</CardContent>
</Card>
{showDnsAdvanced && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Advanced DNS Configuration</CardTitle>
<Button variant="outline" size="sm" onClick={handleEditDnsConfig} className="gap-2">
<Edit className="h-4 w-4" />
Edit Config
</Button>
</div>
</CardHeader>
<CardContent>
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-xs font-mono">
{isDnsGenerating && !dnsConfig && !dnsGenerateData && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{(dnsConfig?.content || dnsGenerateData?.config || dnsGenerateData?.content) && (
dnsConfig?.content || dnsGenerateData?.config || dnsGenerateData?.content
)}
{!isDnsGenerating && !dnsGenerateData && !dnsConfig && (
<div className="text-center p-8 text-sm text-muted-foreground">
<p>Configuration preview will appear here</p>
</div>
)}
</pre>
</CardContent>
</Card>
)}
<Dialog open={showDnsEditDialog} onOpenChange={setShowDnsEditDialog}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Edit DNS Configuration</DialogTitle>
<DialogDescription>
Modify the dnsmasq configuration. Changes will take effect after saving and restarting.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
value={editedDnsConfig}
onChange={(e) => setEditedDnsConfig(e.target.value)}
className="font-mono text-xs min-h-[400px]"
placeholder="# dnsmasq configuration"
/>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setShowDnsEditDialog(false)}>
Cancel
</Button>
<Button variant="outline" onClick={handleSaveDnsConfig}>
Save
</Button>
<Button onClick={handleSaveAndRestartDns}>
Save & Restart
</Button>
</DialogFooter>
<p className="text-xs text-muted-foreground text-center">
Save: Write config without restarting | Save & Restart: Write config and restart service
</p>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,503 @@
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 { Textarea } from './ui/textarea';
import { Badge } from './ui/badge';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
import {
Globe,
Loader2,
AlertCircle,
CheckCircle,
Play,
RotateCw,
Plus,
Trash2,
X,
Check,
ChevronDown,
ChevronRight,
ChevronUp,
} from 'lucide-react';
import { useConfig } from '../hooks';
import { useHaproxy } from '../hooks/useHaproxy';
import { useServices } from '../hooks/useServices';
import { useCert } from '../hooks/useCert';
import { useDdns } from '../hooks/useDdns';
import type { HAProxyCustomRoute } from '../types';
import type { RegisteredService } from '../services/api/networking';
import { haproxyApi } from '../services/api/networking';
function StatusDot({ status, label }: { status: 'ok' | 'warning' | 'na'; label: string }) {
return (
<span className="inline-flex items-center gap-1 text-xs">
{status === 'ok' && <CheckCircle className="h-3.5 w-3.5 text-green-500" />}
{status === 'warning' && <AlertCircle className="h-3.5 w-3.5 text-amber-500" />}
{status === 'na' && <span className="h-3.5 w-3.5 text-muted-foreground inline-flex items-center justify-center">&mdash;</span>}
<span className="text-muted-foreground">{label}</span>
</span>
);
}
function getTypeBadge(backendType: string) {
if (backendType === 'tcp-passthrough') return 'L4';
return 'L7';
}
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'warning' | 'na' {
if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'ok';
if (service.tls === 'terminate') {
return certDomains.has(service.domain) ? 'ok' : 'warning';
}
if (certDomains.has(service.domain)) return 'ok';
return 'na';
}
function getTlsLabel(service: RegisteredService, certDomains: Set<string>): string {
if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') {
return 'passthrough (backend handles)';
}
if (service.tls === 'terminate') {
return certDomains.has(service.domain) ? 'terminate (cert provisioned)' : 'terminate (cert missing)';
}
return 'none';
}
function getDdnsStatus(service: RegisteredService, ddnsDomains: Set<string>): 'ok' | 'na' {
if (service.reach === 'public') {
return ddnsDomains.has(service.domain) ? 'ok' : 'ok'; // if public, DDNS record should exist
}
return 'na';
}
function getProxyLabel(service: RegisteredService): string {
if (service.backend.type === 'tcp-passthrough') {
return `HAProxy L4 SNI passthrough${service.subdomains ? ' + wildcard' : ''}`;
}
return `HAProxy L7 HTTP reverse proxy${service.subdomains ? ' + wildcard' : ''}`;
}
interface ServiceRowProps {
service: RegisteredService;
isExpanded: boolean;
onToggle: () => void;
certDomains: Set<string>;
ddnsDomains: Set<string>;
dnsmasqIp?: string;
publicIp?: string;
}
function ServiceRow({ service, isExpanded, onToggle, certDomains, ddnsDomains, dnsmasqIp, publicIp }: ServiceRowProps) {
const tlsStatus = getTlsStatus(service, certDomains);
const ddnsStatus = getDdnsStatus(service, ddnsDomains);
const typeBadge = getTypeBadge(service.backend.type);
return (
<div className="border-b last:border-b-0">
<button
type="button"
onClick={onToggle}
className="w-full px-3 py-2.5 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className="font-mono text-sm font-medium truncate">{service.domain}</span>
<Badge variant="outline" className="text-xs h-4 px-1 shrink-0">{typeBadge}</Badge>
<Badge
variant={service.reach === 'public' ? 'success' : 'default'}
className="text-xs h-4 px-1 shrink-0"
>
{service.reach}
</Badge>
{service.subdomains && (
<Badge variant="outline" className="text-xs h-4 px-1 shrink-0">*.sub</Badge>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<StatusDot status="ok" label="DNS" />
<StatusDot status="ok" label="Proxy" />
<StatusDot status={tlsStatus} label="TLS" />
<StatusDot status={ddnsStatus} label="DDNS" />
</div>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-1 ml-5 space-y-2">
<div className="border rounded-lg divide-y text-sm">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">DNS (LAN)</span>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">
address=/{service.domain}/{dnsmasqIp ?? '—'}
</span>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
</div>
</div>
{service.reach === 'public' && (
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">DNS (Public)</span>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">
A record &rarr; {publicIp ?? '—'}
</span>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
</div>
</div>
)}
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">Proxy</span>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">{getProxyLabel(service)}</span>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
</div>
</div>
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">TLS</span>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">{getTlsLabel(service, certDomains)}</span>
{tlsStatus === 'ok' && <CheckCircle className="h-3.5 w-3.5 text-green-500" />}
{tlsStatus === 'warning' && <AlertCircle className="h-3.5 w-3.5 text-amber-500" />}
{tlsStatus === 'na' && <span className="h-3.5 w-3.5 text-muted-foreground inline-flex items-center justify-center">&mdash;</span>}
</div>
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground pt-1">
<span>Backend: <span className="font-mono text-foreground">{service.backend.address}</span></span>
<span>Source: <span className="text-foreground">{service.source}</span></span>
</div>
</div>
)}
</div>
);
}
export function ServicesComponent() {
const { config: globalConfig, updateConfig, isUpdating } = useConfig();
const {
status: haproxyStatus,
isLoadingStatus: isHaproxyLoading,
generate: generateHaproxy,
isGenerating: isHaproxyGenerating,
generateData: haproxyGenerateData,
generateError: haproxyGenerateError,
restart: restartHaproxy,
isRestarting: isHaproxyRestarting,
} = useHaproxy();
const { services, isLoading: isServicesLoading } = useServices();
const { status: certStatus } = useCert();
const { status: ddnsStatus } = useDdns();
const [expandedDomain, setExpandedDomain] = useState<string | null>(null);
const [showAddCustomRoute, setShowAddCustomRoute] = useState(false);
const [newCustomRoute, setNewCustomRoute] = useState<HAProxyCustomRoute>({ name: '', port: 0, backend: '' });
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [advancedConfig, setAdvancedConfig] = useState('');
const [loadingAdvanced, setLoadingAdvanced] = useState(false);
// Build lookup sets for cross-referencing
const certDomains = new Set<string>(
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
);
const ddnsDomains = new Set<string>(
(ddnsStatus?.records ?? []).map(r => r.name)
);
const dnsmasqIp = globalConfig?.cloud?.dnsmasq?.ip;
const publicIp = ddnsStatus?.currentIP;
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 handleShowAdvanced = async () => {
if (!showAdvanced) {
setLoadingAdvanced(true);
try {
const result = await haproxyApi.getConfig();
setAdvancedConfig(result.content || '');
} catch {
setAdvancedConfig('# Could not load HAProxy config');
}
setLoadingAdvanced(false);
}
setShowAdvanced(!showAdvanced);
};
const handleAddCustomRoute = async () => {
if (!globalConfig || !newCustomRoute.name || !newCustomRoute.port || !newCustomRoute.backend) return;
try {
const existing = globalConfig.cloud?.haproxy?.customRoutes ?? [];
await updateConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
haproxy: {
...globalConfig.cloud?.haproxy,
customRoutes: [...existing, newCustomRoute],
},
},
});
setNewCustomRoute({ name: '', port: 0, backend: '' });
setShowAddCustomRoute(false);
generateHaproxy();
showSuccess(`Custom route "${newCustomRoute.name}" added`);
} catch (e) {
showError(`Failed to add route: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleDeleteCustomRoute = async (name: string) => {
if (!globalConfig) return;
try {
const updated = (globalConfig.cloud?.haproxy?.customRoutes ?? []).filter(r => r.name !== name);
await updateConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
haproxy: {
...globalConfig.cloud?.haproxy,
customRoutes: updated,
},
},
});
generateHaproxy();
showSuccess(`Custom route "${name}" removed`);
} catch (e) {
showError(`Failed to delete route: ${e instanceof Error ? e.message : String(e)}`);
}
};
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center gap-4 mb-6">
<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">Services</h2>
<p className="text-muted-foreground">Registered domains and their networking status</p>
</div>
</div>
{/* Alerts */}
{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>
)}
{/* Service List */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Registered Services</CardTitle>
{isHaproxyLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Badge variant={haproxyStatus?.status === 'active' ? 'success' : 'warning'} className="gap-1">
{haproxyStatus?.status === 'active' && <CheckCircle className="h-3 w-3" />}
{haproxyStatus?.status === 'active' ? 'Active' : haproxyStatus?.status ?? 'Stopped'}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{isServicesLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading services...
</div>
) : services.length > 0 ? (
<div className="border rounded-lg text-sm">
{services.map(s => (
<ServiceRow
key={s.domain}
service={s}
isExpanded={expandedDomain === s.domain}
onToggle={() => setExpandedDomain(expandedDomain === s.domain ? null : s.domain)}
certDomains={certDomains}
ddnsDomains={ddnsDomains}
dnsmasqIp={dnsmasqIp}
publicIp={publicIp}
/>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No services registered. Services appear here when Wild Cloud or Wild Works registers them.
</p>
)}
</CardContent>
</Card>
{/* Custom TCP Routes */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Custom TCP Routes</CardTitle>
<Button size="sm" variant="outline" onClick={() => setShowAddCustomRoute(!showAddCustomRoute)} className="gap-1">
<Plus className="h-3 w-3" />
Add
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{showAddCustomRoute && (
<Card className="p-3 border-dashed">
<div className="grid grid-cols-3 gap-2 mb-2">
<div>
<Label className="text-xs">Name</Label>
<Input
value={newCustomRoute.name}
onChange={e => setNewCustomRoute(r => ({ ...r, name: e.target.value }))}
placeholder="e.g. civil_ssh"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<Label className="text-xs">Listen Port</Label>
<Input
type="number"
value={newCustomRoute.port || ''}
onChange={e => setNewCustomRoute(r => ({ ...r, port: parseInt(e.target.value) || 0 }))}
placeholder="2222"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<Label className="text-xs">Backend (host:port)</Label>
<Input
value={newCustomRoute.backend}
onChange={e => setNewCustomRoute(r => ({ ...r, backend: e.target.value }))}
placeholder="192.168.8.10:22"
className="mt-1 text-xs font-mono"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setShowAddCustomRoute(false)}>
<X className="h-3 w-3 mr-1" />Cancel
</Button>
<Button
size="sm"
onClick={handleAddCustomRoute}
disabled={!newCustomRoute.name || !newCustomRoute.port || !newCustomRoute.backend || isUpdating}
>
{isUpdating ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Check className="h-3 w-3 mr-1" />}
Add
</Button>
</div>
</Card>
)}
{(globalConfig?.cloud?.haproxy?.customRoutes ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No custom routes configured.</p>
) : (
<div className="border rounded-lg divide-y text-xs">
{(globalConfig?.cloud?.haproxy?.customRoutes ?? []).map(r => (
<div key={r.name} className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-3">
<span className="font-mono font-medium">{r.name}</span>
<span className="text-muted-foreground font-mono">:{r.port} &rarr; {r.backend}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
onClick={() => handleDeleteCustomRoute(r.name)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Advanced */}
<Collapsible open={showAdvanced} onOpenChange={handleShowAdvanced}>
<Card>
<CardHeader>
<CollapsibleTrigger asChild>
<button type="button" className="flex items-center justify-between w-full text-left">
<CardTitle>Advanced</CardTitle>
{loadingAdvanced ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : showAdvanced ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
<div className="flex gap-2">
<Button onClick={() => generateHaproxy()} disabled={isHaproxyGenerating} className="gap-2">
{isHaproxyGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Generate &amp; Apply HAProxy
</Button>
<Button variant="outline" onClick={() => restartHaproxy()} disabled={isHaproxyRestarting} className="gap-2">
{isHaproxyRestarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCw className="h-4 w-4" />}
Restart HAProxy
</Button>
</div>
{haproxyGenerateData && (
<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">
{haproxyGenerateData.message}
</AlertDescription>
</Alert>
)}
{haproxyGenerateError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{(haproxyGenerateError as Error).message}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<span className="text-sm font-medium">Generated config file</span>
<Textarea
value={advancedConfig}
readOnly
className="font-mono text-xs min-h-[300px]"
placeholder="# haproxy configuration"
/>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</div>
);
}

View File

@@ -1,10 +1,6 @@
export { Message } from './Message';
export { DnsmasqSection } from './DnsmasqSection';
export { AppSidebar } from './AppSidebar';
export { ErrorBoundary, ErrorFallback } from './ErrorBoundary';
export { CentralComponent } from './CentralComponent';
export { CloudflareComponent } from './CloudflareComponent';
export { DnsComponent } from './DnsComponent';
export { DhcpComponent } from './DhcpComponent';
export { DownloadButton } from './DownloadButton';
export { CopyButton } from './CopyButton';