import { useState, useRef, useEffect, useMemo } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save, RotateCw, Pencil } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useIsMobile } from '@/hooks/use-mobile'; import { DomainTopology } from './DomainTopology'; import { domainsApi, type RegisteredDomain } from '@/services/api/networking'; import type { CertEntry } from '@/services/api/cert'; import type { DdnsRecordStatus } from '@/services/api/networking'; interface DomainCardProps { domain: RegisteredDomain; cert: CertEntry | undefined; ddnsRecord: DdnsRecordStatus | undefined; vpnPeerCount: number; vpnRunning: boolean; vpnConfigured: boolean; daemons: { haproxy?: boolean; nftables?: boolean; crowdsec?: boolean; wireguard?: boolean; }; certDomains: Set; onProvisionCert: (domain: string) => void; onRenewCert: () => void; isProvisioningCert: boolean; isRenewingCert: boolean; } type GatewayMode = 'dns-only' | 'l4' | 'l7'; /** Editable fields tracked as local draft state */ interface DomainDraft { public: boolean; mode: GatewayMode; subdomains: boolean; backendAddress: string; } function getGatewayMode(d: RegisteredDomain): GatewayMode { const bt = d.routes?.length ? d.routes[0].backend.type : d.backend.type; if (bt === 'dns-only') return 'dns-only'; if (bt === 'tcp-passthrough') return 'l4'; return 'l7'; } function draftFromDomain(d: RegisteredDomain): DomainDraft { return { public: d.public, mode: getGatewayMode(d), subdomains: d.subdomains, backendAddress: d.backend.address, }; } function getModeLabel(mode: GatewayMode): string { switch (mode) { case 'dns-only': return 'Direct'; case 'l4': return 'L4 Passthrough'; case 'l7': return 'L7 Proxy'; } } function getTlsStatus(d: RegisteredDomain, certDomains: Set): 'ok' | 'error' | 'na' { const bt = d.routes?.length ? d.routes[0].backend.type : d.backend.type; if (d.tls === 'passthrough' || d.tls === 'none' || bt === 'tcp-passthrough' || bt === 'dns-only') return 'na'; if (certDomains.has(d.domain)) return 'ok'; const dotIdx = d.domain.indexOf('.'); if (dotIdx > 0 && certDomains.has(d.domain.slice(dotIdx + 1))) return 'ok'; return 'error'; } function getIssues(d: RegisteredDomain, certDomains: Set, ddnsRecord: DdnsRecordStatus | undefined): string[] { const issues: string[] = []; const tls = getTlsStatus(d, certDomains); if (tls === 'error') issues.push('No TLS cert'); if (d.public && ddnsRecord && !ddnsRecord.ok) issues.push('DDNS failed'); return issues; } /** Build a virtual RegisteredDomain from the draft for the topology diagram */ function applyDraftToDomain(domain: RegisteredDomain, draft: DomainDraft): RegisteredDomain { const backendType = draft.mode === 'dns-only' ? 'dns-only' : draft.mode === 'l4' ? 'tcp-passthrough' : 'http'; const tls = draft.mode === 'dns-only' ? 'none' : draft.mode === 'l4' ? 'passthrough' : 'terminate'; return { ...domain, public: draft.public, subdomains: draft.subdomains, backend: { ...domain.backend, address: draft.backendAddress, type: backendType }, tls, }; } /** Compute human-readable list of changes */ function describeChanges(server: DomainDraft, draft: DomainDraft): string[] { const changes: string[] = []; if (draft.public !== server.public) changes.push(draft.public ? 'Make public' : 'Make private'); if (draft.mode !== server.mode) changes.push(`Mode: ${getModeLabel(server.mode)} → ${getModeLabel(draft.mode)}`); if (draft.subdomains !== server.subdomains) changes.push(draft.subdomains ? 'Enable subdomains' : 'Disable subdomains'); if (draft.backendAddress !== server.backendAddress) changes.push(`Backend: ${server.backendAddress} → ${draft.backendAddress}`); return changes; } /** Compact controls for mobile — vertical pipeline: Source → Routing → Destination */ function MobileControls({ draft, onUpdateDraft, domain, cert, ddnsRecord, onProvisionCert, onRenewCert, isProvisioningCert, isRenewingCert, }: { draft: DomainDraft; onUpdateDraft: (patch: Partial) => void; domain: RegisteredDomain; cert: CertEntry | undefined; ddnsRecord: DdnsRecordStatus | undefined; onProvisionCert: (d: string) => void; onRenewCert: () => void; isProvisioningCert: boolean; isRenewingCert: boolean; }) { const isL7 = draft.mode === 'l7'; const isDnsOnly = draft.mode === 'dns-only'; const hasCert = !!cert?.cert.exists; const daysLeft = cert?.cert.daysLeft ?? 0; const hasRoutes = !!domain.routes?.length; const [editingBackend, setEditingBackend] = useState(false); const backendInputRef = useRef(null); const handleBackendSave = () => { const val = backendInputRef.current?.value.trim(); if (val) onUpdateDraft({ backendAddress: val }); setEditingBackend(false); }; return (
{/* === Section 1: Source === */}
Source
onUpdateDraft({ public: v })} />
{draft.public && ddnsRecord && !ddnsRecord.ok && (
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
)}
onUpdateDraft({ subdomains: v })} />
{/* === Section 2: Routing === */}
Routing
{([ { value: 'dns-only' as const, label: 'Direct' }, { value: 'l4' as const, label: 'L4 Passthrough' }, { value: 'l7' as const, label: 'L7 Proxy' }, ]).map(opt => ( ))}
{isDnsOnly && (
No firewall, proxy, or TLS protection — traffic goes directly to your server
)} {isL7 && ( <>
{hasCert ? ( <> TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain} {cert!.cert.daysLeft != null && ({cert!.cert.daysLeft}d)} ) : ( <> No TLS certificate )}
{!hasCert && ( )} {hasCert && daysLeft < 30 && ( )}
{!hasCert && (
L7 routing is inactive until a TLS certificate is provisioned
)} )}
{/* === Section 3: Destination === */}
Destination
{editingBackend ? (
{ if (e.key === 'Enter') handleBackendSave(); if (e.key === 'Escape') setEditingBackend(false); }} />
) : (
{hasRoutes ? `${domain.routes!.length} backends` : draft.backendAddress} {!hasRoutes && ( )}
)} {isL7 && domain.routes && domain.routes.length > 0 && (
Route table
{domain.routes.map((route, i) => (
{route.paths?.length ? route.paths.join(', ') : '/*'} {'->'} {route.backend.address} {route.ipAllow && route.ipAllow.length > 0 && ( {route.ipAllow.length} IP rules )}
))}
)}
); } export function DomainCard({ domain, cert, ddnsRecord, vpnPeerCount, vpnRunning, vpnConfigured, daemons, certDomains, onProvisionCert, onRenewCert, isProvisioningCert, isRenewingCert, }: DomainCardProps) { const queryClient = useQueryClient(); const isMobile = useIsMobile(); const [isExpanded, setIsExpanded] = useState(() => { try { const stored = localStorage.getItem('domains:expanded'); return stored ? JSON.parse(stored).includes(domain.domain) : false; } catch { return false; } }); useEffect(() => { try { const stored = localStorage.getItem('domains:expanded'); const set: string[] = stored ? JSON.parse(stored) : []; const next = isExpanded ? [...new Set([...set, domain.domain])] : set.filter(d => d !== domain.domain); localStorage.setItem('domains:expanded', JSON.stringify(next)); } catch { /* ignore */ } }, [isExpanded, domain.domain]); const isManaged = domain.source !== 'manual'; const serverState = useMemo(() => draftFromDomain(domain), [domain]); const prevServerState = useRef(serverState); const [draft, setDraft] = useState(() => { try { const stored = localStorage.getItem('domains:drafts'); if (stored) { const drafts = JSON.parse(stored); if (drafts[domain.domain]) return drafts[domain.domain]; } } catch { /* ignore */ } return serverState; }); // Reset draft only when server state actually changes (apply, external update) useEffect(() => { const prev = prevServerState.current; if (prev.public !== serverState.public || prev.mode !== serverState.mode || prev.subdomains !== serverState.subdomains || prev.backendAddress !== serverState.backendAddress) { setDraft(serverState); prevServerState.current = serverState; } }, [serverState]); const isDirty = draft.public !== serverState.public || draft.mode !== serverState.mode || draft.subdomains !== serverState.subdomains || draft.backendAddress !== serverState.backendAddress; // Persist dirty drafts, clear when clean useEffect(() => { try { const stored = localStorage.getItem('domains:drafts'); const drafts = stored ? JSON.parse(stored) : {}; if (isDirty) { drafts[domain.domain] = draft; } else { delete drafts[domain.domain]; } if (Object.keys(drafts).length > 0) { localStorage.setItem('domains:drafts', JSON.stringify(drafts)); } else { localStorage.removeItem('domains:drafts'); } } catch { /* ignore */ } }, [draft, isDirty, domain.domain]); const changes = isDirty ? describeChanges(serverState, draft) : []; // The "virtual" domain reflects the draft state for the topology diagram const previewDomain = isDirty ? applyDraftToDomain(domain, draft) : domain; const previewMode = draft.mode; const issues = getIssues(previewDomain, certDomains, ddnsRecord); const updateDraft = (patch: Partial) => { setDraft(prev => ({ ...prev, ...patch })); }; // Apply sends the accumulated draft changes to the server const applyMutation = useMutation({ mutationFn: () => { const backendType = draft.mode === 'dns-only' ? 'dns-only' : draft.mode === 'l4' ? 'tcp-passthrough' : 'http'; const tls = draft.mode === 'dns-only' ? 'none' : draft.mode === 'l4' ? 'passthrough' : 'terminate'; return domainsApi.register({ ...domain, public: draft.public, subdomains: draft.subdomains, backend: { ...domain.backend, address: draft.backendAddress, type: backendType }, tls, } as Parameters[0]); }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }), }); const deregisterMutation = useMutation({ mutationFn: () => domainsApi.deregister(domain.domain), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }), }); const handleDiscard = () => setDraft(serverState); const domainDisplay = draft.subdomains ? `*.${domain.domain}` : domain.domain; return ( {/* === Collapsed Header === */} {/* === Expanded Content === */} {isExpanded && ( {/* Managed domain notice */} {isManaged && (
Registered by {domain.source} — changes may be overwritten on next sync
)} {/* Topology diagram (desktop) / compact controls (mobile) */} {isMobile ? ( onProvisionCert(draft.subdomains ? `*.${domain.domain}` : d || domain.domain)} onRenewCert={onRenewCert} isProvisioningCert={isProvisioningCert} isRenewingCert={isRenewingCert} /> ) : ( updateDraft({ public: !draft.public })} onChangeMode={m => updateDraft({ mode: m })} onChangeBackend={addr => updateDraft({ backendAddress: addr })} onProvisionCert={onProvisionCert} onRenewCert={onRenewCert} isProvisioningCert={isProvisioningCert} isRenewingCert={isRenewingCert} isMutating={applyMutation.isPending} /> )} {/* === Apply bar (visible when dirty) === */} {isDirty && (
Pending changes
{changes.join(' · ')}
)} {/* === Footer: subdomains (desktop only) + source + deregister === */}
{!isMobile && ( )} Source: {domain.source}
)}
); }