import { useState, useMemo } from 'react' import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown, CheckSquare, Square, Trash2 } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { useProxyHosts } from '../hooks/useProxyHosts' import { getMonitors, type UptimeMonitor } from '../api/uptime' import { useCertificates } from '../hooks/useCertificates' import { useAccessLists } from '../hooks/useAccessLists' import { getSettings } from '../api/settings' import { createBackup } from '../api/backups' import { deleteCertificate } from '../api/certificates' import type { ProxyHost } from '../api/proxyHosts' import compareHosts from '../utils/compareHosts' import type { AccessList } from '../api/accessLists' import ProxyHostForm from '../components/ProxyHostForm' import { Switch } from '../components/ui/Switch' import { toast } from 'react-hot-toast' import { formatSettingLabel, settingHelpText, applyBulkSettingsToHosts } from '../utils/proxyHostsHelpers' import { ConfigReloadOverlay } from '../components/LoadingStates' import CertificateCleanupDialog from '../components/dialogs/CertificateCleanupDialog' // Helper functions extracted for unit testing and reuse // Helpers moved to ../utils/proxyHostsHelpers to keep component files component-only for fast refresh type SortColumn = 'name' | 'domain' | 'forward' type SortDirection = 'asc' | 'desc' export default function ProxyHosts() { const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost, bulkUpdateACL, isBulkUpdating, isCreating, isUpdating, isDeleting } = useProxyHosts() const { certificates } = useCertificates() const { data: accessLists } = useAccessLists() const [showForm, setShowForm] = useState(false) const [editingHost, setEditingHost] = useState() const [sortColumn, setSortColumn] = useState('name') const [sortDirection, setSortDirection] = useState('asc') const [selectedHosts, setSelectedHosts] = useState>(new Set()) const [showBulkACLModal, setShowBulkACLModal] = useState(false) const [showBulkApplyModal, setShowBulkApplyModal] = useState(false) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [isCreatingBackup, setIsCreatingBackup] = useState(false) const [showCertCleanupDialog, setShowCertCleanupDialog] = useState(false) const [certCleanupData, setCertCleanupData] = useState<{ hostUUIDs: string[] hostNames: string[] certificates: Array<{ id: number; name: string; domain: string }> isBulk: boolean } | null>(null) const [selectedACLs, setSelectedACLs] = useState>(new Set()) const [bulkACLAction, setBulkACLAction] = useState<'apply' | 'remove'>('apply') const [applyProgress, setApplyProgress] = useState<{ current: number; total: number } | null>(null) const [bulkApplySettings, setBulkApplySettings] = useState>({ ssl_forced: { apply: false, value: true }, http2_support: { apply: false, value: true }, hsts_enabled: { apply: false, value: true }, hsts_subdomains: { apply: false, value: true }, block_exploits: { apply: false, value: true }, websocket_support: { apply: false, value: true }, }) const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings, }) const linkBehavior = settings?.['ui.domain_link_behavior'] || 'new_tab' // Determine if any mutation is in progress const isApplyingConfig = isCreating || isUpdating || isDeleting || isBulkUpdating // Determine contextual message based on operation const getMessage = () => { if (isCreating) return { message: 'Ferrying new host...', submessage: 'Charon is crossing the Styx' } if (isUpdating) return { message: 'Guiding changes across...', submessage: 'Configuration in transit' } if (isDeleting) return { message: 'Returning to shore...', submessage: 'Host departure in progress' } if (isBulkUpdating) return { message: `Ferrying ${selectedHosts.size} souls...`, submessage: 'Bulk operation crossing the river' } return { message: 'Ferrying configuration...', submessage: 'Charon is crossing the Styx' } } const { message, submessage } = getMessage() // Create a map of domain -> certificate status for quick lookup // Handles both single domains and comma-separated multi-domain certs const certStatusByDomain = useMemo(() => { const map: Record = {} certificates.forEach(cert => { // Handle comma-separated domains (SANs) const domains = cert.domain.split(',').map(d => d.trim().toLowerCase()) domains.forEach(domain => { // Only set if not already set (first cert wins) if (!map[domain]) { map[domain] = { status: cert.status, provider: cert.provider } } }) }) return map }, [certificates]) // Sort hosts based on current sort column and direction const sortedHosts = useMemo(() => [...hosts].sort((a, b) => compareHosts(a, b, sortColumn, sortDirection)), [hosts, sortColumn, sortDirection]) const handleSort = (column: SortColumn) => { if (sortColumn === column) { setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc') } else { setSortColumn(column) setSortDirection('asc') } } const SortIcon = ({ column }: { column: SortColumn }) => { if (sortColumn !== column) return null return sortDirection === 'asc' ? : } const handleDomainClick = (e: React.MouseEvent, url: string) => { if (linkBehavior === 'new_window') { e.preventDefault() window.open(url, '_blank', 'noopener,noreferrer,width=1024,height=768') } } // local usage now relies on the exported settingHelpText helper // local usage now relies on exported settingKeyToField helper const handleAdd = () => { setEditingHost(undefined) setShowForm(true) } const handleEdit = (host: ProxyHost) => { setEditingHost(host) setShowForm(true) } const handleSubmit = async (data: Partial) => { if (editingHost) { await updateHost(editingHost.uuid, data) } else { await createHost(data) } setShowForm(false) setEditingHost(undefined) } const handleDelete = async (uuid: string) => { const host = hosts.find(h => h.uuid === uuid) if (!host) return // Check for orphaned certificates that would need cleanup const orphanedCerts: Array<{ id: number; name: string; domain: string }> = [] if (host.certificate_id && host.certificate) { const cert = host.certificate // Check if this is the ONLY proxy host using this certificate const otherHostsUsingCert = hosts.filter(h => h.uuid !== uuid && h.certificate_id === host.certificate_id ).length if (otherHostsUsingCert === 0) { // This is the only host using the certificate // Only consider custom/staging certs (not production Let's Encrypt) const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging') if (isCustomOrStaging) { orphanedCerts.push({ id: cert.id!, name: cert.name || '', domain: cert.domains }) } } } // If there are orphaned certificates, show cleanup dialog if (orphanedCerts.length > 0) { setCertCleanupData({ hostUUIDs: [uuid], hostNames: [host.name || host.domain_names], certificates: orphanedCerts, isBulk: false }) setShowCertCleanupDialog(true) return } // No orphaned certificates, proceed with standard deletion if (!confirm('Are you sure you want to delete this proxy host?')) return try { // See if there are uptime monitors associated with this host (match by upstream_host / forward_host) let associatedMonitors: UptimeMonitor[] = [] try { const monitors = await getMonitors() associatedMonitors = monitors.filter(m => m.upstream_host === host.forward_host || (m.proxy_host_id && m.proxy_host_id === (host as unknown as { id?: number }).id)) } catch { // ignore errors fetching uptime data; continue with host deletion } if (associatedMonitors.length > 0) { const deleteUptime = confirm('This proxy host has uptime monitors associated with it. Delete the monitors as well?') await deleteHost(uuid, deleteUptime) } else { await deleteHost(uuid) } } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete') } } const handleCertCleanupConfirm = async (deleteCerts: boolean) => { if (!certCleanupData) return setShowCertCleanupDialog(false) try { // Delete hosts first if (certCleanupData.isBulk) { // Bulk deletion let deleted = 0 let failed = 0 for (const uuid of certCleanupData.hostUUIDs) { try { await deleteHost(uuid) deleted++ } catch { failed++ } } // Delete certificates if user confirmed if (deleteCerts && certCleanupData.certificates.length > 0) { let certsDeleted = 0 let certsFailed = 0 for (const cert of certCleanupData.certificates) { try { await deleteCertificate(cert.id) certsDeleted++ } catch { certsFailed++ } } if (certsFailed > 0) { toast.error(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s), ${certsFailed} certificate(s) failed`) } else { toast.success(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s)`) } } else { if (failed > 0) { toast.error(`Deleted ${deleted} host(s), ${failed} failed`) } else { toast.success(`Successfully deleted ${deleted} host(s)`) } } } else { // Single deletion const uuid = certCleanupData.hostUUIDs[0] const host = hosts.find(h => h.uuid === uuid) // Check for uptime monitors let associatedMonitors: UptimeMonitor[] = [] try { const monitors = await getMonitors() associatedMonitors = monitors.filter(m => host && (m.upstream_host === host.forward_host || (m.proxy_host_id && m.proxy_host_id === (host as unknown as { id?: number }).id)) ) } catch { // ignore errors } if (associatedMonitors.length > 0) { const deleteUptime = confirm('This proxy host has uptime monitors associated with it. Delete the monitors as well?') await deleteHost(uuid, deleteUptime) } else { await deleteHost(uuid) } // Delete certificate if user confirmed if (deleteCerts && certCleanupData.certificates.length > 0) { try { await deleteCertificate(certCleanupData.certificates[0].id) toast.success('Proxy host and certificate deleted') } catch (err) { toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`) } } } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to delete') } finally { setCertCleanupData(null) setSelectedHosts(new Set()) setShowBulkDeleteModal(false) } } const toggleHostSelection = (uuid: string) => { setSelectedHosts(prev => { const next = new Set(prev) if (next.has(uuid)) { next.delete(uuid) } else { next.add(uuid) } return next }) } const toggleSelectAll = () => { if (selectedHosts.size === hosts.length) { setSelectedHosts(new Set()) } else { setSelectedHosts(new Set(hosts.map(h => h.uuid))) } } const handleBulkApplyACL = async (accessListID: number | null) => { const hostUUIDs = Array.from(selectedHosts) try { const result = await bulkUpdateACL(hostUUIDs, accessListID) if (result.errors.length > 0) { toast.error(`Updated ${result.updated} host(s), ${result.errors.length} failed`) } else { const action = accessListID ? 'applied to' : 'removed from' toast.success(`Access list ${action} ${result.updated} host(s)`) } setSelectedHosts(new Set()) setShowBulkACLModal(false) } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to update hosts') } } const handleBulkDelete = async () => { const hostUUIDs = Array.from(selectedHosts) setIsCreatingBackup(true) try { // Create automatic backup before deletion toast.loading('Creating backup before deletion...') const backup = await createBackup() toast.dismiss() toast.success(`Backup created: ${backup.filename}`) // Collect certificates to potentially delete const certsToConsider: Map = new Map() hostUUIDs.forEach(uuid => { const host = hosts.find(h => h.uuid === uuid) if (host?.certificate_id && host.certificate) { const cert = host.certificate // Only consider custom/staging certs const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging') if (isCustomOrStaging) { // Check if this cert is ONLY used by hosts being deleted const otherHosts = hosts.filter(h => h.certificate_id === host.certificate_id && !hostUUIDs.includes(h.uuid) ) if (otherHosts.length === 0 && cert.id) { certsToConsider.set(cert.id, { id: cert.id, name: cert.name || '', domain: cert.domains }) } } } }) // If there are orphaned certificates, show cleanup dialog if (certsToConsider.size > 0) { const hostNames = hostUUIDs.map(uuid => { const host = hosts.find(h => h.uuid === uuid) return host?.name || host?.domain_names || 'Unnamed' }) setCertCleanupData({ hostUUIDs, hostNames, certificates: Array.from(certsToConsider.values()), isBulk: true }) setShowCertCleanupDialog(true) setIsCreatingBackup(false) return } // No orphaned certificates, proceed with deletion let deleted = 0 let failed = 0 for (const uuid of hostUUIDs) { try { await deleteHost(uuid) deleted++ } catch { failed++ } } if (failed > 0) { toast.error(`Deleted ${deleted} host(s), ${failed} failed`) } else { toast.success(`Successfully deleted ${deleted} host(s). Backup available for restore.`) } setSelectedHosts(new Set()) setShowBulkDeleteModal(false) } catch (err) { toast.dismiss() toast.error(err instanceof Error ? err.message : 'Failed to create backup') } finally { setIsCreatingBackup(false) } } return ( <> {isApplyingConfig && ( )}

Proxy Hosts

{isFetching && !loading && }
{selectedHosts.size > 0 && (
{selectedHosts.size} {selectedHosts.size === hosts.length && '(all)'} selected
)}
{error && (
{error}
)} {/* Bulk Apply Modal */} {showBulkApplyModal && (
setShowBulkApplyModal(false)} >
e.stopPropagation()} >

Bulk Apply Settings

Applying settings to {selectedHosts.size} selected host(s)

{Object.entries(bulkApplySettings).map(([key, cfg]) => (
setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], apply: e.target.checked } }))} className="w-4 h-4 rounded border-gray-600 text-blue-500 bg-gray-700" />
{formatSettingLabel(key)}
{settingHelpText(key)}
Set: setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], value: v } }))} />
))}
{applyProgress && (
Applying settings... ({applyProgress.current}/{applyProgress.total})
)}
)}
{loading ? (
Loading...
) : hosts.length === 0 ? (
No proxy hosts configured yet. Click "Add Proxy Host" to get started.
) : (
{sortedHosts.map((host) => ( ))}
handleSort('name')} style={{ width: '20%' }} className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors" >
Name
handleSort('domain')} style={{ width: '26%' }} className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors" >
Domain
handleSort('forward')} style={{ width: '18%' }} className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors" >
Forward To
SSL Status Actions
{host.name || Unnamed}
{host.domain_names.split(',').map((domain, i) => { const d = domain.trim() const url = `${host.ssl_forced ? 'https' : 'http'}://${d}` return ( ) })}
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
{(() => { // Get the primary domain to look up cert status (case-insensitive) const primaryDomain = host.domain_names.split(',')[0]?.trim().toLowerCase() const certInfo = certStatusByDomain[primaryDomain] const isUntrusted = certInfo?.status === 'untrusted' const isStaging = certInfo?.provider?.includes('staging') return (
{/* Row 1: Proxy Badges */}
{host.ssl_forced && ( isUntrusted || isStaging ? ( SSL (Staging) ) : ( SSL ) )} {host.websocket_support && ( WS )}
{/* Row 2: Security Badges */} {host.access_list_id && (
ACL
)} {/* Certificate info below badges */} {host.certificate && host.certificate.provider === 'custom' && (
{host.certificate.name} (Custom)
)} {host.ssl_forced && !host.certificate && (isUntrusted || isStaging) && (
⚠️ Staging cert - browsers won't trust
)}
) })()}
updateHost(host.uuid, { enabled: checked })} />
)}
{showForm && ( { setShowForm(false) setEditingHost(undefined) }} /> )} {/* Bulk ACL Modal */} {showBulkACLModal && (
setShowBulkACLModal(false)} >
e.stopPropagation()} >

Apply Access List

Applying to {selectedHosts.size} selected host(s)

Note: Each proxy host can have a single Access Control List applied. Selecting multiple lists will apply them sequentially and the last applied list will be the effective one for each host.

{/* Action Toggle */}
{/* ACL Selection List */} {bulkACLAction === 'apply' && (
{/* Select All / Clear header */} {(accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0) > 0 && (
{selectedACLs.size} of {accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0} selected
|
)}
{accessLists?.filter((acl: AccessList) => acl.enabled).length === 0 ? (

No enabled access lists available

) : ( accessLists ?.filter((acl: AccessList) => acl.enabled) .map((acl: AccessList) => ( )) )}
)} {/* Remove ACL Confirmation */} {bulkACLAction === 'remove' && (
🚫

This will remove the access list from all {selectedHosts.size} selected host(s).

The hosts will become publicly accessible.

)} {/* Progress indicator */} {applyProgress && (
Applying ACLs... ({applyProgress.current}/{applyProgress.total})
)} {/* Action Buttons */}
)} {/* Bulk Delete Modal */} {showBulkDeleteModal && (
setShowBulkDeleteModal(false)} >
e.stopPropagation()} >

Delete {selectedHosts.size} Proxy Host{selectedHosts.size > 1 ? 's' : ''}?

This action cannot be undone. A backup will be created automatically before deletion.

Hosts to be deleted:

    {Array.from(selectedHosts).map((uuid) => { const host = hosts.find(h => h.uuid === uuid) return (
  • {host?.name || 'Unnamed'} ({host?.domain_names})
  • ) })}

ℹ️ An automatic backup will be created before deletion. You can restore from the Backups page if needed.

)} {/* Certificate Cleanup Dialog */} {showCertCleanupDialog && certCleanupData && ( { setShowCertCleanupDialog(false) setCertCleanupData(null) }} certificates={certCleanupData.certificates} hostNames={certCleanupData.hostNames} isBulk={certCleanupData.isBulk} /> )}
) }