b2in/app/Services/Monitoring/SystemStatus.php
Kevin Adametz 061f004013 Admin: Monitoring-Seite (Systemstatus) und Navigation neu sortiert
- /admin/monitoring (nur Admin): Prüfungen wie /health, Version und Deploy-Zeit, geplante
  Aufgaben mit nächstem Lauf, letzte Fehler aus dem Log (ohne E-Mail-Adressen), Alarme,
  fehlgeschlagene Jobs, Links zu GlitchTip/Uptime Kuma (MONITORING_STATUS_URL), Test-Alarm
- Navigation: CMS, Cabinet, Produkte, Regionen, Admin, ganz unten Monitoring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:27:04 +00:00

170 lines
6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Monitoring;
use App\Support\Monitoring\ErrorReportScrubber;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Daten für die Monitoring-Seite im Admin (09.10.2026): dieselben Prüfungen wie `/health`, dazu
* Version, geplante Aufgaben, fehlgeschlagene Jobs und die letzten Fehler und Alarme aus den
* Logs. Liest nur, ändert nichts; Logs nur das Ende der Datei, damit die Seite schnell bleibt.
*/
class SystemStatus
{
/** Zeitzone für die Anzeige – die App rechnet in UTC. */
public const DisplayTimezone = 'Europe/Berlin';
private const LogTailBytes = 262144;
private const LogLinePattern = '/^\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.*)$/';
public function __construct(private readonly HealthProbe $probe) {}
/**
* @return array{status: string, checks: list<array{key: string, label: string, status: string, detail: string}>}
*/
public function health(): array
{
$checks = $this->probe->checks();
return ['status' => HealthProbe::worstStatus($checks), 'checks' => $checks];
}
/**
* @return array{version: string, environment: string, debug: bool, php: string, laravel: string, deployed_at: ?Carbon}
*/
public function application(): array
{
$release = base_path('RELEASE');
return [
'version' => (string) (config('app.version') ?? '—'),
'environment' => app()->environment(),
'debug' => (bool) config('app.debug'),
'php' => PHP_VERSION,
'laravel' => app()->version(),
'deployed_at' => is_file($release) ? Carbon::createFromTimestamp((int) filemtime($release))->timezone(self::DisplayTimezone) : null,
];
}
/**
* Geplante Aufgaben mit nächstem Lauf (über `schedule:list`, das die Konsolen-Routen lädt).
*
* @return list<array{command: string, expression: string, next: ?Carbon, description: string}>
*/
public function scheduledTasks(): array
{
try {
Artisan::call('schedule:list', ['--json' => true]);
$tasks = json_decode(Artisan::output(), true);
} catch (Throwable) {
return [];
}
return collect(is_array($tasks) ? $tasks : [])
->map(fn (array $task): array => [
'command' => trim((string) preg_replace('/^.*?artisan\s+/', '', (string) ($task['command'] ?? ''))),
'expression' => (string) ($task['expression'] ?? ''),
'next' => filled($task['next_due_date'] ?? null) ? Carbon::parse($task['next_due_date'])->timezone(self::DisplayTimezone) : null,
'description' => (string) ($task['description'] ?? ''),
])
->values()
->all();
}
/**
* @return array{count: int, latest: list<array{job: string, queue: string, failed_at: Carbon, error: string}>}
*/
public function failedJobs(int $limit = 5): array
{
try {
$table = DB::table((string) config('queue.failed.table', 'failed_jobs'));
return [
'count' => $table->count(),
'latest' => $table->orderByDesc('failed_at')->limit($limit)->get()
->map(fn (object $job): array => [
'job' => (string) (json_decode((string) $job->payload, true)['displayName'] ?? 'Job'),
'queue' => (string) $job->queue,
'failed_at' => Carbon::parse($job->failed_at)->timezone(self::DisplayTimezone),
'error' => ErrorReportScrubber::scrubText(Str::limit(strtok((string) $job->exception, "\n") ?: '', 180)),
])
->all(),
];
} catch (Throwable) {
return ['count' => 0, 'latest' => []];
}
}
/**
* Letzte Einträge ab einer Stufe aus dem jüngsten Log einer Art (`laravel`, `monitoring`).
*
* @param list<string> $levels
* @return list<array{at: Carbon, level: string, message: string}>
*/
public function recentLogEntries(string $prefix, array $levels, int $limit = 10): array
{
$files = glob(storage_path("logs/{$prefix}*.log")) ?: [];
usort($files, fn (string $a, string $b): int => filemtime($b) <=> filemtime($a));
$file = $files[0] ?? null;
if ($file === null) {
return [];
}
$handle = @fopen($file, 'rb');
if ($handle === false) {
return [];
}
$size = (int) filesize($file);
fseek($handle, max(0, $size - self::LogTailBytes));
$tail = (string) stream_get_contents($handle);
fclose($handle);
$entries = [];
foreach (preg_split('/\R/', $tail) ?: [] as $line) {
if (preg_match(self::LogLinePattern, $line, $match) !== 1 || ! in_array(strtoupper($match[2]), $levels, true)) {
continue;
}
$message = (string) preg_replace('/\s*\{"(exception|userId)".*$/s', '', $match[3]);
$entries[] = [
'at' => Carbon::parse($match[1])->timezone(self::DisplayTimezone),
'level' => strtolower($match[2]),
'message' => ErrorReportScrubber::scrubText(Str::limit($message, 220)),
];
}
return array_slice(array_reverse($entries), 0, $limit);
}
/**
* Links zu den Diensten außerhalb der App, soweit konfiguriert.
*
* @return array<string, string>
*/
public function links(): array
{
$links = [];
$dsn = (string) config('sentry.dsn');
if (preg_match('#^https?://[^@]+@([^/]+)/(\d+)#', $dsn, $match) === 1) {
$links['GlitchTip'] = "https://{$match[1]}/";
}
if (filled(config('monitoring.status_url'))) {
$links['Uptime Kuma'] = (string) config('monitoring.status_url');
}
return $links;
}
}