Globinours/app/Services/SystemHealthService.php

207 lines
8 KiB
PHP

<?php
declare(strict_types=1);
final class SystemHealthService
{
public static function report(): array
{
$checks = [];
$root = dirname(__DIR__, 2);
$db = DB::pdo();
foreach (InstallationService::runtimeChecks() as $c) {
$checks[] = self::check(
$c['ok'] ? 'ok' : ($c['level'] === 'required' ? 'error' : 'warning'),
'environment',
$c['label'],
$c['detail'],
);
}
try {
$integrity = (string) $db->query('PRAGMA integrity_check')->fetchColumn();
$checks[] = self::check(
$integrity === 'ok' ? 'ok' : 'error',
'database',
t('health.sqlite_integrity'),
$integrity === 'ok' ? t('health.sqlite_ok') : $integrity,
);
} catch (Throwable $e) {
$checks[] = self::check('error', 'database', t('health.sqlite_integrity'), $e->getMessage());
}
try {
$fk = $db->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC);
$checks[] = self::check(
!$fk ? 'ok' : 'error',
'database',
t('health.foreign_keys'),
!$fk ? t('health.no_fk_error') : t('health.fk_errors', ['count' => count($fk)]),
);
} catch (Throwable $e) {
$checks[] = self::check('error', 'database', t('health.foreign_keys'), $e->getMessage());
}
$migrationFiles = array_map('basename', glob($root . '/migrations/*.sql') ?: []);
$applied = $db->query('SELECT name FROM _migrations')->fetchAll(PDO::FETCH_COLUMN);
$pending = array_values(array_diff($migrationFiles, $applied));
$checks[] = self::check(
!$pending ? 'ok' : 'warning',
'database',
t('health.migrations'),
!$pending
? t('health.migrations_ok', ['count' => count($applied)])
: t('health.migrations_pending', ['count' => count($pending)]),
);
$dbFile = $root . '/data/refuge.sqlite';
$checks[] = self::check(
is_file($dbFile) && is_readable($dbFile) ? 'ok' : 'error',
'storage',
t('health.database_file'),
is_file($dbFile) ? self::bytes((int) filesize($dbFile)) : t('health.file_missing'),
);
$free = @disk_free_space($root);
$status =
$free === false
? 'warning'
: ($free >= 1_073_741_824
? 'ok'
: ($free >= 268_435_456
? 'warning'
: 'error'));
$checks[] = self::check(
$status,
'storage',
t('health.disk_space'),
$free === false ? t('health.unknown') : self::bytes((int) $free),
);
$backups = BackupService::list();
if (!$backups) {
$checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup'));
} else {
$latest = $backups[0];
$date = DateTimeImmutable::createFromFormat(
'd/m/Y H:i:s',
(string) $latest['date'],
new DateTimeZone('Europe/Paris'),
);
$days = $date
? (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a')
: 999;
$checks[] = self::check(
$days <= 7 ? 'ok' : ($days <= 30 ? 'warning' : 'error'),
'continuity',
t('health.last_backup'),
t('health.backup_age', ['date' => $latest['date'], 'days' => $days]),
);
}
[$missing, $orphans] = self::photoConsistency($db, $root);
$checks[] = self::check(
$missing === 0 ? 'ok' : 'warning',
'files',
t('health.missing_photos'),
t('health.file_count', ['count' => $missing]),
);
$checks[] = self::check(
$orphans === 0 ? 'ok' : 'warning',
'files',
t('health.orphan_photos'),
t('health.file_count', ['count' => $orphans]),
);
$display = (string) ini_get('display_errors');
$production = getenv('APP_ENV') !== 'development';
$checks[] = self::check(
!$production || !in_array(strtolower($display), ['1', 'on', 'yes', 'true'], true) ? 'ok' : 'warning',
'security',
t('health.display_errors'),
$production ? t('health.production_value', ['value' => $display ?: 'Off']) : t('health.development_mode'),
);
$logErrors = filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOL);
$errorLog = trim((string) ini_get('error_log'));
$checks[] = self::check(
$logErrors ? 'ok' : 'warning',
'security',
t('health.error_log'),
$logErrors ? ($errorLog ?: t('health.system_log')) : t('health.logging_disabled'),
);
$applicationLog = $root . '/storage/logs/php-errors.log';
$recent = self::recentLogLines($applicationLog, 7);
$checks[] = self::check(
$recent === 0 ? 'ok' : 'warning',
'security',
t('health.recent_errors'),
$recent ? t('health.recent_error_count', ['count' => $recent]) : t('health.no_recent_error'),
);
$summary = ['ok' => 0, 'warning' => 0, 'error' => 0];
foreach ($checks as $c) {
$summary[$c['status']]++;
}
return [
'checks' => $checks,
'summary' => $summary,
'generated_at' => (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('d/m/Y H:i:s'),
'php' => PHP_VERSION,
'sqlite' => (string) $db->query('SELECT sqlite_version()')->fetchColumn(),
];
}
private static function photoConsistency(PDO $db, string $root): array
{
$expected = [];
$missing = 0;
try {
foreach ($db->query('SELECT animal_id,filename FROM animal_photos')->fetchAll(PDO::FETCH_ASSOC) as $row) {
$path =
$root .
'/public/media/animals/' .
(int) $row['animal_id'] .
'/' .
basename((string) $row['filename']);
$expected[$path] = true;
if (!is_file($path)) {
$missing++;
}
}
} catch (Throwable) {
return [0, 0];
}
$orphans = 0;
foreach (glob($root . '/public/media/animals/*/*') ?: [] as $file) {
if (is_file($file) && !isset($expected[$file]) && !str_starts_with(basename($file), '.')) {
$orphans++;
}
}
return [$missing, $orphans];
}
private static function check(string $status, string $group, string $title, string $detail): array
{
return compact('status', 'group', 'title', 'detail');
}
private static function recentLogLines(string $path, int $days): int
{
if (!is_file($path) || filesize($path) === 0) {
return 0;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$dates = [];
for ($i = 0; $i < $days; $i++) {
$dates[] = (new DateTimeImmutable("-$i days"))->format('d-M-Y');
}
$count = 0;
foreach (array_slice($lines, -1000) as $line) {
foreach ($dates as $date) {
if (str_contains($line, $date)) {
$count++;
break;
}
}
}
return $count;
}
private static function bytes(int $bytes): string
{
foreach (['o', 'Ko', 'Mo', 'Go', 'To'] as $unit) {
if ($bytes < 1024 || $unit === 'To') {
return number_format($bytes, $unit === 'o' ? 0 : 1, ',', ' ') . ' ' . $unit;
}
$bytes = (int) round($bytes / 1024);
}
return '';
}
}