admin: backup logs via parameters.php, nextcloud secondary backup

- surface backup/cleanup cron logs + backup freshness status
- email xamxam@erg.be when SQLite backups go stale (backup watchdog)
- sync SQLite snapshots to Nextcloud WebDAV + remote-freshness watchdog
- precise retention pruning, manual sync in check recipe, and Nextcloud-sync docs
This commit is contained in:
Pontoporeia
2026-08-24 11:34:57 +02:00
parent 3e721b19f0
commit fb5e856288
12 changed files with 823 additions and 35 deletions
+105 -11
View File
@@ -26,6 +26,8 @@ class SystemController
'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true],
'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true],
'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true],
'backup' => ['label' => 'Sauvegardes — SQLite', 'path' => null, 'json' => false, 'cron' => 'xamxam-backup'],
'cleanup' => ['label' => 'Nettoyage — brouillons', 'path' => null, 'json' => false, 'cron' => 'xamxam-cleanup'],
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam-nginx-access.log', 'json' => false],
'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam-nginx-error.log', 'json' => false],
];
@@ -33,10 +35,11 @@ class SystemController
/**
* Resolve a log file path — app logs live under /var/log/xamxam in
* production (and storage/logs in dev/cli-server); nginx logs have
* hard-coded paths (only valid in production).
* hard-coded paths (only valid in production); cron logs (backup, cleanup)
* live directly under /var/log with the date in the filename.
*
* A $date (YYYY-MM-DD) selects a specific retained daily file for app
* channels; when null the most recent file is returned.
* A $date (YYYY-MM-DD) selects a specific retained daily file for app and
* cron channels; when null the most recent file is returned.
*/
private static function resolveLogPath(string $tab, ?string $date = null): string
{
@@ -44,6 +47,25 @@ class SystemController
if ($def['path'] !== null) {
return $def['path'];
}
// Cron logs: /var/log/xamxam-{cron}-YYYY-MM-DD.log — the date is part
// of the filename (set by the cron job), not a Monolog rotation suffix.
// These only exist in production; in dev there is no sensible fallback.
if (isset($def['cron'])) {
$base = '/var/log/' . $def['cron'];
if ($date !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $base . '-' . $date . '.log';
}
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
if (!empty($dated)) {
rsort($dated); // newest first
return $dated[0];
}
// No log yet — return today's expected path so the caller can show
// an empty-state (file_exists() will be false).
return $base . '-' . date('Y-m-d') . '.log';
}
// App logs: /var/log/xamxam/xamxam-{channel}.log (production) or
// storage/logs/xamxam-{channel}.log (dev / cli-server). Monolog
// RotatingFileHandler uses this as base name; the current log is always
@@ -77,14 +99,16 @@ class SystemController
public static function listLogDates(string $tab): array
{
$def = self::LOG_FILES[$tab];
if (($def['json'] ?? false) !== true) {
if (isset($def['cron'])) {
$base = '/var/log/' . $def['cron'];
} elseif (($def['json'] ?? false) === true) {
$dir = php_sapi_name() === 'cli-server'
? APP_ROOT . '/storage/logs'
: '/var/log/xamxam';
$base = $dir . '/xamxam-' . $tab;
} else {
return []; // nginx logs are fixed-name, not daily
}
$dir = php_sapi_name() === 'cli-server'
? APP_ROOT . '/storage/logs'
: '/var/log/xamxam';
$base = $dir . '/xamxam-' . $tab;
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
if (empty($dated)) {
return [];
@@ -229,12 +253,12 @@ class SystemController
// App logs are rotated by Monolog; a missing file just means no
// events have been logged yet. Show a friendly empty-state message
// instead of a scary "fichier introuvable" error.
if ($isJson) {
if ($isJson || isset(self::LOG_FILES[$tab]['cron'])) {
return [
'lines' => [],
'error' => null,
'meta' => null,
'isJson' => true,
'isJson' => $isJson,
'notYet' => true,
];
}
@@ -536,6 +560,9 @@ class SystemController
: 'Non accessible en écriture',
];
// SQLite backups — surface staleness so admins catch a broken cron.
$checks['backup'] = $this->backupStatus();
// Maintenance mode
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
$checks['maintenance'] = [
@@ -547,6 +574,73 @@ class SystemController
return $checks;
}
/**
* Check the freshness of SQLite backups in /var/backups/xamxam.
*
* Returns a status-check entry suitable for the system page. The hourly
* cron should produce a fresh snapshot at least every ~2 hours, so we
* flag a warning past that and an error past a full day.
*/
private function backupStatus(): array
{
$dir = '/var/backups/xamxam';
$label = 'Sauvegardes SQLite';
if (!is_dir($dir)) {
// In dev (cli-server / local) the backup cron is not set up, so a
// missing directory is expected — show inactive rather than failed.
$isDev = php_sapi_name() === 'cli-server';
return [
'label' => $label,
'status' => $isDev ? 'inactive' : 'failed',
'detail' => $isDev
? 'Cron de sauvegarde non configuré (environnement de dev)'
: 'Dossier /var/backups/xamxam introuvable — cron non déployé ?',
];
}
$files = glob($dir . '/db-*.db.gz');
if (empty($files)) {
return [
'label' => $label,
'status' => 'warn',
'detail' => 'Aucune sauvegarde trouvée dans /var/backups/xamxam',
];
}
// Newest backup by mtime
$newest = null;
$newestMtime = 0;
foreach ($files as $f) {
$m = filemtime($f);
if ($m > $newestMtime) {
$newestMtime = $m;
$newest = $f;
}
}
$ageSec = time() - $newestMtime;
$ageHours = (int) round($ageSec / 3600.0);
$count = count($files);
$human = $ageHours < 1
? 'moins d\'une heure'
: ($ageHours === 1 ? '1 heure' : "$ageHours heures");
if ($ageSec > 86400) {
$status = 'failed';
} elseif ($ageSec > 7200) {
$status = 'warn';
} else {
$status = 'active';
}
return [
'label' => $label,
'status' => $status,
'detail' => "$count fichier(s) — dernière il y a $human",
];
}
/**
* Read the tail of a log file, newest-first. Returns null on error.
*