Files
Charon/frontend/src/pages/ProxyHosts.tsx
GitHub Actions 3169b05156 fix: skip incomplete system log viewer tests
- Marked 12 tests as skip pending feature implementation
- Features tracked in GitHub issue #686 (system log viewer feature completion)
- Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality
- Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation
- TODO comments in code reference GitHub #686 for feature completion tracking
- Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
2026-02-09 21:55:55 +00:00

1171 lines
44 KiB
TypeScript

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<ProxyHost | undefined>()
const [selectedHosts, setSelectedHosts] = useState<Set<string>>(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<Set<number>>(new Set())
const [bulkACLAction, setBulkACLAction] = useState<'apply' | 'remove'>('apply')
const [applyProgress, setApplyProgress] = useState<{ current: number; total: number } | null>(null)
const [bulkApplySettings, setBulkApplySettings] = useState<Record<string, { apply: boolean; value: boolean }>>({
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<ProxyHost | null>(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<string, { status: string; provider: string }> = {}
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<ProxyHost>) => {
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<number, { id: number; name: string; domain: string }> = 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<ProxyHost>[] = [
{
key: 'name',
header: t('proxyHosts.columnName'),
sortable: true,
width: '18%',
cell: (host) => (
<div className="text-sm font-medium text-content-primary truncate">
{host.name || <span className="text-content-muted italic">{t('proxyHosts.unnamed')}</span>}
</div>
),
},
{
key: 'domain',
header: t('proxyHosts.columnDomain'),
sortable: true,
width: '24%',
cell: (host) => (
<div className="text-sm font-medium text-content-primary">
{host.domain_names.split(',').map((domain, i) => {
const d = domain.trim()
const url = `${host.ssl_forced ? 'https' : 'http'}://${d}`
return (
<div key={i} className="flex items-center gap-1">
<a
href={url}
title={url}
target={linkBehavior === 'same_tab' ? '_self' : '_blank'}
rel="noopener noreferrer"
onClick={(e) => handleDomainClick(e, url)}
className="hover:text-brand-400 hover:underline flex items-center gap-1 truncate"
style={{ maxWidth: '100%' }}
>
<span className="truncate max-w-[30ch]">{d}</span>
<ExternalLink size={12} className="opacity-50 flex-shrink-0" />
</a>
</div>
)
})}
</div>
),
},
{
key: 'forward',
header: t('proxyHosts.columnForwardTo'),
sortable: true,
width: '18%',
cell: (host) => (
<div className="text-sm text-content-secondary">
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
</div>
),
},
{
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 <span className="text-content-muted"></span>
if (isUntrusted || isStaging) {
return (
<Badge variant="warning" size="sm" className="gap-1">
<AlertTriangle size={12} />
{t('proxyHosts.staging')}
</Badge>
)
}
return <Badge variant="success" size="sm">SSL</Badge>
},
},
{
key: 'features',
header: t('proxyHosts.columnFeatures'),
width: '12%',
cell: (host) => (
<div className="flex flex-wrap gap-1">
{host.websocket_support && (
<Badge variant="primary" size="sm">{t('proxyHosts.websocket')}</Badge>
)}
{host.access_list_id && (
<Badge variant="outline" size="sm">{t('proxyHosts.acl')}</Badge>
)}
</div>
),
},
{
key: 'status',
header: t('proxyHosts.columnStatus'),
width: '8%',
cell: (host) => (
<Switch
checked={host.enabled}
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
/>
),
},
{
key: 'actions',
header: t('proxyHosts.columnActions'),
width: '10%',
cell: (host) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleEdit(host)
}}
>
{t('common.edit')}
</Button>
<Button
variant="ghost"
size="sm"
className="text-error hover:text-error hover:bg-error/10"
onClick={(e) => {
e.stopPropagation()
handleDelete(host.uuid)
}}
>
{t('common.delete')}
</Button>
</div>
),
},
]
return (
<>
{isApplyingConfig && (
<ConfigReloadOverlay
message={message}
submessage={submessage}
type="charon"
/>
)}
<PageShell
title={t('proxyHosts.title')}
description={t('proxyHosts.description')}
actions={
<div className="flex items-center gap-3">
{isFetching && !loading && <Loader2 className="animate-spin text-brand-400" size={20} />}
<Button onClick={handleAdd}>{t('proxyHosts.addHost')}</Button>
</div>
}
>
{/* Error Alert */}
{error && (
<Alert variant="error" dismissible>
{error}
</Alert>
)}
{/* Bulk Actions Bar */}
{selectedHosts.size > 0 && (
<Alert variant="info" className="flex items-center justify-between">
<span>
<strong>{selectedHosts.size}</strong> {t('proxyHosts.selectedCount', { count: selectedHosts.size }).replace(`${selectedHosts.size} `, '')}
{selectedHosts.size === hosts.length && ` ${t('proxyHosts.selectedCountAll')}`}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
leftIcon={Settings}
onClick={() => setShowBulkApplyModal(true)}
>
{t('proxyHosts.bulkApply')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkACLModal(true)}
disabled={isBulkUpdating}
isLoading={isBulkUpdating}
>
{t('proxyHosts.manageACL')}
</Button>
<Button
variant="danger"
size="sm"
leftIcon={Trash2}
onClick={() => setShowBulkDeleteModal(true)}
>
{t('common.delete')}
</Button>
</div>
</Alert>
)}
{/* Data Table */}
{loading ? (
<SkeletonTable rows={5} columns={7} />
) : (
<DataTable
data={sortedHosts}
columns={columns}
rowKey={(row) => row.uuid}
selectable
selectedKeys={selectedHosts}
onSelectionChange={setSelectedHosts}
stickyHeader
emptyState={
<EmptyState
icon={<Globe className="h-12 w-12" />}
title={t('proxyHosts.noHosts')}
description={t('proxyHosts.noHostsDescription')}
action={{
label: t('proxyHosts.addHost'),
onClick: handleAdd,
}}
/>
}
/>
)}
{/* Add/Edit Form Dialog */}
{showForm && (
<ProxyHostForm
host={editingHost}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingHost(undefined)
}}
/>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={!!hostToDelete} onOpenChange={(open) => !open && setHostToDelete(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('proxyHosts.deleteConfirmTitle')}</DialogTitle>
<DialogDescription>
{t('proxyHosts.deleteConfirmMessage', { name: hostToDelete?.name || hostToDelete?.domain_names }).split('<strong>').map((part, i) => {
if (i === 0) return part
const [strongText, rest] = part.split('</strong>')
return (
<span key={i}>
<strong>{strongText}</strong>
{rest}
</span>
)
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setHostToDelete(null)}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={handleDeleteConfirm}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Apply Settings Dialog */}
<Dialog open={showBulkApplyModal} onOpenChange={(open) => {
setShowBulkApplyModal(open)
if (!open) {
setBulkSecurityHeaderProfile({ apply: false, profileId: null })
setApplyProgress(null)
}
}}>
<DialogContent className="max-w-md max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('proxyHosts.bulkApplyTitle')}</DialogTitle>
<DialogDescription>
{t('proxyHosts.bulkApplyDescription', { count: selectedHosts.size }).split('<strong>').map((part, i) => {
if (i === 0) return part
const [strongText, rest] = part.split('</strong>')
return (
<span key={i}>
<strong className="text-brand-400">{strongText}</strong>
{rest}
</span>
)
})}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-3 py-4">
{Object.entries(bulkApplySettings).map(([key, cfg]) => (
<div key={key} className="flex items-center justify-between gap-3 p-3 bg-surface-subtle rounded-lg">
<div className="flex items-center gap-3">
<Checkbox
checked={cfg.apply}
onCheckedChange={(checked) => setBulkApplySettings(prev => ({
...prev,
[key]: { ...prev[key], apply: !!checked }
}))}
/>
<div>
<div className="text-sm font-medium text-content-primary">{formatSettingLabel(key)}</div>
<div className="text-xs text-content-muted">{settingHelpText(key)}</div>
</div>
</div>
<Switch
checked={cfg.value}
onCheckedChange={(v) => setBulkApplySettings(prev => ({
...prev,
[key]: { ...prev[key], value: v }
}))}
/>
</div>
))}
{/* Security Header Profile Section */}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between gap-3 p-3 bg-surface-subtle rounded-lg">
<div className="flex items-center gap-3">
<Checkbox
checked={bulkSecurityHeaderProfile.apply}
onCheckedChange={(checked) => setBulkSecurityHeaderProfile(prev => ({
...prev,
apply: !!checked
}))}
/>
<div>
<div className="text-sm font-medium text-content-primary">
{t('proxyHosts.bulkApplySecurityHeaders')}
</div>
<div className="text-xs text-content-muted">
{t('proxyHosts.bulkApplySecurityHeadersHelp')}
</div>
</div>
</div>
</div>
{bulkSecurityHeaderProfile.apply && (
<div className="mt-3 p-3 bg-surface-subtle rounded-lg space-y-3">
<select
value={bulkSecurityHeaderProfile.profileId ?? 0}
onChange={(e) => setBulkSecurityHeaderProfile(prev => ({
...prev,
profileId: e.target.value === "0" ? null : parseInt(e.target.value)
}))}
className="w-full bg-surface-muted border border-border rounded-lg px-4 py-2 text-content-primary focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value={0}>{t('proxyHosts.noSecurityProfile')}</option>
{securityProfiles && securityProfiles.filter(p => p.is_preset).length > 0 && (
<optgroup label={t('securityHeaders.systemProfiles')}>
{securityProfiles
.filter(p => p.is_preset)
.sort((a, b) => a.security_score - b.security_score)
.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name} ({t('common.score')}: {profile.security_score}/100)
</option>
))}
</optgroup>
)}
{securityProfiles && securityProfiles.filter(p => !p.is_preset).length > 0 && (
<optgroup label={t('securityHeaders.customProfiles')}>
{securityProfiles
.filter(p => !p.is_preset)
.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name} ({t('common.score')}: {profile.security_score}/100)
</option>
))}
</optgroup>
)}
</select>
{bulkSecurityHeaderProfile.profileId === null && (
<Alert variant="warning">
{t('proxyHosts.removeSecurityHeadersWarning')}
</Alert>
)}
{bulkSecurityHeaderProfile.profileId && (() => {
const selected = securityProfiles?.find(p => p.id === bulkSecurityHeaderProfile.profileId)
if (!selected) return null
return (
<div className="text-xs text-content-muted">
{selected.description}
</div>
)
})()}
</div>
)}
</div>
</div>
{applyProgress && (
<div className="border border-brand-500/30 rounded-lg bg-brand-500/10 p-4">
<div className="flex items-center gap-3 mb-2">
<Loader2 className="w-5 h-5 animate-spin text-brand-400" />
<span className="text-brand-300 font-medium">
{t('proxyHosts.applyingACLs', { current: applyProgress.current, total: applyProgress.total })}
</span>
</div>
<div className="w-full bg-surface-muted rounded-full h-2">
<div
className="bg-brand-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${(applyProgress.current / applyProgress.total) * 100}%` }}
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setShowBulkApplyModal(false)
setBulkSecurityHeaderProfile({ apply: false, profileId: null })
setApplyProgress(null)
}}
disabled={applyProgress !== null}
>
{t('common.cancel')}
</Button>
<Button
onClick={async () => {
const keysToApply = Object.keys(bulkApplySettings).filter(k => bulkApplySettings[k].apply)
const hostUUIDs = Array.from(selectedHosts)
let totalErrors = 0
// Apply boolean settings
if (keysToApply.length > 0) {
const result = await applyBulkSettingsToHosts({
hosts,
hostUUIDs,
keysToApply,
bulkApplySettings,
updateHost,
setApplyProgress
})
totalErrors += result.errors
}
// Apply security header profile if selected
if (bulkSecurityHeaderProfile.apply) {
try {
const result = await bulkUpdateSecurityHeaders(
hostUUIDs,
bulkSecurityHeaderProfile.profileId
)
totalErrors += result.errors.length
} catch {
totalErrors += hostUUIDs.length
}
}
setApplyProgress(null)
// Show appropriate toast based on results
if (totalErrors > 0 && totalErrors < hostUUIDs.length) {
toast.error(t('notifications.partialFailed', { count: totalErrors }))
} else if (totalErrors >= hostUUIDs.length) {
toast.error(t('notifications.updateFailed'))
} else if (keysToApply.length > 0 || bulkSecurityHeaderProfile.apply) {
toast.success(t('notifications.updateSuccess'))
}
setSelectedHosts(new Set())
setShowBulkApplyModal(false)
setBulkSecurityHeaderProfile({ apply: false, profileId: null })
}}
disabled={
applyProgress !== null ||
(Object.values(bulkApplySettings).every(s => !s.apply) && !bulkSecurityHeaderProfile.apply)
}
isLoading={applyProgress !== null}
>
{t('proxyHosts.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk ACL Modal Dialog */}
<Dialog open={showBulkACLModal} onOpenChange={setShowBulkACLModal}>
<DialogContent className="max-w-md max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('proxyHosts.applyACLTitle')}</DialogTitle>
<DialogDescription>
{t('proxyHosts.applyACLDescription', { count: selectedHosts.size }).split('<strong>').map((part, i) => {
if (i === 0) return part
const [strongText, rest] = part.split('</strong>')
return (
<span key={i}>
<strong className="text-brand-400">{strongText}</strong>
{rest}
</span>
)
})}
</DialogDescription>
</DialogHeader>
{/* Action Toggle */}
<div className="flex gap-2 py-2">
<Button
variant={bulkACLAction === 'apply' ? 'primary' : 'secondary'}
className="flex-1"
onClick={() => {
setBulkACLAction('apply')
setSelectedACLs(new Set())
}}
>
{t('proxyHosts.applyACL')}
</Button>
<Button
variant={bulkACLAction === 'remove' ? 'danger' : 'secondary'}
className="flex-1"
onClick={() => {
setBulkACLAction('remove')
setSelectedACLs(new Set())
}}
>
{t('proxyHosts.removeACL')}
</Button>
</div>
{/* ACL Selection List */}
{bulkACLAction === 'apply' && (
<div className="flex-1 overflow-y-auto border border-border rounded-lg">
{(accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0) > 0 && (
<div className="flex items-center justify-between p-2 border-b border-border bg-surface-subtle">
<span className="text-sm text-content-muted">
{t('proxyHosts.selectedACLCount', { selected: selectedACLs.size, total: accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0 })}
</span>
<div className="flex gap-2">
<button
onClick={() => {
const enabledACLs = accessLists?.filter((acl: AccessList) => acl.enabled) || []
setSelectedACLs(new Set(enabledACLs.map((acl: AccessList) => acl.id!)))
}}
className="text-xs text-brand-400 hover:text-brand-300"
>
{t('proxyHosts.selectAll')}
</button>
<span className="text-content-muted">|</span>
<button
onClick={() => setSelectedACLs(new Set())}
className="text-xs text-content-muted hover:text-content-primary"
>
{t('proxyHosts.clear')}
</button>
</div>
</div>
)}
<div className="p-2 space-y-1">
{accessLists?.filter((acl: AccessList) => acl.enabled).length === 0 ? (
<p className="text-content-muted text-sm p-2">{t('proxyHosts.noEnabledACLs')}</p>
) : (
accessLists
?.filter((acl: AccessList) => acl.enabled)
.map((acl: AccessList) => (
<label
key={acl.id}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
selectedACLs.has(acl.id!)
? 'bg-brand-500/10 border border-brand-500'
: 'bg-surface-subtle border border-transparent hover:bg-surface-muted'
}`}
>
<Checkbox
checked={selectedACLs.has(acl.id!)}
onCheckedChange={(checked) => {
const newSelected = new Set(selectedACLs)
if (checked) {
newSelected.add(acl.id!)
} else {
newSelected.delete(acl.id!)
}
setSelectedACLs(newSelected)
}}
/>
<div className="flex-1">
<span className="text-content-primary font-medium">{acl.name}</span>
{acl.type && (
<span className="ml-2 text-xs text-content-muted">
({acl.type.replace('_', ' ')})
</span>
)}
</div>
</label>
))
)}
</div>
</div>
)}
{/* Remove ACL Confirmation */}
{bulkACLAction === 'remove' && (
<div className="flex-1 flex items-center justify-center border border-error/30 rounded-lg bg-error/5 p-6">
<div className="text-center">
<div className="text-4xl mb-3">🚫</div>
<p className="text-content-secondary">
{t('proxyHosts.removeACLWarning', { count: selectedHosts.size })}
</p>
<p className="text-content-muted text-sm mt-2">
{t('proxyHosts.publicAccessWarning')}
</p>
</div>
</div>
)}
{/* Progress indicator */}
{applyProgress && (
<div className="border border-brand-500/30 rounded-lg bg-brand-500/10 p-4">
<div className="flex items-center gap-3 mb-2">
<Loader2 className="w-5 h-5 animate-spin text-brand-400" />
<span className="text-brand-300 font-medium">
{t('proxyHosts.applyingACLs', { current: applyProgress.current, total: applyProgress.total })}
</span>
</div>
<div className="w-full bg-surface-muted rounded-full h-2">
<div
className="bg-brand-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${(applyProgress.current / applyProgress.total) * 100}%` }}
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setShowBulkACLModal(false)
setSelectedACLs(new Set())
setBulkACLAction('apply')
setApplyProgress(null)
}}
disabled={isBulkUpdating || applyProgress !== null}
>
{t('common.cancel')}
</Button>
<Button
variant={bulkACLAction === 'remove' ? 'danger' : 'primary'}
onClick={async () => {
if (bulkACLAction === 'remove') {
await handleBulkApplyACL(null)
} else if (selectedACLs.size > 0) {
const hostUUIDs = Array.from(selectedHosts)
const aclIds = Array.from(selectedACLs)
const totalOperations = aclIds.length
let completedOperations = 0
let totalErrors = 0
setApplyProgress({ current: 0, total: totalOperations })
for (const aclId of aclIds) {
try {
const result = await bulkUpdateACL(hostUUIDs, aclId)
totalErrors += result.errors.length
} catch {
totalErrors += hostUUIDs.length
}
completedOperations++
setApplyProgress({ current: completedOperations, total: totalOperations })
}
setApplyProgress(null)
if (totalErrors > 0) {
toast.error(t('notifications.updateFailed'))
} else {
toast.success(t('notifications.updateSuccess'))
}
setSelectedHosts(new Set())
setSelectedACLs(new Set())
setShowBulkACLModal(false)
}
}}
disabled={isBulkUpdating || applyProgress !== null || (bulkACLAction === 'apply' && selectedACLs.size === 0)}
isLoading={isBulkUpdating || applyProgress !== null}
>
{bulkACLAction === 'remove' ? t('proxyHosts.removeACL') : (selectedACLs.size > 0 ? t('proxyHosts.applyCount', { count: selectedACLs.size }) : t('proxyHosts.apply'))}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Dialog */}
<Dialog open={showBulkDeleteModal} onOpenChange={setShowBulkDeleteModal}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-error/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-error" />
</div>
<div>
<DialogTitle>{t('proxyHosts.bulkDeleteTitle', { count: selectedHosts.size })}</DialogTitle>
<DialogDescription>
{t('proxyHosts.bulkDeleteDescription')}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="bg-surface-subtle border border-border rounded-lg p-4 max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-content-muted uppercase mb-2">{t('proxyHosts.hostsToDelete')}</p>
<ul className="space-y-1">
{Array.from(selectedHosts).map((uuid) => {
const host = hosts.find(h => h.uuid === uuid)
return (
<li key={uuid} className="text-sm text-content-primary flex items-center gap-2">
<span className="text-error"></span>
<span className="font-medium">{host?.name || t('proxyHosts.unnamed')}</span>
<span className="text-content-muted">({host?.domain_names})</span>
</li>
)
})}
</ul>
</div>
<Alert variant="info">
{t('proxyHosts.backupInfo')}
</Alert>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowBulkDeleteModal(false)}
disabled={isCreatingBackup}
>
{t('common.cancel')}
</Button>
<Button
variant="danger"
leftIcon={Trash2}
onClick={handleBulkDelete}
disabled={isCreatingBackup}
isLoading={isCreatingBackup}
>
{isCreatingBackup ? t('proxyHosts.creatingBackup') : t('proxyHosts.deletePermanently')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Certificate Cleanup Dialog */}
{showCertCleanupDialog && certCleanupData && (
<CertificateCleanupDialog
onConfirm={handleCertCleanupConfirm}
onCancel={() => {
setShowCertCleanupDialog(false)
setCertCleanupData(null)
}}
certificates={certCleanupData.certificates}
hostNames={certCleanupData.hostNames}
isBulk={certCleanupData.isBulk}
/>
)}
</PageShell>
</>
)
}