207 lines
8 KiB
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 '';
|
|
}
|
|
}
|