feat: Service cards with inline toggles + updated docs

Service cards now have:
- Public/Private toggle (switches reach)
- Subdomains toggle (include *.domain)
- TLS badge (passthrough vs terminate, read-only for now)
- Inline status details (DNS, Proxy, TLS status)
- Deregister button
- Add Service form with toggles instead of dropdowns

The card speaks the user's language (public/private, subdomains on/off)
not implementation details (tcp-passthrough vs http, reach: internal).

Also updated docs/registrations.md:
- Added "User-facing concepts" section mapping API fields to toggles
- Added batch deregister endpoint
- Added backend.health field
- Cleaner examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:50:05 +00:00
parent c9732ffa7f
commit 98385449b4
2 changed files with 146 additions and 171 deletions

View File

@@ -10,6 +10,7 @@ GET /api/v1/services List all registrations
GET /api/v1/services/{domain} Get registration details GET /api/v1/services/{domain} Get registration details
PATCH /api/v1/services/{domain} Update a registration PATCH /api/v1/services/{domain} Update a registration
DELETE /api/v1/services/{domain} Remove a registration DELETE /api/v1/services/{domain} Remove a registration
DELETE /api/v1/services/deregister?source=X&backend=Y Batch cleanup
``` ```
The domain is the unique key. One registration per domain. The domain is the unique key. One registration per domain.
@@ -22,48 +23,51 @@ The domain is the unique key. One registration per domain.
| `source` | string | no | `"manual"` | Who registered this: `wild-cloud`, `wild-works`, `manual`. | | `source` | string | no | `"manual"` | Who registered this: `wild-cloud`, `wild-works`, `manual`. |
| `backend.address` | string | yes | — | Target host:port (e.g., `192.168.8.240:443`). | | `backend.address` | string | yes | — | Target host:port (e.g., `192.168.8.240:443`). |
| `backend.type` | string | yes | — | `tcp-passthrough` or `http`. | | `backend.type` | string | yes | — | `tcp-passthrough` or `http`. |
| `backend.health` | string | no | — | Health check path for L7 services (e.g., `/health`). |
| `subdomains` | bool | no | `false` | If true, also routes `*.domain` traffic to this backend. | | `subdomains` | bool | no | `false` | If true, also routes `*.domain` traffic to this backend. |
| `reach` | string | yes | — | `internal` or `public`. | | `reach` | string | yes | — | `internal` or `public`. |
| `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on type. | | `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on backend type. |
### `backend.type` ## User-facing concepts
**`tcp-passthrough`** — Layer 4. Central passes TLS traffic through to the backend without terminating it. The backend handles its own TLS (e.g., a k8s cluster with traefik and its own wildcard cert). HAProxy routes by SNI. The API fields map to three user-visible controls:
**`http`** — Layer 7. Central terminates TLS (using a certbot-provisioned certificate) and reverse-proxies HTTP to the backend. HAProxy routes by Host header. ### Public / Private
### `subdomains` `reach: "public"` or `reach: "internal"`
Controls whether `*.domain` traffic is also routed to this backend. - **Private** (internal): Domain resolves only on the LAN. No public DNS record. Accessible only from your local network or VPN.
- **Public**: Domain resolves on the LAN AND has a public DNS A record. Accessible from the internet through Central's HAProxy.
- **`false`** (default): Only exact-match `domain` is routed. Use for custom domains like `payne.io` or `civilsociety.dev` where you only want that specific hostname. ### Subdomains
- **`true`**: Both `domain` and `*.domain` are routed. Use for instance primary domains like `cloud.payne.io` where apps are served at subdomains (`matrix.cloud.payne.io`, `vaultwarden.cloud.payne.io`).
### `reach` `subdomains: true` or `subdomains: false`
Controls DNS visibility and external access. - **Off** (default): Only the exact domain is routed (`payne.io` routes, but `foo.payne.io` does not).
- **On**: Both the domain and all subdomains are routed (`cloud.payne.io` AND `matrix.cloud.payne.io`, `vaultwarden.cloud.payne.io`, etc.).
- **`internal`**: Domain resolves only on the LAN (dnsmasq `local=/` + `address=/`). Not added to DDNS. Not routable from the internet. ### TLS handling
- **`public`**: Domain resolves on the LAN (dnsmasq `address=/`) AND externally via DDNS A record. Routable from the internet through Central's HAProxy.
### `tls` `tls: "passthrough"` or `tls: "terminate"`
- **`passthrough`**: Central does not terminate TLS — the backend handles it. Default for `tcp-passthrough`. - **Passthrough**: Central forwards encrypted traffic directly to the backend. The backend handles its own TLS certificates (e.g., a k8s cluster with traefik). Maps to `backend.type: "tcp-passthrough"`.
- **`terminate`**: Central provisions a TLS certificate (via certbot + Cloudflare DNS-01) and terminates TLS at HAProxy. Default for `http`. - **Terminate**: Central provisions a TLS certificate and handles HTTPS. Traffic is decrypted at Central and proxied as HTTP to the backend. Maps to `backend.type: "http"`.
## What Central does per registration ## What Central does per registration
| | `tcp-passthrough` | `http` | When a service is registered, Central automatically:
|---|---|---|
| **Internal DNS** | `address=/<domain>/<backend-IP>` — LAN clients connect directly to the backend | `address=/<domain>/<central-IP>` — LAN clients connect to Central's HAProxy | | Effect | Passthrough | Terminate |
| **DNS (internal reach)** | Also `local=/<domain>/` — prevents upstream DNS forwarding | Same | |--------|-------------|-----------|
| **External DNS (public reach)** | DDNS A record at Cloudflare | Same | | **LAN DNS** | `address=/<domain>/<backend-IP>` — direct to backend | `address=/<domain>/<central-IP>` — through Central |
| **HAProxy** | L4 SNI → backend. `subdomains: true` adds `*.domain` matching. | L7 Host header → reverse proxy to backend. Always exact match. | | **LAN DNS (private)** | Also `local=/<domain>/` — prevents upstream forwarding | Same |
| **TLS** | Passthrough — backend handles TLS | Terminate — Central provisions cert via certbot | | **Public DNS** | DDNS A record if public | Same |
| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. |
| **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt |
## Examples ## Examples
### Wild Cloud k8s instance (primary domain with subdomains) ### k8s cluster (passthrough, public, with subdomains)
```json ```json
{ {
@@ -75,30 +79,22 @@ Controls DNS visibility and external access.
} }
``` ```
Result: Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI + wildcard *.cloud.payne.io, TLS passthrough.
- DNS: `cloud.payne.io` → 192.168.8.240, `*.cloud.payne.io` → 192.168.8.240
- HAProxy: SNI `cloud.payne.io` and `*.cloud.payne.io` → passthrough to 192.168.8.240:443
- DDNS: A record for `cloud.payne.io` → public IP
- TLS: handled by k8s traefik (passthrough)
### Wild Cloud k8s instance (internal-only domain) ### Internal web app (terminate, private)
```json ```json
{ {
"domain": "internal.cloud.payne.io", "domain": "wild-cloud.payne.io",
"source": "wild-cloud", "source": "wild-cloud",
"backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"}, "backend": {"address": "127.0.0.1:5055", "type": "http"},
"subdomains": true,
"reach": "internal" "reach": "internal"
} }
``` ```
Result: Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS.
- DNS: `local=/internal.cloud.payne.io/` + `address=/internal.cloud.payne.io/192.168.8.240`
- No DDNS record (internal only)
- HAProxy: SNI matching for internal domain (in case traffic comes through Central)
### Custom app domain (exact match, no subdomains) ### Custom domain (passthrough, public, exact match)
```json ```json
{ {
@@ -110,36 +106,14 @@ Result:
} }
``` ```
Result: Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI exact match only. No wildcard.
- DNS: `address=/payne.io/192.168.8.240`
- HAProxy: SNI exact match `payne.io` only (NOT `*.payne.io`)
- DDNS: A record for `payne.io` → public IP
### Wild Cloud app (HTTP reverse proxy, internal only)
```json
{
"domain": "wild-cloud.payne.io",
"source": "wild-cloud",
"backend": {"address": "127.0.0.1:5055", "type": "http"},
"reach": "internal"
}
```
Result:
- DNS: `local=/wild-cloud.payne.io/` + `address=/wild-cloud.payne.io/192.168.8.151`
- HAProxy: L7 Host `wild-cloud.payne.io` → reverse proxy to 127.0.0.1:5055
- TLS: Central provisions cert via certbot
- No DDNS (internal only)
## What is NOT a registration ## What is NOT a registration
Central's own services are driven by Central config, not registrations: Central's own services come from config, not the registration API:
- **Central's UI domain** (`cloud.central.domain` in config) - Central's UI domain (`cloud.central.domain`)
- **VPN** (`cloud.vpn.*` in config) — WireGuard endpoint, firewall port - VPN (`cloud.vpn.*`)
- **Firewall rules** (`cloud.nftables.*` in config) - Firewall rules (`cloud.nftables.*`)
- **DHCP** (`cloud.dnsmasq.dhcp.*` in config) - DHCP (`cloud.dnsmasq.dhcp.*`)
- **CrowdSec**, certbot credentials, DDNS provider config - CrowdSec, certbot credentials, DDNS provider config
These are managed through Central's own UI and config file. They don't go through the registration API.

View File

@@ -6,11 +6,12 @@ import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert'; import { Alert, AlertDescription } from './ui/alert';
import { Textarea } from './ui/textarea'; import { Textarea } from './ui/textarea';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { Switch } from './ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
import { import {
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
Plus, Trash2, X, ChevronDown, ChevronRight, ChevronUp, Plus, Trash2, X, ChevronDown, ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { useConfig } from '../hooks'; import { useConfig } from '../hooks';
import { useHaproxy } from '../hooks/useHaproxy'; import { useHaproxy } from '../hooks/useHaproxy';
@@ -33,24 +34,10 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st
} }
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' { function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
// Passthrough: Central doesn't handle TLS — grey (not applicable)
if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na'; 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'; 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>): 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() { export function ServicesComponent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { config: globalConfig } = useConfig(); const { config: globalConfig } = useConfig();
@@ -69,12 +56,12 @@ export function ServicesComponent() {
const [advancedConfig, setAdvancedConfig] = useState(''); const [advancedConfig, setAdvancedConfig] = useState('');
const [loadingAdvanced, setLoadingAdvanced] = useState(false); const [loadingAdvanced, setLoadingAdvanced] = useState(false);
// Add service form state // Add form state
const [newDomain, setNewDomain] = useState(''); const [newDomain, setNewDomain] = useState('');
const [newBackendAddr, setNewBackendAddr] = useState(''); const [newBackendAddr, setNewBackendAddr] = useState('');
const [newBackendType, setNewBackendType] = useState<string>('tcp-passthrough');
const [newReach, setNewReach] = useState<string>('public'); const [newReach, setNewReach] = useState<string>('public');
const [newSubdomains, setNewSubdomains] = useState(false); const [newSubdomains, setNewSubdomains] = useState(false);
const [newTls, setNewTls] = useState<string>('passthrough');
const certDomains = new Set<string>( const certDomains = new Set<string>(
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain) (certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
@@ -82,15 +69,20 @@ export function ServicesComponent() {
const publicIp = ddnsStatus?.currentIP; const publicIp = ddnsStatus?.currentIP;
const registerMutation = useMutation({ const registerMutation = useMutation({
mutationFn: (svc: { domain: string; backend: { address: string; type: string }; reach: string; subdomains: boolean; source: string }) => mutationFn: (svc: Record<string, unknown>) => servicesApi.register(svc as any),
servicesApi.register(svc),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] }); queryClient.invalidateQueries({ queryKey: ['services'] });
setShowAddForm(false); setShowAddForm(false);
setNewDomain(''); setNewBackendAddr(''); setNewBackendType('tcp-passthrough'); setNewReach('public'); setNewSubdomains(false); setNewDomain(''); setNewBackendAddr(''); setNewReach('public'); setNewSubdomains(false); setNewTls('passthrough');
}, },
}); });
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'] }),
});
const deregisterMutation = useMutation({ const deregisterMutation = useMutation({
mutationFn: (domain: string) => servicesApi.deregister(domain), mutationFn: (domain: string) => servicesApi.deregister(domain),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }),
@@ -100,22 +92,27 @@ export function ServicesComponent() {
title: 'Services', title: 'Services',
description: ( description: (
<p className="leading-relaxed"> <p className="leading-relaxed">
Each service represents a domain that Wild Central manages. When a service is registered, Each service is a domain that Wild Central manages. Central provides DNS resolution,
Central creates DNS entries, proxy routes, and optionally provisions TLS certificates proxy routing, TLS certificates, and public DNS records based on how you configure each service.
and public DNS records.
</p> </p>
), ),
}); });
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 () => { const handleShowAdvanced = async () => {
if (!showAdvanced) { if (!showAdvanced) {
setLoadingAdvanced(true); setLoadingAdvanced(true);
try { try {
const result = await haproxyApi.getConfig(); const result = await haproxyApi.getConfig();
setAdvancedConfig(result.content || ''); setAdvancedConfig(result.content || '');
} catch { } catch { setAdvancedConfig('# Could not load HAProxy config'); }
setAdvancedConfig('# Could not load HAProxy config');
}
setLoadingAdvanced(false); setLoadingAdvanced(false);
} }
setShowAdvanced(!showAdvanced); setShowAdvanced(!showAdvanced);
@@ -123,7 +120,7 @@ export function ServicesComponent() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Page header */} {/* Header */}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
@@ -135,15 +132,14 @@ export function ServicesComponent() {
</div> </div>
</div> </div>
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1"> <Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />Add Service
Add Service
</Button> </Button>
</div> </div>
{/* Add Service Form */} {/* Add Form */}
{showAddForm && ( {showAddForm && (
<Card className="border-dashed border-primary/50"> <Card className="border-dashed border-primary/50">
<CardContent className="p-4 space-y-3"> <CardContent className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs">Domain</Label> <Label className="text-xs">Domain</Label>
@@ -153,44 +149,35 @@ export function ServicesComponent() {
<Label className="text-xs">Backend (host:port)</Label> <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" /> <Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" />
</div> </div>
<div>
<Label className="text-xs">Type</Label>
<Select value={newBackendType} onValueChange={setNewBackendType}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp-passthrough">L4 TCP Passthrough</SelectItem>
<SelectItem value="http">L7 HTTP Reverse Proxy</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Reach</Label>
<Select value={newReach} onValueChange={setNewReach}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="public">Public (LAN + Internet)</SelectItem>
<SelectItem value="internal">Internal (LAN only)</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={newSubdomains} onChange={e => setNewSubdomains(e.target.checked)} className="rounded" /> <Switch checked={newReach === 'public'} onCheckedChange={v => setNewReach(v ? 'public' : 'internal')} />
Include wildcard subdomains (*.domain) Public
</label> </label>
<label className="flex items-center gap-2 text-sm">
<Switch checked={newSubdomains} onCheckedChange={setNewSubdomains} />
Include subdomains
</label>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">TLS:</span>
<Select value={newTls} onValueChange={setNewTls}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="passthrough">Passthrough</SelectItem>
<SelectItem value="terminate">Terminate</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 justify-end"> <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 variant="outline" size="sm" onClick={() => setShowAddForm(false)}><X className="h-3 w-3 mr-1" />Cancel</Button>
<Button <Button size="sm" disabled={!newDomain || !newBackendAddr || registerMutation.isPending}
size="sm"
disabled={!newDomain || !newBackendAddr || registerMutation.isPending}
onClick={() => registerMutation.mutate({ onClick={() => registerMutation.mutate({
domain: newDomain, domain: newDomain,
backend: { address: newBackendAddr, type: newBackendType }, backend: { address: newBackendAddr, type: newTls === 'passthrough' ? 'tcp-passthrough' : 'http' },
reach: newReach, reach: newReach, subdomains: newSubdomains, tls: newTls, source: 'manual',
subdomains: newSubdomains, })}>
source: 'manual',
})}
>
{registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />} {registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
Register Register
</Button> </Button>
@@ -207,15 +194,12 @@ export function ServicesComponent() {
</Card> </Card>
)} )}
{/* Empty state */} {/* Empty */}
{!isServicesLoading && services.length === 0 && ( {!isServicesLoading && services.length === 0 && (
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Services</h3> <h3 className="text-lg font-medium mb-2">No Services</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground">Services appear when Wild Cloud or Wild Works registers domains, or add one manually.</p>
Services appear here when Wild Cloud or Wild Works registers domains,
or add one manually.
</p>
</Card> </Card>
)} )}
@@ -224,32 +208,18 @@ export function ServicesComponent() {
const isExpanded = expandedDomains.has(service.domain); const isExpanded = expandedDomains.has(service.domain);
const tlsStatus = getTlsStatus(service, certDomains); const tlsStatus = getTlsStatus(service, certDomains);
const ddnsStatus2 = service.reach === 'public' ? 'ok' as const : 'na' as const; const ddnsStatus2 = service.reach === 'public' ? 'ok' as const : 'na' as const;
const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough';
const backendHost = service.backend.address.split(':')[0];
return ( return (
<Card key={service.domain} className="overflow-hidden"> <Card key={service.domain}>
<button {/* Header — always visible */}
type="button" <button type="button" onClick={() => toggleExpand(service.domain)} className="w-full px-4 py-3 text-left hover:bg-muted/30 transition-colors">
onClick={() => setExpandedDomains(prev => {
const next = new Set(prev);
if (next.has(service.domain)) next.delete(service.domain);
else next.add(service.domain);
return next;
})}
className="w-full px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{isExpanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
: <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />}
<span className="font-mono text-sm font-medium truncate"> <span className="font-mono text-sm font-medium truncate">
{service.subdomains ? `*.${service.domain}` : service.domain} {service.subdomains ? `*.${service.domain}` : service.domain}
</span> </span>
<Badge variant="outline" className="text-xs h-5 shrink-0">
{service.backend.type === 'tcp-passthrough' ? 'L4' : 'L7'}
</Badge>
<Badge variant={service.reach === 'public' ? 'success' : 'default'} className="text-xs h-5 shrink-0">
{service.reach}
</Badge>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<StatusDot status="ok" label="DNS" /> <StatusDot status="ok" label="DNS" />
@@ -260,15 +230,48 @@ export function ServicesComponent() {
</div> </div>
</button> </button>
{/* Expanded — controls + status */}
{isExpanded && ( {isExpanded && (
<CardContent className="pt-0 pb-4 px-4 space-y-3"> <CardContent className="pt-0 pb-4 px-4 space-y-4">
<div className="border rounded-lg divide-y text-sm ml-6"> {/* Controls */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
<div>
<Label className="text-xs text-muted-foreground">Backend</Label>
<div className="font-mono text-sm mt-0.5">{service.backend.address}</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={service.reach === 'public'}
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { reach: v ? 'public' : 'internal' } })}
disabled={updateMutation.isPending}
/>
<Label className="text-sm">{service.reach === 'public' ? 'Public' : 'Private'}</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={service.subdomains}
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { subdomains: v } })}
disabled={updateMutation.isPending}
/>
<Label className="text-sm">Subdomains</Label>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">TLS</Label>
<Badge variant={isPassthrough ? 'secondary' : 'default'} className="text-xs">
{isPassthrough ? 'Passthrough' : 'Terminate'}
</Badge>
</div>
</div>
{/* Status details */}
<div className="border rounded-lg divide-y text-sm">
<div className="flex items-center justify-between px-3 py-1.5"> <div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">DNS (LAN)</span> <span className="text-muted-foreground">DNS (LAN)</span>
<span className="font-mono text-xs"> <span className="font-mono text-xs">
{service.backend.type === 'tcp-passthrough' {service.domain} {isPassthrough ? backendHost : (globalConfig?.cloud?.dnsmasq?.ip ?? 'Central')}
? `${service.backend.address.split(':')[0]}`
: `${globalConfig?.cloud?.dnsmasq?.ip ?? '(Central IP)'}`}
</span> </span>
</div> </div>
{service.reach === 'public' && ( {service.reach === 'public' && (
@@ -279,28 +282,26 @@ export function ServicesComponent() {
)} )}
<div className="flex items-center justify-between px-3 py-1.5"> <div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">Proxy</span> <span className="text-muted-foreground">Proxy</span>
<span className="font-mono text-xs">{getProxyLabel(service)}</span> <span className="font-mono text-xs">
{isPassthrough ? 'L4 SNI' : 'L7 HTTP'}{service.subdomains ? ' + wildcard' : ''}
</span>
</div> </div>
<div className="flex items-center justify-between px-3 py-1.5"> <div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground">TLS</span> <span className="text-muted-foreground">TLS</span>
<span className="font-mono text-xs">{getTlsLabel(service, certDomains)}</span> <span className="font-mono text-xs">
{isPassthrough ? 'backend handles' : (certDomains.has(service.domain) ? 'cert provisioned' : 'cert missing')}
</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between ml-6 text-xs text-muted-foreground"> {/* Footer */}
<div className="flex gap-4"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Backend: <span className="font-mono text-foreground">{service.backend.address}</span></span> <span>Source: {service.source}</span>
<span>Source: <span className="text-foreground">{service.source}</span></span> <Button variant="ghost" size="sm"
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20" className="h-7 text-xs text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={(e) => { e.stopPropagation(); deregisterMutation.mutate(service.domain); }} onClick={e => { e.stopPropagation(); deregisterMutation.mutate(service.domain); }}
disabled={deregisterMutation.isPending} disabled={deregisterMutation.isPending}>
> <Trash2 className="h-3 w-3 mr-1" />Deregister
{deregisterMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Trash2 className="h-3 w-3 mr-1" />}
Deregister
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -341,7 +342,7 @@ export function ServicesComponent() {
{haproxyGenerateError && ( {haproxyGenerateError && (
<Alert variant="error"><AlertCircle className="h-4 w-4" /><AlertDescription>{(haproxyGenerateError as Error).message}</AlertDescription></Alert> <Alert variant="error"><AlertCircle className="h-4 w-4" /><AlertDescription>{(haproxyGenerateError as Error).message}</AlertDescription></Alert>
)} )}
<Textarea value={advancedConfig} readOnly className="font-mono text-xs min-h-[200px]" placeholder="# haproxy configuration" /> <Textarea value={advancedConfig} readOnly className="font-mono text-xs min-h-[200px]" />
</CardContent> </CardContent>
</CollapsibleContent> </CollapsibleContent>
</Card> </Card>