, * 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), ]; // Scheduler-Herzschlag und Plattenplatz — dieselben Prüfungen, die // der externe Monitor über /health abfragt (Monitoring-Plan 1.4/1.5). $probe = app(HealthProbe::class); $checks[] = $probe->scheduler(); $checks[] = $probe->disk(); // 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.'), ]; // Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler // der letzten sieben Tage zählen; Zustand D im Formular blockiert // nichts, muss aber sichtbar bleiben. $provenanceFailures = PressReleaseImage::query() ->withTrashed() ->where('provenance_status', 'error') ->where('provenance_checked_at', '>=', now()->subDays(7)) ->count(); $checks[] = [ 'key' => 'image_provenance', 'label' => 'Herkunftsprüfung Bilder', 'status' => $provenanceFailures === 0 ? self::StatusOk : self::StatusWarn, 'detail' => $provenanceFailures === 0 ? 'Keine fehlgeschlagene Herkunftsprüfung in den letzten 7 Tagen.' : $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).', ]; // Beweisarchiv: Schlüssel (Umsetzungsaudit 29.09.2026, A-01/A-15) — // ohne ARCHIVE_KEY wird nichts archiviert und der Nachholer bricht // ab; Einträge mit einem nicht mehr konfigurierten Schlüssel sind // unlesbar. $archive = app(EvidenceArchive::class); $unknownKeyEntries = $archive->isConfigured() ? ArchiveEntry::query()->whereNull('deleted_at')->whereNotNull('key_id')->whereNotIn('key_id', $archive->knownKeyIds())->count() : 0; $checks[] = [ 'key' => 'archive_key', 'label' => 'Beweisarchiv-Schlüssel', 'status' => ! $archive->isConfigured() || $unknownKeyEntries > 0 ? self::StatusCritical : self::StatusOk, 'detail' => match (true) { ! $archive->isConfigured() => 'ARCHIVE_KEY fehlt oder ist ungültig — es wird nichts archiviert, `archive:retry-pending` bricht ab. Schlüssel aus der getrennten Sicherung setzen, `config:cache`.', $unknownKeyEntries > 0 => $unknownKeyEntries.' Archiveintrag/-einträge mit unbekanntem Schlüssel — früheren Schlüssel in ARCHIVE_PREVIOUS_KEYS ergänzen.', default => 'ARCHIVE_KEY gesetzt (Schlüssel-ID '.$archive->keyId().', '.(count($archive->knownKeyIds()) - 1).' frühere).', }, ]; // Offene Archivaufträge (N11, A-02) und Altmarker ohne Auftrag; // dazu weich gelöschte v2-Bilder, deren lokale Beweisdateien noch // nicht bereinigt sind (5.1 e). $pending = app(ArchivePendingQueue::class)->status(); $pendingTotal = $pending['jobs'] + $pending['legacy_markers']; $checks[] = [ 'key' => 'archive_pending', 'label' => 'Beweisarchiv-Nachholer', 'status' => $pendingTotal === 0 ? self::StatusOk : self::StatusWarn, 'detail' => $pendingTotal === 0 ? 'Alle Archiveinträge wurden beim Vorgang geschrieben.' : sprintf( '%d offene(r) Archivauftrag/-aufträge%s%s — `archive:retry-pending` läuft täglich; Log auf ARCHIVE_KEY/Archiv-Disk prüfen.', $pending['jobs'], $pending['oldest'] !== null ? ' (ältester seit '.$pending['oldest']->format('d.m.Y H:i').')' : '', $pending['legacy_markers'] > 0 ? ', '.$pending['legacy_markers'].' Altmarker ohne Auftrag' : '', ), ]; // Orphan-Sweep ausgesetzt (Restore-Abgleich, A-03). $sweepPaused = $archive->isSweepPaused(); $checks[] = [ 'key' => 'archive_sweep', 'label' => 'Beweisarchiv-Orphan-Sweep', 'status' => $sweepPaused ? self::StatusWarn : self::StatusOk, 'detail' => $sweepPaused ? 'Ausgesetzt (Restore-Abgleich oder ARCHIVE_SWEEP_PAUSED) — nach `archive:reregister` mit `archive:sweep-orphans --resume` fortsetzen.' : 'Aktiv; verwaiste Payloads wandern in die Quarantäne ('.(int) config('archive.quarantine_days', 30).' Tage).', ]; $evidenceLeftovers = PressReleaseImageRemover::countLocalEvidenceLeftovers(); $checks[] = [ 'key' => 'image_evidence_leftovers', 'label' => 'Bildnachweise lokal', 'status' => $evidenceLeftovers === 0 ? self::StatusOk : self::StatusWarn, 'detail' => $evidenceLeftovers === 0 ? 'Keine entfernten Bilder mit lokalen Beweisdateien.' : $evidenceLeftovers.' entfernte(s) Bild(er) mit noch vorhandenen lokalen Beweisdateien (Original/Snapshot) — Überführung ausstehend oder Löschung fehlgeschlagen, siehe Log.', ]; // Offene Bild-Sichtungen aus dem KI-Vorab-Check (Block K). $pendingPrechecks = PressReleaseImage::query()->awaitingPrecheckReview()->count(); $checks[] = [ 'key' => 'image_precheck_queue', 'label' => 'Bild-Sichtung', 'status' => $pendingPrechecks === 0 ? self::StatusOk : self::StatusWarn, 'detail' => $pendingPrechecks === 0 ? 'Keine Titelbilder warten auf eine Sichtung.' : $pendingPrechecks.' Titelbild(er) warten auf die Sichtung durch einen Admin (Pressemitteilungen ▸ Bild-Sichtung).', ]; // 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; } }