['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 */ 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); return $code > 0 ? [$code, $ms] : null; } }