import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import Modal from 'react-bootstrap/Modal' import Dropdown from 'react-bootstrap/Dropdown' import { listSites, siteBatch, createSite, getSite, updateSite, setSiteStatus, deleteSite, createSiteBackup, listSiteBackups, restoreSiteBackup, downloadSiteBackup, listSiteRedirects, addSiteRedirect, deleteSiteRedirect, siteGitClone, siteGitPull, listServices, type SiteListItem, } from '../api/client' import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin' function formatPhpVersion(code: string): string { const m: Record = { '74': '7.4', '80': '8.0', '81': '8.1', '82': '8.2' } return m[code] || code } export function SitePage() { const [sites, setSites] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [showCreate, setShowCreate] = useState(false) const [creating, setCreating] = useState(false) const [creatingError, setCreatingError] = useState('') const [backupSiteId, setBackupSiteId] = useState(null) const [backups, setBackups] = useState<{ filename: string; size: number }[]>([]) const [backupLoading, setBackupLoading] = useState(false) const [editSiteId, setEditSiteId] = useState(null) const [editForm, setEditForm] = useState<{ domains: string path: string ps: string php_version: string force_https: boolean } | null>(null) const [editLoading, setEditLoading] = useState(false) const [editError, setEditError] = useState('') const [statusLoading, setStatusLoading] = useState(null) const [redirectSiteId, setRedirectSiteId] = useState(null) const [redirects, setRedirects] = useState<{ id: number; source: string; target: string; code: number }[]>([]) const [redirectSource, setRedirectSource] = useState('') const [redirectTarget, setRedirectTarget] = useState('') const [redirectCode, setRedirectCode] = useState(301) const [redirectAdding, setRedirectAdding] = useState(false) const [gitSiteId, setGitSiteId] = useState(null) const [gitUrl, setGitUrl] = useState('') const [gitBranch, setGitBranch] = useState('main') const [gitAction, setGitAction] = useState<'clone' | 'pull' | null>(null) const [gitLoading, setGitLoading] = useState(false) const [search, setSearch] = useState('') const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(25) const [nginxStatus, setNginxStatus] = useState(null) const [batchLoading, setBatchLoading] = useState(false) const [rowBackupId, setRowBackupId] = useState(null) const loadSites = () => { setLoading(true) listSites() .then(setSites) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) } useEffect(() => { loadSites() }, []) useEffect(() => { listServices() .then((data) => { const n = data.services.find((s) => s.id === 'nginx') setNginxStatus(n?.status ?? null) }) .catch(() => setNginxStatus(null)) }, []) const filteredSites = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return sites return sites.filter((s) => { const blob = [s.name, s.path, s.ps, s.primary_domain, ...(s.domains || [])].join(' ').toLowerCase() return blob.includes(q) }) }, [sites, search]) useEffect(() => setPage(1), [search, pageSize]) const totalPages = Math.max(1, Math.ceil(filteredSites.length / pageSize)) const paginatedSites = useMemo(() => { const start = (page - 1) * pageSize return filteredSites.slice(start, start + pageSize) }, [filteredSites, page, pageSize]) useEffect(() => { if (page > totalPages) setPage(totalPages) }, [page, totalPages]) const toggleSelect = (id: number) => { setSelectedIds((prev) => { const n = new Set(prev) if (n.has(id)) n.delete(id) else n.add(id) return n }) } const selectAllOnPage = () => { const onPage = new Set(paginatedSites.map((s) => s.id)) const allSelected = paginatedSites.length > 0 && paginatedSites.every((s) => selectedIds.has(s.id)) if (allSelected) { setSelectedIds((prev) => { const n = new Set(prev) paginatedSites.forEach((s) => n.delete(s.id)) return n }) } else { setSelectedIds((prev) => new Set([...prev, ...onPage])) } } const runBatch = async (action: 'enable' | 'disable' | 'delete') => { const ids = [...selectedIds] if (ids.length === 0) return const msg = action === 'delete' ? `Delete ${ids.length} site(s)? This cannot be undone.` : `${action === 'enable' ? 'Enable' : 'Disable'} ${ids.length} site(s)?` if (!confirm(msg)) return setBatchLoading(true) try { const r = await siteBatch(action, ids) const failed = r.results.filter((x) => !x.ok) if (failed.length) setError(failed.map((f) => `${f.id}: ${f.msg}`).join('; ')) else setError('') setSelectedIds(new Set()) loadSites() } catch (e) { setError(e instanceof Error ? e.message : 'Batch failed') } finally { setBatchLoading(false) } } const handleQuickBackup = (siteId: number) => { setRowBackupId(siteId) createSiteBackup(siteId) .then(() => loadSites()) .catch((err) => setError(err.message)) .finally(() => setRowBackupId(null)) } const handleCreate = (e: React.FormEvent) => { e.preventDefault() const form = e.currentTarget const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim() const domainsStr = (form.elements.namedItem('domains') as HTMLInputElement).value.trim() const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim() const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim() if (!name || !domainsStr) { setCreatingError('Name and domain(s) are required') return } const domains = domainsStr.split(/[\s,]+/).filter(Boolean) const php_version = (form.elements.namedItem('php_version') as HTMLSelectElement)?.value || '74' const force_https = (form.elements.namedItem('force_https') as HTMLInputElement)?.checked || false setCreating(true) setCreatingError('') createSite({ name, domains, path: path || undefined, ps: ps || undefined, php_version, force_https }) .then(() => { setShowCreate(false) form.reset() loadSites() }) .catch((err) => setCreatingError(err.message)) .finally(() => setCreating(false)) } const handleDelete = (id: number, name: string) => { if (!confirm(`Delete site "${name}"? This cannot be undone.`)) return deleteSite(id) .then(loadSites) .catch((err) => setError(err.message)) } const openBackupModal = (siteId: number) => { setBackupSiteId(siteId) setBackups([]) listSiteBackups(siteId) .then((data) => setBackups(data.backups)) .catch((err) => setError(err.message)) } const handleCreateBackup = () => { if (!backupSiteId) return setBackupLoading(true) createSiteBackup(backupSiteId) .then(() => listSiteBackups(backupSiteId).then((d) => setBackups(d.backups))) .catch((err) => setError(err.message)) .finally(() => setBackupLoading(false)) } const handleRestore = (filename: string) => { if (!backupSiteId || !confirm(`Restore from ${filename}? This will overwrite existing files.`)) return setBackupLoading(true) restoreSiteBackup(backupSiteId, filename) .then(() => setBackupSiteId(null)) .catch((err) => setError(err.message)) .finally(() => setBackupLoading(false)) } const handleDownloadBackup = (filename: string) => { if (!backupSiteId) return downloadSiteBackup(backupSiteId, filename).catch((err) => setError(err.message)) } const openEditModal = (siteId: number) => { setEditSiteId(siteId) setEditError('') getSite(siteId) .then((s) => setEditForm({ domains: (s.domains || []).join(', '), path: s.path || '', ps: s.ps || '', php_version: s.php_version || '74', force_https: !!(s.force_https && s.force_https !== 0), }) ) .catch((err) => setEditError(err.message)) } const handleEdit = (e: React.FormEvent) => { e.preventDefault() if (!editSiteId || !editForm) return const domains = editForm.domains.split(/[\s,]+/).filter(Boolean) if (domains.length === 0) { setEditError('At least one domain is required') return } setEditLoading(true) setEditError('') updateSite(editSiteId, { domains, path: editForm.path || undefined, ps: editForm.ps || undefined, php_version: editForm.php_version, force_https: editForm.force_https, }) .then(() => { setEditSiteId(null) setEditForm(null) loadSites() }) .catch((err) => setEditError(err.message)) .finally(() => setEditLoading(false)) } const handleSetStatus = (siteId: number, enable: boolean) => { setStatusLoading(siteId) setSiteStatus(siteId, enable) .then(loadSites) .catch((err) => setError(err.message)) .finally(() => setStatusLoading(null)) } const openRedirectModal = (siteId: number) => { setRedirectSiteId(siteId) setRedirectSource('') setRedirectTarget('') setRedirectCode(301) listSiteRedirects(siteId) .then(setRedirects) .catch((err) => setError(err.message)) } const handleAddRedirect = (e: React.FormEvent) => { e.preventDefault() if (!redirectSiteId || !redirectSource.trim() || !redirectTarget.trim()) return setRedirectAdding(true) addSiteRedirect(redirectSiteId, redirectSource.trim(), redirectTarget.trim(), redirectCode) .then(() => listSiteRedirects(redirectSiteId).then(setRedirects)) .then(() => { setRedirectSource('') setRedirectTarget('') }) .catch((err) => setError(err.message)) .finally(() => setRedirectAdding(false)) } const handleDeleteRedirect = (redirectId: number) => { if (!redirectSiteId) return deleteSiteRedirect(redirectSiteId, redirectId) .then(() => listSiteRedirects(redirectSiteId).then(setRedirects)) .catch((err) => setError(err.message)) } const handleGitClone = (e: React.FormEvent) => { e.preventDefault() if (!gitSiteId || !gitUrl.trim()) return setGitLoading(true) siteGitClone(gitSiteId, gitUrl.trim(), gitBranch.trim() || 'main') .then(() => { setGitSiteId(null) setGitAction(null) loadSites() }) .catch((err) => setError(err.message)) .finally(() => setGitLoading(false)) } const handleGitPull = () => { if (!gitSiteId) return setGitLoading(true) siteGitPull(gitSiteId) .then(() => { setGitSiteId(null) setGitAction(null) loadSites() }) .catch((err) => setError(err.message)) .finally(() => setGitLoading(false)) } const formatSize = (bytes: number) => { if (bytes < 1024) return bytes + ' B' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' return (bytes / 1024 / 1024).toFixed(1) + ' MB' } if (loading) { return ( <>
Loading…
) } if (error && !sites.length) { return ( <> {error} ) } return ( <> setShowCreate(true)}> Add Site } /> {error ? {error} : null} setShowCreate(false)} centered> Create Site
{creatingError ? {creatingError} : null}
setSearch(e.target.value)} aria-label="Search sites" />
runBatch('enable')} disabled={batchLoading || selectedIds.size === 0}> Enable runBatch('disable')} disabled={batchLoading || selectedIds.size === 0}> Disable runBatch('delete')} disabled={batchLoading || selectedIds.size === 0}> Delete {selectedIds.size > 0 ? `${selectedIds.size} selected` : ''}
Nginx {nginxStatus ?? '—'}
0 && paginatedSites.every((s) => selectedIds.has(s.id))} onChange={selectAllOnPage} aria-label="Select all on page" /> Site Status Backup Quick PHP SSL Note Operate {sites.length === 0 ? ( ) : filteredSites.length === 0 ? ( ) : ( paginatedSites.map((s) => ( e.stopPropagation()}> toggleSelect(s.id)} aria-label={`Select ${s.name}`} />
{s.primary_domain ? ( ) : null}
{s.primary_domain || s.name}
{s.project_type} · {s.path}
{s.status === 1 ? ( ) : ( )} {s.backup_count} {formatPhpVersion(s.php_version || '74')} {(s.ssl?.status ?? 'none') === 'none' ? ( Not set ) : s.ssl?.status === 'expired' ? ( Expired ) : s.ssl?.status === 'expiring' ? ( {s.ssl?.days_left ?? 0}d left ) : ( {s.ssl?.days_left ?? 0} days )} {s.ps || '—'} More openEditModal(s.id)}> Config Logs SSL / Domains { setGitSiteId(s.id) setGitAction('clone') setGitUrl('') setGitBranch('main') }} > Git openRedirectModal(s.id)}> Redirects openBackupModal(s.id)}> Backups handleDelete(s.id, s.name)}> Delete )) )}
{sites.length > 0 && filteredSites.length > 0 ? (
Total {filteredSites.length} site(s) {search.trim() ? ` (filtered from ${sites.length})` : ''}
Page {page} / {totalPages}
) : null}
{ setGitSiteId(null); setGitAction(null) }} centered> Git Deploy
{gitAction === 'clone' ? (
setGitUrl(e.target.value)} placeholder="https://github.com/user/repo.git" className="form-control" required />
setGitBranch(e.target.value)} placeholder="main" className="form-control" />

Site path must be empty for clone.

) : (

Pull latest changes from the remote repository.

)}
setRedirectSiteId(null)} size="lg" centered scrollable> Redirects
setRedirectSource(e.target.value)} placeholder="/old-path" className="form-control form-control-sm" />
setRedirectTarget(e.target.value)} placeholder="/new-path" className="form-control form-control-sm" />
{redirects.length === 0 ? (

No redirects

) : (
    {redirects.map((r) => (
  • {r.source} {r.target} {r.code}
  • ))}
)}
{ setEditSiteId(null); setEditForm(null) }} centered> Edit Site {editForm ? (
{editError ? {editError} : null}
setEditForm({ ...editForm, domains: e.target.value })} type="text" className="form-control" required />
setEditForm({ ...editForm, path: e.target.value })} type="text" className="form-control" />
setEditForm({ ...editForm, force_https: e.target.checked })} />
setEditForm({ ...editForm, ps: e.target.value })} type="text" className="form-control" />
) : null}
setBackupSiteId(null)} size="lg" centered> Site Backup
{backupLoading ? 'Creating…' : 'Create Backup'}
Existing backups
{backups.length === 0 ? (

No backups yet

) : (
    {backups.map((b) => (
  • {b.filename} {formatSize(b.size)}
  • ))}
)}
) }