feat: system tools and admin enhancements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
|
||||
import { useEffect, useMemo, useRef, useState, useId } from 'react'
|
||||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||
import DataTable, { createTheme } from 'react-data-table-component'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
createForum,
|
||||
deleteForum,
|
||||
fetchSettings,
|
||||
fetchStats,
|
||||
fetchVersionCheck,
|
||||
runSystemUpdate,
|
||||
fetchSystemStatus,
|
||||
listAllForums,
|
||||
listRanks,
|
||||
listRoles,
|
||||
listUsers,
|
||||
listAuditLogs,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
saveSettings,
|
||||
@@ -38,7 +43,26 @@ import {
|
||||
deleteAttachmentExtension,
|
||||
} from '../api/client'
|
||||
|
||||
export default function Acp({ isAdmin }) {
|
||||
const StatusIcon = ({ status = 'bad', tooltip }) => {
|
||||
const id = useId()
|
||||
const iconClass =
|
||||
status === 'ok' ? 'bi-check-circle-fill' : status === 'warn' ? 'bi-question-circle-fill' : 'bi-x-circle-fill'
|
||||
const content = (
|
||||
<span className={`bb-status-icon is-${status}`}>
|
||||
<i className={`bi ${iconClass}`} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
if (!tooltip) {
|
||||
return content
|
||||
}
|
||||
return (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip id={id}>{tooltip}</Tooltip>}>
|
||||
<span>{content}</span>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Acp({ isAdmin }) {
|
||||
const { t } = useTranslation()
|
||||
const { roles: authRoles } = useAuth()
|
||||
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
|
||||
@@ -54,6 +78,24 @@ export default function Acp({ isAdmin }) {
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [usersError, setUsersError] = useState('')
|
||||
const [auditLogs, setAuditLogs] = useState([])
|
||||
const [auditSearch, setAuditSearch] = useState('')
|
||||
const [auditLoading, setAuditLoading] = useState(false)
|
||||
const [auditError, setAuditError] = useState('')
|
||||
const [auditLimit, setAuditLimit] = useState(200)
|
||||
const [boardStats, setBoardStats] = useState(null)
|
||||
const [boardStatsLoading, setBoardStatsLoading] = useState(false)
|
||||
const [boardStatsError, setBoardStatsError] = useState('')
|
||||
const [versionCheck, setVersionCheck] = useState(null)
|
||||
const [versionChecking, setVersionChecking] = useState(false)
|
||||
const [versionCheckError, setVersionCheckError] = useState('')
|
||||
const [updateModalOpen, setUpdateModalOpen] = useState(false)
|
||||
const [updateLog, setUpdateLog] = useState([])
|
||||
const [updateRunning, setUpdateRunning] = useState(false)
|
||||
const [updateError, setUpdateError] = useState('')
|
||||
const [systemStatus, setSystemStatus] = useState(null)
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||
@@ -656,6 +698,249 @@ export default function Acp({ isAdmin }) {
|
||||
[themeMode]
|
||||
)
|
||||
|
||||
const formatAuditAction = (action) => {
|
||||
if (!action) return ''
|
||||
return action
|
||||
.replace(/[._]/g, ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
const formatAuditSubject = (entry) => {
|
||||
if (!entry) return '-'
|
||||
const meta = entry.metadata || {}
|
||||
if (meta.title) return meta.title
|
||||
if (meta.original_name) return meta.original_name
|
||||
if (meta.name) return meta.name
|
||||
if (entry.subject_type) {
|
||||
const base = entry.subject_type.split('\\').pop()
|
||||
return entry.subject_id ? `${base} #${entry.subject_id}` : base
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const filteredAuditLogs = useMemo(() => {
|
||||
const term = auditSearch.trim().toLowerCase()
|
||||
if (!term) return auditLogs
|
||||
return auditLogs.filter((entry) => {
|
||||
const metaValues = []
|
||||
if (entry.metadata && typeof entry.metadata === 'object') {
|
||||
Object.values(entry.metadata).forEach((value) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
metaValues.push(String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
const haystack = [
|
||||
formatAuditAction(entry.action),
|
||||
formatAuditSubject(entry),
|
||||
entry.user?.name || '',
|
||||
entry.user?.email || '',
|
||||
entry.ip_address || '',
|
||||
...metaValues,
|
||||
]
|
||||
return haystack.some((value) => value.toLowerCase().includes(term))
|
||||
})
|
||||
}, [auditLogs, auditSearch])
|
||||
|
||||
const adminAuditLogs = useMemo(() => {
|
||||
return auditLogs.filter((entry) =>
|
||||
Array.isArray(entry.user?.roles) && entry.user.roles.includes('ROLE_ADMIN')
|
||||
)
|
||||
}, [auditLogs])
|
||||
|
||||
const recentAdminLogs = useMemo(() => adminAuditLogs.slice(0, 5), [adminAuditLogs])
|
||||
|
||||
const formatNumber = (value) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return new Intl.NumberFormat().format(value)
|
||||
}
|
||||
|
||||
const formatDecimal = (value) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === null || bytes === undefined) return '—'
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
|
||||
const value = bytes / 1024 ** idx
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[idx]}`
|
||||
}
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatBool = (value) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return value ? t('stats.on') : t('stats.off')
|
||||
}
|
||||
|
||||
const handleVersionCheck = async () => {
|
||||
setVersionChecking(true)
|
||||
setVersionCheckError('')
|
||||
try {
|
||||
const data = await fetchVersionCheck()
|
||||
setVersionCheck(data)
|
||||
} catch (err) {
|
||||
setVersionCheckError(err.message)
|
||||
} finally {
|
||||
setVersionChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunUpdate = async () => {
|
||||
setUpdateRunning(true)
|
||||
setUpdateError('')
|
||||
setUpdateLog([])
|
||||
try {
|
||||
const data = await runSystemUpdate()
|
||||
setUpdateLog(data.log || [])
|
||||
} catch (err) {
|
||||
setUpdateError(err.message)
|
||||
} finally {
|
||||
setUpdateRunning(false)
|
||||
handleVersionCheck()
|
||||
}
|
||||
}
|
||||
|
||||
const loadSystemStatus = async () => {
|
||||
setSystemLoading(true)
|
||||
setSystemError('')
|
||||
try {
|
||||
const data = await fetchSystemStatus()
|
||||
setSystemStatus(data)
|
||||
} catch (err) {
|
||||
setSystemError(err.message)
|
||||
} finally {
|
||||
setSystemLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
handleVersionCheck()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
loadSystemStatus()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const statsLeft = useMemo(() => {
|
||||
const versionMeta = (() => {
|
||||
if (versionChecking) return t('version.checking')
|
||||
if (versionCheckError) return t('version.unknown')
|
||||
if (!versionCheck) return t('version.unknown')
|
||||
if (versionCheck.is_latest === true) return t('version.up_to_date')
|
||||
if (versionCheck.is_latest === false) {
|
||||
return versionCheck.latest_version
|
||||
? t('version.update_available', { version: versionCheck.latest_version })
|
||||
: t('version.update_available_short')
|
||||
}
|
||||
return t('version.unknown')
|
||||
})()
|
||||
const showUpdate = versionCheck?.is_latest === false
|
||||
|
||||
return [
|
||||
{ label: t('stats.board_started'), value: formatDateTime(boardStats?.board_started_at) },
|
||||
{ label: t('stats.avatar_directory_size'), value: formatBytes(boardStats?.avatar_directory_size_bytes) },
|
||||
{ label: t('stats.database_size'), value: formatBytes(boardStats?.database_size_bytes) },
|
||||
{ label: t('stats.attachments_size'), value: formatBytes(boardStats?.attachments_size_bytes) },
|
||||
{ label: t('stats.database_server'), value: boardStats?.database_server || '—' },
|
||||
{ label: t('stats.gzip_compression'), value: formatBool(boardStats?.gzip_compression) },
|
||||
{ label: t('stats.php_version'), value: boardStats?.php_version || '—' },
|
||||
{ label: t('stats.orphan_attachments'), value: formatNumber(boardStats?.orphan_attachments) },
|
||||
{
|
||||
label: t('stats.board_version'),
|
||||
value: (
|
||||
<div className="bb-acp-version-inline">
|
||||
<span>{boardStats?.board_version || '—'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 bb-acp-version-link"
|
||||
onClick={handleVersionCheck}
|
||||
disabled={versionChecking}
|
||||
>
|
||||
{t('version.recheck')}
|
||||
</button>
|
||||
{showUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 bb-acp-version-link"
|
||||
onClick={() => setUpdateModalOpen(true)}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
{t('version.update_now')}
|
||||
</button>
|
||||
)}
|
||||
<span className="bb-acp-version-meta">{versionMeta}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [t, boardStats, formatBool, versionCheck, versionChecking, versionCheckError, updateRunning])
|
||||
|
||||
const statsRight = useMemo(() => {
|
||||
return [
|
||||
{ label: t('stats.posts'), value: formatNumber(boardStats?.posts) },
|
||||
{ label: t('stats.posts_per_day'), value: formatDecimal(boardStats?.posts_per_day) },
|
||||
{ label: t('stats.topics'), value: formatNumber(boardStats?.threads) },
|
||||
{ label: t('stats.topics_per_day'), value: formatDecimal(boardStats?.topics_per_day) },
|
||||
{ label: t('stats.users'), value: formatNumber(boardStats?.users) },
|
||||
{ label: t('stats.users_per_day'), value: formatDecimal(boardStats?.users_per_day) },
|
||||
{ label: t('stats.attachments'), value: formatNumber(boardStats?.attachments) },
|
||||
{ label: t('stats.attachments_per_day'), value: formatDecimal(boardStats?.attachments_per_day) },
|
||||
]
|
||||
}, [t, boardStats])
|
||||
|
||||
const auditColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t('audit.created_at'),
|
||||
selector: (row) => row.created_at,
|
||||
sortable: true,
|
||||
width: '190px',
|
||||
cell: (row) =>
|
||||
row.created_at ? new Date(row.created_at).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
name: t('audit.user'),
|
||||
selector: (row) => row.user?.name || '',
|
||||
sortable: true,
|
||||
cell: (row) => row.user?.name || row.user?.email || '-',
|
||||
},
|
||||
{
|
||||
name: t('audit.action'),
|
||||
selector: (row) => row.action || '',
|
||||
sortable: true,
|
||||
cell: (row) => formatAuditAction(row.action),
|
||||
},
|
||||
{
|
||||
name: t('audit.subject'),
|
||||
selector: (row) => formatAuditSubject(row),
|
||||
sortable: true,
|
||||
grow: 2,
|
||||
cell: (row) => formatAuditSubject(row),
|
||||
},
|
||||
{
|
||||
name: t('audit.ip'),
|
||||
selector: (row) => row.ip_address || '',
|
||||
sortable: true,
|
||||
width: '160px',
|
||||
cell: (row) => row.ip_address || '-',
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
|
||||
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
|
||||
const current = Math.min(usersPage, totalPages)
|
||||
@@ -763,6 +1048,44 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshBoardStats = async () => {
|
||||
setBoardStatsLoading(true)
|
||||
setBoardStatsError('')
|
||||
try {
|
||||
const data = await fetchStats()
|
||||
setBoardStats(data)
|
||||
} catch (err) {
|
||||
setBoardStatsError(err.message)
|
||||
} finally {
|
||||
setBoardStatsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
refreshBoardStats()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshAuditLogs = async () => {
|
||||
setAuditLoading(true)
|
||||
setAuditError('')
|
||||
try {
|
||||
const data = await listAuditLogs(auditLimit)
|
||||
setAuditLogs(data)
|
||||
} catch (err) {
|
||||
setAuditError(err.message)
|
||||
} finally {
|
||||
setAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
refreshAuditLogs()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roleMenuOpen) return
|
||||
const handleClick = (event) => {
|
||||
@@ -2260,6 +2583,62 @@ export default function Acp({ isAdmin }) {
|
||||
<p className="bb-muted mb-0">{t('acp.general_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel mb-4">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h5 className="mb-0">{t('acp.statistics')}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={refreshBoardStats}
|
||||
disabled={boardStatsLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{boardStatsError && <p className="text-danger mb-2">{boardStatsError}</p>}
|
||||
{boardStatsLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
|
||||
{!boardStatsLoading && (
|
||||
<div className="bb-acp-stats-grid">
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('stats.statistic')}</th>
|
||||
<th>{t('stats.value')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsLeft.map((stat) => (
|
||||
<tr key={stat.label}>
|
||||
<td>{stat.label}</td>
|
||||
<td className="bb-acp-stats-value">{stat.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('stats.statistic')}</th>
|
||||
<th>{t('stats.value')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsRight.map((stat) => (
|
||||
<tr key={stat.label}>
|
||||
<td>{stat.label}</td>
|
||||
<td className="bb-acp-stats-value">{stat.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{generalError && <p className="text-danger">{generalError}</p>}
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
@@ -2560,6 +2939,71 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel mt-4">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h5 className="mb-1">{t('acp.admin_log_title')}</h5>
|
||||
<p className="bb-muted mb-0">{t('acp.admin_log_hint')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{auditLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
|
||||
{!auditLoading && recentAdminLogs.length === 0 && (
|
||||
<p className="bb-muted mb-0">{t('admin_log.empty')}</p>
|
||||
)}
|
||||
{!auditLoading && recentAdminLogs.length > 0 && (
|
||||
<div className="bb-acp-admin-log">
|
||||
<table className="bb-acp-admin-log__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin_log.username')}</th>
|
||||
<th>{t('admin_log.user_ip')}</th>
|
||||
<th>{t('admin_log.time')}</th>
|
||||
<th>{t('admin_log.action')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentAdminLogs.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.user?.name || entry.user?.email || '—'}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
<td>{formatDateTime(entry.created_at)}</td>
|
||||
<td>{formatAuditAction(entry.action)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0"
|
||||
onClick={() => {
|
||||
const target = document.querySelector('[data-rb-event-key="audit"]')
|
||||
if (target) target.click()
|
||||
}}
|
||||
>
|
||||
{t('acp.view_admin_log')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab>
|
||||
@@ -3011,6 +3455,308 @@ export default function Acp({ isAdmin }) {
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="audit" title={t('acp.audit_logs')}>
|
||||
{auditError && <p className="text-danger">{auditError}</p>}
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<Form.Control
|
||||
className="bb-user-search"
|
||||
value={auditSearch}
|
||||
onChange={(event) => setAuditSearch(event.target.value)}
|
||||
placeholder={t('audit.search')}
|
||||
/>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
type="number"
|
||||
min="50"
|
||||
max="500"
|
||||
value={auditLimit}
|
||||
onChange={(event) => setAuditLimit(Number(event.target.value) || 200)}
|
||||
className="bb-audit-limit"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{auditLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!auditLoading && filteredAuditLogs.length === 0 && (
|
||||
<p className="bb-muted">{t('audit.empty')}</p>
|
||||
)}
|
||||
{!auditLoading && filteredAuditLogs.length > 0 && (
|
||||
<DataTable
|
||||
columns={auditColumns}
|
||||
data={filteredAuditLogs}
|
||||
pagination
|
||||
striped
|
||||
highlightOnHover={themeMode !== 'dark'}
|
||||
dense
|
||||
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
||||
customStyles={userTableStyles}
|
||||
paginationComponentOptions={{
|
||||
rowsPerPageText: t('table.rows_per_page'),
|
||||
rangeSeparatorText: t('table.range_separator'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="system" title={t('acp.system')}>
|
||||
{systemError && <p className="text-danger">{systemError}</p>}
|
||||
{systemLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!systemLoading && systemStatus && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h5 className="mb-0">{t('system.requirements')}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('system.check')}</th>
|
||||
<th>{t('system.path')}</th>
|
||||
<th>{t('system.min_version')}</th>
|
||||
<th>{t('system.current_version')}</th>
|
||||
<th>{t('system.status')}</th>
|
||||
<th>{t('system.recheck')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>PHP</td>
|
||||
<td className="bb-acp-stats-value">{systemStatus.php_selected_path || '—'}</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.php || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.php_selected_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon
|
||||
status={systemStatus.php_selected_ok ? 'ok' : 'bad'}
|
||||
/>
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Composer</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.composer || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.composer || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.composer_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.composer ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Node</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.node || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.node || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.node_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.node ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>npm</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.npm || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.npm || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.npm_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.npm ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>tar</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.tar || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.tar_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.tar ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>rsync</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.rsync || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.rsync_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.rsync ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>proc_* functions</td>
|
||||
<td className="bb-acp-stats-value" colSpan={3}>
|
||||
{systemStatus.proc_functions
|
||||
? Object.entries(systemStatus.proc_functions)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([name]) => name)
|
||||
.join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon
|
||||
status={
|
||||
Boolean(systemStatus.proc_functions) &&
|
||||
Object.values(systemStatus.proc_functions).every(Boolean)
|
||||
? 'ok'
|
||||
: 'bad'
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('system.storage_writable')}</td>
|
||||
<td className="bb-acp-stats-value">storage/</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.storage_writable ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('system.updates_writable')}</td>
|
||||
<td className="bb-acp-stats-value">storage/app/updates</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.updates_writable ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
||||
<Modal.Header closeButton closeVariant="white">
|
||||
@@ -3625,6 +4371,36 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal show={updateModalOpen} onHide={() => setUpdateModalOpen(false)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('version.update_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="bb-muted mb-3">{t('version.update_hint')}</p>
|
||||
{updateError && <p className="text-danger">{updateError}</p>}
|
||||
{updateLog.length > 0 && (
|
||||
<pre className="bb-acp-update-log">
|
||||
{updateLog.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => setUpdateModalOpen(false)}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bb-accent-button"
|
||||
onClick={handleRunUpdate}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
{updateRunning ? t('version.updating') : t('version.update_now')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRankCreate}
|
||||
onHide={() => setShowRankCreate(false)}
|
||||
@@ -3961,3 +4737,6 @@ export default function Acp({ isAdmin }) {
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export { Acp }
|
||||
export default Acp
|
||||
|
||||
Reference in New Issue
Block a user