diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2c7b9a7..ec982e9 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -95,7 +95,7 @@ {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"} {"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"} -{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-01T14:20:04.991351193+01:00"} +{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-01T21:07:06.982356199+01:00","closed_at":"2026-01-01T21:07:06.982356199+01:00","close_reason":"completed"} {"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-umr","title":"Paddle migration: build Paddle API service layer","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:38.529187953+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:44.178675076+01:00","closed_at":"2026-01-01T15:57:44.178675076+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-v31","title":"SEC-MS-01 AV + EXIF scrubber worker integration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:52.476048623+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:58.118336529+01:00","closed_at":"2026-01-01T15:52:58.118336529+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index 792a5db..8dbc3d9 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-arp +fotospiel-app-tym diff --git a/app/Filament/SuperAdmin/Pages/OpsHealthDashboard.php b/app/Filament/SuperAdmin/Pages/OpsHealthDashboard.php new file mode 100644 index 0000000..f7535b7 --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/OpsHealthDashboard.php @@ -0,0 +1,67 @@ + 2, + 'xl' => 2, + ]; + } +} diff --git a/app/Filament/Widgets/QueueHealthWidget.php b/app/Filament/Widgets/QueueHealthWidget.php new file mode 100644 index 0000000..eca863f --- /dev/null +++ b/app/Filament/Widgets/QueueHealthWidget.php @@ -0,0 +1,103 @@ + true, + 'connection' => (string) config('queue.default'), + 'snapshotLabel' => __('admin.ops_health.snapshot_missing'), + 'queues' => [], + 'alerts' => [], + 'alertCount' => 0, + 'stalledAssets' => 0, + 'stalledMinutes' => $stalledMinutes, + ]; + } + + $queues = collect($snapshot['queues'] ?? []) + ->map(fn (array $queue) => $this->formatQueue($queue)) + ->values() + ->all(); + + $alerts = collect($snapshot['alerts'] ?? []) + ->map(fn (array $alert) => $this->formatAlert($alert)) + ->values() + ->all(); + + $snapshotAge = $this->formatSnapshotAge($snapshot['generated_at'] ?? null); + $snapshotLabel = $snapshotAge + ? __('admin.ops_health.snapshot_age', ['age' => $snapshotAge]) + : __('admin.ops_health.snapshot_missing'); + + return [ + 'snapshotMissing' => false, + 'connection' => (string) ($snapshot['connection'] ?? config('queue.default')), + 'snapshotLabel' => $snapshotLabel, + 'queues' => $queues, + 'alerts' => $alerts, + 'alertCount' => count($alerts), + 'stalledAssets' => (int) ($snapshot['stalled_assets'] ?? 0), + 'stalledMinutes' => $stalledMinutes, + ]; + } + + private function formatQueue(array $queue): array + { + $size = (int) ($queue['size'] ?? 0); + $failed = (int) ($queue['failed'] ?? 0); + $limits = $queue['limits'] ?? []; + + return [ + 'name' => (string) ($queue['queue'] ?? 'default'), + 'size' => $size, + 'size_label' => $size < 0 ? '-' : number_format($size), + 'failed' => $failed, + 'failed_label' => number_format($failed), + 'severity' => (string) ($queue['severity'] ?? 'unknown'), + 'warning' => (int) ($limits['warning'] ?? 0), + 'critical' => (int) ($limits['critical'] ?? 0), + ]; + } + + private function formatAlert(array $alert): array + { + return [ + 'queue' => $alert['queue'] ?? null, + 'type' => (string) ($alert['type'] ?? 'unknown'), + 'severity' => (string) ($alert['severity'] ?? 'warning'), + 'size' => $alert['size'] ?? null, + 'failed' => $alert['failed'] ?? null, + 'count' => $alert['count'] ?? null, + 'older_than_minutes' => $alert['older_than_minutes'] ?? null, + ]; + } + + private function formatSnapshotAge(?string $timestamp): ?string + { + if (! $timestamp) { + return null; + } + + try { + return Carbon::parse($timestamp)->diffForHumans(); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Filament/Widgets/UploadPipelineHealthWidget.php b/app/Filament/Widgets/UploadPipelineHealthWidget.php new file mode 100644 index 0000000..9036ae5 --- /dev/null +++ b/app/Filament/Widgets/UploadPipelineHealthWidget.php @@ -0,0 +1,86 @@ +description(__('admin.ops_health.pipeline.no_snapshot_desc')) + ->color('warning'), + ]; + } + + $totals = $this->summarizeAssets($snapshot['targets'] ?? []); + $alerts = $snapshot['alerts'] ?? []; + $snapshotAge = $this->formatSnapshotAge($snapshot['generated_at'] ?? null); + $snapshotLabel = $snapshotAge + ? __('admin.ops_health.snapshot_age', ['age' => $snapshotAge]) + : __('admin.ops_health.snapshot_missing'); + + return [ + Stat::make(__('admin.ops_health.pipeline.total'), number_format($totals['total'])) + ->description(__('admin.ops_health.pipeline.hot_hint', ['count' => number_format($totals['hot'])])) + ->color('primary'), + Stat::make(__('admin.ops_health.pipeline.pending'), number_format($totals['pending'])) + ->description($snapshotLabel) + ->descriptionIcon('heroicon-m-clock') + ->color($totals['pending'] > 0 ? 'warning' : 'success'), + Stat::make(__('admin.ops_health.pipeline.failed'), number_format($totals['failed'])) + ->description(__('admin.ops_health.pipeline.archived_hint', ['count' => number_format($totals['archived'])])) + ->color($totals['failed'] > 0 ? 'danger' : 'success'), + Stat::make(__('admin.ops_health.pipeline.alerts'), number_format(count($alerts))) + ->color(count($alerts) > 0 ? 'danger' : 'success'), + ]; + } + + private function summarizeAssets(array $targets): array + { + $totals = [ + 'total' => 0, + 'pending' => 0, + 'failed' => 0, + 'hot' => 0, + 'archived' => 0, + ]; + + foreach ($targets as $target) { + $assets = $target['assets'] ?? []; + $totals['total'] += (int) ($assets['total'] ?? 0); + + $byStatus = $assets['by_status'] ?? []; + foreach (['pending', 'failed', 'hot', 'archived'] as $status) { + $totals[$status] += (int) ($byStatus[$status]['count'] ?? 0); + } + } + + return $totals; + } + + private function formatSnapshotAge(?string $timestamp): ?string + { + if (! $timestamp) { + return null; + } + + try { + return Carbon::parse($timestamp)->diffForHumans(); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index b00683b..0bbe0ba 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -112,6 +112,7 @@ class SuperAdminPanelProvider extends PanelProvider Pages\Dashboard::class, \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class, \App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class, + \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class, ]) ->authGuard('super_admin'); diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index b59879a..6afbc47 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -219,6 +219,56 @@ return [ 'saved' => 'Gast-Richtlinien aktualisiert.', ], ], + 'ops_health' => [ + 'navigation' => [ + 'label' => 'Ops-Health', + ], + 'heading' => 'Ops-Health', + 'subheading' => 'Storage- und Queue-Snapshots für die Upload-Pipeline.', + 'help' => 'Snapshots werden über die Scheduled Commands storage:monitor und storage:check-upload-queues erzeugt.', + 'snapshot_age' => 'Aktualisiert :age', + 'snapshot_missing' => 'Snapshot fehlt', + 'pipeline' => [ + 'label' => 'Upload-Pipeline', + 'total' => 'Assets gesamt', + 'pending' => 'Ausstehende Assets', + 'failed' => 'Fehlgeschlagene Assets', + 'hot' => 'Hot-Assets', + 'archived' => 'Archivierte Assets', + 'alerts' => 'Alarme', + 'hot_hint' => 'Hot: :count', + 'archived_hint' => 'Archiviert: :count', + 'no_snapshot' => 'Snapshot fehlt', + 'no_snapshot_desc' => 'storage:monitor ausführen, um einen Snapshot zu erzeugen.', + ], + 'queue' => [ + 'heading' => 'Queue-Health', + 'description' => 'Upload-Queues, fehlgeschlagene Jobs und hängende Assets.', + 'connection' => 'Verbindung', + 'snapshot' => 'Snapshot', + 'stalled_assets' => 'Ausstehende Assets > :minutes Min', + 'no_snapshot' => 'Kein Queue-Snapshot vorhanden. storage:check-upload-queues ausführen.', + 'no_queues' => 'Keine Queues konfiguriert.', + 'alerts_heading' => 'Alarme', + 'thresholds' => 'Grenzwerte', + 'size' => 'Größe', + 'failed' => 'Fehler', + 'queue' => 'Queue', + ], + 'alert_types' => [ + 'size' => 'Queue-Größe über Grenzwert (:size)', + 'failed_jobs' => 'Fehlgeschlagene Jobs erkannt (:failed)', + 'pending_assets' => ':count Assets seit mehr als :minutes Min in der Warteschlange', + ], + 'severity' => [ + 'ok' => 'OK', + 'warning' => 'Warnung', + 'critical' => 'Kritisch', + 'unknown' => 'Unbekannt', + 'error' => 'Fehler', + 'unavailable' => 'Nicht verfügbar', + ], + ], 'events' => [ 'fields' => [ diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index b0769cf..cbde573 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -219,6 +219,56 @@ return [ 'saved' => 'Guest policy updated.', ], ], + 'ops_health' => [ + 'navigation' => [ + 'label' => 'Ops health', + ], + 'heading' => 'Ops health', + 'subheading' => 'Storage and queue snapshots for the upload pipeline.', + 'help' => 'Snapshots are generated by scheduled commands: storage:monitor and storage:check-upload-queues.', + 'snapshot_age' => 'Updated :age', + 'snapshot_missing' => 'Snapshot missing', + 'pipeline' => [ + 'label' => 'Upload pipeline', + 'total' => 'Total assets', + 'pending' => 'Pending assets', + 'failed' => 'Failed assets', + 'hot' => 'Hot assets', + 'archived' => 'Archived assets', + 'alerts' => 'Alerts', + 'hot_hint' => 'Hot: :count', + 'archived_hint' => 'Archived: :count', + 'no_snapshot' => 'Snapshot missing', + 'no_snapshot_desc' => 'Run storage:monitor to generate a snapshot.', + ], + 'queue' => [ + 'heading' => 'Queue health', + 'description' => 'Upload queue sizes, failed jobs, and stalled assets.', + 'connection' => 'Connection', + 'snapshot' => 'Snapshot', + 'stalled_assets' => 'Pending assets > :minutes min', + 'no_snapshot' => 'No queue snapshot available. Run storage:check-upload-queues.', + 'no_queues' => 'No queues configured.', + 'alerts_heading' => 'Alerts', + 'thresholds' => 'Thresholds', + 'size' => 'Size', + 'failed' => 'Failed', + 'queue' => 'Queue', + ], + 'alert_types' => [ + 'size' => 'Queue size above threshold (:size)', + 'failed_jobs' => 'Failed jobs detected (:failed)', + 'pending_assets' => ':count assets pending for more than :minutes min', + ], + 'severity' => [ + 'ok' => 'OK', + 'warning' => 'Warning', + 'critical' => 'Critical', + 'unknown' => 'Unknown', + 'error' => 'Error', + 'unavailable' => 'Unavailable', + ], + ], 'events' => [ 'fields' => [ diff --git a/resources/views/filament/super-admin/pages/ops-health-dashboard.blade.php b/resources/views/filament/super-admin/pages/ops-health-dashboard.blade.php new file mode 100644 index 0000000..cd8d448 --- /dev/null +++ b/resources/views/filament/super-admin/pages/ops-health-dashboard.blade.php @@ -0,0 +1,9 @@ + +
+ +

+ {{ __('admin.ops_health.help') }} +

+
+
+
diff --git a/resources/views/filament/widgets/queue-health.blade.php b/resources/views/filament/widgets/queue-health.blade.php new file mode 100644 index 0000000..4bcb6b2 --- /dev/null +++ b/resources/views/filament/widgets/queue-health.blade.php @@ -0,0 +1,115 @@ + + +
+
+ + {{ __('admin.ops_health.queue.connection') }}: + {{ $connection }} + + {{ $snapshotLabel }} +
+
+ + {{ __('admin.ops_health.queue.alerts_heading') }}: {{ number_format($alertCount) }} + + @if($stalledAssets > 0) + + {{ __('admin.ops_health.queue.stalled_assets', ['minutes' => $stalledMinutes]) }}: + {{ number_format($stalledAssets) }} + + @endif +
+
+ + @if($snapshotMissing) +
+ {{ __('admin.ops_health.queue.no_snapshot') }} +
+ @else +
+ @forelse($queues as $queue) +
+
+
+

{{ $queue['name'] }}

+

+ {{ __('admin.ops_health.queue.thresholds') }}: + @if($queue['warning'] || $queue['critical']) + W {{ number_format($queue['warning']) }} / C {{ number_format($queue['critical']) }} + @else + - + @endif +

+
+
+ + {{ __('admin.ops_health.queue.size') }}: + {{ $queue['size_label'] }} + + + {{ __('admin.ops_health.queue.failed') }}: + {{ $queue['failed_label'] }} + + $queue['severity'] === 'ok', + 'bg-amber-100 text-amber-800 dark:bg-amber-400/20 dark:text-amber-200' => $queue['severity'] === 'warning', + 'bg-rose-100 text-rose-800 dark:bg-rose-400/20 dark:text-rose-200' => $queue['severity'] === 'critical', + 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200' => ! in_array($queue['severity'], ['ok', 'warning', 'critical'], true), + ])> + {{ __('admin.ops_health.severity.'.$queue['severity']) }} + +
+
+
+ @empty +

{{ __('admin.ops_health.queue.no_queues') }}

+ @endforelse +
+ + @if($alertCount > 0) +
+

+ {{ __('admin.ops_health.queue.alerts_heading') }} +

+ @foreach($alerts as $alert) +
+
+ @if($alert['queue']) + {{ $alert['queue'] }} + @endif + + @switch($alert['type']) + @case('size') + {{ __('admin.ops_health.alert_types.size', ['size' => number_format($alert['size'] ?? 0)]) }} + @break + @case('failed_jobs') + {{ __('admin.ops_health.alert_types.failed_jobs', ['failed' => number_format($alert['failed'] ?? 0)]) }} + @break + @case('pending_assets') + {{ __('admin.ops_health.alert_types.pending_assets', ['count' => number_format($alert['count'] ?? 0), 'minutes' => $alert['older_than_minutes'] ?? $stalledMinutes]) }} + @break + @default + {{ __('admin.ops_health.snapshot_missing') }} + @endswitch + +
+ $alert['severity'] === 'ok', + 'bg-amber-100 text-amber-800 dark:bg-amber-400/20 dark:text-amber-200' => $alert['severity'] === 'warning', + 'bg-rose-100 text-rose-800 dark:bg-rose-400/20 dark:text-rose-200' => $alert['severity'] === 'critical', + 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200' => ! in_array($alert['severity'], ['ok', 'warning', 'critical'], true), + ])> + {{ __('admin.ops_health.severity.'.$alert['severity']) }} + +
+ @endforeach +
+ @endif + @endif +
+
diff --git a/tests/Unit/SuperAdminNavigationGroupsTest.php b/tests/Unit/SuperAdminNavigationGroupsTest.php index 1ee4f29..eb7717d 100644 --- a/tests/Unit/SuperAdminNavigationGroupsTest.php +++ b/tests/Unit/SuperAdminNavigationGroupsTest.php @@ -41,6 +41,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Resources\InfrastructureActionLogs\InfrastructureActionLogResource::class => 'admin.nav.infrastructure', \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class => 'admin.nav.branding', \App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure', + \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => 'admin.nav.infrastructure', ]; foreach ($expectations as $resourceClass => $key) { @@ -53,6 +54,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Resources\TenantResource::class => DailyOpsCluster::class, \App\Filament\Resources\PurchaseResource::class => DailyOpsCluster::class, \App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class, + \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class, \App\Filament\Resources\TaskResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,