mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
- 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
585 lines
20 KiB
PHP
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;
|
|
}
|
|
}
|