services -> domains
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user