Files
xamxam/app/src/Controllers/SystemController.php
Pontoporeia ae66c2baad Integrate Monolog: replace four logging systems with single PSR-3 factory
- Add monolog/monolog dependency (^3.10)  
- Create app/Logger.php central factory with channels: app, admin, error, audit
- Each channel gets RotatingFileHandler (30-day retention) with pass-through LineFormatter
  preserving existing JSON format contracts
- Rewrite AppLogger as thin facade delegating to Logger::get('app')
- Rewrite ErrorHandler::log() to delegate to Logger::get('error')
- Rewrite AdminLogger file output to delegate to Logger::get('admin'), keep DB writes
- Add Monolog file shadow to Audit via Logger::get('audit') (Option A per monolog-plan)
- Log level controlled by LOG_LEVEL env var (defaults: DEBUG in cli-server, WARNING otherwise)
- Graceful NullHandler fallback when log directory is not writable
- Update SystemController LOG_FILES: remove php_error, add app/admin/error/audit
- JSON app logs parsed to readable one-liners in the log viewer
- Remove nginx config tab (parametres + fragment + template + css)
- Friendly empty-state message when app log files don't exist yet (notYet)
- PHP tail fallback when exec() unavailable
- All 228 PHPUnit tests pass, no call sites changed
2026-05-20 12:28:31 +02:00

585 lines
20 KiB
PHP

<?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, JSON-line parsing)
* - Log 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 = [
'app' => ['label' => 'App — soumissions', 'path' => null, 'json' => true],
'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true],
'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true],
'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true],
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log', 'json' => false],
'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam_error.log', 'json' => false],
];
/**
* Resolve a log file path — app logs live under STORAGE_ROOT, system logs
* have hard-coded paths (only valid in production).
*/
private static function resolveLogPath(string $tab): string
{
$def = self::LOG_FILES[$tab];
if ($def['path'] !== null) {
return $def['path'];
}
// App logs: storage/logs/{channel}.log (Monolog RotatingFileHandler uses
// this as base name; the current log is always at {channel}-YYYY-MM-DD.log)
$dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : APP_ROOT . '/storage/logs';
// Find the most recent dated log file for this channel
$base = $dir . '/' . $tab;
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
if (!empty($dated)) {
rsort($dated); // newest first
return $dated[0];
}
// Fall back to the bare name (pre-existing or non-rotated)
return $base . '.log';
}
public const ALLOWED_LINES = [50, 100, 200, 500];
// ── 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' => (string) ini_get('memory_limit'),
'upload_max' => (string) ini_get('upload_max_filesize'),
'post_max' => (string) 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((float) $used / (float) $total * 100.0) : 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::resolveLogPath($tab);
$isJson = self::LOG_FILES[$tab]['json'] ?? false;
$error = null;
$rawLines = null;
if (!file_exists($logPath)) {
// App logs are rotated by Monolog; a missing file just means no
// events have been logged yet. Show a friendly empty-state message
// instead of a scary "fichier introuvable" error.
if ($isJson) {
return [
'lines' => [],
'error' => null,
'meta' => null,
'isJson' => true,
'notYet' => true,
];
}
// System logs genuinely missing — show an error
$error = 'Fichier introuvable : ' . htmlspecialchars($logPath);
} else {
$rawLines = $this->readLogTail($logPath, $n, $error);
}
// Parse JSON lines into display strings
$lines = null;
if ($rawLines !== null) {
$lines = $isJson ? self::formatJsonLines($rawLines) : $rawLines;
}
$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, 'isJson' => $isJson, 'notYet' => false];
}
/**
* Format JSON log lines into human-readable display strings.
*
* Each line is a flat JSON object. We extract key fields and build a
* compact one-line representation with emoji status indicators.
*
* @param string[] $jsonLines Raw JSON strings (newest first).
* @return string[] Formatted display lines.
*/
private static function formatJsonLines(array $jsonLines): array
{
$formatted = [];
foreach ($jsonLines as $raw) {
$entry = json_decode($raw, true);
if (!is_array($entry)) {
$formatted[] = $raw; // not valid JSON — show raw
continue;
}
$parts = [];
// Timestamp
$ts = $entry['timestamp'] ?? '';
if ($ts !== '') {
$parts[] = substr($ts, 0, 19); // YYYY-MM-DDTHH:MM:SS
}
// Status emoji
$status = $entry['status'] ?? '';
if ($status === 'success' || $status === 'active') {
$parts[] = '✓';
} elseif ($status === 'error' || $status === 'duplicate') {
$parts[] = '✗';
}
// Key identifying fields (vary by channel)
$id = $entry['resource']
?? $entry['source']
?? $entry['context']
?? '';
if ($id !== '') {
$parts[] = $id;
}
// Action
$action = $entry['action'] ?? '';
if ($action !== '') {
$parts[] = $action;
}
// Status text
if ($status !== '' && $status !== 'success') {
$parts[] = $status;
}
// Actor / IP
$actor = $entry['actor'] ?? '';
if ($actor !== '') {
$parts[] = $actor;
} elseif (isset($entry['ip'])) {
$parts[] = $entry['ip'];
}
// User agent (compact)
$ua = $entry['user_agent'] ?? '';
if ($ua !== '' && $ua !== 'unknown' && $ua !== 'cli') {
// Truncate UA: first meaningful segment
$uaShort = preg_match('#^([^(]+)#', $ua, $m) ? trim($m[1]) : $ua;
if (mb_strlen($uaShort) > 60) {
$uaShort = mb_substr($uaShort, 0, 57) . '…';
}
$parts[] = $uaShort;
}
// For error log: exception + message
if (isset($entry['exception']) && isset($entry['message'])) {
$msg = $entry['exception'] . ': ' . $entry['message'];
if (mb_strlen($msg) > 120) {
$msg = mb_substr($msg, 0, 117) . '…';
}
$parts[] = $msg;
}
// For audit log: table + record_id
$table = $entry['table'] ?? '';
if ($table !== '') {
$parts[] = $table . (isset($entry['record_id']) ? '#' . $entry['record_id'] : '');
}
$formatted[] = implode(' ', $parts);
}
return $formatted;
}
// ── 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 '';
}
// ── 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 — probe running PHP version's unit first, then fall back
$phpFpmStatus = null;
$phpFpmUnit = null;
$phpMajMin = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
$fpmCandidates = array_unique([
'php' . $phpMajMin . '-fpm',
'php8.4-fpm', 'php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm',
]);
foreach ($fpmCandidates 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);
$coversDir = $storageDir . '/covers';
$checks['storage'] = [
'label' => 'Répertoire storage',
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
'detail' => $storageWritable
? implode(' · ', array_filter([
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 (sauf /admin et /partage)' : 'Désactivé',
];
return $checks;
}
/**
* Read the tail of a log file, newest-first. Returns null on error.
*
* Prefers tail(1) for efficiency on large files; falls back to a pure-PHP
* read for app log files when exec() is unavailable or tail fails.
*/
private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
{
$errorMsg = 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;
}
// Try tail(1) first — fast on large log files
if (function_exists('exec')) {
$output = [];
$rc = 0;
@exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc === 0) {
return array_reverse($output); // newest first
}
}
// PHP fallback — reads the whole file, returns last N lines
$raw = @file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($raw === false) {
$errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null;
}
$slice = array_slice($raw, -min($n, count($raw)));
return array_reverse($slice);
}
/**
* 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.0);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return $code > 0 ? [$code, $ms] : null;
}
}