mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
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:
2
TODO.md
2
TODO.md
@@ -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
279
public/admin/logs.php
Normal 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) ?>&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'; ?>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
Reference in New Issue
Block a user