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 { Switch } from './ui/switch'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, Plus, Trash2, X, ChevronDown, ChevronUp, } from 'lucide-react'; import { useHaproxy } from '../hooks/useHaproxy'; import { useServices } from '../hooks/useServices'; import { useCert } from '../hooks/useCert'; 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' { if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na'; return certDomains.has(service.domain) ? 'ok' : 'error'; } export function ServicesComponent() { const queryClient = useQueryClient(); const { generate: generateHaproxy, isGenerating: isHaproxyGenerating, generateData: haproxyGenerateData, generateError: haproxyGenerateError, restart: restartHaproxy, isRestarting: isHaproxyRestarting, } = useHaproxy(); const { services, isLoading: isServicesLoading } = useServices(); const { status: certStatus } = useCert(); const [expandedDomains, setExpandedDomains] = useState>(new Set()); const [showAddForm, setShowAddForm] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [advancedConfig, setAdvancedConfig] = useState(''); const [loadingAdvanced, setLoadingAdvanced] = useState(false); // Add form state const [newDomain, setNewDomain] = useState(''); const [newBackendAddr, setNewBackendAddr] = useState(''); const [newPublic, setNewPublic] = useState(true); const [newSubdomains, setNewSubdomains] = useState(false); const [newTls, setNewTls] = useState('passthrough'); const certDomains = new Set( (certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain) ); const registerMutation = useMutation({ mutationFn: (svc: Record) => servicesApi.register(svc as any), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['services'] }); setShowAddForm(false); setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setNewTls('passthrough'); }, }); const updateMutation = useMutation({ mutationFn: ({ domain, updates }: { domain: string; updates: Record }) => servicesApi.register({ ...(services.find(s => s.domain === domain) ?? {}), ...updates } as any), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), }); const deregisterMutation = useMutation({ mutationFn: (domain: string) => servicesApi.deregister(domain), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), }); usePageHelp({ title: 'Services', description: (

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.

), }); const toggleExpand = (domain: string) => { setExpandedDomains(prev => { const next = new Set(prev); if (next.has(domain)) next.delete(domain); else next.add(domain); return next; }); }; const handleShowAdvanced = async () => { if (!showAdvanced) { setLoadingAdvanced(true); try { const result = await haproxyApi.getConfig(); setAdvancedConfig(result.content || ''); } catch { setAdvancedConfig('# Could not load HAProxy config'); } setLoadingAdvanced(false); } setShowAdvanced(!showAdvanced); }; return (
{/* Header */}

Services

Registered domains and their networking status

{/* Add 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 */} {!isServicesLoading && services.length === 0 && (

No Services

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

)} {/* Service Cards */} {services.map(service => { const isExpanded = expandedDomains.has(service.domain); const tlsStatus = getTlsStatus(service, certDomains); const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const; const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough'; return ( {/* Header — always visible */} {/* Expanded — controls + status */} {isExpanded && ( {/* Controls */}
{ const val = e.target.value.trim(); if (val && val !== service.backend.address) { updateMutation.mutate({ domain: service.domain, updates: { backend: { ...service.backend, address: val } }, }); } }} />
updateMutation.mutate({ domain: service.domain, updates: { public: v } })} disabled={updateMutation.isPending} />
updateMutation.mutate({ domain: service.domain, updates: { subdomains: v } })} disabled={updateMutation.isPending} />
updateMutation.mutate({ domain: service.domain, updates: { tls: v ? 'terminate' : 'passthrough', backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' }, }, })} disabled={updateMutation.isPending} />
{/* Source + deregister */}
Source: {service.source}
)}
); })} {/* Advanced */}
{haproxyGenerateData && ( {haproxyGenerateData.message} )} {haproxyGenerateError && ( {(haproxyGenerateError as Error).message} )}