Refine ACP system settings
Some checks failed
CI/CD Pipeline / test (push) Successful in 4s
CI/CD Pipeline / deploy (push) Failing after 19s
CI/CD Pipeline / promote_stable (push) Has been skipped

This commit is contained in:
2026-02-12 19:44:23 +01:00
parent b6ce5160f9
commit 55b9a69c42
4 changed files with 253 additions and 280 deletions

View File

@@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026-02-12
- Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector.
- Added CLI PHP interpreter options (php, keyhelp-php-domain, custom) with KeyHelp guidance.
- Updated CLI update tooling and automation notes (KeyHelp PHP handling, CI runner requirements).
## 2026-02-10 ## 2026-02-10
- Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD). - Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD).
- Moved system requirements table into the CI/CD view with refresh controls. - Moved system requirements table into the CI/CD view with refresh controls.

View File

@@ -9,5 +9,7 @@ Progress (last 2 days):
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.). - Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
- Hardened tests with fakes/mocks to cover error paths and edge cases. - Hardened tests with fakes/mocks to cover error paths and edge cases.
TODO: Make PHP binary path configurable for updates if default PHP is outdated (ACP -> System). TODO: Make the PHP binary path configurable for updates if the default PHP is outdated (ACP -> System).
CI/CD: Runner must have PHP 8.4+ as the default CLI interpreter. CI/CD: Runner must have PHP 8.4+ as the default CLI interpreter.
KeyHelp: `keyhelp-php-domain` can select the PHP version based on the domain of the script location.
KeyHelp: `keyhelp-php-domain` is a Pro feature; on non-Pro setups we must fake the command.

View File

@@ -98,5 +98,5 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"version": "26.0.2", "version": "26.0.2",
"build": "59" "build": "60"
} }

View File

@@ -202,7 +202,8 @@ function Acp({ isAdmin }) {
favicon_256: '', favicon_256: '',
}) })
const [systemCliSettings, setSystemCliSettings] = useState({ const [systemCliSettings, setSystemCliSettings] = useState({
php_binary: '', php_mode: 'php',
php_custom: '',
}) })
const [systemCliSaving, setSystemCliSaving] = useState(false) const [systemCliSaving, setSystemCliSaving] = useState(false)
const [systemCliError, setSystemCliError] = useState('') const [systemCliError, setSystemCliError] = useState('')
@@ -297,8 +298,15 @@ function Acp({ isAdmin }) {
favicon_256: settingsMap.get('favicon_256') || '', favicon_256: settingsMap.get('favicon_256') || '',
} }
setGeneralSettings(next) setGeneralSettings(next)
const configuredPhp = settingsMap.get('system.php_binary') || ''
const phpMode = configuredPhp === 'keyhelp-php-domain'
? 'keyhelp'
: configuredPhp === '' || configuredPhp === 'php'
? 'php'
: 'custom'
setSystemCliSettings({ setSystemCliSettings({
php_binary: settingsMap.get('system.php_binary') || '', php_mode: phpMode,
php_custom: phpMode === 'custom' ? configuredPhp : '',
}) })
setAttachmentSettings({ setAttachmentSettings({
display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true', display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true',
@@ -386,11 +394,22 @@ function Acp({ isAdmin }) {
setSystemCliSaving(true) setSystemCliSaving(true)
setSystemCliError('') setSystemCliError('')
try { try {
const value = typeof systemCliSettings.php_binary === 'string' let value = ''
? systemCliSettings.php_binary.trim() if (systemCliSettings.php_mode === 'custom') {
: String(systemCliSettings.php_binary ?? '') value = typeof systemCliSettings.php_custom === 'string'
? systemCliSettings.php_custom.trim()
: String(systemCliSettings.php_custom ?? '')
} else if (systemCliSettings.php_mode === 'keyhelp') {
value = 'keyhelp-php-domain'
} else {
value = 'php'
}
await saveSetting('system.php_binary', value) await saveSetting('system.php_binary', value)
setSystemCliSettings((prev) => ({ ...prev, php_binary: value })) setSystemCliSettings((prev) => ({
...prev,
php_mode: systemCliSettings.php_mode,
php_custom: systemCliSettings.php_mode === 'custom' ? value : '',
}))
} catch (err) { } catch (err) {
setSystemCliError(err.message) setSystemCliError(err.message)
} finally { } finally {
@@ -398,6 +417,107 @@ function Acp({ isAdmin }) {
} }
} }
const systemChecks = useMemo(() => {
if (!systemStatus) return []
return [
{
id: 'php',
label: 'PHP',
path: systemStatus.php_selected_path || '—',
min: systemStatus.min_versions?.php || '—',
current: systemStatus.php_selected_version || '—',
status: systemStatus.php_selected_ok ? 'ok' : 'bad',
},
{
id: 'composer',
label: 'Composer',
path: systemStatus.composer || t('system.not_found'),
min: systemStatus.min_versions?.composer || '—',
current: systemStatus.composer_version || '—',
status: systemStatus.composer ? 'ok' : 'bad',
},
{
id: 'node',
label: 'Node',
path: systemStatus.node || t('system.not_found'),
min: systemStatus.min_versions?.node || '—',
current: systemStatus.node_version || '—',
status: systemStatus.node ? 'ok' : 'bad',
},
{
id: 'npm',
label: 'npm',
path: systemStatus.npm || t('system.not_found'),
min: systemStatus.min_versions?.npm || '—',
current: systemStatus.npm_version || '—',
status: systemStatus.npm ? 'ok' : 'bad',
},
{
id: 'tar',
label: 'tar',
path: systemStatus.tar || t('system.not_found'),
min: '—',
current: systemStatus.tar_version || '—',
status: systemStatus.tar ? 'ok' : 'bad',
},
{
id: 'rsync',
label: 'rsync',
path: systemStatus.rsync || t('system.not_found'),
min: '—',
current: systemStatus.rsync_version || '—',
status: systemStatus.rsync ? 'ok' : 'bad',
},
{
id: 'proc',
label: 'proc_* functions',
path: systemStatus.proc_functions
? Object.entries(systemStatus.proc_functions)
.filter(([, ok]) => !ok)
.map(([name]) => name)
.join(', ')
: '—',
min: '—',
current: '—',
note: 'Optional. Needed for automated version checks.',
status:
Boolean(systemStatus.proc_functions) &&
Object.values(systemStatus.proc_functions).every(Boolean)
? 'ok'
: 'bad',
pathColSpan: 3,
},
{
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',
},
]
}, [systemStatus, t])
const visibleSystemChecks = useMemo(() => {
const visibilityBySection = {
insite: ['php', 'proc', 'storage', 'updates'],
cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'updates'],
ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'updates'],
info: [],
}
const allowed = new Set(visibilityBySection[systemSection] || [])
return systemChecks.filter((check) => allowed.has(check.id))
}, [systemChecks, systemSection])
const handleLogoUpload = async (file, settingKey) => { const handleLogoUpload = async (file, settingKey) => {
if (!file) return if (!file) return
setGeneralUploading(true) setGeneralUploading(true)
@@ -848,6 +968,74 @@ function Acp({ isAdmin }) {
} }
} }
function renderSystemRequirementsPanel() {
return (
<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">
{!systemStatus && <p className="bb-muted mb-0">{t('system.not_found')}</p>}
{systemStatus && (
<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>
{visibleSystemChecks.map((check) => (
<tr key={check.id}>
<td>{check.label}</td>
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
{check.path}
{check.note && <div className="bb-muted mt-1 text-center">{check.note}</div>}
</td>
{!check.pathColSpan && (
<>
<td className="bb-acp-stats-value">{check.min}</td>
<td className="bb-acp-stats-value">{check.current}</td>
</>
)}
<td className="bb-acp-stats-value">
<StatusIcon status={check.status} />
</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>
)
}
useEffect(() => { useEffect(() => {
if (isAdmin) { if (isAdmin) {
handleVersionCheck() handleVersionCheck()
@@ -3595,17 +3783,20 @@ function Acp({ isAdmin }) {
</div> </div>
)} )}
{systemSection === 'insite' && ( {systemSection === 'insite' && (
<div className="bb-acp-panel"> <>
<div className="bb-acp-panel-header"> <div className="bb-acp-panel">
<h5 className="mb-0">Live Update</h5> <div className="bb-acp-panel-header">
<h5 className="mb-0">Live Update</h5>
</div>
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">
Placeholder: run a live update from inside the forum, with safety checks
and status details.
</p>
</div>
</div> </div>
<div className="bb-acp-panel-body"> {renderSystemRequirementsPanel()}
<p className="bb-muted mb-0"> </>
Placeholder: run a live update from inside the forum, with safety checks
and status details.
</p>
</div>
</div>
)} )}
{systemSection === 'cli' && ( {systemSection === 'cli' && (
<div className="bb-acp-panel"> <div className="bb-acp-panel">
@@ -3617,20 +3808,36 @@ function Acp({ isAdmin }) {
<Form onSubmit={handleSystemCliSave}> <Form onSubmit={handleSystemCliSave}>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>PHP interpreter</Form.Label> <Form.Label>PHP interpreter</Form.Label>
<Form.Control <Form.Select
type="text" className="mb-2"
placeholder={systemStatus?.php_default || '/usr/bin/php'} value={systemCliSettings.php_mode}
value={systemCliSettings.php_binary}
onChange={(event) => onChange={(event) =>
setSystemCliSettings((prev) => ({ setSystemCliSettings((prev) => ({
...prev, ...prev,
php_binary: event.target.value, php_mode: event.target.value,
})) }))
} }
/> >
<option value="php">php (system default)</option>
<option value="keyhelp">keyhelp-php-domain</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"> <Form.Text className="bb-muted">
Used for CLI-based updates and maintenance tasks. Leave empty to use Used for CLI-based updates and maintenance tasks. `keyhelp-php-domain`
the system default. is available on KeyHelp Pro; on non-Pro setups use a custom binary.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={systemCliSaving}> <Button type="submit" variant="dark" disabled={systemCliSaving}>
@@ -3640,263 +3847,22 @@ function Acp({ isAdmin }) {
</div> </div>
</div> </div>
)} )}
{systemSection === 'cli' && renderSystemRequirementsPanel()}
{systemSection === 'ci' && ( {systemSection === 'ci' && (
<div className="bb-acp-panel"> <>
<div className="bb-acp-panel-header"> <div className="bb-acp-panel">
<div className="d-flex align-items-center justify-content-between"> <div className="bb-acp-panel-header">
<h5 className="mb-0">{t('system.requirements')}</h5> <h5 className="mb-0">CI/CD</h5>
<Button </div>
type="button" <div className="bb-acp-panel-body">
size="sm" <p className="bb-muted mb-0">
variant="dark" Placeholder: CI/CD pipelines, runner requirements, and deployment logs will
onClick={loadSystemStatus} live here.
disabled={systemLoading} </p>
>
{t('acp.refresh')}
</Button>
</div> </div>
</div> </div>
<div className="bb-acp-panel-body"> {renderSystemRequirementsPanel()}
{!systemStatus && ( </>
<p className="bb-muted mb-0">
{t('system.not_found')}
</p>
)}
{systemStatus && (
<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>
)} )}
</Col> </Col>
</Row> </Row>