new changes
This commit is contained in:
@@ -74,6 +74,39 @@ export async function createSite(data: { name: string; domains: string[]; path?:
|
||||
})
|
||||
}
|
||||
|
||||
export type SiteSslInfo = {
|
||||
status: 'none' | 'active' | 'expiring' | 'expired'
|
||||
days_left: number | null
|
||||
cert_name: string | null
|
||||
}
|
||||
|
||||
export type SiteListItem = {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
status: number
|
||||
ps: string
|
||||
project_type: string
|
||||
domain_count: number
|
||||
addtime: string | null
|
||||
php_version: string
|
||||
primary_domain: string
|
||||
domains: string[]
|
||||
backup_count: number
|
||||
ssl: SiteSslInfo
|
||||
}
|
||||
|
||||
export async function listSites() {
|
||||
return apiRequest<SiteListItem[]>('/site/list')
|
||||
}
|
||||
|
||||
export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: number[]) {
|
||||
return apiRequest<{ results: { id: number; ok: boolean; msg: string }[] }>('/site/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ids }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSite(siteId: number) {
|
||||
return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
|
||||
`/site/${siteId}`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import Modal from 'react-bootstrap/Modal'
|
||||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md'
|
||||
type Clip = { op: 'copy' | 'cut'; entries: { parent: string; name: string }[] }
|
||||
|
||||
export function FilesPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [path, setPath] = useState('/')
|
||||
const [pathInput, setPathInput] = useState('/')
|
||||
const [items, setItems] = useState<FileListItem[]>([])
|
||||
@@ -92,8 +94,17 @@ export function FilesPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadDir(path)
|
||||
}, [])
|
||||
const raw = searchParams.get('path')
|
||||
let p = '/'
|
||||
if (raw && raw.trim()) {
|
||||
try {
|
||||
p = decodeURIComponent(raw.trim())
|
||||
} catch {
|
||||
p = raw.trim()
|
||||
}
|
||||
}
|
||||
loadDir(p)
|
||||
}, [searchParams, loadDir])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 {
|
||||
apiRequest,
|
||||
listSites,
|
||||
siteBatch,
|
||||
createSite,
|
||||
getSite,
|
||||
updateSite,
|
||||
@@ -16,22 +19,18 @@ import {
|
||||
deleteSiteRedirect,
|
||||
siteGitClone,
|
||||
siteGitPull,
|
||||
listServices,
|
||||
type SiteListItem,
|
||||
} from '../api/client'
|
||||
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
|
||||
|
||||
interface Site {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
status: number
|
||||
ps: string
|
||||
project_type: string
|
||||
domain_count: number
|
||||
addtime: string | null
|
||||
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<Site[]>([])
|
||||
const [sites, setSites] = useState<SiteListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -62,10 +61,17 @@ export function SitePage() {
|
||||
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)
|
||||
apiRequest<Site[]>('/site/list')
|
||||
listSites()
|
||||
.then(setSites)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -75,6 +81,90 @@ export function SitePage() {
|
||||
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
|
||||
@@ -341,99 +431,237 @@ export function SitePage() {
|
||||
</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>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Domains</th>
|
||||
<th>Type</th>
|
||||
<th className="text-end">Actions</th>
|
||||
<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={5} className="p-0">
|
||||
<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>
|
||||
) : (
|
||||
sites.map((s) => (
|
||||
paginatedSites.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="align-middle">{s.name}</td>
|
||||
<td className="align-middle text-muted">{s.path}</td>
|
||||
<td className="align-middle">{s.domain_count}</td>
|
||||
<td className="align-middle">{s.project_type}</td>
|
||||
<td className="align-middle text-end text-nowrap">
|
||||
<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-outline-warning me-1"
|
||||
title="Stop"
|
||||
className="btn btn-sm btn-link text-success p-0"
|
||||
title="Running — click to stop"
|
||||
>
|
||||
<i className="ti ti-player-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-outline-success me-1"
|
||||
title="Start"
|
||||
className="btn btn-sm btn-link text-secondary p-0"
|
||||
title="Stopped — click to start"
|
||||
>
|
||||
<i className="ti ti-player-play" />
|
||||
<i className="ti ti-player-stop" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="align-middle small">
|
||||
<span className="me-2">{s.backup_count}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGitSiteId(s.id)
|
||||
setGitAction('clone')
|
||||
setGitUrl('')
|
||||
setGitBranch('main')
|
||||
}}
|
||||
className="btn btn-sm btn-outline-success me-1"
|
||||
title="Git Deploy"
|
||||
className="btn btn-link btn-sm text-danger p-0 text-decoration-none"
|
||||
onClick={() => handleQuickBackup(s.id)}
|
||||
disabled={rowBackupId === s.id}
|
||||
>
|
||||
<i className="ti ti-git-branch" />
|
||||
{rowBackupId === s.id ? '…' : 'Backup now'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openRedirectModal(s.id)}
|
||||
className="btn btn-sm btn-outline-secondary me-1"
|
||||
title="Redirects"
|
||||
</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-arrows-right-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(s.id)}
|
||||
className="btn btn-sm btn-outline-primary me-1"
|
||||
title="Edit"
|
||||
>
|
||||
<i className="ti ti-pencil" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBackupModal(s.id)}
|
||||
className="btn btn-sm btn-outline-primary me-1"
|
||||
title="Backup"
|
||||
>
|
||||
<i className="ti ti-archive" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(s.id, s.name)}
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
title="Delete"
|
||||
>
|
||||
<i className="ti ti-trash" />
|
||||
</button>
|
||||
<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>
|
||||
))
|
||||
@@ -441,6 +669,35 @@ export function SitePage() {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user