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>