mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
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:
16
TODO.md
16
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)
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<?php
|
||||
require_once __DIR__ . "/../../config/bootstrap.php";
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/SystemCache.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
$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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -71,6 +85,15 @@ function statusClass(string $status): string {
|
||||
};
|
||||
}
|
||||
|
||||
// ── system_status cache (2-minute TTL: systemctl + curl checks) ─────────────
|
||||
$statusCacheAge = $_cache->ageSeconds('system_status');
|
||||
$checksFromCache = $_cache->get('system_status', 120);
|
||||
|
||||
if ($checksFromCache !== null) {
|
||||
$checks = $checksFromCache;
|
||||
$statusCached = true;
|
||||
} else {
|
||||
$statusCached = false;
|
||||
$checks = [];
|
||||
|
||||
// nginx
|
||||
@@ -108,9 +131,8 @@ $checks['site_http'] = [
|
||||
'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';
|
||||
// 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;
|
||||
@@ -119,12 +141,10 @@ $dbSizeHuman = $dbSizeBytes !== null
|
||||
? 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();
|
||||
$dbRowCount = $_db->getThesisCount();
|
||||
} catch (Throwable $e) {
|
||||
$dbRowCount = null;
|
||||
}
|
||||
@@ -161,7 +181,15 @@ $checks['maintenance'] = [
|
||||
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé',
|
||||
];
|
||||
|
||||
// PHP info
|
||||
$_cache->set('system_status', $checks);
|
||||
$statusCacheAge = 0;
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
@@ -170,12 +198,28 @@ $phpInfo = [
|
||||
'post_max' => ini_get('post_max_size'),
|
||||
'max_exec' => ini_get('max_execution_time') . 's',
|
||||
];
|
||||
$_cache->set('php_info', $phpInfo);
|
||||
}
|
||||
|
||||
// Disk
|
||||
// ── 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';
|
||||
|
||||
<p class="sys-refresh-note">
|
||||
Affiché le <?= date('d/m/Y à H:i:s') ?> —
|
||||
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&n=<?= $selectedN ?>">Rafraîchir</a>
|
||||
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&n=<?= $selectedN ?>">Rafraîchir</a> —
|
||||
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&n=<?= $selectedN ?>&refresh=1">Forcer actualisation</a>
|
||||
</p>
|
||||
|
||||
<!-- ── Tab bar ─────────────────────────────────────────────────────── -->
|
||||
@@ -399,7 +444,17 @@ require_once APP_ROOT . '/templates/head.php';
|
||||
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">
|
||||
<?php foreach ($checks as $check): ?>
|
||||
<?php $st = $check['status'] ?? 'unknown'; ?>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
// ========================================================================
|
||||
|
||||
111
src/SystemCache.php
Normal file
111
src/SystemCache.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
8
storage/migrations/007_system_cache.sql
Normal file
8
storage/migrations/007_system_cache.sql
Normal 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.
@@ -283,6 +283,12 @@ CREATE TABLE IF NOT EXISTS thesis_files (
|
||||
-- ============================================================================
|
||||
|
||||
-- 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE, -- 'charte', 'about', 'licenses', 'contact'
|
||||
|
||||
Reference in New Issue
Block a user