feat: system page caching via SystemCache + system_cache SQLite table

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
This commit is contained in:
Pontoporeia
2026-04-02 13:04:00 +02:00
parent 592b1183db
commit ba7814c6dc
8 changed files with 317 additions and 105 deletions

16
TODO.md
View File

@@ -58,20 +58,22 @@ The admin system page (`/admin/system.php`) runs expensive operations on every l
- Nginx config file reading - Nginx config file reading
### Solution: `system_cache` table + background refresh ### 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 - [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
- [ ] **Add migration** `storage/migrations/007_system_cache.sql` to create the table - [x] **Add migration** `storage/migrations/007_system_cache.sql` to create the table
- [ ] **Add `SystemCache` class** (`src/SystemCache.php`) with methods: - [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 - `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 - `set(string $key, array $data): void` — upserts cache row
- `isStale(string $key, int $maxAgeSec = 60): bool` - `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 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 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 3. If cache miss → run checks, store in cache, render
4. Add `?refresh=1` GET param to force-bypass cache 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 - [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
- [ ] **Cache disk info** separately with 5-minute TTL: `SystemCache::get('disk_info', 300)` - [x] **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] **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) ## In Progress (from previous plan)
- [ ] Extract `SearchController` — most complex public page (§2 step 4) - [ ] Extract `SearchController` — most complex public page (§2 step 4)

View File

@@ -1,10 +1,24 @@
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php'; require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php';
AdminAuth::requireLogin(); AdminAuth::requireLogin();
$pageTitle = "Système"; $pageTitle = "Système";
// Bootstrap cache (uses the same SQLite DB as the app)
$_db = new Database();
$_cache = new SystemCache($_db->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 // 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 if ($checksFromCache !== null) {
$nginxStatus = systemdStatus('nginx'); $checks = $checksFromCache;
$nginxVersion = safeExec('nginx -v 2>&1 | head -1'); $statusCached = true;
$checks['nginx'] = [ } else {
'label' => 'nginx', $statusCached = false;
'status' => $nginxStatus, $checks = [];
'detail' => $nginxVersion,
];
// php-fpm // nginx
$phpFpmStatus = null; $nginxStatus = systemdStatus('nginx');
$phpFpmUnit = null; $nginxVersion = safeExec('nginx -v 2>&1 | head -1');
foreach (['php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm'] as $unit) { $checks['nginx'] = [
$s = systemdStatus($unit); 'label' => 'nginx',
if ($s !== null && $s !== 'unknown') { 'status' => $nginxStatus,
$phpFpmStatus = $s; 'detail' => $nginxVersion,
$phpFpmUnit = $unit; ];
break;
// 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'] = [
$checks['php_fpm'] = [ 'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''),
'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''), 'status' => $phpFpmStatus,
'status' => $phpFpmStatus, 'detail' => null,
'detail' => null, ];
];
// Site HTTP ping // Site HTTP ping
$siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/'; $siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/';
$httpResult = localHttpCheck($siteUrl); $httpResult = localHttpCheck($siteUrl);
$checks['site_http'] = [ $checks['site_http'] = [
'label' => 'Site HTTP', 'label' => 'Site HTTP',
'status' => $httpResult !== null ? ($httpResult[0] < 500 ? 'active' : 'failed') : null, 'status' => $httpResult !== null ? ($httpResult[0] < 500 ? 'active' : 'failed') : null,
'detail' => $httpResult !== null ? "HTTP {$httpResult[0]}{$httpResult[1]} ms" : 'curl indisponible', 'detail' => $httpResult !== null ? "HTTP {$httpResult[0]}{$httpResult[1]} ms" : 'curl indisponible',
]; ];
// Database // Database (DB object already created above, reuse it)
require_once APP_ROOT . '/src/Database.php'; $dbPath = $_db->getDatabasePath();
$dbPath = APP_ROOT . '/storage/test.db'; $dbExists = file_exists($dbPath);
$dbExists = file_exists($dbPath); $dbWritable = $dbExists && is_writable($dbPath);
$dbWritable = $dbExists && is_writable($dbPath); $dbSizeBytes = $dbExists ? filesize($dbPath) : null;
$dbSizeBytes = $dbExists ? filesize($dbPath) : null; $dbSizeHuman = $dbSizeBytes !== null
$dbSizeHuman = $dbSizeBytes !== null ? ($dbSizeBytes > 1048576
? ($dbSizeBytes > 1048576 ? number_format($dbSizeBytes / 1048576, 1) . ' MB'
? number_format($dbSizeBytes / 1048576, 1) . ' MB' : number_format($dbSizeBytes / 1024, 1) . ' KB')
: number_format($dbSizeBytes / 1024, 1) . ' KB') : 'N/A';
: 'N/A'; $dbRowCount = null;
if ($dbExists) {
$dbRowCount = null; try {
if ($dbExists) { $dbRowCount = $_db->getThesisCount();
try { } catch (Throwable $e) {
$db = new Database(); $dbRowCount = null;
$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 // ── php_info cache (1-hour TTL: PHP ini values don't change at runtime) ───────
$storageDir = APP_ROOT . '/storage'; $phpInfoFromCache = $_cache->get('php_info', 3600);
$storageWritable = is_dir($storageDir) && is_writable($storageDir); if ($phpInfoFromCache !== null) {
$bannersDir = $storageDir . '/banners'; $phpInfo = $phpInfoFromCache;
$coversDir = $storageDir . '/covers'; } else {
$checks['storage'] = [ $phpInfo = [
'label' => 'Répertoire storage', 'version' => PHP_VERSION,
'status' => $storageWritable ? 'active' : ($storageDir ? 'inactive' : 'failed'), 'sapi' => PHP_SAPI,
'detail' => $storageWritable 'memory_limit' => ini_get('memory_limit'),
? implode(' · ', array_filter([ 'upload_max' => ini_get('upload_max_filesize'),
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null, 'post_max' => ini_get('post_max_size'),
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null, 'max_exec' => ini_get('max_execution_time') . 's',
])) ];
: 'Non accessible en écriture', $_cache->set('php_info', $phpInfo);
]; }
// Maintenance mode // ── disk_info cache (5-minute TTL) ────────────────────────────────────────────
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag'); $diskFromCache = $_cache->get('disk_info', 300);
$checks['maintenance'] = [ if ($diskFromCache !== null) {
'label' => 'Mode maintenance', $diskTotal = $diskFromCache['total'];
'status' => $maintenanceOn ? 'warn' : 'active', $diskFree = $diskFromCache['free'];
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé', $diskUsed = $diskFromCache['used'];
]; $diskPct = $diskFromCache['pct'];
} else {
// PHP info $diskTotal = disk_total_space(APP_ROOT);
$phpInfo = [ $diskFree = disk_free_space(APP_ROOT);
'version' => PHP_VERSION, $diskUsed = $diskTotal - $diskFree;
'sapi' => PHP_SAPI, $diskPct = $diskTotal > 0 ? (int) round($diskUsed / $diskTotal * 100) : 0;
'memory_limit' => ini_get('memory_limit'), $_cache->set('disk_info', [
'upload_max' => ini_get('upload_max_filesize'), 'total' => $diskTotal,
'post_max' => ini_get('post_max_size'), 'free' => $diskFree,
'max_exec' => ini_get('max_execution_time') . 's', 'used' => $diskUsed,
]; 'pct' => $diskPct,
]);
// 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;
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// SECTION 2 — LOGS DATA // SECTION 2 — LOGS DATA
@@ -377,7 +421,8 @@ require_once APP_ROOT . '/templates/head.php';
<p class="sys-refresh-note"> <p class="sys-refresh-note">
Affiché le <?= date('d/m/Y à H:i:s') ?> — Affiché le <?= date('d/m/Y à H:i:s') ?> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a> <a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a>
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>&amp;refresh=1">Forcer actualisation</a>
</p> </p>
<!-- ── Tab bar ─────────────────────────────────────────────────────── --> <!-- ── Tab bar ─────────────────────────────────────────────────────── -->
@@ -399,7 +444,17 @@ require_once APP_ROOT . '/templates/head.php';
STATUS PANEL STATUS PANEL
════════════════════════════════════════════════════════════════════ --> ════════════════════════════════════════════════════════════════════ -->
<h2 class="srv-section-title">Services</h2> <h2 class="srv-section-title">Services
<?php if ($statusCached && $statusCacheAge !== null): ?>
<span class="sys-cache-badge sys-cache-badge--hit" title="Données en cache">
⚡ Cache — il y a <?= $statusCacheAge ?>s
</span>
<?php else: ?>
<span class="sys-cache-badge sys-cache-badge--miss" title="Données fraîches">
⟳ Actualisé
</span>
<?php endif; ?>
</h2>
<div class="srv-grid"> <div class="srv-grid">
<?php foreach ($checks as $check): ?> <?php foreach ($checks as $check): ?>
<?php $st = $check['status'] ?? 'unknown'; ?> <?php $st = $check['status'] ?? 'unknown'; ?>

View File

@@ -254,6 +254,29 @@
} }
.sys-refresh-note a:hover { text-decoration: underline; } .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 config viewer ───────────────────────────────────────────────── */
.nginx-source-badge { .nginx-source-badge {
display: inline-block; display: inline-block;

View File

@@ -77,6 +77,13 @@ class Database {
return $this->pdo; return $this->pdo;
} }
/**
* Return the resolved path of the database file in use.
*/
public function getDatabasePath(): string {
return $this->dbPath;
}
// ======================================================================== // ========================================================================
// TRANSACTION SUPPORT (from formulaire) // TRANSACTION SUPPORT (from formulaire)
// ======================================================================== // ========================================================================

111
src/SystemCache.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
/**
* SystemCache — thin TTL cache for admin system page checks.
*
* Stores JSON-encoded data blobs in the `system_cache` SQLite table.
* The table has a single schema:
* key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
*
* Usage:
* $cache = new SystemCache($pdo);
*
* // Read (returns array or null if stale/missing)
* $data = $cache->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]);
}
}

View File

@@ -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
);

Binary file not shown.

View File

@@ -283,6 +283,12 @@ CREATE TABLE IF NOT EXISTS thesis_files (
-- ============================================================================ -- ============================================================================
-- For managing editable static pages (charte, about, licenses, contact) -- For managing editable static pages (charte, about, licenses, contact)
CREATE TABLE IF NOT EXISTS system_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pages ( CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, -- 'charte', 'about', 'licenses', 'contact' slug TEXT NOT NULL UNIQUE, -- 'charte', 'about', 'licenses', 'contact'