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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && <> · {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>
|
||||
);
|
||||
}
|
||||
432
web/src/components/DashboardComponent.tsx
Normal file
432
web/src/components/DashboardComponent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
503
web/src/components/ServicesComponent.tsx
Normal file
503
web/src/components/ServicesComponent.tsx
Normal 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">—</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 → {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">—</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} → {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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { CentralComponent } from '../../components/CentralComponent';
|
||||
|
||||
export function CentralPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CentralComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input, Label } from '../../components/ui';
|
||||
import { Alert, AlertDescription } from '../../components/ui/alert';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Shield, Loader2, CheckCircle, AlertCircle, RefreshCw, Plus, ShieldCheck, ShieldAlert, X } from 'lucide-react';
|
||||
import { useCert } from '../../hooks/useCert';
|
||||
import { usePageHelp } from '../../hooks/usePageHelp';
|
||||
|
||||
export function CertificatesPage() {
|
||||
const {
|
||||
status: certStatus,
|
||||
isLoading,
|
||||
provision: provisionCert,
|
||||
isProvisioning,
|
||||
renew: renewCerts,
|
||||
isRenewing,
|
||||
} = useCert();
|
||||
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
|
||||
const [showWildcardForm, setShowWildcardForm] = useState(false);
|
||||
const [wildcardInput, setWildcardInput] = useState('');
|
||||
|
||||
usePageHelp({
|
||||
title: 'TLS Certificates',
|
||||
description: (
|
||||
<p className="leading-relaxed">
|
||||
Wild Central provisions TLS certificates for services that need HTTPS.
|
||||
Each service gets its own certificate by default. You can also provision
|
||||
a wildcard certificate to cover multiple services under the same domain.
|
||||
</p>
|
||||
),
|
||||
});
|
||||
|
||||
const certs = certStatus?.certs ?? [];
|
||||
const canProvision = certStatus?.canProvision ?? false;
|
||||
|
||||
const handleProvision = async (domain: string) => {
|
||||
setProvisioningDomain(domain);
|
||||
try {
|
||||
await provisionCert(domain);
|
||||
} finally {
|
||||
setProvisioningDomain(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProvisionWildcard = async () => {
|
||||
if (!wildcardInput) return;
|
||||
const domain = wildcardInput.startsWith('*.') ? wildcardInput : `*.${wildcardInput}`;
|
||||
setProvisioningDomain(domain);
|
||||
try {
|
||||
await provisionCert(domain);
|
||||
setShowWildcardForm(false);
|
||||
setWildcardInput('');
|
||||
} finally {
|
||||
setProvisioningDomain(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||
<p className="text-muted-foreground">Loading certificate status...</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const allExist = certs.length > 0 && certs.every((c) => c.cert?.exists || c.coveredBy);
|
||||
const someExist = certs.some((c) => c.cert?.exists || c.coveredBy);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage HTTPS certificates for registered services
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{allExist ? (
|
||||
<Badge variant="success" className="gap-1"><ShieldCheck className="h-3 w-3" />All Valid</Badge>
|
||||
) : someExist ? (
|
||||
<Badge variant="warning" className="gap-1"><ShieldAlert className="h-3 w-3" />Incomplete</Badge>
|
||||
) : certs.length > 0 ? (
|
||||
<Badge variant="destructive" className="gap-1"><ShieldAlert className="h-3 w-3" />Missing</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!canProvision && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Certificate provisioning requires a Cloudflare API token and operator email.
|
||||
{!certStatus?.hasToken && ' Configure the token on the Cloudflare page.'}
|
||||
{!certStatus?.hasEmail && ' Set the operator email in Overview.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{certs.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Services Registered</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Register services with Wild Central to manage their certificates here.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Service Certificates</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{canProvision && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowWildcardForm(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />Wildcard
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => renewCerts()} disabled={isRenewing || !someExist}>
|
||||
{isRenewing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RefreshCw className="h-4 w-4 mr-1" />}
|
||||
Renew All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{showWildcardForm && (
|
||||
<div className="p-3 border rounded-lg bg-muted/50 space-y-2">
|
||||
<Label>Provision Wildcard Certificate</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1 flex-1">
|
||||
<span className="text-sm text-muted-foreground">*.</span>
|
||||
<Input
|
||||
value={wildcardInput}
|
||||
onChange={(e) => setWildcardInput(e.target.value)}
|
||||
placeholder="cloud.payne.io"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleProvisionWildcard} disabled={isProvisioning || !wildcardInput}>
|
||||
{provisioningDomain?.startsWith('*.') ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
Provision
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setShowWildcardForm(false); setWildcardInput(''); }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A wildcard cert covers all subdomains. E.g., *.cloud.payne.io covers app1.cloud.payne.io, app2.cloud.payne.io, etc.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg divide-y">
|
||||
{certs.map((entry) => {
|
||||
const exists = entry.cert?.exists;
|
||||
const covered = entry.coveredBy;
|
||||
const isThisProvisioning = provisioningDomain === entry.domain;
|
||||
|
||||
return (
|
||||
<div key={entry.domain} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-sm truncate">{entry.domain}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{entry.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-4">
|
||||
{exists ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<ShieldCheck className="h-3 w-3" />{entry.cert.daysLeft}d
|
||||
</Badge>
|
||||
) : covered ? (
|
||||
<Badge variant="outline" className="gap-1 text-green-600 border-green-300">
|
||||
<CheckCircle className="h-3 w-3" />{covered}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="destructive" className="gap-1">Missing</Badge>
|
||||
{canProvision && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleProvision(entry.domain)} disabled={isProvisioning}>
|
||||
{isThisProvisioning ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { CloudflareComponent } from '../../components/CloudflareComponent';
|
||||
|
||||
export function CloudflarePage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CloudflareComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
web/src/router/pages/DashboardPage.tsx
Normal file
10
web/src/router/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DashboardComponent } from '../../components/DashboardComponent';
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DashboardComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DdnsComponent } from '../../components/DdnsComponent';
|
||||
|
||||
export function DdnsPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DdnsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DnsComponent } from '../../components/DnsComponent';
|
||||
|
||||
export function DnsPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DnsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { IngressProxyComponent } from '../../components/IngressProxyComponent';
|
||||
|
||||
export function IngressProxyPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<IngressProxyComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||
import { LanDnsComponent } from '../../components/LanDnsComponent';
|
||||
|
||||
export function LanDnsPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<LanDnsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
web/src/router/pages/ServicesPage.tsx
Normal file
10
web/src/router/pages/ServicesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { ServicesComponent } from '../../components/ServicesComponent';
|
||||
|
||||
export function ServicesPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ServicesComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,12 @@ import { Navigate } from 'react-router';
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { CentralLayout } from './CentralLayout';
|
||||
import { NotFoundPage } from './pages/NotFoundPage';
|
||||
import { CentralPage } from './pages/CentralPage';
|
||||
import { DhcpPage } from './pages/DhcpPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { ServicesPage } from './pages/ServicesPage';
|
||||
import { FirewallPage } from './pages/FirewallPage';
|
||||
import { IngressProxyPage } from './pages/IngressProxyPage';
|
||||
import { DnsPage } from './pages/DnsPage';
|
||||
import { CrowdSecPage } from './pages/CrowdSecPage';
|
||||
import { VpnPage } from './pages/VpnPage';
|
||||
import { CloudflarePage } from './pages/CloudflarePage';
|
||||
import { CertificatesPage } from './pages/CertificatesPage';
|
||||
import { DdnsPage } from './pages/DdnsPage';
|
||||
import { LanDnsPage } from './pages/LanDnsPage';
|
||||
import { CrowdSecPage } from './pages/CrowdSecPage';
|
||||
import { DhcpPage } from './pages/DhcpPage';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
@@ -23,17 +18,12 @@ export const routes: RouteObject[] = [
|
||||
path: '/central',
|
||||
element: <CentralLayout />,
|
||||
children: [
|
||||
{ index: true, element: <CentralPage /> },
|
||||
{ path: 'cloudflare', element: <CloudflarePage /> },
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'services', element: <ServicesPage /> },
|
||||
{ path: 'firewall', element: <FirewallPage /> },
|
||||
{ path: 'dhcp', element: <DhcpPage /> },
|
||||
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
||||
{ path: 'ingress', element: <IngressProxyPage /> },
|
||||
{ path: 'dns', element: <DnsPage /> },
|
||||
{ path: 'ddns', element: <DdnsPage /> },
|
||||
{ path: 'lan-dns', element: <LanDnsPage /> },
|
||||
{ path: 'vpn', element: <VpnPage /> },
|
||||
{ path: 'certificates', element: <CertificatesPage /> },
|
||||
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
||||
{ path: 'dhcp', element: <DhcpPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user