diff --git a/web/src/components/ServicesComponent.tsx b/web/src/components/ServicesComponent.tsx index 7ee4514..b28a932 100644 --- a/web/src/components/ServicesComponent.tsx +++ b/web/src/components/ServicesComponent.tsx @@ -1,34 +1,25 @@ import { useState } from 'react'; -import { Card, CardHeader, CardTitle, CardContent } from './ui/card'; +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, - Check, - ChevronDown, - ChevronRight, - ChevronUp, + 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 type { HAProxyCustomRoute } from '../types'; +import { usePageHelp } from '../hooks/usePageHelp'; import type { RegisteredService } from '../services/api/networking'; -import { haproxyApi } from '../services/api/networking'; +import { servicesApi, haproxyApi } from '../services/api/networking'; function StatusDot({ status, label }: { status: 'ok' | 'warning' | 'na'; label: string }) { return ( @@ -41,189 +32,78 @@ function StatusDot({ status, label }: { status: 'ok' | 'warning' | 'na'; label: ); } -function getTypeBadge(backendType: string) { - if (backendType === 'tcp-passthrough') return 'L4'; - return 'L7'; -} - function getTlsStatus(service: RegisteredService, certDomains: Set): 'ok' | 'warning' | 'na' { if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'ok'; - if (service.tls === 'terminate') { - return certDomains.has(service.domain) ? 'ok' : 'warning'; - } - if (certDomains.has(service.domain)) return 'ok'; - return 'na'; -} - -function getTlsLabel(service: RegisteredService, certDomains: Set): string { - if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') { - return 'passthrough (backend handles)'; - } - if (service.tls === 'terminate') { - return certDomains.has(service.domain) ? 'terminate (cert provisioned)' : 'terminate (cert missing)'; - } - return 'none'; -} - -function getDdnsStatus(service: RegisteredService, ddnsDomains: Set): 'ok' | 'na' { - if (service.reach === 'public') { - return ddnsDomains.has(service.domain) ? 'ok' : 'ok'; // if public, DDNS record should exist - } - return 'na'; + return certDomains.has(service.domain) ? 'ok' : 'warning'; } function getProxyLabel(service: RegisteredService): string { if (service.backend.type === 'tcp-passthrough') { - return `HAProxy L4 SNI passthrough${service.subdomains ? ' + wildcard' : ''}`; + return `L4 SNI passthrough${service.subdomains ? ' + wildcard' : ''}`; } - return `HAProxy L7 HTTP reverse proxy${service.subdomains ? ' + wildcard' : ''}`; + return `L7 HTTP reverse proxy`; } -interface ServiceRowProps { - service: RegisteredService; - isExpanded: boolean; - onToggle: () => void; - certDomains: Set; - ddnsDomains: Set; - dnsmasqIp?: string; - publicIp?: string; -} - -function ServiceRow({ service, isExpanded, onToggle, certDomains, ddnsDomains, dnsmasqIp, publicIp }: ServiceRowProps) { - const tlsStatus = getTlsStatus(service, certDomains); - const ddnsStatus = getDdnsStatus(service, ddnsDomains); - const typeBadge = getTypeBadge(service.backend.type); - - return ( -
- - - {isExpanded && ( -
-
-
- DNS (LAN) -
- - address=/{service.domain}/{dnsmasqIp ?? '—'} - - -
-
- {service.reach === 'public' && ( -
- DNS (Public) -
- - A record → {publicIp ?? '—'} - - -
-
- )} -
- Proxy -
- {getProxyLabel(service)} - -
-
-
- TLS -
- {getTlsLabel(service, certDomains)} - {tlsStatus === 'ok' && } - {tlsStatus === 'warning' && } - {tlsStatus === 'na' && } -
-
-
- -
- Backend: {service.backend.address} - Source: {service.source} -
-
- )} -
- ); +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 { config: globalConfig, updateConfig, isUpdating } = useConfig(); + const queryClient = useQueryClient(); + const { config: globalConfig } = useConfig(); const { - status: haproxyStatus, - isLoadingStatus: isHaproxyLoading, - generate: generateHaproxy, - isGenerating: isHaproxyGenerating, - generateData: haproxyGenerateData, - generateError: haproxyGenerateError, - restart: restartHaproxy, - isRestarting: isHaproxyRestarting, + 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 [showAddCustomRoute, setShowAddCustomRoute] = useState(false); - const [newCustomRoute, setNewCustomRoute] = useState({ name: '', port: 0, backend: '' }); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [advancedConfig, setAdvancedConfig] = useState(''); const [loadingAdvanced, setLoadingAdvanced] = useState(false); - // Build lookup sets for cross-referencing + // 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 ddnsDomains = new Set( - (ddnsStatus?.records ?? []).map(r => r.name) - ); - - const dnsmasqIp = globalConfig?.cloud?.dnsmasq?.ip; const publicIp = ddnsStatus?.currentIP; - const showSuccess = (msg: string) => { - setSuccessMessage(msg); - setErrorMessage(null); - setTimeout(() => setSuccessMessage(null), 5000); - }; + 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 showError = (msg: string) => { - setErrorMessage(msg); - setSuccessMessage(null); - setTimeout(() => setErrorMessage(null), 8000); - }; + 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) { @@ -239,258 +119,222 @@ export function ServicesComponent() { setShowAdvanced(!showAdvanced); }; - const handleAddCustomRoute = async () => { - if (!globalConfig || !newCustomRoute.name || !newCustomRoute.port || !newCustomRoute.backend) return; - try { - const existing = globalConfig.cloud?.haproxy?.customRoutes ?? []; - await updateConfig({ - ...globalConfig, - cloud: { - ...globalConfig.cloud, - haproxy: { - ...globalConfig.cloud?.haproxy, - customRoutes: [...existing, newCustomRoute], - }, - }, - }); - setNewCustomRoute({ name: '', port: 0, backend: '' }); - setShowAddCustomRoute(false); - generateHaproxy(); - showSuccess(`Custom route "${newCustomRoute.name}" added`); - } catch (e) { - showError(`Failed to add route: ${e instanceof Error ? e.message : String(e)}`); - } - }; - - const handleDeleteCustomRoute = async (name: string) => { - if (!globalConfig) return; - try { - const updated = (globalConfig.cloud?.haproxy?.customRoutes ?? []).filter(r => r.name !== name); - await updateConfig({ - ...globalConfig, - cloud: { - ...globalConfig.cloud, - haproxy: { - ...globalConfig.cloud?.haproxy, - customRoutes: updated, - }, - }, - }); - generateHaproxy(); - showSuccess(`Custom route "${name}" removed`); - } catch (e) { - showError(`Failed to delete route: ${e instanceof Error ? e.message : String(e)}`); - } - }; - return ( -
+
{/* Page header */} -
-
- -
-
-

Services

-

Registered domains and their networking status

+
+
+
+ +
+
+

Services

+

Registered domains and their networking status

+
+
- {/* Alerts */} - {successMessage && ( - - - {successMessage} - - )} - {errorMessage && ( - - - {errorMessage} - + {/* 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" /> +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
)} - {/* Service List */} - - -
- Registered Services - {isHaproxyLoading ? ( - - ) : ( - - {haproxyStatus?.status === 'active' && } - {haproxyStatus?.status === 'active' ? 'Active' : haproxyStatus?.status ?? 'Stopped'} - - )} -
-
- - {isServicesLoading ? ( -
- Loading services... -
- ) : services.length > 0 ? ( -
- {services.map(s => ( - setExpandedDomain(expandedDomain === s.domain ? null : s.domain)} - certDomains={certDomains} - ddnsDomains={ddnsDomains} - dnsmasqIp={dnsmasqIp} - publicIp={publicIp} - /> - ))} -
- ) : ( -

- No services registered. Services appear here when Wild Cloud or Wild Works registers them. -

- )} -
-
+ {/* Loading */} + {isServicesLoading && ( + + +

Loading services...

+
+ )} - {/* Custom TCP Routes */} - - -
- Custom TCP Routes - -
-
- - {showAddCustomRoute && ( - -
-
- - setNewCustomRoute(r => ({ ...r, name: e.target.value }))} - placeholder="e.g. civil_ssh" - className="mt-1 text-xs font-mono" - /> + {/* 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 ( + + - -
- - )} - {(globalConfig?.cloud?.haproxy?.customRoutes ?? []).length === 0 ? ( -

No custom routes configured.

- ) : ( -
- {(globalConfig?.cloud?.haproxy?.customRoutes ?? []).map(r => ( -
-
- {r.name} - :{r.port} → {r.backend} + + + {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} - + {haproxyGenerateData.message} )} {haproxyGenerateError && ( - - - {(haproxyGenerateError as Error).message} - + {(haproxyGenerateError as Error).message} )} - -
- Generated config file -