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
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
$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) ?? PHP_VERSION)
|
||||
: PHP_VERSION;
|
||||
? ($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) {
|
||||
|
||||
@@ -98,5 +98,5 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"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) {
|
||||
const body = new FormData()
|
||||
body.append('file', 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'
|
||||
const value = resolveSystemCliBinaryValue(event.currentTarget)
|
||||
await persistSystemPhpBinary(value, systemCliSettings.php_mode)
|
||||
} catch (err) {
|
||||
setSystemCliError(err.message)
|
||||
} finally {
|
||||
setSystemCliSaving(false)
|
||||
}
|
||||
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)
|
||||
|
||||
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: systemCliSettings.php_mode,
|
||||
php_custom: systemCliSettings.php_mode === 'custom' ? value : '',
|
||||
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,68 +3894,86 @@ 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 className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">{t('version.update_title')}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{renderSystemRequirementsPanel()}
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
{systemSection === 'cli' && (
|
||||
<span className="bb-acp-version-meta">{systemUpdateMeta}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -3892,6 +4003,27 @@ function Acp({ isAdmin }) {
|
||||
</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">
|
||||
@@ -3899,57 +4031,64 @@ function Acp({ isAdmin }) {
|
||||
<Form.Select
|
||||
className="mb-2"
|
||||
value={systemCliSettings.php_mode}
|
||||
onChange={(event) =>
|
||||
setSystemCliSettings((prev) => ({
|
||||
...prev,
|
||||
php_mode: event.target.value,
|
||||
}))
|
||||
}
|
||||
onChange={(event) => handleSystemCliModeChange(event.target.value)}
|
||||
disabled={systemCliSaving || systemCliChecking}
|
||||
>
|
||||
<option value="php">php (system default)</option>
|
||||
<option value="custom">Custom binary (e.g. keyhelp-php84)</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"
|
||||
placeholder="keyhelp-php84"
|
||||
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
|
||||
like php84. On KeyHelp setups use e.g. `keyhelp-php84`.
|
||||
e.g. {suggestedPhpBinary} or {suggestedKeyhelpPhpBinary}.
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
{renderSystemRequirementsPanel()}
|
||||
{(systemUpdateSection === 'cli' || systemUpdateSection === 'ci') && renderSystemRequirementsPanel()}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user