From 573e4ca31cb59782e859f962956ceb927ff7ec52 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 14 Jul 2026 13:49:57 +0000 Subject: [PATCH] =?UTF-8?q?admin:=20Betriebs=C3=BCberwachung=20im=20Backen?= =?UTF-8?q?d=20(Queue-Worker=20&=20Deployment-Checks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Reports-Seite (settings:manage) zeigt den Queue-Zustand (ältester wartender Job, Worker aktiv/gestoppt, fehlgeschlagene Jobs) und Deployment-Checks als Ampel: Permissions ausgerollt, APP_DEBUG, Session-Secure-Cookie, ausstehende Medien-Privatisierung. Damit ist die für Magic-Link-Login und fristrelevante Mails kritische Queue-Abhängigkeit aus dem Admin sichtbar. Co-Authored-By: Claude Fable 5 --- app/Services/Admin/AdminSystemHealth.php | 283 ++++++++++++++++++ .../admin/reports/system-health.blade.php | 146 +++++++++ tests/Feature/Admin/SystemHealthTest.php | 114 +++++++ 3 files changed, 543 insertions(+) create mode 100644 app/Services/Admin/AdminSystemHealth.php create mode 100644 resources/views/livewire/admin/reports/system-health.blade.php create mode 100644 tests/Feature/Admin/SystemHealthTest.php diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php new file mode 100644 index 00000000..8339dab6 --- /dev/null +++ b/app/Services/Admin/AdminSystemHealth.php @@ -0,0 +1,283 @@ +, + * checks: list + * } + */ + public function snapshot(): array + { + $queue = $this->queueHealth(); + $checks = $this->deploymentChecks($queue); + + $statuses = array_merge( + [$queue['status']], + array_map(static fn (array $check): string => $check['status'], $checks), + ); + + return [ + 'generated_at' => now(), + 'overall' => $this->worstStatus($statuses), + 'queue' => $queue, + 'checks' => $checks, + ]; + } + + /** + * @return array + */ + private function queueHealth(): array + { + $connection = (string) config('queue.default'); + $usesDatabase = $connection === 'database'; + + if (! $usesDatabase || ! Schema::hasTable('jobs')) { + return [ + 'status' => self::StatusWarn, + 'driver' => $connection, + 'supported' => false, + 'pending' => 0, + 'delayed' => 0, + 'reserved' => 0, + 'failed_total' => Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0, + 'failed_recent' => 0, + 'oldest_pending_seconds' => null, + 'worker_running' => null, + 'recent_failures' => [], + ]; + } + + $now = now()->getTimestamp(); + + $pending = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', $now)->count(); + $delayed = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', $now)->count(); + $reserved = DB::table('jobs')->whereNotNull('reserved_at')->count(); + + $oldestAvailable = DB::table('jobs') + ->whereNull('reserved_at') + ->where('available_at', '<=', $now) + ->min('available_at'); + + $oldestPendingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : null; + + $failedTotal = DB::table('failed_jobs')->count(); + $failedRecent = DB::table('failed_jobs') + ->where('failed_at', '>=', now()->subDay()) + ->count(); + + $recentFailures = DB::table('failed_jobs') + ->orderByDesc('failed_at') + ->limit(10) + ->get(['uuid', 'queue', 'payload', 'exception', 'failed_at']) + ->map(fn ($row): array => [ + 'uuid' => $row->uuid, + 'queue' => $row->queue, + 'job' => $this->jobDisplayName($row->payload), + 'error' => $this->firstExceptionLine($row->exception), + 'failed_at' => Carbon::parse($row->failed_at), + ]) + ->all(); + + // Worker gilt als aktiv, wenn kein wartender Job zu lange unbearbeitet + // liegt. Ein reservierter Job zeigt aktive Verarbeitung an. + $workerRunning = $oldestPendingSeconds === null || $oldestPendingSeconds < self::PendingCriticalSeconds; + + $status = self::StatusOk; + if ($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingCriticalSeconds) { + $status = self::StatusCritical; + } elseif (($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingWarnSeconds) || $failedRecent > 0) { + $status = self::StatusWarn; + } + + return [ + 'status' => $status, + 'driver' => $connection, + 'supported' => true, + 'pending' => $pending, + 'delayed' => $delayed, + 'reserved' => $reserved, + 'failed_total' => $failedTotal, + 'failed_recent' => $failedRecent, + 'oldest_pending_seconds' => $oldestPendingSeconds, + 'worker_running' => $workerRunning, + 'recent_failures' => $recentFailures, + ]; + } + + /** + * @param array $queue + * @return list + */ + private function deploymentChecks(array $queue): array + { + $checks = []; + + // Queue-Worker als expliziter Check (spiegelt den Queue-Block). + $checks[] = [ + 'key' => 'queue_worker', + 'label' => 'Queue-Worker', + 'status' => $queue['status'], + 'detail' => $this->queueWorkerDetail($queue), + ]; + + // Neue Permissions ausgerollt? + $expected = PermissionCatalog::adminOnly(); + $existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all(); + $missing = array_values(array_diff($expected, $existing)); + $checks[] = [ + 'key' => 'permissions', + 'label' => 'Berechtigungen ausgerollt', + 'status' => $missing === [] ? self::StatusOk : self::StatusCritical, + 'detail' => $missing === [] + ? 'Alle erwarteten Rollen-Permissions sind vorhanden.' + : 'Fehlende Permissions (Seeder ausführen): '.implode(', ', $missing), + ]; + + // APP_DEBUG in nicht-lokalen Umgebungen. + $isLocal = app()->environment('local'); + $debug = (bool) config('app.debug'); + $checks[] = [ + 'key' => 'app_debug', + 'label' => 'Debug-Modus', + 'status' => ($debug && ! $isLocal) ? self::StatusCritical : self::StatusOk, + 'detail' => $debug + ? ('APP_DEBUG ist aktiv'.($isLocal ? ' (lokal ok).' : ' — im Produktivbetrieb deaktivieren!')) + : 'APP_DEBUG ist deaktiviert.', + ]; + + // Secure Session Cookie außerhalb lokaler Umgebung. + $secureCookie = (bool) config('session.secure'); + $checks[] = [ + 'key' => 'session_secure_cookie', + 'label' => 'Session Secure-Cookie', + 'status' => (! $secureCookie && ! $isLocal) ? self::StatusWarn : self::StatusOk, + 'detail' => $secureCookie + ? 'Session-Cookie wird nur über HTTPS gesetzt.' + : 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'), + ]; + + // Ausstehende Medien-Privatisierung. + $publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count(); + $publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count(); + $publicMedia = $publicImages + $publicAttachments; + $checks[] = [ + 'key' => 'media_privatization', + 'label' => 'Medien-Privatisierung', + 'status' => $publicMedia > 0 ? self::StatusWarn : self::StatusOk, + 'detail' => $publicMedia > 0 + ? "Noch {$publicMedia} PM-Medien auf der public-Disk — `press-releases:privatize-media` ausführen." + : 'Alle PM-Medien liegen auf der privaten Disk.', + ]; + + return $checks; + } + + /** + * @param array $queue + */ + private function queueWorkerDetail(array $queue): string + { + if (! $queue['supported']) { + return "Queue-Treiber „{$queue['driver']}\" — Job-Metriken hier nicht verfügbar."; + } + + if ($queue['oldest_pending_seconds'] === null) { + return 'Keine wartenden Jobs. Worker im Leerlauf oder aktiv.'; + } + + $age = $this->humanizeSeconds((int) $queue['oldest_pending_seconds']); + + if ($queue['status'] === self::StatusCritical) { + return "Ältester wartender Job seit {$age} unbearbeitet — Worker vermutlich gestoppt."; + } + + return "Ältester wartender Job seit {$age} in der Warteschlange."; + } + + private function jobDisplayName(string $payload): string + { + try { + $data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return 'Unbekannter Job'; + } + + if (is_array($data) && is_string($data['displayName'] ?? null)) { + return $data['displayName']; + } + + return 'Unbekannter Job'; + } + + private function firstExceptionLine(string $exception): string + { + $line = trim(strtok($exception, "\n") ?: ''); + + return $line === '' ? 'Unbekannter Fehler' : mb_strimwidth($line, 0, 240, '…'); + } + + private function humanizeSeconds(int $seconds): string + { + if ($seconds < 60) { + return "{$seconds}s"; + } + + if ($seconds < 3600) { + return floor($seconds / 60).'min'; + } + + return floor($seconds / 3600).'h'; + } + + /** + * @param list $statuses + */ + private function worstStatus(array $statuses): string + { + if (in_array(self::StatusCritical, $statuses, true)) { + return self::StatusCritical; + } + + if (in_array(self::StatusWarn, $statuses, true)) { + return self::StatusWarn; + } + + return self::StatusOk; + } +} diff --git a/resources/views/livewire/admin/reports/system-health.blade.php b/resources/views/livewire/admin/reports/system-health.blade.php new file mode 100644 index 00000000..c72670d3 --- /dev/null +++ b/resources/views/livewire/admin/reports/system-health.blade.php @@ -0,0 +1,146 @@ +authorize('settings:manage'); + } + + public function refresh(): void + { + // Löst nur ein Re-Render aus; snapshot() wird in with() neu berechnet. + } + + /** + * @return array + */ + public function with(AdminSystemHealth $health): array + { + return [ + 'health' => $health->snapshot(), + ]; + } +}; ?> + +@php + $statusMeta = [ + 'ok' => ['color' => 'green', 'label' => __('OK'), 'variant' => 'ok'], + 'warn' => ['color' => 'amber', 'label' => __('Achtung'), 'variant' => 'warn'], + 'critical' => ['color' => 'red', 'label' => __('Kritisch'), 'variant' => 'warn'], + ]; + $overall = $statusMeta[$health['overall']] ?? $statusMeta['warn']; + $queue = $health['queue']; +@endphp + +
+ {{-- ============== PAGE HEADER ============== --}} + + + {{ __('Gesamtstatus') }}: {{ $overall['label'] }} + + {{ __('Aktualisieren') }} + + + + +

+ {{ __('Stand') }}: {{ $health['generated_at']->format('d.m.Y H:i:s') }} +

+ + {{-- ============== QUEUE-KPIs ============== --}} +
+ + {{ __('Treiber') }}: {{ $queue['driver'] }} + + @if ($queue['oldest_pending_seconds'] !== null) + {{ __('ältester Job') }}: {{ $queue['oldest_pending_seconds'] }}s + @else + {{ __('keine Wartejobs') }} + @endif + + + + + {{ __('verzögert') }}: {{ number_format($queue['delayed']) }} + {{ __('in Verarbeitung') }}: {{ number_format($queue['reserved']) }} + + + + {{ __('gesamt') }}: {{ number_format($queue['failed_total']) }} + {{ __('failed_jobs') }} + + + + {{ __('Betrieb') }} + {{ __('Queue + Deployment') }} + +
+ + {{-- ============== DEPLOYMENT-CHECKS ============== --}} +
+
+ {{ __('Deployment- & Konfigurations-Checks') }} +
+
+ @foreach ($health['checks'] as $check) + @php $meta = $statusMeta[$check['status']] ?? $statusMeta['warn']; @endphp +
+
+

{{ $check['label'] }}

+

{{ $check['detail'] }}

+
+ {{ $meta['label'] }} +
+ @endforeach +
+
+ + {{-- ============== FEHLGESCHLAGENE JOBS ============== --}} +
+
+ {{ __('Zuletzt fehlgeschlagene Jobs') }} + {{ count($queue['recent_failures']) }} +
+ @if (count($queue['recent_failures']) === 0) +
+ {{ __('Keine fehlgeschlagenen Jobs.') }} +
+ @else +
+ + + + + + + + + + + @foreach ($queue['recent_failures'] as $failure) + + + + + + + @endforeach + +
{{ __('Zeitpunkt') }}{{ __('Queue') }}{{ __('Job') }}{{ __('Fehler') }}
{{ $failure['failed_at']->format('d.m. H:i') }}{{ $failure['queue'] }}{{ $failure['job'] }}{{ $failure['error'] }}
+
+ @endif +
+
diff --git a/tests/Feature/Admin/SystemHealthTest.php b/tests/Feature/Admin/SystemHealthTest.php new file mode 100644 index 00000000..f14936aa --- /dev/null +++ b/tests/Feature/Admin/SystemHealthTest.php @@ -0,0 +1,114 @@ +seed(RolesAndPermissionsSeeder::class); + // In der Testumgebung ist die Queue `sync`; für die Queue-Metriken den + // Produktions-Treiber (database) verwenden, der die jobs-Tabellen liest. + config()->set('queue.default', 'database'); +}); + +test('editor cannot open the system health report', function () { + /** @var TestCase $this */ + $editor = User::factory()->create(['is_active' => true]); + $editor->assignRole('editor'); + $this->actingAs($editor); + + $this->get('https://pressekonto.test/admin/reports/system-health')->assertForbidden(); +}); + +test('admin can open the system health report', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $this->get('https://pressekonto.test/admin/reports/system-health') + ->assertSuccessful() + ->assertSee('Betriebsüberwachung') + ->assertSee('Queue-Worker'); +}); + +test('snapshot reports an ok queue when there are no waiting jobs', function () { + /** @var TestCase $this */ + $snapshot = app(AdminSystemHealth::class)->snapshot(); + + expect($snapshot['queue']['pending'])->toBe(0) + ->and($snapshot['queue']['worker_running'])->toBeTrue() + ->and($snapshot['queue']['status'])->toBe('ok'); +}); + +test('snapshot flags a stalled worker when a job has waited too long', function () { + /** @var TestCase $this */ + DB::table('jobs')->insert([ + 'queue' => 'default', + 'payload' => json_encode(['displayName' => 'App\\Jobs\\Example']), + 'attempts' => 0, + 'reserved_at' => null, + 'available_at' => now()->subMinutes(10)->getTimestamp(), + 'created_at' => now()->subMinutes(10)->getTimestamp(), + ]); + + $snapshot = app(AdminSystemHealth::class)->snapshot(); + + expect($snapshot['queue']['status'])->toBe('critical') + ->and($snapshot['queue']['worker_running'])->toBeFalse() + ->and($snapshot['overall'])->toBe('critical'); +}); + +test('snapshot surfaces recent failed jobs', function () { + /** @var TestCase $this */ + DB::table('failed_jobs')->insert([ + 'uuid' => (string) Str::uuid(), + 'connection' => 'database', + 'queue' => 'default', + 'payload' => json_encode(['displayName' => 'App\\Jobs\\Broken']), + 'exception' => "RuntimeException: boom\nStack trace...", + 'failed_at' => now(), + ]); + + $snapshot = app(AdminSystemHealth::class)->snapshot(); + + expect($snapshot['queue']['failed_recent'])->toBe(1) + ->and($snapshot['queue']['recent_failures'][0]['job'])->toBe('App\\Jobs\\Broken') + ->and($snapshot['queue']['recent_failures'][0]['error'])->toContain('boom') + ->and($snapshot['queue']['status'])->toBe('warn'); +}); + +test('snapshot flags missing permissions as critical', function () { + /** @var TestCase $this */ + Permission::query()->where('name', 'billing:manage')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks']) + ->keyBy('key'); + + expect($checks['permissions']['status'])->toBe('critical') + ->and($checks['permissions']['detail'])->toContain('billing:manage'); +}); + +test('snapshot flags pending media privatization', function () { + /** @var TestCase $this */ + $pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create(); + PressReleaseImage::query()->create([ + 'press_release_id' => $pr->id, + 'disk' => 'public', + 'path' => 'press-releases/'.$pr->id.'/images/legacy.jpg', + ]); + + $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); + + expect($checks['media_privatization']['status'])->toBe('warn') + ->and($checks['media_privatization']['detail'])->toContain('1'); +});