From ba7814c6dc13ae82e71eb5010f38a80b7609e7fb Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Thu, 2 Apr 2026 13:04:00 +0200 Subject: [PATCH] feat: system page caching via SystemCache + system_cache SQLite table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TTL-based cache for the expensive checks on the admin system page, eliminating repeated systemctl subprocess calls (~4×~100ms), curl self-pings (~200-500ms), disk_*_space() and PHP ini reads on every page load. Changes: - storage/migrations/007_system_cache.sql: new migration creating the system_cache table (key TEXT PK, value TEXT, updated_at INTEGER) - storage/schema.sql: system_cache table added before pages table - Applied migration to live storage/posterg.db - src/SystemCache.php: new class with get/set/isStale/ageSeconds/invalidate; uses SQLite INSERT … ON CONFLICT upsert; no external dependencies - src/Database.php: added getDatabasePath(): string accessor - public/admin/system.php: - Bootstrap SystemCache at request start using the existing DB PDO handle - system_status: cached with 2-min TTL (systemctl + curl checks) - php_info: cached with 1-hour TTL (PHP ini values are runtime-constant) - disk_info: cached with 5-min TTL (total/free/used/pct tuple) - Logs section: unchanged — always reads live log tail per active tab - ?refresh=1 GET param invalidates all three cache keys before rendering - Status panel heading shows cache badge: '⚡ Cache — il y a Xs' (hit) or '⟳ Actualisé' (miss/fresh), styled via new .sys-cache-badge rules - public/assets/css/system.css: .sys-cache-badge / --hit / --miss styles --- TODO.md | 16 +- public/admin/system.php | 251 +++++++++++++++--------- public/assets/css/system.css | 23 +++ src/Database.php | 7 + src/SystemCache.php | 111 +++++++++++ storage/migrations/007_system_cache.sql | 8 + storage/posterg.db | Bin 221184 -> 229376 bytes storage/schema.sql | 6 + 8 files changed, 317 insertions(+), 105 deletions(-) create mode 100644 src/SystemCache.php create mode 100644 storage/migrations/007_system_cache.sql diff --git a/TODO.md b/TODO.md index 9a5fe5a..3861185 100644 --- a/TODO.md +++ b/TODO.md @@ -58,20 +58,22 @@ The admin system page (`/admin/system.php`) runs expensive operations on every l - Nginx config file reading ### Solution: `system_cache` table + background refresh -- [ ] **Add `system_cache` table** to schema: `CREATE TABLE system_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)` — stores JSON-encoded status snapshots keyed by section -- [ ] **Add migration** `storage/migrations/007_system_cache.sql` to create the table -- [ ] **Add `SystemCache` class** (`src/SystemCache.php`) with methods: +- [x] **Add `system_cache` table** to schema: `CREATE TABLE system_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)` — stores JSON-encoded status snapshots keyed by section +- [x] **Add migration** `storage/migrations/007_system_cache.sql` to create the table +- [x] **Add `SystemCache` class** (`src/SystemCache.php`) with methods: - `get(string $key, int $maxAgeSec = 60): ?array` — returns cached JSON data if fresh, null if stale - `set(string $key, array $data): void` — upserts cache row - `isStale(string $key, int $maxAgeSec = 60): bool` -- [ ] **Refactor `system.php` status section** to: + - `ageSeconds(string $key): ?int` — returns age of cached entry in seconds + - `invalidate(string $key): void` — force-deletes a cache entry +- [x] **Refactor `system.php` status section** to: 1. Check `SystemCache::get('system_status', 120)` — 2-minute TTL 2. If cache hit → render from cache, show "mis en cache il y a X sec" label 3. If cache miss → run checks, store in cache, render 4. Add `?refresh=1` GET param to force-bypass cache -- [ ] **Refactor `system.php` log sections** to not cache (logs should always be live) but avoid re-reading on every tab switch — only read the active tab's log -- [ ] **Cache disk info** separately with 5-minute TTL: `SystemCache::get('disk_info', 300)` -- [ ] **Cache PHP info** separately with 1-hour TTL: `SystemCache::get('php_info', 3600)` — PHP config doesn't change at runtime +- [x] **Refactor `system.php` log sections** to not cache (logs should always be live) but avoid re-reading on every tab switch — only read the active tab's log +- [x] **Cache disk info** separately with 5-minute TTL: `SystemCache::get('disk_info', 300)` +- [x] **Cache PHP info** separately with 1-hour TTL: `SystemCache::get('php_info', 3600)` — PHP config doesn't change at runtime ## In Progress (from previous plan) - [ ] Extract `SearchController` — most complex public page (§2 step 4) diff --git a/public/admin/system.php b/public/admin/system.php index 8c3245f..6d0458f 100644 --- a/public/admin/system.php +++ b/public/admin/system.php @@ -1,10 +1,24 @@ getPDO()); + +// ?refresh=1 force-busts all cached sections +$forceRefresh = isset($_GET['refresh']) && $_GET['refresh'] === '1'; +if ($forceRefresh) { + $_cache->invalidate('system_status'); + $_cache->invalidate('disk_info'); + $_cache->invalidate('php_info'); +} + // ═══════════════════════════════════════════════════════════════════════════════ // SECTION 1 — STATUS DATA // ═══════════════════════════════════════════════════════════════════════════════ @@ -71,111 +85,141 @@ function statusClass(string $status): string { }; } -$checks = []; +// ── system_status cache (2-minute TTL: systemctl + curl checks) ───────────── +$statusCacheAge = $_cache->ageSeconds('system_status'); +$checksFromCache = $_cache->get('system_status', 120); -// nginx -$nginxStatus = systemdStatus('nginx'); -$nginxVersion = safeExec('nginx -v 2>&1 | head -1'); -$checks['nginx'] = [ - 'label' => 'nginx', - 'status' => $nginxStatus, - 'detail' => $nginxVersion, -]; +if ($checksFromCache !== null) { + $checks = $checksFromCache; + $statusCached = true; +} else { + $statusCached = false; + $checks = []; -// php-fpm -$phpFpmStatus = null; -$phpFpmUnit = null; -foreach (['php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm'] as $unit) { - $s = systemdStatus($unit); - if ($s !== null && $s !== 'unknown') { - $phpFpmStatus = $s; - $phpFpmUnit = $unit; - break; + // nginx + $nginxStatus = systemdStatus('nginx'); + $nginxVersion = safeExec('nginx -v 2>&1 | head -1'); + $checks['nginx'] = [ + 'label' => 'nginx', + 'status' => $nginxStatus, + 'detail' => $nginxVersion, + ]; + + // php-fpm + $phpFpmStatus = null; + $phpFpmUnit = null; + foreach (['php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm'] as $unit) { + $s = systemdStatus($unit); + if ($s !== null && $s !== 'unknown') { + $phpFpmStatus = $s; + $phpFpmUnit = $unit; + break; + } } -} -$checks['php_fpm'] = [ - 'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''), - 'status' => $phpFpmStatus, - 'detail' => null, -]; + $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 = 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', -]; + // Site HTTP ping + $siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/'; + $httpResult = 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 -require_once APP_ROOT . '/src/Database.php'; -$dbPath = APP_ROOT . '/storage/test.db'; -$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 { - $db = new Database(); - $dbRowCount = $db->getThesisCount(); - } catch (Throwable $e) { - $dbRowCount = null; + // Database (DB object already created above, reuse it) + $dbPath = $_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 = $_db->getThesisCount(); + } catch (Throwable $e) { + $dbRowCount = null; + } } + $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' : ($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é', + ]; + + $_cache->set('system_status', $checks); + $statusCacheAge = 0; } -$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' : ($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', -]; +// ── php_info cache (1-hour TTL: PHP ini values don't change at runtime) ─────── +$phpInfoFromCache = $_cache->get('php_info', 3600); +if ($phpInfoFromCache !== null) { + $phpInfo = $phpInfoFromCache; +} else { + $phpInfo = [ + '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', + ]; + $_cache->set('php_info', $phpInfo); +} -// 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é', -]; - -// PHP info -$phpInfo = [ - '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', -]; - -// Disk -$diskTotal = disk_total_space(APP_ROOT); -$diskFree = disk_free_space(APP_ROOT); -$diskUsed = $diskTotal - $diskFree; -$diskPct = $diskTotal > 0 ? (int) round($diskUsed / $diskTotal * 100) : 0; +// ── disk_info cache (5-minute TTL) ──────────────────────────────────────────── +$diskFromCache = $_cache->get('disk_info', 300); +if ($diskFromCache !== null) { + $diskTotal = $diskFromCache['total']; + $diskFree = $diskFromCache['free']; + $diskUsed = $diskFromCache['used']; + $diskPct = $diskFromCache['pct']; +} else { + $diskTotal = disk_total_space(APP_ROOT); + $diskFree = disk_free_space(APP_ROOT); + $diskUsed = $diskTotal - $diskFree; + $diskPct = $diskTotal > 0 ? (int) round($diskUsed / $diskTotal * 100) : 0; + $_cache->set('disk_info', [ + 'total' => $diskTotal, + 'free' => $diskFree, + 'used' => $diskUsed, + 'pct' => $diskPct, + ]); +} // ═══════════════════════════════════════════════════════════════════════════════ // SECTION 2 — LOGS DATA @@ -377,7 +421,8 @@ require_once APP_ROOT . '/templates/head.php';

Affiché le — - Rafraîchir + Rafraîchir — + Forcer actualisation

@@ -399,7 +444,17 @@ require_once APP_ROOT . '/templates/head.php'; STATUS PANEL ════════════════════════════════════════════════════════════════════ --> -

Services

+

Services + + + ⚡ Cache — il y a s + + + + ⟳ Actualisé + + +

diff --git a/public/assets/css/system.css b/public/assets/css/system.css index e9da731..7795e2c 100644 --- a/public/assets/css/system.css +++ b/public/assets/css/system.css @@ -254,6 +254,29 @@ } .sys-refresh-note a:hover { text-decoration: underline; } +/* ── Cache freshness badges ──────────────────────────────────────────────── */ +.sys-cache-badge { + display: inline-block; + font-size: .68rem; + font-weight: 400; + font-family: ui-monospace, monospace; + padding: .1rem .45rem; + border-radius: 3px; + margin-left: .7rem; + vertical-align: middle; + line-height: 1.6; +} +.sys-cache-badge--hit { + background: rgba(255,193,7,.12); + color: #ffc107; + border: 1px solid rgba(255,193,7,.35); +} +.sys-cache-badge--miss { + background: rgba(76,175,80,.12); + color: #4caf50; + border: 1px solid rgba(76,175,80,.35); +} + /* ── Nginx config viewer ───────────────────────────────────────────────── */ .nginx-source-badge { display: inline-block; diff --git a/src/Database.php b/src/Database.php index ff710b8..e0e0c11 100644 --- a/src/Database.php +++ b/src/Database.php @@ -77,6 +77,13 @@ class Database { return $this->pdo; } + /** + * Return the resolved path of the database file in use. + */ + public function getDatabasePath(): string { + return $this->dbPath; + } + // ======================================================================== // TRANSACTION SUPPORT (from formulaire) // ======================================================================== diff --git a/src/SystemCache.php b/src/SystemCache.php new file mode 100644 index 0000000..5adfc74 --- /dev/null +++ b/src/SystemCache.php @@ -0,0 +1,111 @@ +get('system_status', 120); + * + * // Write + * $cache->set('system_status', $myArray); + * + * // Check freshness without reading value + * if ($cache->isStale('disk_info', 300)) { ... } + * + * // Force-invalidate a key (e.g. on ?refresh=1) + * $cache->invalidate('system_status'); + */ +class SystemCache +{ + private PDO $pdo; + + public function __construct(PDO $pdo) + { + $this->pdo = $pdo; + } + + /** + * Return cached data for $key if it is no older than $maxAgeSec seconds. + * Returns null when the entry is missing or stale. + */ + public function get(string $key, int $maxAgeSec = 60): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT value, updated_at FROM system_cache WHERE key = ?' + ); + $stmt->execute([$key]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + if ((time() - (int)$row['updated_at']) > $maxAgeSec) { + return null; // stale + } + + $decoded = json_decode((string)$row['value'], true); + return is_array($decoded) ? $decoded : null; + } + + /** + * Upsert $data (JSON-encoded) for $key with current timestamp. + */ + public function set(string $key, array $data): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO system_cache (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at' + ); + $stmt->execute([$key, json_encode($data), time()]); + } + + /** + * Return true when the entry is missing or older than $maxAgeSec. + */ + public function isStale(string $key, int $maxAgeSec = 60): bool + { + $stmt = $this->pdo->prepare( + 'SELECT updated_at FROM system_cache WHERE key = ?' + ); + $stmt->execute([$key]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return true; + } + + return (time() - (int)$row['updated_at']) > $maxAgeSec; + } + + /** + * Return the age of the cached entry in seconds, or null if missing. + */ + public function ageSeconds(string $key): ?int + { + $stmt = $this->pdo->prepare( + 'SELECT updated_at FROM system_cache WHERE key = ?' + ); + $stmt->execute([$key]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? (time() - (int)$row['updated_at']) : null; + } + + /** + * Delete a cached entry, forcing the next get() to re-compute. + */ + public function invalidate(string $key): void + { + $stmt = $this->pdo->prepare('DELETE FROM system_cache WHERE key = ?'); + $stmt->execute([$key]); + } +} diff --git a/storage/migrations/007_system_cache.sql b/storage/migrations/007_system_cache.sql new file mode 100644 index 0000000..243cf4d --- /dev/null +++ b/storage/migrations/007_system_cache.sql @@ -0,0 +1,8 @@ +-- Migration 007: Add system_cache table for admin system page caching +-- Stores JSON-encoded status snapshots keyed by section with a TTL mechanism. + +CREATE TABLE IF NOT EXISTS system_cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/storage/posterg.db b/storage/posterg.db index 61c33a51fee808f3c9982a3f79c47f7ba1cd82a8..84582c71e2eb57efa51717ca8e3d5940ed8ffcbf 100644 GIT binary patch delta 480 zcmZoTz}wKkrxP6LlUb6gkd|MRn^>Y?%pkzP#KhpBpuoVuU=G9tYr@mK_@#zcAM~s#~y&tEw_K36>-# z<)ju@7MG;v#wRBxXP~gnor7E*LtGU?9G!ez6_8aaXmBY2L3U~-hysBS*N6}h9T4Q{ z>lhTN;O!cz16EU(m{Xbxl0m4^_45x=@C)_vfv6}gNJ%V7O^FA3($g=*)!j8n0j7XU z)7*0UhDt^mX=K~u4Uu^k+drBx3v&yx^KE0`KgmCvKa<~{UzG0=-?oj73-}DR*qKd@ z4GhfVk@e^m7v>ll7@6rBnCKc9DHs}B85mj_85y!MI~z}rjAxeK&g9R`#3;nhZ^OWU zm;Vs|V*VcfQvOhWn~jZX{E`yPri|E(WS<@y&%Bz87Zfc53=I4iHwy~9=bsqBKIsDo J6aN<&4*-QjgpL3J delta 162 zcmZo@;A=R*s}mgPlUb6gkd|MRn^>Y?%pkzP$jIQJpuoVuU;@NuKnw$F6LpLkO&Swg z6Bt_)n6@S`FVPobXZgs$Z^xIxyMZl>^&QJc*2NneZ?kOwXu>SaEx^Y2h=Knk|7`wD zetUjVzDFAy&+)MuvN1awPmhdemfp_f&&