mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
Extract SystemController: centralise system page data logic, eliminate frag_ helper duplication
- Add src/SystemController.php (452 lines) encapsulating:
- runStatusChecks(): nginx, php-fpm, HTTP ping, SQLite DB, storage, maintenance flag
- getStatusData() / getPhpInfo() / getDiskInfo() with SystemCache TTL delegation
- getLogData(tab, n): log file tail reading + file metadata
- getNginxConfigData(): live-then-local nginx config reading
- Static helpers: logLineClass(), nginxLineClass(), statusLabel(), statusClass(),
humanBytes(), diskColor() — shared by both entry points
- invalidateAll() for ?refresh=1 cache busting
- Rewrite admin/system.php: 582 → 282 lines
- All free functions (safeExec, systemdStatus, localHttpCheck, humanBytes,
statusLabel, statusClass, logLineClass, nginxLineClass, readLogTail) removed
- Data sections replaced by controller method calls
- View template unchanged; now calls SystemController::statusClass() etc. directly
- Rewrite admin/system-fragment.php: 213 → 137 lines
- All duplicated frag_readLogTail(), frag_logLineClass(), frag_nginxLineClass()
helpers removed
- Now instantiates SystemController and delegates getLogData()/getNginxConfigData()
- Identical rendering logic preserved; constant references updated to
SystemController::LOG_FILES and SystemController::ALLOWED_LINES
No behaviour change; no CSS/JS changes.
This commit is contained in:
452
src/SystemController.php
Normal file
452
src/SystemController.php
Normal file
@@ -0,0 +1,452 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SystemController
|
||||
*
|
||||
* Centralises all data-fetching for the admin system page and its
|
||||
* fetch()-based tab-panel fragment endpoint.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - System status checks (nginx, php-fpm, HTTP ping, database, storage,
|
||||
* maintenance mode) with SystemCache TTL caching
|
||||
* - PHP environment info (1-hour TTL)
|
||||
* - Disk usage info (5-minute TTL)
|
||||
* - Log file reading (tail, meta)
|
||||
* - Nginx config file reading
|
||||
* - Log/nginx line classifiers used by both system.php and system-fragment.php
|
||||
*
|
||||
* Both system.php (full page) and system-fragment.php (AJAX panel) delegate
|
||||
* here so helpers are never duplicated.
|
||||
*/
|
||||
class SystemController
|
||||
{
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
public const LOG_FILES = [
|
||||
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/posterg_access.log'],
|
||||
'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/posterg_error.log'],
|
||||
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'],
|
||||
];
|
||||
|
||||
public const ALLOWED_LINES = [50, 100, 200, 500];
|
||||
|
||||
/** Live deployed nginx config path. */
|
||||
public const NGINX_CONFIG_LIVE = '/etc/nginx/sites-available/posterg';
|
||||
/** Local reference copy used as fallback in dev. */
|
||||
public const NGINX_CONFIG_LOCAL = APP_ROOT . '/nginx/posterg.conf';
|
||||
|
||||
// ── TTLs ──────────────────────────────────────────────────────────────────
|
||||
private const TTL_STATUS = 120; // 2 minutes
|
||||
private const TTL_PHP = 3600; // 1 hour
|
||||
private const TTL_DISK = 300; // 5 minutes
|
||||
|
||||
private Database $db;
|
||||
private SystemCache $cache;
|
||||
|
||||
public function __construct(Database $db, SystemCache $cache)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
// ── Cache invalidation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force-bust all cached sections (called on ?refresh=1).
|
||||
*/
|
||||
public function invalidateAll(): void
|
||||
{
|
||||
$this->cache->invalidate('system_status');
|
||||
$this->cache->invalidate('disk_info');
|
||||
$this->cache->invalidate('php_info');
|
||||
}
|
||||
|
||||
// ── Status data ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return system status checks array, from cache when fresh.
|
||||
*
|
||||
* @return array{checks: array, cached: bool, cacheAge: ?int}
|
||||
*/
|
||||
public function getStatusData(): array
|
||||
{
|
||||
$cacheAge = $this->cache->ageSeconds('system_status');
|
||||
$cached = $this->cache->get('system_status', self::TTL_STATUS);
|
||||
|
||||
if ($cached !== null) {
|
||||
return ['checks' => $cached, 'cached' => true, 'cacheAge' => $cacheAge];
|
||||
}
|
||||
|
||||
$checks = $this->runStatusChecks();
|
||||
$this->cache->set('system_status', $checks);
|
||||
|
||||
return ['checks' => $checks, 'cached' => false, 'cacheAge' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return PHP environment info, from cache when fresh.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getPhpInfo(): array
|
||||
{
|
||||
$cached = $this->cache->get('php_info', self::TTL_PHP);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$info = [
|
||||
'version' => PHP_VERSION,
|
||||
'sapi' => PHP_SAPI,
|
||||
'memory_limit' => ini_get('memory_limit'),
|
||||
'upload_max' => ini_get('upload_max_filesize'),
|
||||
'post_max' => ini_get('post_max_size'),
|
||||
'max_exec' => ini_get('max_execution_time') . 's',
|
||||
];
|
||||
$this->cache->set('php_info', $info);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return disk usage info, from cache when fresh.
|
||||
*
|
||||
* @return array{total: int, free: int, used: int, pct: int}
|
||||
*/
|
||||
public function getDiskInfo(): array
|
||||
{
|
||||
$cached = $this->cache->get('disk_info', self::TTL_DISK);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$total = (int) disk_total_space(APP_ROOT);
|
||||
$free = (int) disk_free_space(APP_ROOT);
|
||||
$used = $total - $free;
|
||||
$pct = $total > 0 ? (int) round($used / $total * 100) : 0;
|
||||
|
||||
$info = ['total' => $total, 'free' => $free, 'used' => $used, 'pct' => $pct];
|
||||
$this->cache->set('disk_info', $info);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
// ── Log tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and return data for a log tab.
|
||||
*
|
||||
* @return array{lines: ?array, error: ?string, meta: ?array}
|
||||
*/
|
||||
public function getLogData(string $tab, int $n): array
|
||||
{
|
||||
$logPath = self::LOG_FILES[$tab]['path'];
|
||||
$error = null;
|
||||
$lines = $this->readLogTail($logPath, $n, $error);
|
||||
$meta = null;
|
||||
|
||||
if (file_exists($logPath)) {
|
||||
$sz = filesize($logPath);
|
||||
$meta = [
|
||||
'size' => $sz > 1048576
|
||||
? number_format($sz / 1048576, 2) . ' MB'
|
||||
: number_format($sz / 1024, 1) . ' KB',
|
||||
'mtime' => date('d/m/Y H:i:s', filemtime($logPath)),
|
||||
'path' => $logPath,
|
||||
];
|
||||
}
|
||||
|
||||
return ['lines' => $lines, 'error' => $error, 'meta' => $meta];
|
||||
}
|
||||
|
||||
// ── Nginx config tab ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and return data for the nginx config tab.
|
||||
*
|
||||
* @return array{lines: ?array, source: ?string, meta: ?array, error: ?string}
|
||||
*/
|
||||
public function getNginxConfigData(): array
|
||||
{
|
||||
$livePath = self::NGINX_CONFIG_LIVE;
|
||||
$localPath = self::NGINX_CONFIG_LOCAL;
|
||||
|
||||
foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) {
|
||||
if (file_exists($path) && is_readable($path)) {
|
||||
$raw = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if ($raw !== false) {
|
||||
$sz = filesize($path);
|
||||
$meta = [
|
||||
'path' => $path,
|
||||
'size' => $sz > 1048576
|
||||
? number_format($sz / 1048576, 2) . ' MB'
|
||||
: number_format($sz / 1024, 1) . ' KB',
|
||||
'mtime' => date('d/m/Y H:i:s', filemtime($path)),
|
||||
];
|
||||
return ['lines' => $raw, 'source' => $src, 'meta' => $meta, 'error' => null];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$error = file_exists($livePath)
|
||||
? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath)
|
||||
: "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ").";
|
||||
|
||||
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
|
||||
}
|
||||
|
||||
// ── Line classifiers (used by both system.php and system-fragment.php) ────
|
||||
|
||||
/**
|
||||
* Return the CSS class for a log line.
|
||||
*/
|
||||
public static function logLineClass(string $line): string
|
||||
{
|
||||
if (preg_match('/\[(crit|emerg|alert)\]/', $line)) return 'log-crit';
|
||||
if (preg_match('/\[error\]/', $line)) return 'log-error';
|
||||
if (preg_match('/\[warn\]/', $line)) return 'log-warn';
|
||||
if (preg_match('/\[notice\]/', $line)) return 'log-notice';
|
||||
if (preg_match('/" [45]\d\d /', $line)) return 'log-error';
|
||||
if (preg_match('/" 3\d\d /', $line)) return 'log-notice';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the CSS class for a nginx config line.
|
||||
*/
|
||||
public static function nginxLineClass(string $line): string
|
||||
{
|
||||
$trimmed = ltrim($line);
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '#')) return 'nginx-comment';
|
||||
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) return 'nginx-block';
|
||||
return 'nginx-directive';
|
||||
}
|
||||
|
||||
// ── View helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Human-readable byte string (GB / MB / KB).
|
||||
*/
|
||||
public static function humanBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes > 1073741824) return number_format($bytes / 1073741824, 1) . ' GB';
|
||||
if ($bytes > 1048576) return number_format($bytes / 1048576, 1) . ' MB';
|
||||
return number_format($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
|
||||
/**
|
||||
* French status label with leading symbol.
|
||||
*/
|
||||
public static function statusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active' => '● En ligne',
|
||||
'inactive' => '○ Inactif',
|
||||
'failed' => '✕ Erreur',
|
||||
'warn' => '⚠ Attention',
|
||||
default => '? Inconnu',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class for a status value.
|
||||
*/
|
||||
public static function statusClass(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active' => 'status-ok',
|
||||
'inactive' => 'status-warn',
|
||||
'warn' => 'status-warn',
|
||||
'failed' => 'status-err',
|
||||
default => 'status-unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS colour string for a disk-usage percentage.
|
||||
*/
|
||||
public static function diskColor(int $pct): string
|
||||
{
|
||||
if ($pct > 85) return '#e05555';
|
||||
if ($pct > 70) return '#ffc107';
|
||||
return '#4caf50';
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute all six status checks and return the checks array.
|
||||
*/
|
||||
private function runStatusChecks(): array
|
||||
{
|
||||
$checks = [];
|
||||
|
||||
// nginx
|
||||
$nginxStatus = $this->systemdStatus('nginx');
|
||||
$nginxVersion = $this->safeExec('nginx -v 2>&1 | head -1');
|
||||
$checks['nginx'] = [
|
||||
'label' => 'nginx',
|
||||
'status' => $nginxStatus,
|
||||
'detail' => $nginxVersion,
|
||||
];
|
||||
|
||||
// php-fpm (try versioned unit names first)
|
||||
$phpFpmStatus = null;
|
||||
$phpFpmUnit = null;
|
||||
foreach (['php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm'] as $unit) {
|
||||
$s = $this->systemdStatus($unit);
|
||||
if ($s !== null && $s !== 'unknown') {
|
||||
$phpFpmStatus = $s;
|
||||
$phpFpmUnit = $unit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$checks['php_fpm'] = [
|
||||
'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''),
|
||||
'status' => $phpFpmStatus,
|
||||
'detail' => null,
|
||||
];
|
||||
|
||||
// Site HTTP ping
|
||||
$siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/';
|
||||
$httpResult = $this->localHttpCheck($siteUrl);
|
||||
$checks['site_http'] = [
|
||||
'label' => 'Site HTTP',
|
||||
'status' => $httpResult !== null ? ($httpResult[0] < 500 ? 'active' : 'failed') : null,
|
||||
'detail' => $httpResult !== null
|
||||
? "HTTP {$httpResult[0]} — {$httpResult[1]} ms"
|
||||
: 'curl indisponible',
|
||||
];
|
||||
|
||||
// Database
|
||||
$dbPath = $this->db->getDatabasePath();
|
||||
$dbExists = file_exists($dbPath);
|
||||
$dbWritable = $dbExists && is_writable($dbPath);
|
||||
$dbSizeBytes = $dbExists ? filesize($dbPath) : null;
|
||||
$dbSizeHuman = $dbSizeBytes !== null
|
||||
? ($dbSizeBytes > 1048576
|
||||
? number_format($dbSizeBytes / 1048576, 1) . ' MB'
|
||||
: number_format($dbSizeBytes / 1024, 1) . ' KB')
|
||||
: 'N/A';
|
||||
$dbRowCount = null;
|
||||
if ($dbExists) {
|
||||
try {
|
||||
$dbRowCount = $this->db->getThesisCount();
|
||||
} catch (Throwable) {}
|
||||
}
|
||||
$checks['database'] = [
|
||||
'label' => 'Base de données SQLite',
|
||||
'status' => $dbExists ? ($dbWritable ? 'active' : 'inactive') : 'failed',
|
||||
'detail' => $dbExists
|
||||
? ($dbRowCount !== null ? "$dbRowCount thèses — $dbSizeHuman" : "Lecture impossible — $dbSizeHuman")
|
||||
: 'Fichier introuvable',
|
||||
];
|
||||
|
||||
// Storage directory
|
||||
$storageDir = APP_ROOT . '/storage';
|
||||
$storageWritable = is_dir($storageDir) && is_writable($storageDir);
|
||||
$bannersDir = $storageDir . '/banners';
|
||||
$coversDir = $storageDir . '/covers';
|
||||
$checks['storage'] = [
|
||||
'label' => 'Répertoire storage',
|
||||
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
|
||||
'detail' => $storageWritable
|
||||
? implode(' · ', array_filter([
|
||||
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
|
||||
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
|
||||
]))
|
||||
: 'Non accessible en écriture',
|
||||
];
|
||||
|
||||
// Maintenance mode
|
||||
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
|
||||
$checks['maintenance'] = [
|
||||
'label' => 'Mode maintenance',
|
||||
'status' => $maintenanceOn ? 'warn' : 'active',
|
||||
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé',
|
||||
];
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the tail of a log file, newest-first. Returns null on error.
|
||||
*/
|
||||
private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
|
||||
{
|
||||
$errorMsg = null;
|
||||
|
||||
if (!function_exists('exec')) {
|
||||
$errorMsg = "exec() est désactivé sur ce serveur.";
|
||||
return null;
|
||||
}
|
||||
if (!file_exists($logPath)) {
|
||||
$errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath);
|
||||
return null;
|
||||
}
|
||||
if (!is_readable($logPath)) {
|
||||
$errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$rc = 0;
|
||||
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
|
||||
|
||||
if ($rc !== 0) {
|
||||
$errorMsg = "Erreur lors de la lecture du fichier journal.";
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_reverse($output); // newest first
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command safely, returning trimmed stdout or null on failure.
|
||||
*/
|
||||
private function safeExec(string $cmd): ?string
|
||||
{
|
||||
if (!function_exists('exec')) return null;
|
||||
$output = [];
|
||||
$rc = 0;
|
||||
exec($cmd . ' 2>/dev/null', $output, $rc);
|
||||
return $rc === 0 ? trim(implode("\n", $output)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query systemd for a unit's active state.
|
||||
*/
|
||||
private function systemdStatus(string $unit): ?string
|
||||
{
|
||||
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
|
||||
if ($raw === null) return null;
|
||||
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
|
||||
? $raw : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a lightweight HEAD request to $url and return [httpCode, ms].
|
||||
* Returns null if curl is unavailable.
|
||||
*/
|
||||
private function localHttpCheck(string $url): ?array
|
||||
{
|
||||
if (!function_exists('curl_init')) return null;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => 0,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
]);
|
||||
$start = microtime(true);
|
||||
curl_exec($ch);
|
||||
$ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return $code > 0 ? [$code, $ms] : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user