import { useState } from 'react'; 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 { Badge } from './ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, Plus, Trash2, X, ChevronDown, ChevronRight, ChevronUp, } from 'lucide-react'; import { useConfig } from '../hooks'; import { useHaproxy } from '../hooks/useHaproxy'; import { useServices } from '../hooks/useServices'; import { useCert } from '../hooks/useCert'; import { useDdns } from '../hooks/useDdns'; import { usePageHelp } from '../hooks/usePageHelp'; import type { RegisteredService } from '../services/api/networking'; import { servicesApi, haproxyApi } from '../services/api/networking'; function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) { return ( {status === 'ok' && } {status === 'error' && } {status === 'na' && } {label} ); } function getTlsStatus(service: RegisteredService, certDomains: Set): 'ok' | 'error' | 'na' { // Passthrough: Central doesn't handle TLS — grey (not applicable) if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na'; // Terminate: Central handles TLS — green if cert exists, red if missing return certDomains.has(service.domain) ? 'ok' : 'error'; } function getProxyLabel(service: RegisteredService): string { if (service.backend.type === 'tcp-passthrough') { return `L4 SNI passthrough${service.subdomains ? ' + wildcard' : ''}`; } return `L7 HTTP reverse proxy`; } function getTlsLabel(service: RegisteredService, certDomains: Set): string { if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'passthrough (backend handles)'; return certDomains.has(service.domain) ? 'terminate (cert provisioned)' : 'terminate (cert missing)'; } export function ServicesComponent() { const queryClient = useQueryClient(); const { config: globalConfig } = useConfig(); const { generate: generateHaproxy, isGenerating: isHaproxyGenerating, generateData: haproxyGenerateData, generateError: haproxyGenerateError, restart: restartHaproxy, isRestarting: isHaproxyRestarting, } = useHaproxy(); const { services, isLoading: isServicesLoading } = useServices(); const { status: certStatus } = useCert(); const { status: ddnsStatus } = useDdns(); const [expandedDomain, setExpandedDomain] = useState(null); const [showAddForm, setShowAddForm] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [advancedConfig, setAdvancedConfig] = useState(''); const [loadingAdvanced, setLoadingAdvanced] = useState(false); // Add service form state const [newDomain, setNewDomain] = useState(''); const [newBackendAddr, setNewBackendAddr] = useState(''); const [newBackendType, setNewBackendType] = useState('tcp-passthrough'); const [newReach, setNewReach] = useState('public'); const [newSubdomains, setNewSubdomains] = useState(false); const certDomains = new Set( (certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain) ); const publicIp = ddnsStatus?.currentIP; const registerMutation = useMutation({ mutationFn: (svc: { domain: string; backend: { address: string; type: string }; reach: string; subdomains: boolean; source: string }) => servicesApi.register(svc), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['services'] }); setShowAddForm(false); setNewDomain(''); setNewBackendAddr(''); setNewBackendType('tcp-passthrough'); setNewReach('public'); setNewSubdomains(false); }, }); const deregisterMutation = useMutation({ mutationFn: (domain: string) => servicesApi.deregister(domain), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), }); usePageHelp({ title: 'Services', description: (

Each service represents a domain that Wild Central manages. When a service is registered, Central creates DNS entries, proxy routes, and optionally provisions TLS certificates and public DNS records.

), }); 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 (
{/* Page header */}

Services

Registered domains and their networking status

{/* Add Service Form */} {showAddForm && (
setNewDomain(e.target.value)} placeholder="cloud.payne.io" className="mt-1 font-mono" />
setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" />
)} {/* Loading */} {isServicesLoading && (

Loading services...

)} {/* Empty state */} {!isServicesLoading && services.length === 0 && (

No Services

Services appear here when Wild Cloud or Wild Works registers domains, or add one manually.

)} {/* Service Cards */} {services.map(service => { const isExpanded = expandedDomain === service.domain; const tlsStatus = getTlsStatus(service, certDomains); const ddnsStatus2 = service.reach === 'public' ? 'ok' as const : 'na' as const; return ( {isExpanded && (
DNS (LAN) {service.backend.type === 'tcp-passthrough' ? `→ ${service.backend.address.split(':')[0]}` : `→ ${globalConfig?.cloud?.dnsmasq?.ip ?? '(Central IP)'}`}
{service.reach === 'public' && (
DNS (Public) A record → {publicIp ?? '—'}
)}
Proxy {getProxyLabel(service)}
TLS {getTlsLabel(service, certDomains)}
Backend: {service.backend.address} Source: {service.source}
)}
); })} {/* Advanced */}
{haproxyGenerateData && ( {haproxyGenerateData.message} )} {haproxyGenerateError && ( {(haproxyGenerateError as Error).message} )}