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,16 @@
# Changelog
## 2026-02-27
- Reworked ACP System navigation into `Health` and `Updates`.
- Moved update/version actions into the new `Updates` area and grouped update checks under `Live Update`, `CLI`, and `CI/CD`.
- Added CLI PHP interpreter `Check` action (no persistence) plus save-time validation endpoint.
- Updated CLI PHP save UX to keep persistent inline errors and avoid duplicate danger toasts.
- Added iconized, accent-styled `Check` and `Save` actions in ACP CLI settings.
- Fixed system-status PHP detection to avoid false positives when a configured CLI binary is invalid.
- Switched `Health` PHP requirement checks to the web runtime interpreter (`PHP_BINARY`/`PHP_VERSION`) instead of configured CLI binary.
- Limited `Health` checks to runtime-relevant items (removed `tar`/`rsync` from Health view).
- Fixed `public/storage` symlink health check to correctly resolve absolute and relative symlink targets.
## 2026-02-24
- Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button.
- Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`.

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\Process\Process;
class SettingController extends Controller
{
@@ -38,6 +39,12 @@ class SettingController extends Controller
]);
$value = $data['value'] ?? '';
if ($data['key'] === 'system.php_binary') {
$validationError = $this->validatePhpBinarySetting($value);
if ($validationError !== null) {
return response()->json(['message' => $validationError], 422);
}
}
$setting = Setting::updateOrCreate(
['key' => $data['key']],
@@ -67,6 +74,12 @@ class SettingController extends Controller
$updated = [];
foreach ($data['settings'] as $entry) {
if (($entry['key'] ?? '') === 'system.php_binary') {
$validationError = $this->validatePhpBinarySetting($entry['value'] ?? '');
if ($validationError !== null) {
return response()->json(['message' => $validationError], 422);
}
}
$setting = Setting::updateOrCreate(
['key' => $entry['key']],
['value' => $entry['value'] ?? '']
@@ -80,4 +93,66 @@ class SettingController extends Controller
return response()->json($updated);
}
public function validateSystemPhpBinary(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'value' => ['required', 'string'],
]);
$validationError = $this->validatePhpBinarySetting($data['value']);
if ($validationError !== null) {
return response()->json(['message' => $validationError], 422);
}
return response()->json([
'message' => 'PHP interpreter is valid.',
]);
}
private function validatePhpBinarySetting(string $value): ?string
{
$binary = trim($value);
if ($binary === '' || $binary === 'php') {
return null;
}
if ($binary === 'keyhelp-php-domain') {
return '`keyhelp-php-domain` is disabled. Use a concrete binary (e.g. keyhelp-php84).';
}
$resolved = null;
if (str_contains($binary, '/')) {
if (!is_executable($binary)) {
return "Configured PHP binary '{$binary}' is not executable.";
}
$resolved = $binary;
} else {
$escapedBinary = escapeshellarg($binary);
$process = new Process(['sh', '-lc', "command -v {$escapedBinary}"]);
$process->setTimeout(5);
$process->run();
if (!$process->isSuccessful()) {
return "Configured PHP binary '{$binary}' was not found in PATH.";
}
$resolved = trim($process->getOutput());
if ($resolved === '') {
return "Configured PHP binary '{$binary}' was not found in PATH.";
}
}
$phpCheck = new Process([$resolved, '-r', 'echo PHP_VERSION;']);
$phpCheck->setTimeout(5);
$phpCheck->run();
if (!$phpCheck->isSuccessful() || trim($phpCheck->getOutput()) === '') {
return "Configured binary '{$binary}' is not a working PHP CLI executable.";
}
return null;
}
}

View File

@@ -19,11 +19,22 @@ class SystemStatusController extends Controller
$phpDefaultPath = $this->resolveBinary('php');
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
$phpSelectedOk = (bool) $phpSelectedPath;
$phpSelectedVersion = $phpSelectedPath
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
: PHP_VERSION;
$phpSelectedPath = null;
$phpSelectedVersion = null;
$phpSelectedOk = false;
if ($phpConfiguredPath !== '') {
$resolvedConfiguredPhpPath = $this->resolveConfiguredPhpBinaryPath($phpConfiguredPath);
$phpSelectedPath = $resolvedConfiguredPhpPath ?: $phpConfiguredPath;
$phpSelectedVersion = $resolvedConfiguredPhpPath ? $this->resolvePhpVersion($resolvedConfiguredPhpPath) : null;
$phpSelectedOk = $resolvedConfiguredPhpPath !== null && $phpSelectedVersion !== null;
} else {
$phpSelectedPath = PHP_BINARY ?: $phpDefaultPath;
$phpSelectedVersion = $phpSelectedPath
? ($this->resolvePhpVersion($phpSelectedPath) ?? $phpDefaultVersion ?? PHP_VERSION)
: null;
$phpSelectedOk = $phpSelectedPath !== null && $phpSelectedVersion !== null;
}
$minVersions = $this->resolveMinVersions();
$composerPath = $this->resolveBinary('composer');
$nodePath = $this->resolveBinary('node');
@@ -44,6 +55,8 @@ class SystemStatusController extends Controller
return response()->json([
'php' => PHP_VERSION,
'php_web_path' => PHP_BINARY ?: null,
'php_web_version' => PHP_VERSION ?: null,
'php_default' => $phpDefaultPath,
'php_default_version' => $phpDefaultVersion,
'php_configured' => $phpConfiguredPath ?: null,
@@ -82,7 +95,12 @@ class SystemStatusController extends Controller
return false;
}
$resolvedTarget = realpath(dirname($publicStorage) . DIRECTORY_SEPARATOR . $target);
$targetPath = $target;
if (!str_starts_with($target, DIRECTORY_SEPARATOR) && !preg_match('/^[A-Za-z]:[\\\\\\/]/', $target)) {
$targetPath = dirname($publicStorage) . DIRECTORY_SEPARATOR . $target;
}
$resolvedTarget = realpath($targetPath);
$expectedTarget = realpath($storagePublic);
return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget;
@@ -116,6 +134,20 @@ class SystemStatusController extends Controller
return $output !== '' ? $output : null;
}
private function resolveConfiguredPhpBinaryPath(string $binary): ?string
{
$value = trim($binary);
if ($value === '') {
return null;
}
if (str_contains($value, '/')) {
return is_executable($value) ? $value : null;
}
return $this->resolveBinary($value);
}
private function resolveBinaryVersion(?string $path, array $args): ?string
{
if (!$path) {

View File

@@ -98,5 +98,5 @@
"minimum-stability": "stable",
"prefer-stable": true,
"version": "26.0.3",
"build": "104"
"build": "105"
}

View File

@@ -198,6 +198,13 @@ export async function saveSettings(settings) {
})
}
export async function validateSystemPhpBinary(value) {
return apiFetch('/settings/system/php-binary/validate', {
method: 'POST',
body: JSON.stringify({ value }),
})
}
export async function uploadLogo(file) {
const body = new FormData()
body.append('file', file)

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>

View File

@@ -46,6 +46,7 @@ Route::get('/stats', StatsController::class);
Route::get('/settings', [SettingController::class, 'index']);
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
Route::post('/settings/system/php-binary/validate', [SettingController::class, 'validateSystemPhpBinary'])->middleware('auth:sanctum');
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');