Refine ACP system health/update checks and CLI PHP validation
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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
|
## 2026-02-24
|
||||||
- Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button.
|
- 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`.
|
- Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class SettingController extends Controller
|
class SettingController extends Controller
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,12 @@ class SettingController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$value = $data['value'] ?? '';
|
$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(
|
$setting = Setting::updateOrCreate(
|
||||||
['key' => $data['key']],
|
['key' => $data['key']],
|
||||||
@@ -67,6 +74,12 @@ class SettingController extends Controller
|
|||||||
$updated = [];
|
$updated = [];
|
||||||
|
|
||||||
foreach ($data['settings'] as $entry) {
|
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(
|
$setting = Setting::updateOrCreate(
|
||||||
['key' => $entry['key']],
|
['key' => $entry['key']],
|
||||||
['value' => $entry['value'] ?? '']
|
['value' => $entry['value'] ?? '']
|
||||||
@@ -80,4 +93,66 @@ class SettingController extends Controller
|
|||||||
|
|
||||||
return response()->json($updated);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,22 @@ class SystemStatusController extends Controller
|
|||||||
$phpDefaultPath = $this->resolveBinary('php');
|
$phpDefaultPath = $this->resolveBinary('php');
|
||||||
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
|
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
|
||||||
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
$phpSelectedPath = null;
|
||||||
$phpSelectedOk = (bool) $phpSelectedPath;
|
$phpSelectedVersion = null;
|
||||||
$phpSelectedVersion = $phpSelectedPath
|
$phpSelectedOk = false;
|
||||||
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
|
||||||
: PHP_VERSION;
|
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();
|
$minVersions = $this->resolveMinVersions();
|
||||||
$composerPath = $this->resolveBinary('composer');
|
$composerPath = $this->resolveBinary('composer');
|
||||||
$nodePath = $this->resolveBinary('node');
|
$nodePath = $this->resolveBinary('node');
|
||||||
@@ -44,6 +55,8 @@ class SystemStatusController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'php' => PHP_VERSION,
|
'php' => PHP_VERSION,
|
||||||
|
'php_web_path' => PHP_BINARY ?: null,
|
||||||
|
'php_web_version' => PHP_VERSION ?: null,
|
||||||
'php_default' => $phpDefaultPath,
|
'php_default' => $phpDefaultPath,
|
||||||
'php_default_version' => $phpDefaultVersion,
|
'php_default_version' => $phpDefaultVersion,
|
||||||
'php_configured' => $phpConfiguredPath ?: null,
|
'php_configured' => $phpConfiguredPath ?: null,
|
||||||
@@ -82,7 +95,12 @@ class SystemStatusController extends Controller
|
|||||||
return false;
|
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);
|
$expectedTarget = realpath($storagePublic);
|
||||||
|
|
||||||
return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget;
|
return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget;
|
||||||
@@ -116,6 +134,20 @@ class SystemStatusController extends Controller
|
|||||||
return $output !== '' ? $output : null;
|
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
|
private function resolveBinaryVersion(?string $path, array $args): ?string
|
||||||
{
|
{
|
||||||
if (!$path) {
|
if (!$path) {
|
||||||
|
|||||||
@@ -98,5 +98,5 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"version": "26.0.3",
|
"version": "26.0.3",
|
||||||
"build": "104"
|
"build": "105"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
export async function uploadLogo(file) {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', file)
|
body.append('file', file)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useId } from 'react'
|
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 DataTable, { createTheme } from 'react-data-table-component'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
reorderForums,
|
reorderForums,
|
||||||
saveSetting,
|
saveSetting,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
validateSystemPhpBinary,
|
||||||
createRank,
|
createRank,
|
||||||
deleteRank,
|
deleteRank,
|
||||||
updateUserRank,
|
updateUserRank,
|
||||||
@@ -96,7 +97,8 @@ function Acp({ isAdmin }) {
|
|||||||
const [systemStatus, setSystemStatus] = useState(null)
|
const [systemStatus, setSystemStatus] = useState(null)
|
||||||
const [systemLoading, setSystemLoading] = useState(false)
|
const [systemLoading, setSystemLoading] = useState(false)
|
||||||
const [systemError, setSystemError] = useState('')
|
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 [usersPage, setUsersPage] = useState(1)
|
||||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||||
@@ -206,7 +208,9 @@ function Acp({ isAdmin }) {
|
|||||||
php_custom: '',
|
php_custom: '',
|
||||||
})
|
})
|
||||||
const [systemCliSaving, setSystemCliSaving] = useState(false)
|
const [systemCliSaving, setSystemCliSaving] = useState(false)
|
||||||
|
const [systemCliChecking, setSystemCliChecking] = useState(false)
|
||||||
const [systemCliError, setSystemCliError] = useState('')
|
const [systemCliError, setSystemCliError] = useState('')
|
||||||
|
const [systemCliToast, setSystemCliToast] = useState({ show: false, variant: 'success', message: '' })
|
||||||
const settingsDetailMap = {
|
const settingsDetailMap = {
|
||||||
forum_name: 'forumName',
|
forum_name: 'forumName',
|
||||||
default_theme: 'defaultTheme',
|
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) => {
|
const handleSystemCliSave = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSystemCliSaving(true)
|
setSystemCliSaving(true)
|
||||||
setSystemCliError('')
|
setSystemCliError('')
|
||||||
try {
|
try {
|
||||||
let value = ''
|
const value = resolveSystemCliBinaryValue(event.currentTarget)
|
||||||
if (systemCliSettings.php_mode === 'custom') {
|
await persistSystemPhpBinary(value, systemCliSettings.php_mode)
|
||||||
value = typeof systemCliSettings.php_custom === 'string'
|
} catch (err) {
|
||||||
? systemCliSettings.php_custom.trim()
|
setSystemCliError(err.message)
|
||||||
: String(systemCliSettings.php_custom ?? '')
|
} finally {
|
||||||
} else {
|
setSystemCliSaving(false)
|
||||||
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).')
|
const handleSystemCliCheck = async (event) => {
|
||||||
}
|
setSystemCliChecking(true)
|
||||||
await saveSetting('system.php_binary', value)
|
setSystemCliError('')
|
||||||
setSystemCliSettings((prev) => ({
|
try {
|
||||||
...prev,
|
const formElement = event?.currentTarget?.form || null
|
||||||
php_mode: systemCliSettings.php_mode,
|
const value = resolveSystemCliBinaryValue(formElement)
|
||||||
php_custom: systemCliSettings.php_mode === 'custom' ? value : '',
|
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) {
|
} catch (err) {
|
||||||
setSystemCliError(err.message)
|
setSystemCliError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -468,14 +552,6 @@ function Acp({ isAdmin }) {
|
|||||||
return mins.reduce((lowest, current) => (compareSemver(current, lowest) < 0 ? current : lowest))
|
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(() => {
|
const phpSelectedIsSufficient = useMemo(() => {
|
||||||
if (!systemStatus?.php_selected_ok) return false
|
if (!systemStatus?.php_selected_ok) return false
|
||||||
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
|
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
|
||||||
@@ -485,6 +561,41 @@ function Acp({ isAdmin }) {
|
|||||||
return compareSemver(current, minimum) >= 0
|
return compareSemver(current, minimum) >= 0
|
||||||
}, [systemStatus])
|
}, [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(() => {
|
const systemChecks = useMemo(() => {
|
||||||
if (!systemStatus) return []
|
if (!systemStatus) return []
|
||||||
return [
|
return [
|
||||||
@@ -536,6 +647,30 @@ function Acp({ isAdmin }) {
|
|||||||
current: systemStatus.rsync_version || '—',
|
current: systemStatus.rsync_version || '—',
|
||||||
status: systemStatus.rsync ? 'ok' : 'bad',
|
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',
|
id: 'proc',
|
||||||
label: 'proc_* functions',
|
label: 'proc_* functions',
|
||||||
@@ -555,43 +690,38 @@ function Acp({ isAdmin }) {
|
|||||||
: 'bad',
|
: 'bad',
|
||||||
pathColSpan: 3,
|
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])
|
}, [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 visibleSystemChecks = useMemo(() => {
|
||||||
const visibilityBySection = {
|
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'],
|
cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'storage_link'],
|
||||||
ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'storage_link', 'updates'],
|
ci: ['php', 'composer', 'node', 'npm', 'storage', 'updates', 'storage_link', 'proc'],
|
||||||
info: [],
|
|
||||||
}
|
}
|
||||||
const allowed = new Set(visibilityBySection[systemSection] || [])
|
const allowed = new Set(visibilityBySection[systemUpdateSection] || [])
|
||||||
return systemChecks.filter((check) => allowed.has(check.id))
|
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) => {
|
const handleLogoUpload = async (file, settingKey) => {
|
||||||
@@ -1044,7 +1174,7 @@ function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSystemRequirementsPanel() {
|
function renderSystemRequirementsPanel(checks = visibleSystemChecks) {
|
||||||
return (
|
return (
|
||||||
<div className="bb-acp-panel">
|
<div className="bb-acp-panel">
|
||||||
<div className="bb-acp-panel-header">
|
<div className="bb-acp-panel-header">
|
||||||
@@ -1076,7 +1206,7 @@ function Acp({ isAdmin }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleSystemChecks.map((check) => (
|
{checks.map((check) => (
|
||||||
<tr key={check.id}>
|
<tr key={check.id}>
|
||||||
<td>{check.label}</td>
|
<td>{check.label}</td>
|
||||||
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
|
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
|
||||||
@@ -1125,20 +1255,6 @@ function Acp({ isAdmin }) {
|
|||||||
}, [isAdmin])
|
}, [isAdmin])
|
||||||
|
|
||||||
const statsLeft = useMemo(() => {
|
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 [
|
return [
|
||||||
{ label: t('stats.board_started'), value: formatDateTime(boardStats?.board_started_at) },
|
{ 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.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.orphan_attachments'), value: formatNumber(boardStats?.orphan_attachments) },
|
||||||
{
|
{
|
||||||
label: t('stats.board_version'),
|
label: t('stats.board_version'),
|
||||||
value: (
|
value: boardStats?.board_version || '—',
|
||||||
<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])
|
}, [t, boardStats, formatBool])
|
||||||
|
|
||||||
const statsRight = useMemo(() => {
|
const statsRight = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -3801,155 +3894,201 @@ function Acp({ isAdmin }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`list-group-item list-group-item-action ${
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`list-group-item list-group-item-action ${
|
className={`list-group-item list-group-item-action ${
|
||||||
systemSection === 'insite' ? 'is-active' : ''
|
systemSection === 'updates' ? 'is-active' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSystemSection('insite')}
|
onClick={() => setSystemSection('updates')}
|
||||||
>
|
>
|
||||||
Live Update
|
Updates
|
||||||
</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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} lg>
|
<Col xs={12} lg>
|
||||||
{systemSection === 'info' && (
|
{systemSection === 'overview' && (
|
||||||
<div className="bb-acp-panel">
|
renderSystemRequirementsPanel(visibleHealthChecks)
|
||||||
<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 === 'insite' && (
|
{systemSection === 'updates' && (
|
||||||
<>
|
<>
|
||||||
<div className="bb-acp-panel mb-3">
|
<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">
|
<div className="bb-acp-panel-header">
|
||||||
<h5 className="mb-0">CI/CD</h5>
|
<h5 className="mb-0">{t('version.update_title')}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-acp-panel-body">
|
<div className="bb-acp-panel-body d-flex flex-wrap align-items-center gap-2">
|
||||||
<p className="bb-muted mb-0">
|
<Button
|
||||||
Placeholder: CI/CD pipelines, runner requirements, and deployment logs will
|
type="button"
|
||||||
live here.
|
variant="dark"
|
||||||
</p>
|
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>
|
||||||
</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>
|
</Col>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Route::get('/stats', StatsController::class);
|
|||||||
Route::get('/settings', [SettingController::class, 'index']);
|
Route::get('/settings', [SettingController::class, 'index']);
|
||||||
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->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('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::get('/user-settings', [UserSettingController::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');
|
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
|||||||
Reference in New Issue
Block a user