, * 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; } }