Refine ACP system health/update checks and CLI PHP validation

This commit is contained in:
2026-02-27 19:59:29 +01:00
parent 41387be802
commit 1f26aa7fb5
7 changed files with 497 additions and 232 deletions

View File

@@ -1,5 +1,5 @@
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 { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Toast, ToastContainer, Tooltip } from 'react-bootstrap'
import DataTable, { createTheme } from 'react-data-table-component'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
@@ -20,6 +20,7 @@ import {
reorderForums,
saveSetting,
saveSettings,
validateSystemPhpBinary,
createRank,
deleteRank,
updateUserRank,
@@ -96,7 +97,8 @@ function Acp({ isAdmin }) {
const [systemStatus, setSystemStatus] = useState(null)
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemSection, setSystemSection] = useState('info')
const [systemSection, setSystemSection] = useState('overview')
const [systemUpdateSection, setSystemUpdateSection] = useState('insite')
const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
@@ -206,7 +208,9 @@ function Acp({ isAdmin }) {
php_custom: '',
})
const [systemCliSaving, setSystemCliSaving] = useState(false)
const [systemCliChecking, setSystemCliChecking] = useState(false)
const [systemCliError, setSystemCliError] = useState('')
const [systemCliToast, setSystemCliToast] = useState({ show: false, variant: 'success', message: '' })
const settingsDetailMap = {
forum_name: 'forumName',
default_theme: 'defaultTheme',
@@ -385,28 +389,108 @@ function Acp({ isAdmin }) {
}
}
const persistSystemPhpBinary = async (rawValue, mode) => {
const value = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '')
if (!value) {
throw new Error('Please provide a PHP binary.')
}
if (value === 'keyhelp-php-domain') {
throw new Error('`keyhelp-php-domain` is disabled in ACP CLI settings. Use a custom binary (e.g. keyhelp-php84).')
}
await saveSetting('system.php_binary', value)
// Read-back verification avoids silent non-persist situations.
const allSettings = await fetchSettings()
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
const persisted = settingsMap.get('system.php_binary') || ''
if (persisted !== value) {
throw new Error(`Persist failed: expected "${value}", got "${persisted || 'empty'}".`)
}
setSystemCliSettings((prev) => ({
...prev,
php_mode: mode,
php_custom: mode === 'custom' ? value : '',
}))
setSystemCliToast({
show: true,
variant: 'success',
message: `PHP interpreter saved: ${value}`,
})
}
const resolveSystemCliBinaryValue = (formElement) => {
if (systemCliSettings.php_mode !== 'custom') {
return 'php'
}
if (formElement) {
const formData = new FormData(formElement)
const submitted = String(formData.get('system_php_custom') || '').trim()
if (submitted) {
return submitted
}
}
return typeof systemCliSettings.php_custom === 'string'
? systemCliSettings.php_custom.trim()
: String(systemCliSettings.php_custom || '').trim()
}
const handleSystemCliSave = async (event) => {
event.preventDefault()
setSystemCliSaving(true)
setSystemCliError('')
try {
let value = ''
if (systemCliSettings.php_mode === 'custom') {
value = typeof systemCliSettings.php_custom === 'string'
? systemCliSettings.php_custom.trim()
: String(systemCliSettings.php_custom ?? '')
} else {
value = 'php'
}
if (value === 'keyhelp-php-domain') {
throw new Error('`keyhelp-php-domain` is disabled in ACP CLI settings. Use a custom binary (e.g. keyhelp-php84).')
}
await saveSetting('system.php_binary', value)
setSystemCliSettings((prev) => ({
...prev,
php_mode: systemCliSettings.php_mode,
php_custom: systemCliSettings.php_mode === 'custom' ? value : '',
}))
const value = resolveSystemCliBinaryValue(event.currentTarget)
await persistSystemPhpBinary(value, systemCliSettings.php_mode)
} catch (err) {
setSystemCliError(err.message)
} finally {
setSystemCliSaving(false)
}
}
const handleSystemCliCheck = async (event) => {
setSystemCliChecking(true)
setSystemCliError('')
try {
const formElement = event?.currentTarget?.form || null
const value = resolveSystemCliBinaryValue(formElement)
const response = await validateSystemPhpBinary(value)
setSystemCliToast({
show: true,
variant: 'success',
message: response?.message || `PHP interpreter check passed: ${value}`,
})
} catch (err) {
setSystemCliError(err.message)
} finally {
setSystemCliChecking(false)
}
}
const handleSystemCliModeChange = async (nextMode) => {
setSystemCliSettings((prev) => ({
...prev,
php_mode: nextMode,
php_custom:
nextMode === 'custom' && !String(prev.php_custom || '').trim()
? suggestedPhpBinary
: prev.php_custom,
}))
setSystemCliError('')
// "php" mode has no additional input, so persist immediately.
if (nextMode !== 'php') {
return
}
setSystemCliSaving(true)
try {
await persistSystemPhpBinary('php', 'php')
} catch (err) {
setSystemCliError(err.message)
} finally {
@@ -468,14 +552,6 @@ function Acp({ isAdmin }) {
return mins.reduce((lowest, current) => (compareSemver(current, lowest) < 0 ? current : lowest))
}
const cliDefaultPhpIsSufficient = useMemo(() => {
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
const current = normalizeSemver(systemStatus?.php_default_version)
if (!minimum) return true
if (!current) return false
return compareSemver(current, minimum) >= 0
}, [systemStatus])
const phpSelectedIsSufficient = useMemo(() => {
if (!systemStatus?.php_selected_ok) return false
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
@@ -485,6 +561,41 @@ function Acp({ isAdmin }) {
return compareSemver(current, minimum) >= 0
}, [systemStatus])
const phpWebIsSufficient = useMemo(() => {
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
const current = normalizeSemver(systemStatus?.php_web_version)
if (!minimum) return true
if (!current) return false
return compareSemver(current, minimum) >= 0
}, [systemStatus])
const suggestedPhpBinary = useMemo(() => {
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
if (!minimum) return 'php8.4'
return `php${minimum[0]}.${minimum[1]}`
}, [systemStatus])
const suggestedKeyhelpPhpBinary = useMemo(() => {
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
if (!minimum) return 'keyhelp-php84'
return `keyhelp-php${minimum[0]}${minimum[1]}`
}, [systemStatus])
const systemUpdateMeta = useMemo(() => {
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')
}, [t, versionCheck, versionCheckError, versionChecking])
const systemUpdateAvailable = versionCheck?.is_latest === false
const systemChecks = useMemo(() => {
if (!systemStatus) return []
return [
@@ -536,6 +647,30 @@ function Acp({ isAdmin }) {
current: systemStatus.rsync_version || '—',
status: systemStatus.rsync ? 'ok' : 'bad',
},
{
id: 'storage',
label: t('system.storage_writable'),
path: 'storage/',
min: '—',
current: '—',
status: systemStatus.storage_writable ? 'ok' : 'bad',
},
{
id: 'updates',
label: t('system.updates_writable'),
path: 'storage/app/updates',
min: '—',
current: '—',
status: systemStatus.updates_writable ? 'ok' : 'bad',
},
{
id: 'storage_link',
label: t('system.storage_linked'),
path: 'public/storage -> storage/app/public',
min: '—',
current: '—',
status: systemStatus.storage_public_linked ? 'ok' : 'bad',
},
{
id: 'proc',
label: 'proc_* functions',
@@ -555,43 +690,38 @@ function Acp({ isAdmin }) {
: 'bad',
pathColSpan: 3,
},
{
id: 'storage',
label: t('system.storage_writable'),
path: 'storage/',
min: '—',
current: '—',
status: systemStatus.storage_writable ? 'ok' : 'bad',
},
{
id: 'storage_link',
label: t('system.storage_linked'),
path: 'public/storage -> storage/app/public',
min: '—',
current: '—',
status: systemStatus.storage_public_linked ? 'ok' : 'bad',
},
{
id: 'updates',
label: t('system.updates_writable'),
path: 'storage/app/updates',
min: '—',
current: '—',
status: systemStatus.updates_writable ? 'ok' : 'bad',
},
]
}, [phpSelectedIsSufficient, systemStatus, t])
const systemHealthChecks = useMemo(() => {
if (!systemStatus) return []
return systemChecks.map((check) => {
if (check.id !== 'php') {
return check
}
return {
...check,
path: systemStatus.php_web_path || '—',
current: systemStatus.php_web_version || '—',
status: phpWebIsSufficient ? 'ok' : 'bad',
}
})
}, [phpWebIsSufficient, systemChecks, systemStatus])
const visibleSystemChecks = useMemo(() => {
const visibilityBySection = {
insite: ['php', 'proc', 'storage', 'storage_link', 'updates'],
insite: ['php', 'tar', 'rsync', 'storage', 'updates', 'storage_link', 'proc'],
cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'storage_link'],
ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'storage_link', 'updates'],
info: [],
ci: ['php', 'composer', 'node', 'npm', 'storage', 'updates', 'storage_link', 'proc'],
}
const allowed = new Set(visibilityBySection[systemSection] || [])
const allowed = new Set(visibilityBySection[systemUpdateSection] || [])
return systemChecks.filter((check) => allowed.has(check.id))
}, [systemChecks, systemSection])
}, [systemChecks, systemUpdateSection])
const visibleHealthChecks = useMemo(() => {
const allowed = new Set(['php', 'storage', 'updates', 'storage_link', 'proc'])
return systemHealthChecks.filter((check) => allowed.has(check.id))
}, [systemHealthChecks])
const handleLogoUpload = async (file, settingKey) => {
@@ -1044,7 +1174,7 @@ function Acp({ isAdmin }) {
}
}
function renderSystemRequirementsPanel() {
function renderSystemRequirementsPanel(checks = visibleSystemChecks) {
return (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
@@ -1076,7 +1206,7 @@ function Acp({ isAdmin }) {
</tr>
</thead>
<tbody>
{visibleSystemChecks.map((check) => (
{checks.map((check) => (
<tr key={check.id}>
<td>{check.label}</td>
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
@@ -1125,20 +1255,6 @@ function Acp({ isAdmin }) {
}, [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) },
@@ -1150,33 +1266,10 @@ function Acp({ isAdmin }) {
{ 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>
),
value: boardStats?.board_version || '—',
},
]
}, [t, boardStats, formatBool, versionCheck, versionChecking, versionCheckError, updateRunning])
}, [t, boardStats, formatBool])
const statsRight = useMemo(() => {
return [
@@ -3801,155 +3894,201 @@ function Acp({ isAdmin }) {
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'info' ? 'is-active' : ''
systemSection === 'overview' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('info')}
onClick={() => setSystemSection('overview')}
>
Overview
Health
</button>
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'insite' ? 'is-active' : ''
systemSection === 'updates' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('insite')}
onClick={() => setSystemSection('updates')}
>
Live Update
</button>
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'cli' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('cli')}
>
CLI
</button>
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'ci' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('ci')}
>
CI/CD
Updates
</button>
</div>
</div>
</div>
</Col>
<Col xs={12} lg>
{systemSection === 'info' && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">System overview</h5>
</div>
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">
Placeholder: summary, upgrade guidance, and environment health notes will
live here.
</p>
</div>
</div>
{systemSection === 'overview' && (
renderSystemRequirementsPanel(visibleHealthChecks)
)}
{systemSection === 'insite' && (
{systemSection === 'updates' && (
<>
<div className="bb-acp-panel mb-3">
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">Live update controls will appear here.</p>
</div>
</div>
{renderSystemRequirementsPanel()}
</>
)}
{systemSection === 'cli' && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">CLI</h5>
<p className="bb-muted mb-0 mt-1">
CLI default php: {systemStatus?.php_default || '—'} (
{systemStatus?.php_default_version || 'unknown'}){' '}
{phpSelectedIsSufficient ? (
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
) : (
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="cli-default-php-warning" data-bs-theme="light">
The selected PHP interpreter is not sufficient for the required
composer.json PHP version.
</Tooltip>
}
>
<span>
<i
className="bi bi-exclamation-triangle-fill text-warning"
aria-hidden="true"
/>
</span>
</OverlayTrigger>
)}
</p>
</div>
<div className="bb-acp-panel-body">
{systemCliError && <p className="text-danger">{systemCliError}</p>}
<Form onSubmit={handleSystemCliSave}>
<Form.Group className="mb-3">
<Form.Label>PHP interpreter</Form.Label>
<Form.Select
className="mb-2"
value={systemCliSettings.php_mode}
onChange={(event) =>
setSystemCliSettings((prev) => ({
...prev,
php_mode: event.target.value,
}))
}
>
<option value="php">php (system default)</option>
<option value="custom">Custom binary (e.g. keyhelp-php84)</option>
</Form.Select>
{systemCliSettings.php_mode === 'custom' && (
<Form.Control
type="text"
placeholder="keyhelp-php84"
value={systemCliSettings.php_custom}
onChange={(event) =>
setSystemCliSettings((prev) => ({
...prev,
php_custom: event.target.value,
}))
}
/>
)}
<Form.Text className="bb-muted">
Minimum required PHP (from composer.json):{' '}
{systemStatus?.min_versions?.php || 'unknown'}. Use a custom binary
like php84. On KeyHelp setups use e.g. `keyhelp-php84`.
</Form.Text>
</Form.Group>
<Button type="submit" variant="dark" disabled={systemCliSaving}>
{t('acp.save')}
</Button>
</Form>
</div>
</div>
)}
{systemSection === 'cli' && renderSystemRequirementsPanel()}
{systemSection === 'ci' && (
<>
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">CI/CD</h5>
<h5 className="mb-0">{t('version.update_title')}</h5>
</div>
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">
Placeholder: CI/CD pipelines, runner requirements, and deployment logs will
live here.
</p>
<div className="bb-acp-panel-body d-flex flex-wrap align-items-center gap-2">
<Button
type="button"
variant="dark"
onClick={handleVersionCheck}
disabled={versionChecking}
>
{t('version.recheck')}
</Button>
{systemUpdateAvailable && (
<Button
type="button"
variant="primary"
onClick={() => setUpdateModalOpen(true)}
disabled={updateRunning}
>
{t('version.update_now')}
</Button>
)}
<span className="bb-acp-version-meta">{systemUpdateMeta}</span>
</div>
</div>
{renderSystemRequirementsPanel()}
<div className="bb-acp-panel mb-3">
<div className="bb-acp-panel-body">
<ButtonGroup aria-label="Updates sections">
<Button
type="button"
variant={systemUpdateSection === 'insite' ? 'primary' : 'dark'}
onClick={() => setSystemUpdateSection('insite')}
>
Live Update
</Button>
<Button
type="button"
variant={systemUpdateSection === 'cli' ? 'primary' : 'dark'}
onClick={() => setSystemUpdateSection('cli')}
>
CLI
</Button>
<Button
type="button"
variant={systemUpdateSection === 'ci' ? 'primary' : 'dark'}
onClick={() => setSystemUpdateSection('ci')}
>
CI/CD
</Button>
</ButtonGroup>
</div>
</div>
{systemUpdateSection === 'insite' && renderSystemRequirementsPanel()}
{systemUpdateSection === 'cli' && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">CLI</h5>
<p className="bb-muted mb-0 mt-1">
CLI default php: {systemStatus?.php_default || '—'} (
{systemStatus?.php_default_version || 'unknown'}){' '}
{phpSelectedIsSufficient ? (
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
) : (
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="cli-default-php-warning" data-bs-theme="light">
The selected PHP interpreter is not sufficient for the required
composer.json PHP version.
</Tooltip>
}
>
<span>
<i
className="bi bi-exclamation-triangle-fill text-warning"
aria-hidden="true"
/>
</span>
</OverlayTrigger>
)}
</p>
</div>
<div className="bb-acp-panel-body">
<ToastContainer position="top-end" className="p-0 mb-3">
<Toast
show={systemCliToast.show}
onClose={() =>
setSystemCliToast((prev) => ({ ...prev, show: false }))
}
delay={2800}
autohide
bg={systemCliToast.variant}
>
<Toast.Body
className={
systemCliToast.variant === 'danger'
? 'text-white'
: undefined
}
>
{systemCliToast.message}
</Toast.Body>
</Toast>
</ToastContainer>
{systemCliError && <p className="text-danger">{systemCliError}</p>}
<Form onSubmit={handleSystemCliSave}>
<Form.Group className="mb-3">
<Form.Label>PHP interpreter</Form.Label>
<Form.Select
className="mb-2"
value={systemCliSettings.php_mode}
onChange={(event) => handleSystemCliModeChange(event.target.value)}
disabled={systemCliSaving || systemCliChecking}
>
<option value="php">php (system default)</option>
<option value="custom">
{`Custom binary (e.g. ${suggestedPhpBinary} or ${suggestedKeyhelpPhpBinary})`}
</option>
</Form.Select>
{systemCliSettings.php_mode === 'custom' && (
<div className="d-flex gap-2 align-items-start">
<Form.Control
type="text"
name="system_php_custom"
placeholder={`Enter binary (e.g. ${suggestedPhpBinary})`}
value={systemCliSettings.php_custom}
disabled={systemCliSaving || systemCliChecking}
onChange={(event) =>
setSystemCliSettings((prev) => ({
...prev,
php_custom: event.target.value,
}))
}
onInput={() => setSystemCliError('')}
/>
<Button
type="button"
className="bb-accent-button flex-shrink-0"
onClick={handleSystemCliCheck}
disabled={systemCliSaving || systemCliChecking}
>
<span className="d-inline-flex align-items-center gap-2">
<i className="bi bi-search" aria-hidden="true" />
Check
</span>
</Button>
<Button
type="submit"
className="bb-accent-button flex-shrink-0"
disabled={systemCliSaving || systemCliChecking}
>
<span className="d-inline-flex align-items-center gap-2">
<i className="bi bi-floppy" aria-hidden="true" />
{t('acp.save')}
</span>
</Button>
</div>
)}
<Form.Text className="bb-muted">
Minimum required PHP (from composer.json):{' '}
{systemStatus?.min_versions?.php || 'unknown'}. Use a custom binary
e.g. {suggestedPhpBinary} or {suggestedKeyhelpPhpBinary}.
</Form.Text>
</Form.Group>
</Form>
</div>
</div>
)}
{(systemUpdateSection === 'cli' || systemUpdateSection === 'ci') && renderSystemRequirementsPanel()}
</>
)}
</Col>