import { useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, ExternalLink, AlertTriangle, Trash2, Globe, Settings } 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 { useSecurityHeaderProfiles } from '../hooks/useSecurityHeaders' 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 { PageShell } from '../components/layout/PageShell' import { Badge, Alert, Button, Switch, DataTable, EmptyState, SkeletonTable, Checkbox, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, type Column, } from '../components/ui' import { toast } from 'react-hot-toast' import { formatSettingLabel, settingHelpText, applyBulkSettingsToHosts } from '../utils/proxyHostsHelpers' import { ConfigReloadOverlay } from '../components/LoadingStates' import CertificateCleanupDialog from '../components/dialogs/CertificateCleanupDialog' export default function ProxyHosts() { const { t } = useTranslation() const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost, bulkUpdateACL, bulkUpdateSecurityHeaders, isBulkUpdating, isCreating, isUpdating, isDeleting } = useProxyHosts() const { certificates } = useCertificates() const { data: accessLists } = useAccessLists() const { data: securityProfiles } = useSecurityHeaderProfiles() const [showForm, setShowForm] = useState(false) const [editingHost, setEditingHost] = useState() 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 }, enable_standard_headers: { apply: false, value: true }, }) const [bulkSecurityHeaderProfile, setBulkSecurityHeaderProfile] = useState<{ apply: boolean; profileId: number | null; }>({ apply: false, profileId: null }) const [hostToDelete, setHostToDelete] = useState(null) 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: t('proxyHosts.ferryingNewHost'), submessage: t('proxyHosts.ferryingNewHostSub') } if (isUpdating) return { message: t('proxyHosts.guidingChanges'), submessage: t('proxyHosts.guidingChangesSub') } if (isDeleting) return { message: t('proxyHosts.returningToShore'), submessage: t('proxyHosts.returningToShoreSub') } if (isBulkUpdating) return { message: t('proxyHosts.ferryingSouls', { count: selectedHosts.size }), submessage: t('proxyHosts.ferryingSoulsSub') } return { message: t('proxyHosts.ferryingConfig'), submessage: t('proxyHosts.ferryingNewHostSub') } } const { message, submessage } = getMessage() // Create a map of domain -> certificate status for quick lookup const certStatusByDomain = useMemo(() => { const map: Record = {} certificates.forEach(cert => { const domains = cert.domain.split(',').map(d => d.trim().toLowerCase()) domains.forEach(domain => { if (!map[domain]) { map[domain] = { status: cert.status, provider: cert.provider } } }) }) return map }, [certificates]) // Sort hosts alphabetically by name for display const sortedHosts = useMemo(() => [...hosts].sort((a, b) => compareHosts(a, b, 'name', 'asc')), [hosts]) const handleDomainClick = (e: React.MouseEvent, url: string) => { if (linkBehavior === 'new_window') { e.preventDefault() window.open(url, '_blank', 'noopener,noreferrer,width=1024,height=768') } } 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 handleDeleteClick = (host: ProxyHost) => { setHostToDelete(host) } const handleDeleteConfirm = async () => { if (!hostToDelete) return const host = hostToDelete // 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 const otherHostsUsingCert = hosts.filter(h => h.uuid !== host.uuid && h.certificate_id === host.certificate_id ).length if (otherHostsUsingCert === 0) { 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: [host.uuid], hostNames: [host.name || host.domain_names], certificates: orphanedCerts, isBulk: false }) setShowCertCleanupDialog(true) setHostToDelete(null) return } try { 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 } if (associatedMonitors.length > 0) { const deleteUptime = confirm('This proxy host has uptime monitors associated with it. Delete the monitors as well?') await deleteHost(host.uuid, deleteUptime) } else { await deleteHost(host.uuid) } setHostToDelete(null) } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to delete') } } const handleDelete = async (uuid: string) => { const host = hosts.find(h => h.uuid === uuid) if (host) { handleDeleteClick(host) } } 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 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) setShowBulkDeleteModal(false) // Close bulk delete modal when showing cert cleanup 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) } } // DataTable columns definition const columns: Column[] = [ { key: 'name', header: t('proxyHosts.columnName'), sortable: true, width: '18%', cell: (host) => (
{host.name || {t('proxyHosts.unnamed')}}
), }, { key: 'domain', header: t('proxyHosts.columnDomain'), sortable: true, width: '24%', cell: (host) => (
{host.domain_names.split(',').map((domain, i) => { const d = domain.trim() const url = `${host.ssl_forced ? 'https' : 'http'}://${d}` return ( ) })}
), }, { key: 'forward', header: t('proxyHosts.columnForwardTo'), sortable: true, width: '18%', cell: (host) => (
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
), }, { key: 'ssl', header: t('proxyHosts.columnSSL'), width: '10%', cell: (host) => { const primaryDomain = host.domain_names.split(',')[0]?.trim().toLowerCase() const certInfo = certStatusByDomain[primaryDomain] const isUntrusted = certInfo?.status === 'untrusted' const isStaging = certInfo?.provider?.includes('staging') if (!host.ssl_forced) return if (isUntrusted || isStaging) { return ( {t('proxyHosts.staging')} ) } return SSL }, }, { key: 'features', header: t('proxyHosts.columnFeatures'), width: '12%', cell: (host) => (
{host.websocket_support && ( {t('proxyHosts.websocket')} )} {host.access_list_id && ( {t('proxyHosts.acl')} )}
), }, { key: 'status', header: t('proxyHosts.columnStatus'), width: '8%', cell: (host) => ( updateHost(host.uuid, { enabled: checked })} /> ), }, { key: 'actions', header: t('proxyHosts.columnActions'), width: '10%', cell: (host) => (
), }, ] return ( <> {isApplyingConfig && ( )} {isFetching && !loading && } } > {/* Error Alert */} {error && ( {error} )} {/* Bulk Actions Bar */} {selectedHosts.size > 0 && ( {selectedHosts.size} {t('proxyHosts.selectedCount', { count: selectedHosts.size }).replace(`${selectedHosts.size} `, '')} {selectedHosts.size === hosts.length && ` ${t('proxyHosts.selectedCountAll')}`}
)} {/* Data Table */} {loading ? ( ) : ( row.uuid} selectable selectedKeys={selectedHosts} onSelectionChange={setSelectedHosts} stickyHeader emptyState={ } title={t('proxyHosts.noHosts')} description={t('proxyHosts.noHostsDescription')} action={{ label: t('proxyHosts.addHost'), onClick: handleAdd, }} /> } /> )} {/* Add/Edit Form Dialog */} {showForm && ( { setShowForm(false) setEditingHost(undefined) }} /> )} {/* Delete Confirmation Dialog */} !open && setHostToDelete(null)}> {t('proxyHosts.deleteConfirmTitle')} {t('proxyHosts.deleteConfirmMessage', { name: hostToDelete?.name || hostToDelete?.domain_names }).split('').map((part, i) => i === 0 ? part : <>{part.split('')[0]}{part.split('')[1]} )} {/* Bulk Apply Settings Dialog */} { setShowBulkApplyModal(open) if (!open) { setBulkSecurityHeaderProfile({ apply: false, profileId: null }) setApplyProgress(null) } }}> {t('proxyHosts.bulkApplyTitle')} {t('proxyHosts.bulkApplyDescription', { count: selectedHosts.size }).split('').map((part, i) => i === 0 ? part : <>{part.split('')[0]}{part.split('')[1]} )}
{Object.entries(bulkApplySettings).map(([key, cfg]) => (
setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], apply: !!checked } }))} />
{formatSettingLabel(key)}
{settingHelpText(key)}
setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], value: v } }))} />
))} {/* Security Header Profile Section */}
setBulkSecurityHeaderProfile(prev => ({ ...prev, apply: !!checked }))} />
{t('proxyHosts.bulkApplySecurityHeaders')}
{t('proxyHosts.bulkApplySecurityHeadersHelp')}
{bulkSecurityHeaderProfile.apply && (
{bulkSecurityHeaderProfile.profileId === null && ( {t('proxyHosts.removeSecurityHeadersWarning')} )} {bulkSecurityHeaderProfile.profileId && (() => { const selected = securityProfiles?.find(p => p.id === bulkSecurityHeaderProfile.profileId) if (!selected) return null return (
{selected.description}
) })()}
)}
{applyProgress && (
{t('proxyHosts.applyingACLs', { current: applyProgress.current, total: applyProgress.total })}
)}
{/* Bulk ACL Modal Dialog */} {t('proxyHosts.applyACLTitle')} {t('proxyHosts.applyACLDescription', { count: selectedHosts.size }).split('').map((part, i) => i === 0 ? part : <>{part.split('')[0]}{part.split('')[1]} )} {/* Action Toggle */}
{/* ACL Selection List */} {bulkACLAction === 'apply' && (
{(accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0) > 0 && (
{t('proxyHosts.selectedACLCount', { selected: selectedACLs.size, total: accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0 })}
|
)}
{accessLists?.filter((acl: AccessList) => acl.enabled).length === 0 ? (

{t('proxyHosts.noEnabledACLs')}

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

{t('proxyHosts.removeACLWarning', { count: selectedHosts.size })}

{t('proxyHosts.publicAccessWarning')}

)} {/* Progress indicator */} {applyProgress && (
{t('proxyHosts.applyingACLs', { current: applyProgress.current, total: applyProgress.total })}
)}
{/* Bulk Delete Dialog */}
{t('proxyHosts.bulkDeleteTitle', { count: selectedHosts.size })} {t('proxyHosts.bulkDeleteDescription')}

{t('proxyHosts.hostsToDelete')}

    {Array.from(selectedHosts).map((uuid) => { const host = hosts.find(h => h.uuid === uuid) return (
  • {host?.name || t('proxyHosts.unnamed')} ({host?.domain_names})
  • ) })}
{t('proxyHosts.backupInfo')}
{/* Certificate Cleanup Dialog */} {showCertCleanupDialog && certCleanupData && ( { setShowCertCleanupDialog(false) setCertCleanupData(null) }} certificates={certCleanupData.certificates} hostNames={certCleanupData.hostNames} isBulk={certCleanupData.isBulk} /> )}
) }