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>
This commit is contained in:
Kevin Adametz 2026-10-09 10:27:04 +00:00
parent 8ddc85422e
commit 061f004013
6 changed files with 525 additions and 42 deletions

View file

@ -0,0 +1,170 @@
<?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;
}
}