admin: add server log viewer; fix curl_close() PHP 8.5 deprecation in status.php

- public/admin/logs.php: new page tailing nginx error/access + PHP-FPM logs.
  Selector for log file and line count (50/100/200/500, default 100).
  Lines reversed (newest first), colour-coded by severity, numbered gutter.
  Graceful degradation when exec() unavailable or file unreadable (dev msg).

- templates/admin/head.php: 'Journaux' nav link added after 'Statut'.

- public/admin/status.php: remove curl_close() call deprecated in PHP 8.5
  (no-op since PHP 8.0); replace with unset($ch) to silence the warning
  that was leaking raw text above the page output.
This commit is contained in:
Pontoporeia
2026-03-24 15:47:08 +01:00
parent c678b75494
commit 020bfa5a33
4 changed files with 283 additions and 2 deletions

View File

@@ -331,6 +331,6 @@ Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis
- [x] Exclude `.claude` and `.pi` from rsync deploy
- [x] Update `docs/SERVER_SETUP.md` with correct permissions rationale and troubleshooting
- [x] Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
- [ ] Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- [x] Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- [ ] Add nginx config deploy flow to admin panel (upload `scripts/deploy-server.sh`, run remotely)
- [ ] Add admin user management UI (wraps `scripts/manage-admin-users.sh` on server)

279
public/admin/logs.php Normal file
View File

@@ -0,0 +1,279 @@
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Journaux serveur";
// ── Log file definitions ──────────────────────────────────────────────────────
// Paths match the nginx config (posterg_access.log / posterg_error.log).
// On the dev server these files won't exist; we handle that gracefully.
const LOG_FILES = [
'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/posterg_error.log'],
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/posterg_access.log'],
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'],
];
const ALLOWED_LINES = [50, 100, 200, 500];
// ── Input validation ──────────────────────────────────────────────────────────
$selectedKey = $_GET['log'] ?? 'nginx_error';
$selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100;
if (!array_key_exists($selectedKey, LOG_FILES)) {
$selectedKey = 'nginx_error';
}
if (!in_array($selectedN, ALLOWED_LINES, true)) {
$selectedN = 100;
}
$logDef = LOG_FILES[$selectedKey];
$logPath = $logDef['path'];
// ── Read log ──────────────────────────────────────────────────────────────────
$lines = null; // null = unavailable, [] = empty, [...] = lines
$readError = null;
if (!function_exists('exec')) {
$readError = "exec() est désactivé sur ce serveur.";
} elseif (!file_exists($logPath)) {
$readError = "Fichier introuvable : " . htmlspecialchars($logPath);
} elseif (!is_readable($logPath)) {
$readError = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
} else {
$output = [];
$rc = 0;
// tail -n N ensures we never load the whole file into memory
exec('tail -n ' . intval($selectedN) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) {
$readError = "Erreur lors de la lecture du fichier journal.";
} else {
// Reverse so newest lines appear first
$lines = array_reverse($output);
}
}
// ── File metadata ─────────────────────────────────────────────────────────────
$fileMeta = null;
if (file_exists($logPath)) {
$size = filesize($logPath);
$mtime = filemtime($logPath);
$fileMeta = [
'size' => $size > 1048576
? number_format($size / 1048576, 2) . ' MB'
: number_format($size / 1024, 1) . ' KB',
'mtime' => date('d/m/Y H:i:s', $mtime),
];
}
// ── Log-level classifier (for nginx error log coloring) ───────────────────────
/**
* Returns a CSS class based on the log level token in a log line.
* nginx error lines look like: 2024/01/15 12:34:56 [error] 1234#1234: …
* nginx access lines: 1.2.3.4 - - [15/Jan/2024:12:34:56 +0000] "GET / HTTP/1.1" 200 …
*/
function logLineClass(string $line): string {
// nginx error log: [crit], [error], [warn], [notice], [info], [debug]
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';
// access log: highlight HTTP 4xx / 5xx
if (preg_match('/" [45]\d\d /', $line)) return 'log-error';
if (preg_match('/" 3\d\d /', $line)) return 'log-notice';
return '';
}
require_once APP_ROOT . '/templates/admin/head.php';
?>
<style>
/* ── Log viewer layout ─────────────────────────────────────────────────── */
.log-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .6rem;
margin-bottom: 1.25rem;
}
.log-toolbar select,
.log-toolbar input[type=submit] {
background: var(--admin-bg-alt);
border: 1px solid var(--admin-border);
color: var(--admin-text);
border-radius: 4px;
padding: .4rem .7rem;
font-size: .85rem;
font-family: inherit;
}
.log-toolbar select:focus { outline: 2px solid var(--admin-purple); }
.log-toolbar input[type=submit] {
cursor: pointer;
background: var(--admin-purple);
border-color: var(--admin-purple);
color: #fff;
font-weight: 600;
}
.log-toolbar input[type=submit]:hover { opacity: .85; }
.log-meta {
font-size: .78rem;
color: var(--admin-text-muted);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
margin-bottom: .75rem;
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.log-meta span::before { content: attr(data-label) ": "; opacity: .6; }
.log-unavailable {
background: var(--admin-bg-alt);
border: 1px solid var(--admin-border);
border-radius: 4px;
padding: 1.5rem;
color: var(--admin-text-muted);
font-size: .88rem;
}
.log-unavailable .log-unavail-path {
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: .8rem;
margin-top: .4rem;
opacity: .7;
}
.log-empty {
color: var(--admin-text-muted);
font-size: .88rem;
padding: 1rem 0;
}
/* Log output block */
.log-output {
background: #0d0d0d;
border: 1px solid var(--admin-border);
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
font-family: ui-monospace, "SFMono-Regular", Consolas, "Courier New", monospace;
font-size: .76rem;
line-height: 1.55;
max-height: 72vh;
overflow-y: auto;
/* Reverse scroll so newest (first in DOM) is at top */
}
.log-line {
display: block;
white-space: pre;
padding: .05rem .1rem;
border-radius: 2px;
color: #bbb;
}
.log-line + .log-line { border-top: 1px solid rgba(255,255,255,.03); }
.log-crit { color: #ff7070; background: rgba(200,0,0,.12); }
.log-error { color: #f08080; }
.log-warn { color: #ffd080; }
.log-notice { color: #a0c8ff; }
/* line number gutter */
.log-line::before {
content: attr(data-n);
display: inline-block;
min-width: 4ch;
margin-right: .75rem;
opacity: .3;
text-align: right;
user-select: none;
}
.log-count-badge {
background: var(--admin-bg-alt);
border: 1px solid var(--admin-border);
border-radius: 3px;
font-size: .76rem;
padding: .15rem .5rem;
color: var(--admin-text-muted);
font-family: ui-monospace, monospace;
}
.log-refresh-note {
font-size: .78rem;
color: var(--admin-text-muted);
margin-bottom: 1.25rem;
}
.log-refresh-note a { color: var(--admin-purple); text-decoration: none; }
.log-refresh-note a:hover { text-decoration: underline; }
</style>
<main class="admin-main">
<h1 class="admin-page-title">Journaux serveur</h1>
<p class="log-refresh-note">
Affiché le <?= date('d/m/Y à H:i:s') ?> —
<a href="?log=<?= htmlspecialchars($selectedKey) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a>
</p>
<!-- Toolbar: log selector + line count -->
<form class="log-toolbar" method="get" action="">
<select name="log" aria-label="Fichier journal">
<?php foreach (LOG_FILES as $key => $def): ?>
<option value="<?= htmlspecialchars($key) ?>"
<?= $key === $selectedKey ? 'selected' : '' ?>>
<?= htmlspecialchars($def['label']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="n" aria-label="Nombre de lignes">
<?php foreach (ALLOWED_LINES as $n): ?>
<option value="<?= $n ?>" <?= $n === $selectedN ? 'selected' : '' ?>>
<?= $n ?> dernières lignes
</option>
<?php endforeach; ?>
</select>
<input type="submit" value="Afficher">
<?php if ($lines !== null && count($lines) > 0): ?>
<span class="log-count-badge"><?= count($lines) ?> ligne(s)</span>
<?php endif; ?>
</form>
<!-- File metadata -->
<?php if ($fileMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($logPath) ?></span>
<span data-label="Taille"><?= $fileMeta['size'] ?></span>
<span data-label="Modifié"><?= $fileMeta['mtime'] ?></span>
</div>
<?php endif; ?>
<!-- Output -->
<?php if ($readError !== null): ?>
<div class="log-unavailable">
<strong>Journaux non disponibles</strong>
<div class="log-unavail-path"><?= $readError ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div style="margin-top:.6rem;font-size:.8rem;opacity:.7;">
En environnement de développement, les logs nginx ne sont pas disponibles.
Cette page est pleinement fonctionnelle sur le serveur de production.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($lines)): ?>
<div class="log-empty">Le fichier journal est vide.</div>
<?php else: ?>
<div class="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<?php foreach ($lines as $i => $line): ?>
<span class="log-line <?= logLineClass($line) ?>"
data-n="<?= count($lines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -50,7 +50,8 @@ function localHttpCheck(string $url): ?array {
curl_exec($ch);
$ms = (int) round((microtime(true) - $start) * 1000);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// curl_close() is a no-op since PHP 8.0 and deprecated in PHP 8.5; omitted.
unset($ch);
return $code > 0 ? [$code, $ms] : null;
}

View File

@@ -30,6 +30,7 @@
<a href="/admin/pages.php" class="admin-nav__link <?= in_array($currentPage, ['pages.php','pages-edit.php']) ? 'active' : '' ?>">Pages statiques</a>
<a href="/admin/tags.php" class="admin-nav__link <?= $currentPage === 'tags.php' ? 'active' : '' ?>">Mots-clés</a>
<a href="/admin/status.php" class="admin-nav__link <?= $currentPage === 'status.php' ? 'active' : '' ?>">Statut</a>
<a href="/admin/logs.php" class="admin-nav__link <?= $currentPage === 'logs.php' ? 'active' : '' ?>">Journaux</a>
<?php if ($thesisId && in_array($currentPage, ['edit.php', 'thanks.php'])): ?>
<a href="/admin/edit.php?id=<?= intval($thesisId) ?>" class="admin-nav__link <?= $currentPage === 'edit.php' ? 'active' : '' ?>">Modifier</a>
<?php endif; ?>