['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 */ 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; } }