Advanced pages. Model-driven controls.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { NavLink } from 'react-router';
|
||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning } from 'lucide-react';
|
||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network } from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -66,6 +66,14 @@ export function AppSidebar() {
|
||||
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
||||
];
|
||||
|
||||
const advancedItems = [
|
||||
{ to: '/central/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
|
||||
{ to: '/central/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
|
||||
{ to: '/central/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
||||
{ to: '/central/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
||||
{ to: '/central/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Sidebar variant="sidebar" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
@@ -82,7 +90,7 @@ export function AppSidebar() {
|
||||
<SidebarContent>
|
||||
{state === 'collapsed' ? (
|
||||
<SidebarMenu>
|
||||
{[...domainsItems, ...centralItems].map((item) => {
|
||||
{[...domainsItems, ...centralItems, ...advancedItems].map((item) => {
|
||||
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
|
||||
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
||||
return (
|
||||
@@ -149,6 +157,32 @@ export function AppSidebar() {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Advanced</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||
const active = centralStatus?.daemons?.[daemon]?.active;
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
<NavLink to={to}>
|
||||
{({ isActive }) => (
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="truncate">{label}</span>
|
||||
{active !== undefined && (
|
||||
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
@@ -3,52 +3,24 @@ import { useMutation, 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 { Textarea } from './ui/textarea';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
|
||||
import {
|
||||
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
|
||||
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
|
||||
Globe, Loader2,
|
||||
Plus, X,
|
||||
} from 'lucide-react';
|
||||
import { useHaproxy } from '../hooks/useHaproxy';
|
||||
import { useDomains } from '../hooks/useDomains';
|
||||
import { useDdns } from '../hooks/useDdns';
|
||||
import { useCert } from '../hooks/useCert';
|
||||
import { useVpn } from '../hooks/useVpn';
|
||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||
import { usePageHelp } from '../hooks/usePageHelp';
|
||||
import type { RegisteredDomain } from '../services/api/networking';
|
||||
import { domainsApi } from '../services/api/networking';
|
||||
import type { CertEntry } from '../services/api/cert';
|
||||
import { domainsApi, haproxyApi } from '../services/api/networking';
|
||||
|
||||
function StatusDot({ status, label }: { status: 'ok' | 'error' | '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 === 'error' && <AlertCircle className="h-3.5 w-3.5 text-red-500" />}
|
||||
{status === 'na' && <CheckCircle className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getTlsStatus(service: RegisteredDomain, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
||||
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
|
||||
if (service.tls === 'passthrough' || service.tls === 'none' || backendType === 'tcp-passthrough' || backendType === 'dns-only') return 'na';
|
||||
// Check exact match or wildcard coverage
|
||||
if (certDomains.has(service.domain)) return 'ok';
|
||||
const dotIdx = service.domain.indexOf('.');
|
||||
if (dotIdx > 0) {
|
||||
const parent = service.domain.slice(dotIdx + 1);
|
||||
if (certDomains.has(parent)) return 'ok';
|
||||
}
|
||||
return 'error';
|
||||
}
|
||||
import { DomainCard } from './domains/DomainCard';
|
||||
|
||||
function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
||||
// Exact match first
|
||||
const exact = certs.find(c => c.cert.exists && c.domain === domain);
|
||||
if (exact) return exact;
|
||||
// Wildcard: check parent domain
|
||||
const dotIdx = domain.indexOf('.');
|
||||
if (dotIdx > 0) {
|
||||
const parent = domain.slice(dotIdx + 1);
|
||||
@@ -57,27 +29,14 @@ function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasRoutes(service: RegisteredDomain): boolean {
|
||||
return (service.routes?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
|
||||
function getBackendType(service: RegisteredDomain): string {
|
||||
if (hasRoutes(service)) return service.routes![0].backend.type;
|
||||
return service.backend.type;
|
||||
}
|
||||
|
||||
export function DomainsComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
generate: generateHaproxy, isGenerating: isHaproxyGenerating,
|
||||
generateData: haproxyGenerateData, generateError: haproxyGenerateError,
|
||||
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
|
||||
} = useHaproxy();
|
||||
|
||||
const { domains, isLoading: isDomainsLoading } = useDomains();
|
||||
const { status: ddnsStatus, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
|
||||
const { status: ddnsStatus } = useDdns();
|
||||
const { status: certStatus, provision: provisionCert, renew: renewCert } = useCert();
|
||||
const { status: vpnStatus, peers: vpnPeers } = useVpn();
|
||||
const { data: centralStatus } = useCentralStatus();
|
||||
|
||||
const provisionMutation = useMutation({
|
||||
mutationFn: (domain: string) => provisionCert(domain),
|
||||
@@ -88,13 +47,8 @@ export function DomainsComponent() {
|
||||
mutationFn: () => renewCert(),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
|
||||
});
|
||||
|
||||
|
||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [advancedConfig, setAdvancedConfig] = useState('');
|
||||
const [loadingAdvanced, setLoadingAdvanced] = useState(false);
|
||||
|
||||
// Add form state
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
@@ -103,12 +57,24 @@ export function DomainsComponent() {
|
||||
const [newSubdomains, setNewSubdomains] = useState(false);
|
||||
const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy');
|
||||
|
||||
// Derived data for cards
|
||||
const certDomains = new Set<string>(
|
||||
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
|
||||
);
|
||||
|
||||
const daemons = {
|
||||
haproxy: centralStatus?.daemons?.haproxy?.active,
|
||||
nftables: centralStatus?.daemons?.nftables?.active,
|
||||
crowdsec: centralStatus?.daemons?.crowdsec?.active,
|
||||
wireguard: centralStatus?.daemons?.wireguard?.active,
|
||||
};
|
||||
|
||||
const vpnRunning = vpnStatus?.running ?? false;
|
||||
const vpnConfigured = !!(vpnStatus || vpnPeers.length > 0);
|
||||
const vpnPeerCount = vpnPeers.length;
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: (svc: Record<string, unknown>) => domainsApi.register(svc as any),
|
||||
mutationFn: (svc: Parameters<typeof domainsApi.register>[0]) => domainsApi.register(svc),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
||||
setShowAddForm(false);
|
||||
@@ -116,47 +82,16 @@ export function DomainsComponent() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) =>
|
||||
domainsApi.register({ ...(domains.find(d => d.domain === domain) ?? {}), ...updates } as any),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||
});
|
||||
|
||||
const deregisterMutation = useMutation({
|
||||
mutationFn: (domain: string) => domainsApi.deregister(domain),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||
});
|
||||
|
||||
usePageHelp({
|
||||
title: 'Domains',
|
||||
description: (
|
||||
<p className="leading-relaxed">
|
||||
Each domain is managed by Wild Central. For each, configure whether it just resolves to an IP
|
||||
or routes through Central's gateway for DNS, proxy routing, TLS, and public exposure.
|
||||
Each domain is managed by Wild Central. The topology diagram shows how traffic
|
||||
reaches your services — from the internet, your LAN, and VPN connections.
|
||||
</p>
|
||||
),
|
||||
});
|
||||
|
||||
const toggleExpand = (domain: string) => {
|
||||
setExpandedDomains(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(domain)) next.delete(domain); else next.add(domain);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -167,7 +102,7 @@ export function DomainsComponent() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Domains</h2>
|
||||
<p className="text-muted-foreground">Registered domains and their networking status</p>
|
||||
<p className="text-muted-foreground">Registered domains and their networking topology</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
|
||||
@@ -253,294 +188,24 @@ export function DomainsComponent() {
|
||||
)}
|
||||
|
||||
{/* Domain Cards */}
|
||||
{domains.map(service => {
|
||||
const isExpanded = expandedDomains.has(service.domain);
|
||||
const backendType = getBackendType(service);
|
||||
const isDnsOnly = backendType === 'dns-only';
|
||||
const isPassthrough = service.tls === 'passthrough' || backendType === 'tcp-passthrough';
|
||||
const hasGateway = !isDnsOnly;
|
||||
const tlsStatus = getTlsStatus(service, certDomains);
|
||||
const ddnsRecord = ddnsStatus?.records?.find(r => r.name === service.domain);
|
||||
const ddnsStatus2: 'ok' | 'error' | 'na' = !service.public ? 'na' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na';
|
||||
const routeCount = service.routes?.length ?? 0;
|
||||
{domains.map(domain => (
|
||||
<DomainCard
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
cert={findCert(domain.domain, certStatus?.certs ?? [])}
|
||||
ddnsRecord={ddnsStatus?.records?.find(r => r.name === domain.domain)}
|
||||
vpnPeerCount={vpnPeerCount}
|
||||
vpnRunning={vpnRunning}
|
||||
vpnConfigured={vpnConfigured}
|
||||
daemons={daemons}
|
||||
certDomains={certDomains}
|
||||
onProvisionCert={d => provisionMutation.mutate(d)}
|
||||
onRenewCert={() => renewMutation.mutate()}
|
||||
isProvisioningCert={provisionMutation.isPending}
|
||||
isRenewingCert={renewMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
|
||||
return (
|
||||
<Card key={service.domain}>
|
||||
{/* Header — always visible */}
|
||||
<button type="button" onClick={() => toggleExpand(service.domain)} className="w-full px-4 py-3 text-left hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-sm font-medium truncate">
|
||||
{service.subdomains ? `*.${service.domain}` : service.domain}
|
||||
</span>
|
||||
{routeCount > 1 && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{routeCount} routes</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusDot status="ok" label="DNS" />
|
||||
{hasGateway && <StatusDot status="ok" label="Gateway" />}
|
||||
{hasGateway && !isPassthrough && <StatusDot status={tlsStatus} label="TLS" />}
|
||||
<StatusDot status={ddnsStatus2} label="DDNS" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded — controls + status */}
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4 px-4 space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
|
||||
{!hasRoutes(service) && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{isDnsOnly ? 'Resolves to' : 'Backend'}</Label>
|
||||
<Input
|
||||
defaultValue={service.backend.address}
|
||||
className="mt-0.5 h-8 font-mono text-sm w-48"
|
||||
onBlur={e => {
|
||||
const val = e.target.value.trim();
|
||||
if (val && val !== service.backend.address) {
|
||||
updateMutation.mutate({
|
||||
domain: service.domain,
|
||||
updates: { backend: { ...service.backend, address: val } },
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={service.public}
|
||||
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { public: v } })}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Label className="text-sm">{service.public ? 'Public' : 'Private'}</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={service.subdomains}
|
||||
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { subdomains: v } })}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Label className="text-sm">Subdomains</Label>
|
||||
</div>
|
||||
|
||||
{hasGateway && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={!isPassthrough}
|
||||
onCheckedChange={v => {
|
||||
if (hasRoutes(service)) {
|
||||
updateMutation.mutate({
|
||||
domain: service.domain,
|
||||
updates: { tls: v ? 'terminate' : 'passthrough' },
|
||||
});
|
||||
} else {
|
||||
updateMutation.mutate({
|
||||
domain: service.domain,
|
||||
updates: {
|
||||
tls: v ? 'terminate' : 'passthrough',
|
||||
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routes detail */}
|
||||
{hasRoutes(service) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Route className="h-3 w-3" />Routes
|
||||
</Label>
|
||||
{service.routes!.map((route, i) => (
|
||||
<div key={i} className="bg-muted/50 rounded-md p-3 space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs">
|
||||
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||
</span>
|
||||
<Input
|
||||
defaultValue={route.backend.address}
|
||||
className="h-7 font-mono text-xs w-44"
|
||||
onBlur={e => {
|
||||
const val = e.target.value.trim();
|
||||
if (val && val !== route.backend.address) {
|
||||
const updatedRoutes = [...service.routes!];
|
||||
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
|
||||
domainsApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['domains'] }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{route.headers && Object.keys(route.headers.response ?? {}).length > 0 && (
|
||||
<div className="text-xs space-y-0.5">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />Response headers
|
||||
</span>
|
||||
{Object.entries(route.headers.response!).map(([k, v]) => (
|
||||
<div key={k} className="font-mono pl-4 text-muted-foreground">
|
||||
{k}: <span className="text-foreground">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{route.headers && Object.keys(route.headers.request ?? {}).length > 0 && (
|
||||
<div className="text-xs space-y-0.5">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />Request headers
|
||||
</span>
|
||||
{Object.entries(route.headers.request!).map(([k, v]) => (
|
||||
<div key={k} className="font-mono pl-4 text-muted-foreground">
|
||||
{k}: <span className="text-foreground">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />IP allow: <span className="font-mono text-foreground">{route.ipAllow.join(', ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLS cert info */}
|
||||
{hasGateway && !isPassthrough && (() => {
|
||||
const cert = findCert(service.domain, certStatus?.certs ?? []);
|
||||
if (!cert) {
|
||||
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
No TLS certificate
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={provisionMutation.isPending}
|
||||
onClick={() => provisionMutation.mutate(provisionDomain)}>
|
||||
{provisionMutation.isPending
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <Plus className="h-3 w-3" />}
|
||||
Provision {provisionDomain}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isWildcard = cert.domain !== service.domain;
|
||||
const daysLeft = cert.cert.daysLeft ?? 0;
|
||||
const expiry = cert.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : 'unknown';
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
{isWildcard ? `*.${cert.domain}` : cert.domain}
|
||||
</span>
|
||||
<span>Expires {expiry} ({daysLeft}d)</span>
|
||||
{cert.cert.issuerCN && <span className="truncate max-w-48">{cert.cert.issuerCN}</span>}
|
||||
</div>
|
||||
{daysLeft < 30 && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={renewMutation.isPending}
|
||||
onClick={() => renewMutation.mutate()}>
|
||||
{renewMutation.isPending
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <RotateCw className="h-3 w-3" />}
|
||||
Renew
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* DDNS error info */}
|
||||
{ddnsStatus2 === 'error' && ddnsRecord && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}.
|
||||
{' '}Check that your Cloudflare API token has access to this domain's zone.
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1 shrink-0 ml-2"
|
||||
disabled={isDdnsTriggering}
|
||||
onClick={() => triggerDdns()}>
|
||||
{isDdnsTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source + deregister */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Source: {service.source}</span>
|
||||
<Button variant="ghost" size="sm"
|
||||
className="h-7 text-xs text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={e => { e.stopPropagation(); deregisterMutation.mutate(service.domain); }}
|
||||
disabled={deregisterMutation.isPending}>
|
||||
<Trash2 className="h-3 w-3 mr-1" />Deregister
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Advanced */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={handleShowAdvanced}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex items-center justify-between w-full text-left px-4 py-3">
|
||||
<span className="text-sm font-medium">Advanced</span>
|
||||
{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>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => generateHaproxy()} disabled={isHaproxyGenerating} size="sm" className="gap-1">
|
||||
{isHaproxyGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||
Generate & Apply
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => restartHaproxy()} disabled={isHaproxyRestarting} size="sm" className="gap-1">
|
||||
{isHaproxyRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
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>
|
||||
)}
|
||||
<Textarea value={advancedConfig} readOnly className="font-mono text-xs min-h-[200px]" />
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Shield, Loader2, AlertCircle, CheckCircle, Check, X, Plus, Trash2, BookOpen, ChevronDown, ChevronUp, Lock } from 'lucide-react';
|
||||
import { Shield, Loader2, AlertCircle, CheckCircle, Check, X, Plus, Trash2, BookOpen, Lock } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNftables } from '../hooks/useNftables';
|
||||
@@ -46,7 +46,7 @@ export function FirewallComponent() {
|
||||
mutationFn: (data: Parameters<typeof nftablesConfigApi.update>[0]) => nftablesConfigApi.update(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['nftables', 'config'] }),
|
||||
});
|
||||
const { status: nftablesStatus, isLoadingStatus: isNftablesLoading, interfaces, refetchStatus } = useNftables();
|
||||
const { interfaces, refetchStatus } = useNftables();
|
||||
const { config: vpnConfig } = useVpn();
|
||||
|
||||
const isEnabled = nftCfg?.enabled !== false;
|
||||
@@ -58,7 +58,6 @@ export function FirewallComponent() {
|
||||
const [wanValue, setWanValue] = useState('');
|
||||
const [savingWan, setSavingWan] = useState(false);
|
||||
const [togglingFirewall, setTogglingFirewall] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -484,46 +483,6 @@ export function FirewallComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Advanced: raw rules */}
|
||||
<Card className={!isEnabled ? 'opacity-50' : ''}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Active Ruleset</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isNftablesLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Badge variant={nftablesStatus?.rules ? 'success' : isEnabled ? 'warning' : 'secondary'} className="gap-1">
|
||||
{nftablesStatus?.rules && <CheckCircle className="h-3 w-3" />}
|
||||
{nftablesStatus?.rules ? 'Active' : 'Not loaded'}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showAdvanced && (
|
||||
<CardContent>
|
||||
{nftablesStatus?.rules ? (
|
||||
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono">
|
||||
{nftablesStatus.rules}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No rules loaded yet. Rules are generated when you apply an HAProxy configuration.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
145
web/src/components/SubsystemPage.tsx
Normal file
145
web/src/components/SubsystemPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Loader2, CheckCircle, AlertCircle, Play, RotateCw } from 'lucide-react';
|
||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||
|
||||
interface MetadataItem {
|
||||
label: string;
|
||||
value: string | number | undefined;
|
||||
}
|
||||
|
||||
interface SubsystemPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
daemonKey: string;
|
||||
|
||||
configContent?: string;
|
||||
isLoadingConfig?: boolean;
|
||||
|
||||
rawStatusLabel?: string;
|
||||
rawStatusContent?: string;
|
||||
|
||||
onGenerate?: () => void;
|
||||
isGenerating?: boolean;
|
||||
generateLabel?: string;
|
||||
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
restartLabel?: string;
|
||||
|
||||
successMessage?: string | null;
|
||||
errorMessage?: string | null;
|
||||
|
||||
metadata?: MetadataItem[];
|
||||
}
|
||||
|
||||
export function SubsystemPage({
|
||||
title, description, icon: Icon, daemonKey,
|
||||
configContent, isLoadingConfig,
|
||||
rawStatusLabel, rawStatusContent,
|
||||
onGenerate, isGenerating, generateLabel = 'Generate & Apply',
|
||||
onRestart, isRestarting, restartLabel = 'Restart',
|
||||
successMessage, errorMessage,
|
||||
metadata,
|
||||
}: SubsystemPageProps) {
|
||||
const { data: centralStatus } = useCentralStatus();
|
||||
const active = centralStatus?.daemons?.[daemonKey]?.active;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{title}</h2>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{active !== undefined && (
|
||||
<Badge variant={active ? 'success' : 'destructive'} className="gap-1">
|
||||
{active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Actions + Metadata */}
|
||||
{(onGenerate || onRestart || metadata?.length) && (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{(onGenerate || onRestart) && (
|
||||
<div className="flex gap-2">
|
||||
{onGenerate && (
|
||||
<Button onClick={onGenerate} disabled={isGenerating} size="sm" className="gap-1">
|
||||
{isGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||
{generateLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onRestart && (
|
||||
<Button variant="outline" onClick={onRestart} disabled={isRestarting} size="sm" className="gap-1">
|
||||
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
{restartLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{metadata && metadata.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{metadata.map(m => (
|
||||
<div key={m.label}>
|
||||
<span className="text-muted-foreground">{m.label}</span>
|
||||
<div className="font-mono mt-0.5">{m.value ?? '--'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Config */}
|
||||
{isLoadingConfig ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Loader2 className="h-8 w-8 text-primary mx-auto mb-2 animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading configuration...</p>
|
||||
</Card>
|
||||
) : configContent !== undefined ? (
|
||||
<Textarea value={configContent} readOnly className="font-mono text-xs min-h-[300px]" />
|
||||
) : null}
|
||||
|
||||
{/* Raw status */}
|
||||
{rawStatusLabel && rawStatusContent && (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<span className="text-sm font-medium">{rawStatusLabel}</span>
|
||||
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono">
|
||||
{rawStatusContent}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Shield, Loader2, Edit2, Plus, Trash2, Copy, Check, CheckCircle, RefreshCw, KeyRound, Play, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Shield, Loader2, Edit2, Plus, Trash2, Copy, Check, CheckCircle, RefreshCw, KeyRound, Play } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
@@ -65,7 +65,6 @@ export function VpnComponent() {
|
||||
const [peerConfigModal, setPeerConfigModal] = useState<VpnPeerConfig | null>(null);
|
||||
const [loadingPeerConfig, setLoadingPeerConfig] = useState<string | null>(null);
|
||||
const [deleteConfirmPeer, setDeleteConfirmPeer] = useState<VpnPeer | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -233,25 +232,6 @@ export function VpnComponent() {
|
||||
<p className="font-mono">{status?.listenPort ?? config?.listenPort ?? 51820}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 h-7 text-xs"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
{showAdvanced && status?.rawOutput && (
|
||||
<div className="border-t pt-3">
|
||||
<span className="text-sm font-medium">WireGuard interface status</span>
|
||||
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono mt-2">
|
||||
{status.rawOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
22
web/src/components/advanced/CrowdsecSubsystem.tsx
Normal file
22
web/src/components/advanced/CrowdsecSubsystem.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useCrowdSec } from '../../hooks/useCrowdSec';
|
||||
import { SubsystemPage } from '../SubsystemPage';
|
||||
|
||||
export function CrowdsecSubsystem() {
|
||||
const { status, isLoadingStatus } = useCrowdSec();
|
||||
|
||||
return (
|
||||
<SubsystemPage
|
||||
title="CrowdSec"
|
||||
description="Collaborative intrusion detection"
|
||||
icon={ShieldAlert}
|
||||
daemonKey="crowdsec"
|
||||
isLoadingConfig={isLoadingStatus}
|
||||
metadata={[
|
||||
{ label: 'Machines', value: status?.machines?.length },
|
||||
{ label: 'Bouncers', value: status?.bouncers?.length },
|
||||
{ label: 'Firewall Bouncer', value: status?.firewallBouncer ? 'Active' : 'Inactive' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
web/src/components/advanced/DnsmasqSubsystem.tsx
Normal file
33
web/src/components/advanced/DnsmasqSubsystem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Wifi } from 'lucide-react';
|
||||
import { useDnsmasq } from '../../hooks/useDnsmasq';
|
||||
import { SubsystemPage } from '../SubsystemPage';
|
||||
|
||||
export function DnsmasqSubsystem() {
|
||||
const {
|
||||
config, isLoadingConfig, fetchConfig,
|
||||
generateConfig, isGenerating, generateData, generateError,
|
||||
restart, isRestarting, restartError,
|
||||
} = useDnsmasq();
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
|
||||
return (
|
||||
<SubsystemPage
|
||||
title="dnsmasq"
|
||||
description="DNS resolver and DHCP server"
|
||||
icon={Wifi}
|
||||
daemonKey="dnsmasq"
|
||||
configContent={config?.content}
|
||||
isLoadingConfig={isLoadingConfig}
|
||||
onGenerate={() => generateConfig(false)}
|
||||
isGenerating={isGenerating}
|
||||
generateLabel="Regenerate"
|
||||
onRestart={() => restart()}
|
||||
isRestarting={isRestarting}
|
||||
restartLabel="Restart dnsmasq"
|
||||
successMessage={generateData?.message}
|
||||
errorMessage={generateError?.message ?? restartError?.message ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
web/src/components/advanced/HaproxySubsystem.tsx
Normal file
41
web/src/components/advanced/HaproxySubsystem.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Network } from 'lucide-react';
|
||||
import { useHaproxy } from '../../hooks/useHaproxy';
|
||||
import { haproxyApi } from '../../services/api/networking';
|
||||
import { SubsystemPage } from '../SubsystemPage';
|
||||
|
||||
export function HaproxySubsystem() {
|
||||
const {
|
||||
status,
|
||||
generate, isGenerating, generateData, generateError,
|
||||
restart, isRestarting,
|
||||
} = useHaproxy();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['haproxy', 'config'],
|
||||
queryFn: () => haproxyApi.getConfig(),
|
||||
});
|
||||
|
||||
return (
|
||||
<SubsystemPage
|
||||
title="HAProxy"
|
||||
description="Reverse proxy and load balancer"
|
||||
icon={Network}
|
||||
daemonKey="haproxy"
|
||||
configContent={configQuery.data?.content}
|
||||
isLoadingConfig={configQuery.isLoading}
|
||||
onGenerate={() => generate()}
|
||||
isGenerating={isGenerating}
|
||||
generateLabel="Generate & Apply"
|
||||
onRestart={() => restart()}
|
||||
isRestarting={isRestarting}
|
||||
restartLabel="Restart HAProxy"
|
||||
successMessage={generateData?.message}
|
||||
errorMessage={generateError ? (generateError as Error).message : null}
|
||||
metadata={[
|
||||
{ label: 'Config File', value: status?.configFile },
|
||||
{ label: 'PID', value: status?.pid },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
web/src/components/advanced/NftablesSubsystem.tsx
Normal file
25
web/src/components/advanced/NftablesSubsystem.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useNftables } from '../../hooks/useNftables';
|
||||
import { SubsystemPage } from '../SubsystemPage';
|
||||
|
||||
export function NftablesSubsystem() {
|
||||
const { status, isLoadingStatus, apply, isApplying, applyError } = useNftables();
|
||||
|
||||
return (
|
||||
<SubsystemPage
|
||||
title="nftables"
|
||||
description="Kernel packet filtering and firewall"
|
||||
icon={Shield}
|
||||
daemonKey="nftables"
|
||||
configContent={status?.rules ?? (isLoadingStatus ? undefined : '# No rules loaded')}
|
||||
isLoadingConfig={isLoadingStatus}
|
||||
onGenerate={() => apply()}
|
||||
isGenerating={isApplying}
|
||||
generateLabel="Apply Rules"
|
||||
errorMessage={applyError ? (applyError as Error).message : null}
|
||||
metadata={[
|
||||
{ label: 'Rules File', value: status?.rulesFile },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
web/src/components/advanced/WireguardSubsystem.tsx
Normal file
29
web/src/components/advanced/WireguardSubsystem.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useVpn } from '../../hooks/useVpn';
|
||||
import { SubsystemPage } from '../SubsystemPage';
|
||||
|
||||
export function WireguardSubsystem() {
|
||||
const { status, config, isLoadingConfig, apply, isApplying, applyError } = useVpn();
|
||||
|
||||
return (
|
||||
<SubsystemPage
|
||||
title="WireGuard"
|
||||
description="VPN tunnel interface"
|
||||
icon={Lock}
|
||||
daemonKey="wireguard"
|
||||
configContent={config ? JSON.stringify(config, null, 2) : undefined}
|
||||
isLoadingConfig={isLoadingConfig}
|
||||
onRestart={() => apply()}
|
||||
isRestarting={isApplying}
|
||||
restartLabel="Apply Config"
|
||||
errorMessage={applyError ? (applyError as Error).message : null}
|
||||
rawStatusLabel="WireGuard Interface Status"
|
||||
rawStatusContent={status?.rawOutput}
|
||||
metadata={[
|
||||
{ label: 'Interface', value: status?.interface },
|
||||
{ label: 'Listen Port', value: status?.listenPort },
|
||||
{ label: 'Peers', value: status?.peerCount },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
web/src/components/advanced/index.ts
Normal file
5
web/src/components/advanced/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { HaproxySubsystem } from './HaproxySubsystem';
|
||||
export { DnsmasqSubsystem } from './DnsmasqSubsystem';
|
||||
export { NftablesSubsystem } from './NftablesSubsystem';
|
||||
export { WireguardSubsystem } from './WireguardSubsystem';
|
||||
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
||||
379
web/src/components/domains/DomainCard.tsx
Normal file
379
web/src/components/domains/DomainCard.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Trash2, CheckCircle, AlertCircle, Pencil, Loader2, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { DomainTopology } from './DomainTopology';
|
||||
import { domainsApi, type RegisteredDomain } from '@/services/api/networking';
|
||||
import type { CertEntry } from '@/services/api/cert';
|
||||
import type { DdnsRecordStatus } from '@/services/api/networking';
|
||||
|
||||
interface DomainCardProps {
|
||||
domain: RegisteredDomain;
|
||||
cert: CertEntry | undefined;
|
||||
ddnsRecord: DdnsRecordStatus | undefined;
|
||||
vpnPeerCount: number;
|
||||
vpnRunning: boolean;
|
||||
vpnConfigured: boolean;
|
||||
daemons: {
|
||||
haproxy?: boolean;
|
||||
nftables?: boolean;
|
||||
crowdsec?: boolean;
|
||||
wireguard?: boolean;
|
||||
};
|
||||
certDomains: Set<string>;
|
||||
onProvisionCert: (domain: string) => void;
|
||||
onRenewCert: () => void;
|
||||
isProvisioningCert: boolean;
|
||||
isRenewingCert: boolean;
|
||||
}
|
||||
|
||||
type GatewayMode = 'dns-only' | 'l4' | 'l7';
|
||||
|
||||
function getGatewayMode(d: RegisteredDomain): GatewayMode {
|
||||
const bt = d.routes?.length ? d.routes[0].backend.type : d.backend.type;
|
||||
if (bt === 'dns-only') return 'dns-only';
|
||||
if (bt === 'tcp-passthrough') return 'l4';
|
||||
return 'l7';
|
||||
}
|
||||
|
||||
function getModeLabel(mode: GatewayMode): string {
|
||||
switch (mode) {
|
||||
case 'dns-only': return 'DNS only';
|
||||
case 'l4': return 'L4 Passthrough';
|
||||
case 'l7': return 'L7 Proxy';
|
||||
}
|
||||
}
|
||||
|
||||
function getTlsStatus(d: RegisteredDomain, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
||||
const bt = d.routes?.length ? d.routes[0].backend.type : d.backend.type;
|
||||
if (d.tls === 'passthrough' || d.tls === 'none' || bt === 'tcp-passthrough' || bt === 'dns-only') return 'na';
|
||||
if (certDomains.has(d.domain)) return 'ok';
|
||||
const dotIdx = d.domain.indexOf('.');
|
||||
if (dotIdx > 0 && certDomains.has(d.domain.slice(dotIdx + 1))) return 'ok';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function getIssues(d: RegisteredDomain, certDomains: Set<string>, ddnsRecord: DdnsRecordStatus | undefined): string[] {
|
||||
const issues: string[] = [];
|
||||
const tls = getTlsStatus(d, certDomains);
|
||||
if (tls === 'error') issues.push('No TLS cert');
|
||||
if (d.public && ddnsRecord && !ddnsRecord.ok) issues.push('DDNS failed');
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Compact controls for mobile — replaces the topology diagram */
|
||||
function MobileControls({
|
||||
domain, cert, mode,
|
||||
onTogglePublic, onChangeMode, onProvisionCert, isProvisioningCert, isMutating,
|
||||
}: {
|
||||
domain: RegisteredDomain;
|
||||
cert: CertEntry | undefined;
|
||||
mode: GatewayMode;
|
||||
onTogglePublic: () => void;
|
||||
onChangeMode: (m: GatewayMode) => void;
|
||||
onProvisionCert: (d: string) => void;
|
||||
isProvisioningCert: boolean;
|
||||
isMutating: boolean;
|
||||
}) {
|
||||
const isL7 = mode === 'l7';
|
||||
const hasCert = !!cert?.cert.exists;
|
||||
const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
{/* Public toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Public (internet-accessible)</Label>
|
||||
<Switch checked={domain.public} onCheckedChange={onTogglePublic} disabled={isMutating} />
|
||||
</div>
|
||||
|
||||
{/* Mode selector */}
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block">Routing mode</Label>
|
||||
<div className="flex gap-1 bg-muted rounded-md p-1">
|
||||
{([
|
||||
{ value: 'dns-only' as const, label: 'DNS only' },
|
||||
{ value: 'l4' as const, label: 'L4 Passthrough' },
|
||||
{ value: 'l7' as const, label: 'L7 Proxy' },
|
||||
]).map(opt => (
|
||||
<button key={opt.value} type="button"
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
mode === opt.value ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => onChangeMode(opt.value)}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLS status */}
|
||||
{isL7 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{hasCert ? (
|
||||
<>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>TLS: {cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<span>No TLS certificate</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isProvisioningCert}
|
||||
onClick={() => onProvisionCert(provisionDomain)}
|
||||
>
|
||||
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Backend: </span>
|
||||
<span className="font-mono">{domain.routes?.length ? `${domain.routes.length} routes` : domain.backend.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DomainCard({
|
||||
domain,
|
||||
cert,
|
||||
ddnsRecord,
|
||||
vpnPeerCount,
|
||||
vpnRunning,
|
||||
vpnConfigured,
|
||||
daemons,
|
||||
certDomains,
|
||||
onProvisionCert,
|
||||
onRenewCert,
|
||||
isProvisioningCert,
|
||||
isRenewingCert,
|
||||
}: DomainCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [editingBackend, setEditingBackend] = useState(false);
|
||||
const backendInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isManaged = domain.source !== 'manual';
|
||||
const mode = getGatewayMode(domain);
|
||||
const issues = getIssues(domain, certDomains, ddnsRecord);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ updates }: { updates: Record<string, unknown> }) =>
|
||||
domainsApi.register({ ...domain, ...updates } as Parameters<typeof domainsApi.register>[0]),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||
});
|
||||
|
||||
const deregisterMutation = useMutation({
|
||||
mutationFn: () => domainsApi.deregister(domain.domain),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||
});
|
||||
|
||||
const handleTogglePublic = () => {
|
||||
updateMutation.mutate({ updates: { public: !domain.public } });
|
||||
};
|
||||
|
||||
const handleChangeMode = (newMode: GatewayMode) => {
|
||||
if (newMode === mode) return;
|
||||
const backendType = newMode === 'dns-only' ? 'dns-only' : newMode === 'l4' ? 'tcp-passthrough' : 'http';
|
||||
const tls = newMode === 'dns-only' ? 'none' : newMode === 'l4' ? 'passthrough' : 'terminate';
|
||||
updateMutation.mutate({
|
||||
updates: {
|
||||
tls,
|
||||
backend: { ...domain.backend, type: backendType },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBackend = () => {
|
||||
const val = backendInputRef.current?.value.trim();
|
||||
if (val && val !== domain.backend.address) {
|
||||
updateMutation.mutate({
|
||||
updates: { backend: { ...domain.backend, address: val } },
|
||||
});
|
||||
}
|
||||
setEditingBackend(false);
|
||||
};
|
||||
|
||||
const handleToggleSubdomains = () => {
|
||||
updateMutation.mutate({ updates: { subdomains: !domain.subdomains } });
|
||||
};
|
||||
|
||||
const domainDisplay = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* === Collapsed Header === */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left: domain name */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-sm font-medium truncate">{domainDisplay}</span>
|
||||
</div>
|
||||
|
||||
{/* Right: mode + visibility + backend + status */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
||||
{getModeLabel(mode)}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={domain.public ? 'default' : 'secondary'}
|
||||
className="text-[10px] px-1.5 py-0 h-5"
|
||||
>
|
||||
{domain.public ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
|
||||
{!domain.routes?.length && (
|
||||
<span className="text-xs text-muted-foreground font-mono hidden sm:inline">
|
||||
{'-> '}{domain.backend.address}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{issues.length === 0 ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">{issues[0]}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* === Expanded Content === */}
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4 px-4 space-y-3">
|
||||
{/* Managed domain notice */}
|
||||
{isManaged && (
|
||||
<div className="text-[10px] text-muted-foreground bg-muted/30 rounded-md px-3 py-1.5">
|
||||
Registered by <span className="font-medium">{domain.source}</span> — changes may be overwritten on next sync
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topology diagram (desktop) / compact controls (mobile) */}
|
||||
{isMobile ? (
|
||||
<MobileControls
|
||||
domain={domain}
|
||||
cert={cert}
|
||||
mode={mode}
|
||||
onTogglePublic={handleTogglePublic}
|
||||
onChangeMode={handleChangeMode}
|
||||
onProvisionCert={onProvisionCert}
|
||||
isProvisioningCert={isProvisioningCert}
|
||||
isMutating={updateMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<DomainTopology
|
||||
domain={domain}
|
||||
cert={cert}
|
||||
ddnsRecord={ddnsRecord ? { ok: ddnsRecord.ok, ip: ddnsRecord.ip, error: ddnsRecord.error } : undefined}
|
||||
vpnPeerCount={vpnPeerCount}
|
||||
vpnRunning={vpnRunning}
|
||||
vpnConfigured={vpnConfigured}
|
||||
daemons={daemons}
|
||||
isManaged={isManaged}
|
||||
onTogglePublic={handleTogglePublic}
|
||||
onChangeMode={handleChangeMode}
|
||||
onProvisionCert={onProvisionCert}
|
||||
onRenewCert={onRenewCert}
|
||||
isProvisioningCert={isProvisioningCert}
|
||||
isRenewingCert={isRenewingCert}
|
||||
isMutating={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Footer: backend editing + subdomains + source + deregister === */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-4">
|
||||
{!domain.routes?.length && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editingBackend ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
ref={backendInputRef}
|
||||
defaultValue={domain.backend.address}
|
||||
className="h-6 text-xs font-mono w-40"
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleSaveBackend();
|
||||
if (e.key === 'Escape') setEditingBackend(false);
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[10px] px-1.5" onClick={handleSaveBackend}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[10px] px-1.5" onClick={() => setEditingBackend(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group"
|
||||
onClick={() => setEditingBackend(true)}
|
||||
>
|
||||
<span className="font-mono">{domain.backend.address}</span>
|
||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={handleToggleSubdomains}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{domain.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||
</button>
|
||||
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Source: <span className="font-mono">{domain.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-6 text-[10px] text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={e => { e.stopPropagation(); deregisterMutation.mutate(); }}
|
||||
disabled={deregisterMutation.isPending}
|
||||
>
|
||||
{deregisterMutation.isPending
|
||||
? <Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
: <Trash2 className="h-3 w-3 mr-1" />}
|
||||
Deregister
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
536
web/src/components/domains/DomainTopology.tsx
Normal file
536
web/src/components/domains/DomainTopology.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
ReactFlow, Handle, Position,
|
||||
type Node, type Edge, type NodeProps,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
Globe, Wifi, Lock, Shield, ShieldAlert,
|
||||
AlertCircle, Loader2, Plus, RotateCw,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TopologyNode, type NodeStatus } from './TopologyNode';
|
||||
import type { RegisteredDomain } from '@/services/api/networking';
|
||||
import type { CertEntry } from '@/services/api/cert';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
type GatewayMode = 'dns-only' | 'l4' | 'l7';
|
||||
|
||||
interface DaemonStatuses {
|
||||
haproxy?: boolean;
|
||||
nftables?: boolean;
|
||||
crowdsec?: boolean;
|
||||
wireguard?: boolean;
|
||||
}
|
||||
|
||||
interface DdnsRecordInfo {
|
||||
ok: boolean;
|
||||
ip?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DomainTopologyProps {
|
||||
domain: RegisteredDomain;
|
||||
cert: CertEntry | undefined;
|
||||
ddnsRecord: DdnsRecordInfo | undefined;
|
||||
vpnPeerCount: number;
|
||||
vpnRunning: boolean;
|
||||
vpnConfigured: boolean;
|
||||
daemons: DaemonStatuses;
|
||||
isManaged: boolean;
|
||||
onTogglePublic: () => void;
|
||||
onChangeMode: (mode: GatewayMode) => void;
|
||||
onProvisionCert: (domain: string) => void;
|
||||
onRenewCert: () => void;
|
||||
isProvisioningCert: boolean;
|
||||
isRenewingCert: boolean;
|
||||
isMutating: boolean;
|
||||
}
|
||||
|
||||
// ── Custom React Flow node ─────────────────────────────────────────────
|
||||
// Callbacks are passed through data to avoid context issues with React Flow.
|
||||
|
||||
const handleStyle = { opacity: 0, width: 4, height: 4 };
|
||||
|
||||
function FlowNode({ data }: NodeProps) {
|
||||
const v = data.variant as string;
|
||||
|
||||
const leftHandle = <Handle id="left" type="target" position={Position.Left} style={handleStyle} />;
|
||||
const rightHandle = <Handle id="right" type="source" position={Position.Right} style={handleStyle} />;
|
||||
const bottomHandle = <Handle id="bottom" type="target" position={Position.Bottom} style={handleStyle} />;
|
||||
|
||||
if (v === 'internet') {
|
||||
const isPublic = data.isPublic as boolean;
|
||||
const status = data.status as NodeStatus;
|
||||
const sublabel = data.sublabel as string;
|
||||
const ddnsIp = data.ddnsIp as string | undefined;
|
||||
const ddnsError = data.ddnsError as string | undefined;
|
||||
const onTogglePublic = data.onTogglePublic as (() => void) | undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<TopologyNode
|
||||
label="Internet"
|
||||
sublabel={sublabel}
|
||||
status={status}
|
||||
icon={<Globe className="h-3.5 w-3.5" />}
|
||||
interactive
|
||||
onClick={onTogglePublic}
|
||||
tooltip={
|
||||
isPublic ? (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Public domain</div>
|
||||
{ddnsIp && <div>IP: {ddnsIp}</div>}
|
||||
{ddnsError && <div className="text-red-400">{ddnsError}</div>}
|
||||
<div className="text-muted-foreground">Click to make private</div>
|
||||
</div>
|
||||
) : 'Click to make public (enables DDNS)'
|
||||
}
|
||||
/>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (v === 'security') {
|
||||
const fwOk = data.firewallOk as boolean;
|
||||
const csOk = data.crowdsecOk as boolean;
|
||||
const isL4 = data.isL4 as boolean;
|
||||
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link to="/central/firewall" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="Firewall"
|
||||
status={fwOk ? 'ok' : 'na'}
|
||||
icon={<Shield className="h-3 w-3" />}
|
||||
className="group-hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<Link to="/central/crowdsec" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="CrowdSec"
|
||||
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||
status={csOk ? 'ok' : 'na'}
|
||||
icon={<ShieldAlert className="h-3 w-3" />}
|
||||
className="group-hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (v === 'gateway') {
|
||||
const mode = data.mode as GatewayMode;
|
||||
const haproxyOk = data.haproxyOk as boolean;
|
||||
const isMutating = data.isMutating as boolean;
|
||||
const onChangeMode = data.onChangeMode as ((m: GatewayMode) => void) | undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<TopologyNode
|
||||
label={mode === 'dns-only' ? 'No gateway' : mode === 'l4' ? 'L4 Passthrough' : 'L7 Proxy'}
|
||||
sublabel={mode === 'dns-only' ? 'DNS resolution only' : mode === 'l4' ? 'SNI routing' : 'HTTP reverse proxy'}
|
||||
status={mode === 'dns-only' ? 'inactive' : haproxyOk ? 'ok' : 'error'}
|
||||
>
|
||||
{onChangeMode && (
|
||||
<div className="flex gap-0.5 mt-2 bg-muted rounded p-0.5">
|
||||
{(['dns-only', 'l4', 'l7'] as const).map(m => (
|
||||
<button key={m} type="button"
|
||||
className={cn(
|
||||
'nopan nodrag flex-1 text-[10px] py-0.5 px-2 rounded transition-colors !cursor-pointer',
|
||||
mode === m ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
style={{ pointerEvents: 'all' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onChangeMode(m)}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{m === 'dns-only' ? 'DNS' : m.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TopologyNode>
|
||||
{rightHandle}
|
||||
{bottomHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (v === 'tls') {
|
||||
const status = data.status as NodeStatus;
|
||||
const certDomain = data.certDomain as string | undefined;
|
||||
const daysLeft = data.daysLeft as number;
|
||||
const hasCert = data.hasCert as boolean;
|
||||
const provisionDomain = data.provisionDomain as string;
|
||||
const expiry = data.expiry as string | undefined;
|
||||
const issuer = data.issuer as string | undefined;
|
||||
const isProvisioningCert = data.isProvisioningCert as boolean;
|
||||
const isRenewingCert = data.isRenewingCert as boolean;
|
||||
const onProvisionCert = data.onProvisionCert as ((d: string) => void) | undefined;
|
||||
const onRenewCert = data.onRenewCert as (() => void) | undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<TopologyNode
|
||||
label="TLS"
|
||||
sublabel={certDomain ?? 'No certificate'}
|
||||
status={status}
|
||||
tooltip={
|
||||
hasCert ? (
|
||||
<div className="space-y-1">
|
||||
<div>Certificate: {certDomain}</div>
|
||||
<div>Expires: {expiry ?? 'unknown'} ({daysLeft}d)</div>
|
||||
{issuer && <div>Issuer: {issuer}</div>}
|
||||
</div>
|
||||
) : 'No TLS certificate provisioned'
|
||||
}
|
||||
>
|
||||
{!hasCert && onProvisionCert && (
|
||||
<Button variant="outline" size="sm"
|
||||
className="mt-1.5 h-5 text-[10px] gap-0.5 w-full !cursor-pointer"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
disabled={isProvisioningCert}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onProvisionCert(provisionDomain)}
|
||||
>
|
||||
{isProvisioningCert ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Plus className="h-2.5 w-2.5" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
{hasCert && daysLeft < 30 && onRenewCert && (
|
||||
<Button variant="outline" size="sm"
|
||||
className="mt-1.5 h-5 text-[10px] gap-0.5 w-full !cursor-pointer"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
disabled={isRenewingCert}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onRenewCert()}
|
||||
>
|
||||
{isRenewingCert ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCw className="h-2.5 w-2.5" />}
|
||||
Renew
|
||||
</Button>
|
||||
)}
|
||||
</TopologyNode>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (v === 'backend') {
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<TopologyNode
|
||||
label={data.label as string}
|
||||
sublabel={data.sublabel as string | undefined}
|
||||
status="ok"
|
||||
className="font-mono"
|
||||
/>
|
||||
{rightHandle}
|
||||
{bottomHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (v === 'lan') {
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<TopologyNode
|
||||
label="LAN"
|
||||
sublabel="Always connected"
|
||||
status="ok"
|
||||
icon={<Wifi className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (v === 'vpn') {
|
||||
const peerCount = data.peerCount as number;
|
||||
const running = data.running as boolean;
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<Link to="/central/vpn" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="VPN"
|
||||
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
|
||||
status={running ? 'ok' : 'na'}
|
||||
icon={<Lock className="h-3.5 w-3.5" />}
|
||||
className="hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeTypes = { topology: FlowNode };
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function getGatewayMode(domain: RegisteredDomain): GatewayMode {
|
||||
const bt = domain.routes?.length ? domain.routes[0].backend.type : domain.backend.type;
|
||||
if (bt === 'dns-only') return 'dns-only';
|
||||
if (bt === 'tcp-passthrough') return 'l4';
|
||||
return 'l7';
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────
|
||||
|
||||
export function DomainTopology(props: DomainTopologyProps) {
|
||||
const {
|
||||
domain, cert, ddnsRecord, vpnPeerCount, vpnRunning, vpnConfigured,
|
||||
daemons,
|
||||
onTogglePublic, onChangeMode, onProvisionCert, onRenewCert,
|
||||
isProvisioningCert, isRenewingCert, isMutating,
|
||||
} = props;
|
||||
|
||||
const mode = getGatewayMode(domain);
|
||||
const isPublic = domain.public;
|
||||
const isDnsOnly = mode === 'dns-only';
|
||||
const isL4 = mode === 'l4';
|
||||
const isL7 = mode === 'l7';
|
||||
const showSecurity = isPublic && !isDnsOnly;
|
||||
const showGateway = true; // Always show so mode selector is accessible
|
||||
const showTls = isL7;
|
||||
const showVpn = vpnConfigured;
|
||||
|
||||
// DDNS
|
||||
const ddnsStatus: NodeStatus = !isPublic ? 'inactive' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na';
|
||||
const ddnsSublabel = !isPublic ? 'Disconnected' : ddnsRecord?.ok ? (ddnsRecord.ip ?? 'Synced') : ddnsRecord?.error ? 'Sync failed' : 'DDNS';
|
||||
|
||||
// TLS
|
||||
const hasCert = !!cert?.cert.exists;
|
||||
const tlsStatus: NodeStatus = hasCert ? 'ok' : 'error';
|
||||
const certDomain = hasCert ? (cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain) : undefined;
|
||||
const daysLeft = cert?.cert.daysLeft ?? 0;
|
||||
const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||
|
||||
// Backend
|
||||
const backendLabel = domain.routes?.length ? `${domain.routes.length} backends` : domain.backend.address;
|
||||
const backendSublabel = isDnsOnly ? 'Direct access' : isL4 ? 'Handles own TLS' : domain.routes?.length ? 'Path-based routing' : undefined;
|
||||
|
||||
// ── Build nodes & edges ──
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const n: Node[] = [];
|
||||
const e: Edge[] = [];
|
||||
|
||||
// Column x positions
|
||||
let x = 0;
|
||||
const sourceX = x;
|
||||
|
||||
x += 170;
|
||||
const securityX = showSecurity ? x : -1;
|
||||
if (showSecurity) x += 160;
|
||||
|
||||
const gatewayX = showGateway ? x : -1;
|
||||
if (showGateway) x += showTls ? 150 : 200;
|
||||
|
||||
const tlsX = showTls ? x : -1;
|
||||
if (showTls) x += 140;
|
||||
|
||||
const backendX = x;
|
||||
|
||||
// Row y positions
|
||||
const pipelineY = 0;
|
||||
const lanY = 110;
|
||||
const vpnY = 180;
|
||||
|
||||
// ── Nodes (callbacks passed via data) ──
|
||||
|
||||
n.push({
|
||||
id: 'internet', type: 'topology',
|
||||
position: { x: sourceX, y: pipelineY },
|
||||
data: {
|
||||
variant: 'internet', isPublic, status: ddnsStatus, sublabel: ddnsSublabel,
|
||||
ddnsIp: ddnsRecord?.ip, ddnsError: ddnsRecord?.error,
|
||||
onTogglePublic,
|
||||
},
|
||||
});
|
||||
|
||||
if (showSecurity) {
|
||||
n.push({
|
||||
id: 'security', type: 'topology',
|
||||
position: { x: securityX, y: pipelineY - 10 },
|
||||
data: { variant: 'security', firewallOk: !!daemons.nftables, crowdsecOk: !!daemons.crowdsec, isL4 },
|
||||
});
|
||||
}
|
||||
|
||||
if (showGateway) {
|
||||
n.push({
|
||||
id: 'gateway', type: 'topology',
|
||||
position: { x: gatewayX, y: pipelineY },
|
||||
data: { variant: 'gateway', mode, haproxyOk: !!daemons.haproxy, isMutating, onChangeMode },
|
||||
});
|
||||
}
|
||||
|
||||
if (showTls) {
|
||||
n.push({
|
||||
id: 'tls', type: 'topology',
|
||||
position: { x: tlsX, y: pipelineY },
|
||||
data: {
|
||||
variant: 'tls', status: tlsStatus, certDomain, daysLeft, hasCert, provisionDomain,
|
||||
expiry: cert?.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : undefined,
|
||||
issuer: cert?.cert.issuerCN,
|
||||
isProvisioningCert, isRenewingCert, onProvisionCert, onRenewCert,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
n.push({
|
||||
id: 'backend', type: 'topology',
|
||||
position: { x: backendX, y: pipelineY },
|
||||
data: { variant: 'backend', label: backendLabel, sublabel: backendSublabel },
|
||||
});
|
||||
|
||||
n.push({
|
||||
id: 'lan', type: 'topology',
|
||||
position: { x: sourceX, y: lanY },
|
||||
data: { variant: 'lan' },
|
||||
});
|
||||
|
||||
if (showVpn) {
|
||||
n.push({
|
||||
id: 'vpn', type: 'topology',
|
||||
position: { x: sourceX, y: vpnY },
|
||||
data: { variant: 'vpn', peerCount: vpnPeerCount, running: vpnRunning },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Edges ──
|
||||
|
||||
const pipeline = { type: 'smoothstep' as const, sourceHandle: 'right', targetHandle: 'left', style: { strokeWidth: 1.5 } };
|
||||
const labelStyle = { fontSize: 9, fill: 'var(--color-muted-foreground)' };
|
||||
|
||||
// Internet path
|
||||
if (isPublic && showSecurity) {
|
||||
e.push({ id: 'e-internet-security', source: 'internet', target: 'security', ...pipeline });
|
||||
e.push({ id: 'e-security-gateway', source: 'security', target: 'gateway', ...pipeline });
|
||||
} else if (isPublic && !isDnsOnly) {
|
||||
e.push({ id: 'e-internet-gateway', source: 'internet', target: 'gateway', ...pipeline });
|
||||
} else if (isPublic && isDnsOnly) {
|
||||
e.push({ id: 'e-internet-backend', source: 'internet', target: 'backend', ...pipeline, style: { ...pipeline.style, strokeDasharray: '6 3' }, label: 'DNS', labelStyle });
|
||||
}
|
||||
|
||||
// Central processing → backend (not for DNS-only)
|
||||
if (!isDnsOnly) {
|
||||
if (isL7 && showTls) {
|
||||
e.push({ id: 'e-gateway-tls', source: 'gateway', target: 'tls', ...pipeline });
|
||||
e.push({ id: 'e-tls-backend', source: 'tls', target: 'backend', ...pipeline, animated: hasCert });
|
||||
} else {
|
||||
e.push({ id: 'e-gateway-backend', source: 'gateway', target: 'backend', ...pipeline, style: { ...pipeline.style, strokeWidth: isL4 ? 2.5 : 1.5 } });
|
||||
}
|
||||
}
|
||||
|
||||
// LAN / VPN paths — enter from below via bottom handle
|
||||
const lanVpnTarget = isL7 ? 'gateway' : 'backend';
|
||||
const lanVpnEdge = { type: 'smoothstep' as const, sourceHandle: 'right', targetHandle: 'bottom', style: { strokeWidth: 1.5 }, label: 'dnsmasq', labelStyle };
|
||||
|
||||
e.push({ id: 'e-lan-target', source: 'lan', target: lanVpnTarget, ...lanVpnEdge });
|
||||
|
||||
if (showVpn) {
|
||||
e.push({ id: 'e-vpn-target', source: 'vpn', target: lanVpnTarget, ...lanVpnEdge });
|
||||
}
|
||||
|
||||
return { nodes: n, edges: e };
|
||||
}, [
|
||||
isPublic, isDnsOnly, isL4, isL7, showSecurity, showGateway, showTls, showVpn,
|
||||
mode, daemons, ddnsStatus, ddnsSublabel, ddnsRecord,
|
||||
tlsStatus, certDomain, daysLeft, hasCert, provisionDomain, cert,
|
||||
backendLabel, backendSublabel, vpnPeerCount, vpnRunning,
|
||||
isMutating, isProvisioningCert, isRenewingCert,
|
||||
onTogglePublic, onChangeMode, onProvisionCert, onRenewCert,
|
||||
]);
|
||||
|
||||
const containerHeight = showVpn ? 260 : 200;
|
||||
|
||||
const onInit = useCallback((instance: { fitView: () => void }) => {
|
||||
setTimeout(() => instance.fitView(), 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
<div style={{ height: containerHeight }} className="w-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
onInit={onInit}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
preventScrolling={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="!bg-transparent"
|
||||
style={{ '--xy-edge-label-background-color': 'hsl(var(--card))', '--xy-edge-label-color': 'hsl(var(--muted-foreground))' } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{isDnsOnly && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
No firewall, proxy, or TLS protection from Central — traffic goes directly to your server
|
||||
</div>
|
||||
)}
|
||||
{isL7 && !hasCert && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
L7 routing is inactive until a TLS certificate is provisioned
|
||||
</div>
|
||||
)}
|
||||
{isPublic && ddnsRecord && !ddnsRecord.ok && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route table */}
|
||||
{isL7 && domain.routes && domain.routes.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-md p-2 space-y-1">
|
||||
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
|
||||
{domain.routes.map((route, i) => (
|
||||
<div key={i} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="font-mono text-muted-foreground w-20 truncate">
|
||||
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{'->'}</span>
|
||||
<span className="font-mono">{route.backend.address}</span>
|
||||
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||
<span className="text-muted-foreground flex items-center gap-0.5">
|
||||
<Shield className="h-2.5 w-2.5" />
|
||||
{route.ipAllow.length} IP rules
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
web/src/components/domains/TopologyNode.tsx
Normal file
91
web/src/components/domains/TopologyNode.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export type NodeStatus = 'ok' | 'error' | 'na' | 'inactive';
|
||||
|
||||
interface TopologyNodeProps {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
status?: NodeStatus;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
interactive?: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const statusColors: Record<NodeStatus, string> = {
|
||||
ok: 'border-green-500/60 bg-green-50/50 dark:bg-green-950/20',
|
||||
error: 'border-red-500/60 bg-red-50/50 dark:bg-red-950/20',
|
||||
na: 'border-muted-foreground/30 bg-muted/30',
|
||||
inactive: 'border-dashed border-muted-foreground/20 bg-muted/10 opacity-50',
|
||||
};
|
||||
|
||||
const statusDotColors: Record<NodeStatus, string> = {
|
||||
ok: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
na: 'bg-muted-foreground/40',
|
||||
inactive: 'bg-muted-foreground/20',
|
||||
};
|
||||
|
||||
export const TopologyNode = forwardRef<HTMLDivElement, TopologyNodeProps>(
|
||||
function TopologyNode(
|
||||
{ label, sublabel, status = 'ok', icon, onClick, interactive, disabled, tooltip, className, children },
|
||||
ref,
|
||||
) {
|
||||
const isClickable = (interactive || onClick) && !disabled;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={ref}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onClick={isClickable ? onClick : undefined}
|
||||
// Stop pointerdown propagation so React Flow doesn't swallow the click
|
||||
onPointerDown={isClickable ? (e) => e.stopPropagation() : undefined}
|
||||
onKeyDown={isClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.(); } } : undefined}
|
||||
style={{ pointerEvents: 'all', position: 'relative' }}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-xs transition-all duration-200',
|
||||
'nopan nodrag',
|
||||
statusColors[status],
|
||||
isClickable && '!cursor-pointer hover:shadow-sm hover:border-primary/40',
|
||||
disabled && '!pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <span className="shrink-0 text-muted-foreground">{icon}</span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full shrink-0', statusDotColors[status])} />
|
||||
<span className="font-medium truncate">{label}</span>
|
||||
</div>
|
||||
{sublabel && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">{sublabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-64 text-xs">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user