services -> domains

This commit is contained in:
2026-07-10 20:46:22 +00:00
parent 3c99d26830
commit 79c0c32b98
45 changed files with 1447 additions and 1270 deletions

View File

@@ -15,10 +15,12 @@ import {
useSidebar,
} from './ui/sidebar';
import { useTheme } from '../contexts/ThemeContext';
import { useCentralStatus } from '../hooks/useCentralStatus';
export function AppSidebar() {
const { theme, setTheme } = useTheme();
const { state } = useSidebar();
const { data: centralStatus } = useCentralStatus();
const cycleTheme = () => {
if (theme === 'light') {
@@ -52,16 +54,16 @@ export function AppSidebar() {
}
};
const servicesItems = [
const domainsItems = [
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/central/services', icon: Globe, label: 'Services' },
{ to: '/central/domains', icon: Globe, label: 'Domains' },
];
const centralItems = [
{ to: '/central/vpn', icon: Lock, label: 'VPN' },
{ to: '/central/firewall', icon: Shield, label: 'Firewall' },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP' },
{ to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
{ to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
];
return (
@@ -80,26 +82,33 @@ export function AppSidebar() {
<SidebarContent>
{state === 'collapsed' ? (
<SidebarMenu>
{[...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={item.label}>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
))}
{[...domainsItems, ...centralItems].map((item) => {
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
return (
<SidebarMenuItem key={item.to}>
<NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}>
{({ isActive }) => (
<SidebarMenuButton isActive={isActive} tooltip={item.label}>
<item.icon className="h-4 w-4" />
<span>{item.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>
) : (
<>
<SidebarGroup>
<SidebarGroupLabel>Services</SidebarGroupLabel>
<SidebarGroupLabel>Domains</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{servicesItems.map(({ to, icon: Icon, label, end }) => (
{domainsItems.map(({ to, icon: Icon, label, end }) => (
<SidebarMenuItem key={to}>
<NavLink to={to} end={end}>
{({ isActive }) => (
@@ -119,18 +128,24 @@ export function AppSidebar() {
<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>
))}
{centralItems.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>

View File

@@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X,
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key,
} from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
@@ -22,7 +22,7 @@ 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 { config: globalConfig, updateConfig } = useConfig();
const { config: globalConfig } = useConfig();
const { config: vpnConfig } = useVpn();
const queryClient = useQueryClient();
@@ -36,8 +36,6 @@ export function DashboardComponent() {
const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState('');
const [addingDdnsRecord, setAddingDdnsRecord] = useState(false);
const [newDdnsRecord, setNewDdnsRecord] = useState('');
const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
@@ -260,76 +258,33 @@ export function DashboardComponent() {
</div>
</div>
{/* Record list with remove buttons */}
{globalConfig?.cloud?.ddns?.records?.length ? (
{/* Record list (read-only — derived from public domains) */}
{ddnsStatus.records?.length ? (
<div className="space-y-1">
{globalConfig.cloud.ddns.records.map((r: string) => {
const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find((s: DdnsRecordStatus) => s.name === r);
return (
<div key={r} className="flex items-center gap-2 text-xs group">
{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>
)}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 ml-auto"
onClick={async () => {
if (!globalConfig) return;
const records = (globalConfig.cloud?.ddns?.records || []).filter((rec: string) => rec !== r);
await updateConfig({
...globalConfig,
cloud: { ...globalConfig.cloud, ddns: { ...globalConfig.cloud?.ddns, records } },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
{ddnsStatus.records.map((rs: DdnsRecordStatus) => (
<div key={rs.name} className="flex items-center gap-2 text-xs">
{rs.ok ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
)}
<span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span>
{rs.ok && rs.ip && (
<span className="text-muted-foreground font-mono">{rs.ip}</span>
)}
{!rs.ok && rs.error && (
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
)}
</div>
))}
<p className="text-xs text-muted-foreground mt-1">
Records are derived from public domains. Toggle Public on the Domains page to manage.
</p>
</div>
) : null}
{/* Add DDNS record */}
{!addingDdnsRecord ? (
<Button variant="ghost" size="sm" onClick={() => setAddingDdnsRecord(true)} className="gap-1 text-muted-foreground">
<Plus className="h-3 w-3" />
Add Record
</Button>
) : (
<div className="flex gap-2 items-center">
<Input
value={newDdnsRecord}
onChange={(e) => setNewDdnsRecord(e.target.value)}
placeholder="dev.payne.io"
className="h-8 text-xs font-mono flex-1"
/>
<Button size="sm" className="h-8" disabled={!newDdnsRecord} onClick={async () => {
if (!globalConfig || !newDdnsRecord) return;
const records = [...(globalConfig.cloud?.ddns?.records || []), newDdnsRecord];
await updateConfig({
...globalConfig,
cloud: { ...globalConfig.cloud, ddns: { ...globalConfig.cloud?.ddns, records } },
});
setNewDdnsRecord('');
setAddingDdnsRecord(false);
}}>Add</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setAddingDdnsRecord(false); setNewDdnsRecord(''); }}>
<X className="h-3 w-3" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
</p>
)}
<Button
@@ -345,7 +300,7 @@ export function DashboardComponent() {
</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.
DDNS is not enabled. Enable it in the global config to keep public domain DNS records in sync with your public IP.
</p>
)}
</Card>
@@ -378,10 +333,11 @@ export function DashboardComponent() {
<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" />
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
</div>
{/* Version and uptime */}
@@ -452,13 +408,12 @@ export function DashboardComponent() {
);
}
/** 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.
/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
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" />
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
{name}
</span>
);

View File

@@ -12,12 +12,13 @@ import {
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
} from 'lucide-react';
import { useHaproxy } from '../hooks/useHaproxy';
import { useServices } from '../hooks/useServices';
import { useDomains } from '../hooks/useDomains';
import { useDdns } from '../hooks/useDdns';
import { useCert } from '../hooks/useCert';
import { usePageHelp } from '../hooks/usePageHelp';
import type { RegisteredService } from '../services/api/networking';
import type { RegisteredDomain } from '../services/api/networking';
import type { CertEntry } from '../services/api/cert';
import { servicesApi, haproxyApi } from '../services/api/networking';
import { domainsApi, haproxyApi } from '../services/api/networking';
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
return (
@@ -30,9 +31,9 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st
);
}
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
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' || backendType === 'tcp-passthrough') return 'na';
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('.');
@@ -56,21 +57,17 @@ function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
return undefined;
}
function hasRoutes(service: RegisteredService): boolean {
function hasRoutes(service: RegisteredDomain): boolean {
return (service.routes?.length ?? 0) > 0;
}
function getBackendAddress(service: RegisteredService): string {
if (hasRoutes(service)) return service.routes![0].backend.address;
return service.backend.address;
}
function getBackendType(service: RegisteredService): string {
function getBackendType(service: RegisteredDomain): string {
if (hasRoutes(service)) return service.routes![0].backend.type;
return service.backend.type;
}
export function ServicesComponent() {
export function DomainsComponent() {
const queryClient = useQueryClient();
const {
@@ -78,8 +75,9 @@ export function ServicesComponent() {
generateData: haproxyGenerateData, generateError: haproxyGenerateError,
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
} = useHaproxy();
const { services, isLoading: isServicesLoading } = useServices();
const { status: certStatus, provision: provisionCert, isProvisioning, renew: renewCert, isRenewing } = useCert();
const { domains, isLoading: isDomainsLoading } = useDomains();
const { status: ddnsStatus, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
const { status: certStatus, provision: provisionCert, renew: renewCert } = useCert();
const provisionMutation = useMutation({
mutationFn: (domain: string) => provisionCert(domain),
@@ -103,38 +101,38 @@ export function ServicesComponent() {
const [newBackendAddr, setNewBackendAddr] = useState('');
const [newPublic, setNewPublic] = useState(true);
const [newSubdomains, setNewSubdomains] = useState(false);
const [newTls, setNewTls] = useState<string>('passthrough');
const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy');
const certDomains = new Set<string>(
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
);
const registerMutation = useMutation({
mutationFn: (svc: Record<string, unknown>) => servicesApi.register(svc as any),
mutationFn: (svc: Record<string, unknown>) => domainsApi.register(svc as any),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
queryClient.invalidateQueries({ queryKey: ['domains'] });
setShowAddForm(false);
setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setNewTls('passthrough');
setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setGatewayMode('reverse-proxy');
},
});
const updateMutation = useMutation({
mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) =>
servicesApi.register({ ...(services.find(s => s.domain === domain) ?? {}), ...updates } as any),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }),
domainsApi.register({ ...(domains.find(d => d.domain === domain) ?? {}), ...updates } as any),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
});
const deregisterMutation = useMutation({
mutationFn: (domain: string) => servicesApi.deregister(domain),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }),
mutationFn: (domain: string) => domainsApi.deregister(domain),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
});
usePageHelp({
title: 'Services',
title: 'Domains',
description: (
<p className="leading-relaxed">
Each service is a domain that Wild Central manages. Central provides DNS resolution,
proxy routing, TLS certificates, and public DNS records based on how you configure each service.
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.
</p>
),
});
@@ -168,12 +166,12 @@ export function ServicesComponent() {
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Services</h2>
<h2 className="text-2xl font-semibold">Domains</h2>
<p className="text-muted-foreground">Registered domains and their networking status</p>
</div>
</div>
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
<Plus className="h-4 w-4" />Add Service
<Plus className="h-4 w-4" />Add Domain
</Button>
</div>
@@ -181,16 +179,32 @@ export function ServicesComponent() {
{showAddForm && (
<Card className="border-dashed border-primary/50">
<CardContent className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Domain</Label>
<Input value={newDomain} onChange={e => setNewDomain(e.target.value)} placeholder="cloud.payne.io" className="mt-1 font-mono" />
</div>
<div>
<Label className="text-xs">Backend (host:port)</Label>
<Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" />
<div>
<Label className="text-xs">Domain</Label>
<Input value={newDomain} onChange={e => setNewDomain(e.target.value)} placeholder="dev.payne.io" className="mt-1 font-mono" />
</div>
<div>
<Label className="text-xs mb-2 block">Gateway</Label>
<div className="flex gap-1 bg-muted rounded-md p-1">
{([
{ value: 'none' as const, label: 'None' },
{ value: 'reverse-proxy' as const, label: 'Reverse proxy' },
{ value: 'passthrough' as const, label: 'Passthrough' },
] as const).map(opt => (
<button key={opt.value} type="button"
className={`flex-1 text-xs py-1.5 px-2 rounded transition-colors ${gatewayMode === opt.value ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setGatewayMode(opt.value)}>
{opt.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-xs">{gatewayMode === 'none' ? 'Resolves to (IP)' : 'Backend (host:port)'}</Label>
<Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)}
placeholder={gatewayMode === 'none' ? '192.168.8.222' : '192.168.8.240:443'}
className="mt-1 font-mono" />
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<Switch checked={newPublic} onCheckedChange={setNewPublic} />
@@ -200,19 +214,19 @@ export function ServicesComponent() {
<Switch checked={newSubdomains} onCheckedChange={setNewSubdomains} />
Include subdomains
</label>
<label className="flex items-center gap-2 text-sm">
<Switch checked={newTls === 'terminate'} onCheckedChange={v => setNewTls(v ? 'terminate' : 'passthrough')} />
Central handles TLS
</label>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setShowAddForm(false)}><X className="h-3 w-3 mr-1" />Cancel</Button>
<Button size="sm" disabled={!newDomain || !newBackendAddr || registerMutation.isPending}
onClick={() => registerMutation.mutate({
domain: newDomain,
backend: { address: newBackendAddr, type: newTls === 'passthrough' ? 'tcp-passthrough' : 'http' },
public: newPublic, subdomains: newSubdomains, tls: newTls, source: 'manual',
})}>
onClick={() => {
const backendType = gatewayMode === 'none' ? 'dns-only' : gatewayMode === 'passthrough' ? 'tcp-passthrough' : 'http';
const tls = gatewayMode === 'none' ? 'none' : gatewayMode === 'passthrough' ? 'passthrough' : 'terminate';
registerMutation.mutate({
domain: newDomain,
backend: { address: newBackendAddr, type: backendType },
public: newPublic, subdomains: newSubdomains, tls, source: 'manual',
});
}}>
{registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
Register
</Button>
@@ -222,28 +236,32 @@ export function ServicesComponent() {
)}
{/* Loading */}
{isServicesLoading && (
{isDomainsLoading && (
<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 services...</p>
<p className="text-sm text-muted-foreground">Loading domains...</p>
</Card>
)}
{/* Empty */}
{!isServicesLoading && services.length === 0 && (
{!isDomainsLoading && domains.length === 0 && (
<Card className="p-8 text-center">
<Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Services</h3>
<p className="text-muted-foreground">Services appear when Wild Cloud or Wild Works registers domains, or add one manually.</p>
<h3 className="text-lg font-medium mb-2">No Domains</h3>
<p className="text-muted-foreground">Domains appear when Wild Cloud or Wild Works registers them, or add one manually.</p>
</Card>
)}
{/* Service Cards */}
{services.map(service => {
{/* 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 ddnsStatus2 = service.public ? 'ok' as const : 'na' as const;
const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough';
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;
return (
@@ -261,8 +279,8 @@ export function ServicesComponent() {
</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" />
{hasGateway && <StatusDot status="ok" label="Gateway" />}
{hasGateway && !isPassthrough && <StatusDot status={tlsStatus} label="TLS" />}
<StatusDot status={ddnsStatus2} label="DDNS" />
</div>
</div>
@@ -275,7 +293,7 @@ export function ServicesComponent() {
<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">Backend</Label>
<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"
@@ -310,29 +328,31 @@ export function ServicesComponent() {
<Label className="text-sm">Subdomains</Label>
</div>
<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>
{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 */}
@@ -355,8 +375,8 @@ export function ServicesComponent() {
if (val && val !== route.backend.address) {
const updatedRoutes = [...service.routes!];
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
servicesApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
.then(() => queryClient.invalidateQueries({ queryKey: ['services'] }));
domainsApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
.then(() => queryClient.invalidateQueries({ queryKey: ['domains'] }));
}
}}
/>
@@ -401,7 +421,7 @@ export function ServicesComponent() {
)}
{/* TLS cert info */}
{!isPassthrough && (() => {
{hasGateway && !isPassthrough && (() => {
const cert = findCert(service.domain, certStatus?.certs ?? []);
if (!cert) {
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
@@ -449,6 +469,25 @@ export function ServicesComponent() {
);
})()}
{/* 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>

View File

@@ -1,6 +1,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../services/api/client';
interface DaemonStatus {
active: boolean;
}
interface CentralStatus {
status: string;
version: string;
@@ -10,6 +14,7 @@ interface CentralStatus {
appsDir: string;
setupFiles: string;
pendingRestart: boolean;
daemons?: Record<string, DaemonStatus>;
}
/**

View File

@@ -1,62 +1,61 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
import type { GlobalConfig, GlobalConfigResponse } from '../types';
import type { CentralState, CentralStateResponse } from '../types';
interface CreateConfigResponse {
status: string;
}
/**
* Hook for managing Wild Central global configuration
* Endpoint: /api/v1/config
* Hook for managing Wild Central runtime state
* Endpoint: /api/v1/state
* File: {dataDir}/state.yaml
*/
export const useConfig = () => {
const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false);
const configQuery = useQuery<GlobalConfigResponse>({
queryKey: ['globalConfig'],
const stateQuery = useQuery<CentralStateResponse>({
queryKey: ['state'],
queryFn: () => apiService.getConfig(),
});
// Update showConfigSetup based on query data
useEffect(() => {
if (configQuery.data) {
setShowConfigSetup(configQuery.data.configured === false);
if (stateQuery.data) {
setShowConfigSetup(stateQuery.data.configured === false);
}
}, [configQuery.data]);
}, [stateQuery.data]);
const createConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({
mutationFn: (config) => apiService.createConfig(config),
const createStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
mutationFn: (state) => apiService.createConfig(state),
onSuccess: () => {
// Invalidate and refetch config after successful creation
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
queryClient.invalidateQueries({ queryKey: ['state'] });
setShowConfigSetup(false);
},
});
const updateConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({
mutationFn: (config) => apiService.updateConfig(config),
const updateStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
mutationFn: (state) => apiService.updateConfig(state),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
queryClient.invalidateQueries({ queryKey: ['state'] });
// Immediately re-check daemon status so the pending-restart banner appears
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
},
});
return {
config: configQuery.data?.config || null,
isConfigured: configQuery.data?.configured || false,
config: stateQuery.data?.state || null,
isConfigured: stateQuery.data?.configured || false,
showConfigSetup,
setShowConfigSetup,
isLoading: configQuery.isLoading,
isCreating: createConfigMutation.isPending,
isUpdating: updateConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error || updateConfigMutation.error,
createConfig: createConfigMutation.mutate,
updateConfig: updateConfigMutation.mutateAsync,
refetch: configQuery.refetch,
isLoading: stateQuery.isLoading,
isCreating: createStateMutation.isPending,
isUpdating: updateStateMutation.isPending,
error: stateQuery.error || createStateMutation.error || updateStateMutation.error,
createConfig: createStateMutation.mutate,
updateConfig: updateStateMutation.mutateAsync,
refetch: stateQuery.refetch,
};
};
};

View File

@@ -14,10 +14,12 @@ export const useDdns = () => {
const triggerMutation = useMutation({
mutationFn: () => ddnsApi.trigger(),
onSuccess: () => {
// Refresh status shortly after trigger
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] });
}, 2000);
// Refresh status after trigger — stagger to catch async completion
for (const delay of [1000, 3000, 6000]) {
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] });
}, delay);
}
},
});

View File

@@ -1,16 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { servicesApi } from '../services/api/networking';
import { domainsApi } from '../services/api/networking';
export const useServices = () => {
export const useDomains = () => {
const query = useQuery({
queryKey: ['services'],
queryFn: () => servicesApi.list(),
queryKey: ['domains'],
queryFn: () => domainsApi.list(),
refetchInterval: 30000,
staleTime: 10000,
});
return {
services: query.data?.services ?? [],
domains: query.data?.domains ?? [],
isLoading: query.isLoading,
error: query.error,
};

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router';
import { CentralLayout } from './CentralLayout';
import { NotFoundPage } from './pages/NotFoundPage';
import { DashboardPage } from './pages/DashboardPage';
import { ServicesPage } from './pages/ServicesPage';
import { DomainsPage } from './pages/DomainsPage';
import { FirewallPage } from './pages/FirewallPage';
import { VpnPage } from './pages/VpnPage';
import { CrowdSecPage } from './pages/CrowdSecPage';
@@ -19,7 +19,7 @@ export const routes: RouteObject[] = [
element: <CentralLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'services', element: <ServicesPage /> },
{ path: 'domains', element: <DomainsPage /> },
{ path: 'firewall', element: <FirewallPage /> },
{ path: 'vpn', element: <VpnPage /> },
{ path: 'crowdsec', element: <CrowdSecPage /> },

View File

@@ -1,7 +1,7 @@
import type {
Status,
GlobalConfig,
GlobalConfigResponse,
CentralState,
CentralStateResponse,
HealthResponse,
StatusResponse,
NetworkInfo,
@@ -51,35 +51,35 @@ class ApiService {
// ========================================
// Global Config APIs (Wild Central level)
// Endpoint: /api/v1/config
// Endpoint: /api/v1/state
// ========================================
async getConfig(): Promise<GlobalConfigResponse> {
return this.request<GlobalConfigResponse>('/api/v1/config');
async getConfig(): Promise<CentralStateResponse> {
return this.request<CentralStateResponse>('/api/v1/state');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml');
return this.requestText('/api/v1/state/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', {
return this.request<StatusResponse>('/api/v1/state/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: GlobalConfig): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
async createConfig(config: CentralState): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: GlobalConfig): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
async updateConfig(config: CentralState): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)

View File

@@ -1,8 +1,8 @@
export { apiClient, ApiError } from './client';
export * from './types';
export { dnsmasqApi } from './dnsmasq';
export { servicesApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking';
export type { RegisteredService, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision, CrowdSecProvisionResult } from './networking';
export { domainsApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking';
export type { RegisteredDomain, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision } from './networking';
export { vpnApi } from './vpn';
export type { VpnStatus, VpnConfig, VpnPeer, VpnPeerConfig } from './vpn';
export { certApi } from './cert';

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client';
// Services
// Domains
export interface HeaderConfig {
request?: Record<string, string>;
@@ -18,7 +18,7 @@ export interface Route {
ipAllow?: string[];
}
export interface RegisteredService {
export interface RegisteredDomain {
domain: string;
source: string;
subdomains: boolean;
@@ -32,15 +32,15 @@ export interface RegisteredService {
routes?: Route[];
}
export const servicesApi = {
async list(): Promise<{ services: RegisteredService[] }> {
return apiClient.get('/api/v1/services');
export const domainsApi = {
async list(): Promise<{ domains: RegisteredDomain[] }> {
return apiClient.get('/api/v1/domains');
},
async register(svc: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string }): Promise<{ message: string; service: RegisteredService }> {
return apiClient.post('/api/v1/services', svc);
async register(dom: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string; tls?: string }): Promise<{ message: string; domain: RegisteredDomain }> {
return apiClient.post('/api/v1/domains', dom);
},
async deregister(domain: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/services/${domain}`);
return apiClient.delete(`/api/v1/domains/${domain}`);
},
};

View File

@@ -7,7 +7,7 @@ export interface Status {
// ========================================
// Global Config Types (Wild Central level)
// Endpoint: /api/v1/config
// Endpoint: /api/v1/state
// File: {dataDir}/state.yaml
// ========================================
@@ -17,7 +17,7 @@ export interface HAProxyCustomRoute {
backend: string;
}
export interface GlobalConfig {
export interface CentralState {
operator?: {
email?: string;
};
@@ -48,15 +48,14 @@ export interface GlobalConfig {
ddns?: {
enabled?: boolean;
provider?: string;
records?: string[];
intervalMinutes?: number;
};
};
}
export interface GlobalConfigResponse {
export interface CentralStateResponse {
configured: boolean;
config?: GlobalConfig;
state?: CentralState;
message?: string;
}