Files
Charon/frontend/src/pages/ProxyHosts.tsx
GitHub Actions 3e4323155f feat: add loading overlays and animations across various pages
- Implemented new CSS animations for UI elements including bobbing, pulsing, rotating, and spinning effects.
- Integrated loading overlays in CrowdSecConfig, Login, ProxyHosts, Security, and WafConfig pages to enhance user experience during asynchronous operations.
- Added contextual messages for loading states to inform users about ongoing processes.
- Created tests for Login and Security pages to ensure overlays function correctly during login attempts and security operations.
2025-12-04 15:10:02 +00:00

915 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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'
// 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<ProxyHost | undefined>()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
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 [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 },
})
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<string, { status: string; provider: string }> = {}
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' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
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<ProxyHost>) => {
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
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 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}`)
// Delete each host
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 && (
<ConfigReloadOverlay
message={message}
submessage={submessage}
type="charon"
/>
)}
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
</div>
<div className="flex gap-3">
{selectedHosts.size > 0 && (
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">
{selectedHosts.size} {selectedHosts.size === hosts.length && '(all)'} selected
</span>
<button
onClick={() => setShowBulkApplyModal(true)}
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg font-medium transition-colors"
>
Bulk Apply
</button>
<button
onClick={() => setShowBulkACLModal(true)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={isBulkUpdating}
>
{isBulkUpdating ? 'Updating...' : 'Manage ACL'}
</button>
<button
onClick={() => setShowBulkDeleteModal(true)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2"
>
<Trash2 size={16} />
Delete
</button>
</div>
)}
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Add Proxy Host
</button>
</div>
</div>
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
{error}
</div>
)}
{/* Bulk Apply Modal */}
{showBulkApplyModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setShowBulkApplyModal(false)}
>
<div
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-white mb-4">Bulk Apply Settings</h2>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
<p className="text-sm text-gray-400">
Applying settings to <span className="text-blue-400 font-medium">{selectedHosts.size}</span> selected host(s)
</p>
<div className="flex-1 overflow-y-auto border border-gray-700 rounded-lg p-3 space-y-3">
{Object.entries(bulkApplySettings).map(([key, cfg]) => (
<div key={key} className="flex items-center justify-between gap-3 p-2 bg-gray-900/30 rounded">
<div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={cfg.apply}
onChange={(e) => 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"
/>
<div>
<div className="text-white font-medium">{formatSettingLabel(key)}</div>
<div className="text-xs text-gray-400">{settingHelpText(key)}</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">Set:</span>
<Switch
checked={cfg.value}
onCheckedChange={(v: boolean) => setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], value: v } }))}
/>
</div>
</div>
))}
</div>
{applyProgress && (
<div className="border border-blue-800/50 rounded-lg bg-blue-900/20 p-4">
<div className="flex items-center gap-3 mb-2">
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
<span className="text-blue-300 font-medium">
Applying settings... ({applyProgress.current}/{applyProgress.total})
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${(applyProgress.current / applyProgress.total) * 100}%` }}
/>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => {
setShowBulkApplyModal(false)
}}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={applyProgress !== null}
>
Cancel
</button>
<button
onClick={async () => {
const keysToApply = Object.keys(bulkApplySettings).filter(k => bulkApplySettings[k].apply)
if (keysToApply.length === 0) return
const hostUUIDs = Array.from(selectedHosts)
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
if (result.errors > 0) {
toast.error(`Applied settings with ${result.errors} error(s)`)
} else {
toast.success(`Applied settings to ${hostUUIDs.length} host(s)`)
}
setSelectedHosts(new Set())
setShowBulkApplyModal(false)
}}
disabled={applyProgress !== null || Object.values(bulkApplySettings).every(s => !s.apply)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{(applyProgress !== null) && <Loader2 className="w-4 h-4 animate-spin mr-2" />}
Apply
</button>
</div>
</div>
</div>
</div>
)}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">Loading...</div>
) : hosts.length === 0 ? (
<div className="text-center text-gray-400 py-12">
No proxy hosts configured yet. Click "Add Proxy Host" to get started.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full table-fixed min-w-0">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th
onClick={() => 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"
>
<div className="flex items-center gap-1">
Name
<SortIcon column="name" />
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center gap-1">
Domain
<SortIcon column="domain" />
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center gap-1">
Forward To
<SortIcon column="forward" />
</div>
</th>
<th style={{ width: '8%' }} className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
SSL
</th>
<th style={{ width: '10%' }} className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
Status
</th>
<th style={{ width: '12%' }} className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
<th style={{ width: '6%' }} className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<button
onClick={toggleSelectAll}
role="checkbox"
aria-checked={selectedHosts.size === hosts.length}
className="text-gray-400 hover:text-white transition-colors"
title={selectedHosts.size === hosts.length ? 'Deselect all' : 'Select all'}
>
{selectedHosts.size === hosts.length ? (
<CheckSquare size={18} />
) : (
<Square size={18} />
)}
</button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{sortedHosts.map((host) => (
<tr key={host.uuid} className="hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white max-w-full truncate">
{host.name || <span className="text-gray-500 italic">Unnamed</span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white">
{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-blue-400 hover:underline flex items-center gap-1 truncate block max-w-full"
style={{ maxWidth: '100%' }}
>
<span className="truncate block max-w-[40ch]">{d}</span>
<ExternalLink size={12} className="opacity-50" />
</a>
</div>
)
})}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-300">
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{(() => {
// 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 (
<div className="flex flex-col gap-2">
{/* Row 1: Proxy Badges */}
<div className="flex flex-wrap justify-center gap-2">
{host.ssl_forced && (
isUntrusted || isStaging ? (
<span className="px-2 py-1 text-xs bg-orange-900/30 text-orange-400 rounded flex items-center gap-1">
<AlertTriangle size={12} />
SSL (Staging)
</span>
) : (
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
SSL
</span>
)
)}
{host.websocket_support && (
<span className="px-2 py-1 text-xs bg-blue-900/30 text-blue-400 rounded">
WS
</span>
)}
</div>
{/* Row 2: Security Badges */}
{host.access_list_id && (
<div className="flex flex-wrap justify-center gap-2">
<span className="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 rounded">
ACL
</span>
</div>
)}
{/* Certificate info below badges */}
{host.certificate && host.certificate.provider === 'custom' && (
<div className="text-xs text-gray-400">
{host.certificate.name} (Custom)
</div>
)}
{host.ssl_forced && !host.certificate && (isUntrusted || isStaging) && (
<div className="text-xs text-orange-400">
Staging cert - browsers won't trust
</div>
)}
</div>
)
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<Switch
checked={host.enabled}
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(host)}
className="text-blue-400 hover:text-blue-300 mr-4"
>
Edit
</button>
<button
onClick={() => handleDelete(host.uuid)}
className="text-red-400 hover:text-red-300"
>
Delete
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<button
onClick={() => toggleHostSelection(host.uuid)}
role="checkbox"
aria-checked={selectedHosts.has(host.uuid)}
aria-label={`Select ${host.name}`}
className="text-gray-400 hover:text-white transition-colors"
>
{selectedHosts.has(host.uuid) ? (
<CheckSquare size={18} className="text-blue-400" />
) : (
<Square size={18} />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{showForm && (
<ProxyHostForm
host={editingHost}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingHost(undefined)
}}
/>
)}
{/* Bulk ACL Modal */}
{showBulkACLModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setShowBulkACLModal(false)}
>
<div
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-white mb-4">Apply Access List</h2>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
<p className="text-sm text-gray-400">
Applying to <span className="text-blue-400 font-medium">{selectedHosts.size}</span> selected host(s)
</p>
<p className="text-xs text-gray-500 mt-1">
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.
</p>
{/* Action Toggle */}
<div className="flex gap-2">
<button
onClick={() => {
setBulkACLAction('apply')
setSelectedACLs(new Set())
}}
className={`flex-1 px-3 py-2 rounded-lg font-medium transition-colors ${
bulkACLAction === 'apply'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Apply ACL
</button>
<button
onClick={() => {
setBulkACLAction('remove')
setSelectedACLs(new Set())
}}
className={`flex-1 px-3 py-2 rounded-lg font-medium transition-colors ${
bulkACLAction === 'remove'
? 'bg-red-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Remove ACL
</button>
</div>
{/* ACL Selection List */}
{bulkACLAction === 'apply' && (
<div className="flex-1 overflow-y-auto border border-gray-700 rounded-lg">
{/* Select All / Clear header */}
{(accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0) > 0 && (
<div className="flex items-center justify-between p-2 border-b border-gray-700 bg-gray-800/50">
<span className="text-sm text-gray-400">
{selectedACLs.size} of {accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0} selected
</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-blue-400 hover:text-blue-300"
>
Select All
</button>
<span className="text-gray-600">|</span>
<button
onClick={() => setSelectedACLs(new Set())}
className="text-xs text-gray-400 hover:text-gray-300"
>
Clear
</button>
</div>
</div>
)}
<div className="p-2 space-y-1">
{accessLists?.filter((acl: AccessList) => acl.enabled).length === 0 ? (
<p className="text-gray-500 text-sm p-2">No enabled access lists available</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-blue-600/20 border border-blue-500'
: 'bg-gray-800/50 border border-transparent hover:bg-gray-800'
}`}
>
<input
type="checkbox"
checked={selectedACLs.has(acl.id!)}
onChange={(e) => {
const newSelected = new Set(selectedACLs)
if (e.target.checked) {
newSelected.add(acl.id!)
} else {
newSelected.delete(acl.id!)
}
setSelectedACLs(newSelected)
}}
className="w-4 h-4 rounded border-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-0 bg-gray-700"
/>
<div className="flex-1">
<span className="text-white font-medium">{acl.name}</span>
{acl.type && (
<span className="ml-2 text-xs text-gray-500">
({acl.type.replace('_', ' ')})
</span>
)}
</div>
</label>
))
)}
</div>
</div>
)}
{/* Remove ACL Confirmation */}
{bulkACLAction === 'remove' && (
<div className="flex-1 flex items-center justify-center border border-red-900/50 rounded-lg bg-red-900/10 p-6">
<div className="text-center">
<div className="text-4xl mb-3">🚫</div>
<p className="text-gray-300">
This will remove the access list from all {selectedHosts.size} selected host(s).
</p>
<p className="text-gray-500 text-sm mt-2">
The hosts will become publicly accessible.
</p>
</div>
</div>
)}
{/* Progress indicator */}
{applyProgress && (
<div className="border border-blue-800/50 rounded-lg bg-blue-900/20 p-4">
<div className="flex items-center gap-3 mb-2">
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
<span className="text-blue-300 font-medium">
Applying ACLs... ({applyProgress.current}/{applyProgress.total})
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${(applyProgress.current / applyProgress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => {
setShowBulkACLModal(false)
setSelectedACLs(new Set())
setBulkACLAction('apply')
setApplyProgress(null)
}}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={isBulkUpdating || applyProgress !== null}
>
Cancel
</button>
<button
onClick={async () => {
if (bulkACLAction === 'remove') {
await handleBulkApplyACL(null)
} else if (selectedACLs.size > 0) {
// Apply each selected ACL sequentially with progress
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(`Applied ${selectedACLs.size} ACL(s) with some errors`)
} else {
toast.success(`Applied ${selectedACLs.size} ACL(s) to ${selectedHosts.size} host(s)`)
}
setSelectedHosts(new Set())
setSelectedACLs(new Set())
setShowBulkACLModal(false)
}
}}
disabled={isBulkUpdating || applyProgress !== null || (bulkACLAction === 'apply' && selectedACLs.size === 0)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
bulkACLAction === 'remove'
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-blue-600 hover:bg-blue-500 text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{(isBulkUpdating || applyProgress !== null) && <Loader2 className="w-4 h-4 animate-spin" />}
{bulkACLAction === 'remove' ? 'Remove ACL' : `Apply ${selectedACLs.size > 0 ? `(${selectedACLs.size})` : ''}`}
</button>
</div>
</div>
</div>
</div>
)}
{/* Bulk Delete Modal */}
{showBulkDeleteModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setShowBulkDeleteModal(false)}
>
<div
className="bg-dark-card border border-red-900/50 rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-900/30 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-red-400" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-white">Delete {selectedHosts.size} Proxy Host{selectedHosts.size > 1 ? 's' : ''}?</h2>
<p className="text-sm text-gray-400 mt-1">
This action cannot be undone. A backup will be created automatically before deletion.
</p>
</div>
</div>
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-4 mb-4 max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-gray-400 uppercase mb-2">Hosts to be deleted:</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-white flex items-center gap-2">
<span className="text-red-400">•</span>
<span className="font-medium">{host?.name || 'Unnamed'}</span>
<span className="text-gray-500">({host?.domain_names})</span>
</li>
)
})}
</ul>
</div>
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-3 mb-4">
<p className="text-xs text-blue-300 flex items-start gap-2">
<span className="text-blue-400"></span>
<span>An automatic backup will be created before deletion. You can restore from the Backups page if needed.</span>
</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowBulkDeleteModal(false)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={isCreatingBackup}
>
Cancel
</button>
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2"
disabled={isCreatingBackup}
>
{isCreatingBackup ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating Backup...
</>
) : (
<>
<Trash2 size={16} />
Delete Permanently
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
</>
)
}