1068 lines
42 KiB
TypeScript
1068 lines
42 KiB
TypeScript
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<string, string> = { '74': '7.4', '80': '8.0', '81': '8.1', '82': '8.2' }
|
|
return m[code] || code
|
|
}
|
|
|
|
export function SitePage() {
|
|
const [sites, setSites] = useState<SiteListItem[]>([])
|
|
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<number | null>(null)
|
|
const [backups, setBackups] = useState<{ filename: string; size: number }[]>([])
|
|
const [backupLoading, setBackupLoading] = useState(false)
|
|
const [editSiteId, setEditSiteId] = useState<number | null>(null)
|
|
const [editForm, setEditForm] = useState<{
|
|
domains: string
|
|
path: string
|
|
ps: string
|
|
php_version: string
|
|
force_https: boolean
|
|
proxy_upstream: string
|
|
proxy_websocket: boolean
|
|
dir_auth_path: string
|
|
dir_auth_user_file: string
|
|
php_deny_execute: boolean
|
|
} | null>(null)
|
|
const [editLoading, setEditLoading] = useState(false)
|
|
const [editError, setEditError] = useState('')
|
|
const [statusLoading, setStatusLoading] = useState<number | null>(null)
|
|
const [redirectSiteId, setRedirectSiteId] = useState<number | null>(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<number | null>(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<Set<number>>(() => new Set())
|
|
const [page, setPage] = useState(1)
|
|
const [pageSize, setPageSize] = useState(25)
|
|
const [nginxStatus, setNginxStatus] = useState<string | null>(null)
|
|
const [batchLoading, setBatchLoading] = useState(false)
|
|
const [rowBackupId, setRowBackupId] = useState<number | null>(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<HTMLFormElement>) => {
|
|
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),
|
|
proxy_upstream: s.proxy_upstream || '',
|
|
proxy_websocket: !!(s.proxy_websocket && Number(s.proxy_websocket) !== 0),
|
|
dir_auth_path: s.dir_auth_path || '',
|
|
dir_auth_user_file: s.dir_auth_user_file || '',
|
|
php_deny_execute: !!(s.php_deny_execute && Number(s.php_deny_execute) !== 0),
|
|
})
|
|
)
|
|
.catch((err) => setEditError(err.message))
|
|
}
|
|
|
|
const handleEdit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
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,
|
|
proxy_upstream: editForm.proxy_upstream,
|
|
proxy_websocket: editForm.proxy_websocket,
|
|
dir_auth_path: editForm.dir_auth_path,
|
|
dir_auth_user_file: editForm.dir_auth_user_file,
|
|
php_deny_execute: editForm.php_deny_execute,
|
|
})
|
|
.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<HTMLFormElement>) => {
|
|
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<HTMLFormElement>) => {
|
|
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 (
|
|
<>
|
|
<PageHeader title="Website" />
|
|
<div className="text-center py-5 text-muted">Loading…</div>
|
|
</>
|
|
)
|
|
}
|
|
if (error && !sites.length) {
|
|
return (
|
|
<>
|
|
<PageHeader title="Website" />
|
|
<AdminAlert variant="danger">{error}</AdminAlert>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Website"
|
|
actions={
|
|
<AdminButton onClick={() => setShowCreate(true)}>
|
|
<i className="ti ti-plus me-1" />
|
|
Add Site
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
|
|
{error ? <AdminAlert variant="warning">{error}</AdminAlert> : null}
|
|
|
|
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
|
|
<Modal.Header closeButton>
|
|
<Modal.Title>Create Site</Modal.Title>
|
|
</Modal.Header>
|
|
<form onSubmit={handleCreate}>
|
|
<Modal.Body>
|
|
{creatingError ? <AdminAlert variant="danger">{creatingError}</AdminAlert> : null}
|
|
<div className="mb-3">
|
|
<label className="form-label">Site Name</label>
|
|
<input name="name" type="text" placeholder="example.com" className="form-control" required />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Domain(s) (comma or space separated)</label>
|
|
<input
|
|
name="domains"
|
|
type="text"
|
|
placeholder="example.com www.example.com"
|
|
className="form-control"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Path (optional)</label>
|
|
<input name="path" type="text" placeholder="/www/wwwroot/example.com" className="form-control" />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">PHP Version</label>
|
|
<select name="php_version" className="form-select">
|
|
<option value="74">7.4</option>
|
|
<option value="80">8.0</option>
|
|
<option value="81">8.1</option>
|
|
<option value="82">8.2</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-check mb-3">
|
|
<input name="force_https" type="checkbox" id="create_force_https" className="form-check-input" />
|
|
<label htmlFor="create_force_https" className="form-check-label">
|
|
Force HTTPS (redirect HTTP to HTTPS)
|
|
</label>
|
|
</div>
|
|
<hr className="my-3" />
|
|
<p className="small text-secondary mb-2">Reverse proxy (optional): leave empty for PHP/static site.</p>
|
|
<div className="mb-3">
|
|
<label className="form-label">Upstream URL</label>
|
|
<input name="proxy_upstream" type="text" className="form-control" placeholder="http://127.0.0.1:3000" />
|
|
</div>
|
|
<div className="form-check mb-3">
|
|
<input name="proxy_websocket" type="checkbox" id="create_proxy_ws" className="form-check-input" />
|
|
<label htmlFor="create_proxy_ws" className="form-check-label">
|
|
WebSocket headers (Upgrade)
|
|
</label>
|
|
</div>
|
|
<p className="small text-secondary mb-2">Directory HTTP auth (requires htpasswd file on server).</p>
|
|
<div className="mb-2">
|
|
<input name="dir_auth_path" type="text" className="form-control form-control-sm" placeholder="Path prefix e.g. /staff" />
|
|
</div>
|
|
<div className="mb-3">
|
|
<input name="dir_auth_user_file" type="text" className="form-control form-control-sm" placeholder="Absolute path to htpasswd" />
|
|
</div>
|
|
<div className="form-check mb-3">
|
|
<input name="php_deny_execute" type="checkbox" id="create_php_deny" className="form-check-input" />
|
|
<label htmlFor="create_php_deny" className="form-check-label">
|
|
Block PHP execution under /uploads and /storage
|
|
</label>
|
|
</div>
|
|
<div className="mb-0">
|
|
<label className="form-label">Note (optional)</label>
|
|
<input name="ps" type="text" placeholder="My website" className="form-control" />
|
|
</div>
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<button type="button" className="btn btn-light" onClick={() => setShowCreate(false)}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={creating} className="btn btn-primary">
|
|
{creating ? 'Creating…' : 'Create'}
|
|
</button>
|
|
</Modal.Footer>
|
|
</form>
|
|
</Modal>
|
|
|
|
<div className="card shadow-sm border-0 mb-3">
|
|
<div className="card-body py-3">
|
|
<div className="row g-2 align-items-center">
|
|
<div className="col-md-4">
|
|
<div className="input-group input-group-sm">
|
|
<span className="input-group-text">
|
|
<i className="ti ti-search" aria-hidden />
|
|
</span>
|
|
<input
|
|
type="search"
|
|
className="form-control"
|
|
placeholder="Site name, domain, path, or note…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
aria-label="Search sites"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-5">
|
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
|
<AdminButton variant="secondary" size="sm" onClick={() => runBatch('enable')} disabled={batchLoading || selectedIds.size === 0}>
|
|
Enable
|
|
</AdminButton>
|
|
<AdminButton variant="secondary" size="sm" onClick={() => runBatch('disable')} disabled={batchLoading || selectedIds.size === 0}>
|
|
Disable
|
|
</AdminButton>
|
|
<AdminButton variant="danger" size="sm" onClick={() => runBatch('delete')} disabled={batchLoading || selectedIds.size === 0}>
|
|
Delete
|
|
</AdminButton>
|
|
<span className="text-secondary small ms-md-2">
|
|
{selectedIds.size > 0 ? `${selectedIds.size} selected` : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3 text-md-end">
|
|
<span
|
|
className={`badge ${nginxStatus === 'active' ? 'bg-success' : 'bg-secondary'} me-2`}
|
|
title="Nginx (systemd)"
|
|
>
|
|
Nginx {nginxStatus ?? '—'}
|
|
</span>
|
|
<select
|
|
className="form-select form-select-sm d-inline-block w-auto"
|
|
value={pageSize}
|
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
|
aria-label="Rows per page"
|
|
>
|
|
<option value={10}>10 / page</option>
|
|
<option value={25}>25 / page</option>
|
|
<option value={50}>50 / page</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card shadow-sm border-0">
|
|
<div className="card-body p-0">
|
|
<AdminTable>
|
|
<thead className="table-light">
|
|
<tr>
|
|
<th style={{ width: 40 }}>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={paginatedSites.length > 0 && paginatedSites.every((s) => selectedIds.has(s.id))}
|
|
onChange={selectAllOnPage}
|
|
aria-label="Select all on page"
|
|
/>
|
|
</th>
|
|
<th>Site</th>
|
|
<th className="text-center">Status</th>
|
|
<th>Backup</th>
|
|
<th className="text-center">Quick</th>
|
|
<th className="text-center">PHP</th>
|
|
<th>SSL</th>
|
|
<th className="d-none d-xl-table-cell">Note</th>
|
|
<th className="text-end">Operate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sites.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={9} className="p-0">
|
|
<EmptyState title="No sites yet" description='Click "Add Site" to create one.' />
|
|
</td>
|
|
</tr>
|
|
) : filteredSites.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={9} className="p-0">
|
|
<EmptyState title="No matches" description="Try a different search." />
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedSites.map((s) => (
|
|
<tr key={s.id}>
|
|
<td className="align-middle" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={selectedIds.has(s.id)}
|
|
onChange={() => toggleSelect(s.id)}
|
|
aria-label={`Select ${s.name}`}
|
|
/>
|
|
</td>
|
|
<td className="align-middle">
|
|
<div className="d-flex align-items-start gap-2">
|
|
{s.primary_domain ? (
|
|
<a
|
|
href={`https://${s.primary_domain.split(':')[0]}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-decoration-none"
|
|
title="Open site"
|
|
>
|
|
<i className="ti ti-external-link text-muted" aria-hidden />
|
|
</a>
|
|
) : null}
|
|
<div className="small">
|
|
<div className="fw-medium">{s.primary_domain || s.name}</div>
|
|
<div className="text-muted text-truncate" style={{ maxWidth: 220 }} title={s.path}>
|
|
{s.project_type} · {s.path}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="align-middle text-center">
|
|
{s.status === 1 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSetStatus(s.id, false)}
|
|
disabled={statusLoading === s.id}
|
|
className="btn btn-sm btn-link text-success p-0"
|
|
title="Running — click to stop"
|
|
>
|
|
<i className="ti ti-player-play" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSetStatus(s.id, true)}
|
|
disabled={statusLoading === s.id}
|
|
className="btn btn-sm btn-link text-secondary p-0"
|
|
title="Stopped — click to start"
|
|
>
|
|
<i className="ti ti-player-stop" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="align-middle small">
|
|
<span className="me-2">{s.backup_count}</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-link btn-sm text-danger p-0 text-decoration-none"
|
|
onClick={() => handleQuickBackup(s.id)}
|
|
disabled={rowBackupId === s.id}
|
|
>
|
|
{rowBackupId === s.id ? '…' : 'Backup now'}
|
|
</button>
|
|
</td>
|
|
<td className="align-middle text-center text-nowrap">
|
|
<Link
|
|
to={`/files?path=${encodeURIComponent(s.path)}`}
|
|
className="btn btn-sm btn-light border"
|
|
title="Site directory"
|
|
>
|
|
<i className="ti ti-folder" />
|
|
</Link>
|
|
</td>
|
|
<td className="align-middle text-center small">
|
|
<span className="badge bg-light text-dark border">{formatPhpVersion(s.php_version || '74')}</span>
|
|
</td>
|
|
<td className="align-middle small">
|
|
{(s.ssl?.status ?? 'none') === 'none' ? (
|
|
<span className="text-warning">Not set</span>
|
|
) : s.ssl?.status === 'expired' ? (
|
|
<span className="text-danger">Expired</span>
|
|
) : s.ssl?.status === 'expiring' ? (
|
|
<span className="text-warning">{s.ssl?.days_left ?? 0}d left</span>
|
|
) : (
|
|
<span className="text-success">{s.ssl?.days_left ?? 0} days</span>
|
|
)}
|
|
</td>
|
|
<td className="align-middle small text-muted d-none d-xl-table-cell text-truncate" style={{ maxWidth: 140 }} title={s.ps}>
|
|
{s.ps || '—'}
|
|
</td>
|
|
<td className="align-middle text-end">
|
|
<Dropdown align="end">
|
|
<Dropdown.Toggle variant="light" size="sm" className="py-0 border">
|
|
More
|
|
</Dropdown.Toggle>
|
|
<Dropdown.Menu>
|
|
<Dropdown.Item onClick={() => openEditModal(s.id)}>
|
|
<i className="ti ti-settings me-2" />
|
|
Config
|
|
</Dropdown.Item>
|
|
<Dropdown.Item as={Link} to="/logs">
|
|
<i className="ti ti-file-text me-2" />
|
|
Logs
|
|
</Dropdown.Item>
|
|
<Dropdown.Item as={Link} to="/ssl_domain">
|
|
<i className="ti ti-shield-lock me-2" />
|
|
SSL / Domains
|
|
</Dropdown.Item>
|
|
<Dropdown.Divider />
|
|
<Dropdown.Item
|
|
onClick={() => {
|
|
setGitSiteId(s.id)
|
|
setGitAction('clone')
|
|
setGitUrl('')
|
|
setGitBranch('main')
|
|
}}
|
|
>
|
|
<i className="ti ti-git-branch me-2" />
|
|
Git
|
|
</Dropdown.Item>
|
|
<Dropdown.Item onClick={() => openRedirectModal(s.id)}>
|
|
<i className="ti ti-arrows-right-left me-2" />
|
|
Redirects
|
|
</Dropdown.Item>
|
|
<Dropdown.Item onClick={() => openBackupModal(s.id)}>
|
|
<i className="ti ti-archive me-2" />
|
|
Backups
|
|
</Dropdown.Item>
|
|
<Dropdown.Divider />
|
|
<Dropdown.Item className="text-danger" onClick={() => handleDelete(s.id, s.name)}>
|
|
<i className="ti ti-trash me-2" />
|
|
Delete
|
|
</Dropdown.Item>
|
|
</Dropdown.Menu>
|
|
</Dropdown>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</AdminTable>
|
|
</div>
|
|
{sites.length > 0 && filteredSites.length > 0 ? (
|
|
<div className="card-footer py-2 d-flex flex-wrap align-items-center justify-content-between gap-2 small text-secondary">
|
|
<span>
|
|
Total <strong>{filteredSites.length}</strong> site(s)
|
|
{search.trim() ? ` (filtered from ${sites.length})` : ''}
|
|
</span>
|
|
<div className="d-flex align-items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-outline-secondary"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
Prev
|
|
</button>
|
|
<span>
|
|
Page {page} / {totalPages}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-outline-secondary"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<Modal show={!!gitSiteId && !!gitAction} onHide={() => { setGitSiteId(null); setGitAction(null) }} centered>
|
|
<Modal.Header closeButton>
|
|
<Modal.Title>Git Deploy</Modal.Title>
|
|
</Modal.Header>
|
|
<Modal.Body>
|
|
<div className="btn-group mb-3" role="group">
|
|
<button
|
|
type="button"
|
|
className={`btn btn-sm ${gitAction === 'clone' ? 'btn-success' : 'btn-outline-secondary'}`}
|
|
onClick={() => setGitAction('clone')}
|
|
>
|
|
Clone
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-sm ${gitAction === 'pull' ? 'btn-success' : 'btn-outline-secondary'}`}
|
|
onClick={() => setGitAction('pull')}
|
|
>
|
|
Pull
|
|
</button>
|
|
</div>
|
|
{gitAction === 'clone' ? (
|
|
<form onSubmit={handleGitClone}>
|
|
<div className="mb-3">
|
|
<label className="form-label">Repository URL</label>
|
|
<input
|
|
value={gitUrl}
|
|
onChange={(e) => setGitUrl(e.target.value)}
|
|
placeholder="https://github.com/user/repo.git"
|
|
className="form-control"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Branch</label>
|
|
<input
|
|
value={gitBranch}
|
|
onChange={(e) => setGitBranch(e.target.value)}
|
|
placeholder="main"
|
|
className="form-control"
|
|
/>
|
|
</div>
|
|
<p className="small text-muted">Site path must be empty for clone.</p>
|
|
<div className="d-flex justify-content-end gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-light"
|
|
onClick={() => {
|
|
setGitSiteId(null)
|
|
setGitAction(null)
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={gitLoading} className="btn btn-success">
|
|
{gitLoading ? 'Cloning…' : 'Clone'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div>
|
|
<p className="text-muted small">Pull latest changes from the remote repository.</p>
|
|
<div className="d-flex justify-content-end gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-light"
|
|
onClick={() => {
|
|
setGitSiteId(null)
|
|
setGitAction(null)
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="button" onClick={handleGitPull} disabled={gitLoading} className="btn btn-success">
|
|
{gitLoading ? 'Pulling…' : 'Pull'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal.Body>
|
|
</Modal>
|
|
|
|
<Modal show={redirectSiteId != null} onHide={() => setRedirectSiteId(null)} size="lg" centered scrollable>
|
|
<Modal.Header closeButton>
|
|
<Modal.Title>Redirects</Modal.Title>
|
|
</Modal.Header>
|
|
<Modal.Body>
|
|
<form onSubmit={handleAddRedirect} className="row g-2 align-items-end mb-3">
|
|
<div className="col-md-3">
|
|
<label className="form-label small">Source</label>
|
|
<input
|
|
value={redirectSource}
|
|
onChange={(e) => setRedirectSource(e.target.value)}
|
|
placeholder="/old-path"
|
|
className="form-control form-control-sm"
|
|
/>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label small">Target</label>
|
|
<input
|
|
value={redirectTarget}
|
|
onChange={(e) => setRedirectTarget(e.target.value)}
|
|
placeholder="/new-path"
|
|
className="form-control form-control-sm"
|
|
/>
|
|
</div>
|
|
<div className="col-md-2">
|
|
<label className="form-label small">Code</label>
|
|
<select
|
|
value={redirectCode}
|
|
onChange={(e) => setRedirectCode(Number(e.target.value))}
|
|
className="form-select form-select-sm"
|
|
>
|
|
<option value={301}>301</option>
|
|
<option value={302}>302</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-2">
|
|
<button type="submit" disabled={redirectAdding} className="btn btn-primary btn-sm w-100">
|
|
Add
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{redirects.length === 0 ? (
|
|
<p className="text-muted small mb-0">No redirects</p>
|
|
) : (
|
|
<ul className="list-group list-group-flush">
|
|
{redirects.map((r) => (
|
|
<li key={r.id} className="list-group-item d-flex align-items-center gap-2 small">
|
|
<code className="text-truncate">{r.source}</code>
|
|
<span className="text-muted">→</span>
|
|
<code className="text-truncate flex-grow-1">{r.target}</code>
|
|
<span className="badge bg-secondary">{r.code}</span>
|
|
<button type="button" className="btn btn-sm btn-link text-danger p-0" onClick={() => handleDeleteRedirect(r.id)}>
|
|
<i className="ti ti-trash" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<button type="button" className="btn btn-light" onClick={() => setRedirectSiteId(null)}>
|
|
Close
|
|
</button>
|
|
</Modal.Footer>
|
|
</Modal>
|
|
|
|
<Modal show={editSiteId != null && editForm != null} onHide={() => { setEditSiteId(null); setEditForm(null) }} centered>
|
|
<Modal.Header closeButton>
|
|
<Modal.Title>Edit Site</Modal.Title>
|
|
</Modal.Header>
|
|
{editForm ? (
|
|
<form onSubmit={handleEdit}>
|
|
<Modal.Body>
|
|
{editError ? <AdminAlert variant="danger">{editError}</AdminAlert> : null}
|
|
<div className="mb-3">
|
|
<label className="form-label">Domain(s)</label>
|
|
<input
|
|
value={editForm.domains}
|
|
onChange={(e) => setEditForm({ ...editForm, domains: e.target.value })}
|
|
type="text"
|
|
className="form-control"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Path (optional)</label>
|
|
<input
|
|
value={editForm.path}
|
|
onChange={(e) => setEditForm({ ...editForm, path: e.target.value })}
|
|
type="text"
|
|
className="form-control"
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">PHP Version</label>
|
|
<select
|
|
value={editForm.php_version}
|
|
onChange={(e) => setEditForm({ ...editForm, php_version: e.target.value })}
|
|
className="form-select"
|
|
>
|
|
<option value="74">7.4</option>
|
|
<option value="80">8.0</option>
|
|
<option value="81">8.1</option>
|
|
<option value="82">8.2</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-check mb-3">
|
|
<input
|
|
type="checkbox"
|
|
id="edit_force_https"
|
|
className="form-check-input"
|
|
checked={editForm.force_https}
|
|
onChange={(e) => setEditForm({ ...editForm, force_https: e.target.checked })}
|
|
/>
|
|
<label htmlFor="edit_force_https" className="form-check-label">
|
|
Force HTTPS
|
|
</label>
|
|
</div>
|
|
<hr className="my-2" />
|
|
<div className="mb-2">
|
|
<label className="form-label small">Reverse proxy upstream</label>
|
|
<input
|
|
value={editForm.proxy_upstream}
|
|
onChange={(e) => setEditForm({ ...editForm, proxy_upstream: e.target.value })}
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
placeholder="http://127.0.0.1:3000"
|
|
/>
|
|
</div>
|
|
<div className="form-check mb-3">
|
|
<input
|
|
type="checkbox"
|
|
id="edit_proxy_ws"
|
|
className="form-check-input"
|
|
checked={editForm.proxy_websocket}
|
|
onChange={(e) => setEditForm({ ...editForm, proxy_websocket: e.target.checked })}
|
|
/>
|
|
<label htmlFor="edit_proxy_ws" className="form-check-label small">
|
|
WebSocket proxy headers
|
|
</label>
|
|
</div>
|
|
<div className="mb-2">
|
|
<label className="form-label small">Auth path prefix</label>
|
|
<input
|
|
value={editForm.dir_auth_path}
|
|
onChange={(e) => setEditForm({ ...editForm, dir_auth_path: e.target.value })}
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label small">htpasswd file path</label>
|
|
<input
|
|
value={editForm.dir_auth_user_file}
|
|
onChange={(e) => setEditForm({ ...editForm, dir_auth_user_file: e.target.value })}
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
/>
|
|
</div>
|
|
<div className="form-check mb-3">
|
|
<input
|
|
type="checkbox"
|
|
id="edit_php_deny"
|
|
className="form-check-input"
|
|
checked={editForm.php_deny_execute}
|
|
onChange={(e) => setEditForm({ ...editForm, php_deny_execute: e.target.checked })}
|
|
/>
|
|
<label htmlFor="edit_php_deny" className="form-check-label small">
|
|
Deny PHP under /uploads and /storage
|
|
</label>
|
|
</div>
|
|
<div className="mb-0">
|
|
<label className="form-label">Note (optional)</label>
|
|
<input
|
|
value={editForm.ps}
|
|
onChange={(e) => setEditForm({ ...editForm, ps: e.target.value })}
|
|
type="text"
|
|
className="form-control"
|
|
/>
|
|
</div>
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<button type="button" className="btn btn-light" onClick={() => { setEditSiteId(null); setEditForm(null) }}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={editLoading} className="btn btn-primary">
|
|
{editLoading ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</Modal.Footer>
|
|
</form>
|
|
) : null}
|
|
</Modal>
|
|
|
|
<Modal show={backupSiteId != null} onHide={() => setBackupSiteId(null)} size="lg" centered>
|
|
<Modal.Header closeButton>
|
|
<Modal.Title>Site Backup</Modal.Title>
|
|
</Modal.Header>
|
|
<Modal.Body>
|
|
<div className="mb-3">
|
|
<AdminButton onClick={handleCreateBackup} disabled={backupLoading}>
|
|
<i className="ti ti-archive me-1" />
|
|
{backupLoading ? 'Creating…' : 'Create Backup'}
|
|
</AdminButton>
|
|
</div>
|
|
<h6 className="text-muted small">Existing backups</h6>
|
|
{backups.length === 0 ? (
|
|
<p className="text-muted small">No backups yet</p>
|
|
) : (
|
|
<ul className="list-group list-group-flush">
|
|
{backups.map((b) => (
|
|
<li key={b.filename} className="list-group-item d-flex align-items-center justify-content-between gap-2">
|
|
<code className="small text-truncate flex-grow-1">{b.filename}</code>
|
|
<span className="text-muted small">{formatSize(b.size)}</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-link"
|
|
onClick={() => handleDownloadBackup(b.filename)}
|
|
title="Download"
|
|
>
|
|
<i className="ti ti-download" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-link text-warning"
|
|
onClick={() => handleRestore(b.filename)}
|
|
disabled={backupLoading}
|
|
>
|
|
<i className="ti ti-restore" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<button type="button" className="btn btn-light" onClick={() => setBackupSiteId(null)}>
|
|
Close
|
|
</button>
|
|
</Modal.Footer>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|