From 8b4950c79debb2a252bba4ea3bf3939f22639dd2 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Jan 2026 21:35:22 +0100 Subject: [PATCH] Refine ops health widget layout --- .beads/issues.jsonl | 1 + .beads/last-touched | 2 +- app/Filament/Widgets/QueueHealthWidget.php | 60 +++++ .../Widgets/UploadPipelineHealthWidget.php | 6 +- resources/lang/de/admin.php | 4 + resources/lang/en/admin.php | 4 + .../pages/ops-health-dashboard.blade.php | 14 +- .../filament/widgets/queue-health.blade.php | 248 +++++++++++------- tests/Feature/OpsHealthWidgetsTest.php | 67 +++++ 9 files changed, 295 insertions(+), 111 deletions(-) create mode 100644 tests/Feature/OpsHealthWidgetsTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ec982e9..31c3033 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -110,5 +110,6 @@ {"id":"fotospiel-app-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"} {"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"} +{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"} {"id":"fotospiel-app-z5g","title":"Tenant admin onboarding: PWA/Capacitor/TWA packaging prep","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:46.126417696+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:46.126417696+01:00"} {"id":"fotospiel-app-zli","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:03.625388684+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:09.286391766+01:00","closed_at":"2026-01-01T15:55:09.286391766+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index 8dbc3d9..6827291 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-tym +fotospiel-app-z2k diff --git a/app/Filament/Widgets/QueueHealthWidget.php b/app/Filament/Widgets/QueueHealthWidget.php index eca863f..df06ae5 100644 --- a/app/Filament/Widgets/QueueHealthWidget.php +++ b/app/Filament/Widgets/QueueHealthWidget.php @@ -54,6 +54,7 @@ class QueueHealthWidget extends Widget 'alertCount' => count($alerts), 'stalledAssets' => (int) ($snapshot['stalled_assets'] ?? 0), 'stalledMinutes' => $stalledMinutes, + 'statusSummary' => $this->summarizeSeverities($queues), ]; } @@ -72,6 +73,7 @@ class QueueHealthWidget extends Widget 'severity' => (string) ($queue['severity'] ?? 'unknown'), 'warning' => (int) ($limits['warning'] ?? 0), 'critical' => (int) ($limits['critical'] ?? 0), + 'utilization_label' => $this->formatUtilizationLabel($size, $limits), ]; } @@ -100,4 +102,62 @@ class QueueHealthWidget extends Widget return null; } } + + private function summarizeSeverities(array $queues): array + { + $counts = [ + 'ok' => 0, + 'warning' => 0, + 'critical' => 0, + 'unknown' => 0, + ]; + + foreach ($queues as $queue) { + $severity = $queue['severity'] ?? 'unknown'; + if (! array_key_exists($severity, $counts)) { + $severity = 'unknown'; + } + + $counts[$severity]++; + } + + return collect($counts) + ->map(fn (int $count, string $severity) => [ + 'severity' => $severity, + 'label' => __('admin.ops_health.severity.'.$severity), + 'count' => $count, + ]) + ->values() + ->all(); + } + + private function formatUtilizationLabel(int $size, array $limits): string + { + if ($size < 0) { + return __('admin.ops_health.queue.utilization_na'); + } + + $critical = (int) ($limits['critical'] ?? 0); + $warning = (int) ($limits['warning'] ?? 0); + + if ($critical > 0) { + $percent = (int) round(($size / $critical) * 100); + + return __('admin.ops_health.queue.utilization_of', [ + 'percent' => $percent, + 'label' => __('admin.ops_health.severity.critical'), + ]); + } + + if ($warning > 0) { + $percent = (int) round(($size / $warning) * 100); + + return __('admin.ops_health.queue.utilization_of', [ + 'percent' => $percent, + 'label' => __('admin.ops_health.severity.warning'), + ]); + } + + return __('admin.ops_health.queue.utilization_na'); + } } diff --git a/app/Filament/Widgets/UploadPipelineHealthWidget.php b/app/Filament/Widgets/UploadPipelineHealthWidget.php index 9036ae5..774f5c2 100644 --- a/app/Filament/Widgets/UploadPipelineHealthWidget.php +++ b/app/Filament/Widgets/UploadPipelineHealthWidget.php @@ -33,9 +33,6 @@ class UploadPipelineHealthWidget extends StatsOverviewWidget : __('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') @@ -43,6 +40,9 @@ class UploadPipelineHealthWidget extends StatsOverviewWidget 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.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.alerts'), number_format(count($alerts))) ->color(count($alerts) > 0 ? 'danger' : 'success'), ]; diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 6afbc47..3d500f8 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -250,10 +250,14 @@ return [ 'no_snapshot' => 'Kein Queue-Snapshot vorhanden. storage:check-upload-queues ausführen.', 'no_queues' => 'Keine Queues konfiguriert.', 'alerts_heading' => 'Alarme', + 'alerts_empty' => 'Keine aktiven Alarme.', 'thresholds' => 'Grenzwerte', 'size' => 'Größe', 'failed' => 'Fehler', 'queue' => 'Queue', + 'utilization' => 'Auslastung', + 'utilization_of' => ':percent% von :label', + 'utilization_na' => 'k.A.', ], 'alert_types' => [ 'size' => 'Queue-Größe über Grenzwert (:size)', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index cbde573..802b841 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -250,10 +250,14 @@ return [ 'no_snapshot' => 'No queue snapshot available. Run storage:check-upload-queues.', 'no_queues' => 'No queues configured.', 'alerts_heading' => 'Alerts', + 'alerts_empty' => 'No active alerts.', 'thresholds' => 'Thresholds', 'size' => 'Size', 'failed' => 'Failed', 'queue' => 'Queue', + 'utilization' => 'Utilization', + 'utilization_of' => ':percent% of :label', + 'utilization_na' => 'N/A', ], 'alert_types' => [ 'size' => 'Queue size above threshold (:size)', 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 index cd8d448..b6b5035 100644 --- a/resources/views/filament/super-admin/pages/ops-health-dashboard.blade.php +++ b/resources/views/filament/super-admin/pages/ops-health-dashboard.blade.php @@ -1,9 +1,9 @@ +@php + use Filament\Support\Icons\Heroicon; +@endphp + -
- -

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

-
-
+ + {{ __('admin.ops_health.help') }} +
diff --git a/resources/views/filament/widgets/queue-health.blade.php b/resources/views/filament/widgets/queue-health.blade.php index 4bcb6b2..7b32ce2 100644 --- a/resources/views/filament/widgets/queue-health.blade.php +++ b/resources/views/filament/widgets/queue-health.blade.php @@ -1,115 +1,163 @@ +@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; + + $severityColors = [ + 'ok' => 'success', + 'warning' => 'warning', + 'critical' => 'danger', + 'unknown' => 'gray', + ]; + + $severityIcons = [ + 'ok' => Heroicon::CheckCircle, + 'warning' => Heroicon::ExclamationTriangle, + 'critical' => Heroicon::XCircle, + 'unknown' => Heroicon::QuestionMarkCircle, + ]; + + $summaryGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'sm' => 2, 'lg' => 4]); + $summaryRow = (new ComponentAttributeBag())->grid(['default' => 2]); + $mainGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'xl' => 3]); + $queuesColumn = (new ComponentAttributeBag())->gridColumn(['default' => 'full', 'xl' => 2]); + $alertsColumn = (new ComponentAttributeBag())->gridColumn(['default' => 'full', 'xl' => 1]); + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); +@endphp + -
-
- - {{ __('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 -
-
+ + + {{ $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']) }} - -
+
+ @foreach($statusSummary as $summary) + +
+ + {{ $summary['label'] }} + + + {{ number_format($summary['count']) }} +
-
- @empty -

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

- @endforelse + + @endforeach
- @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 +
+
+
+ @forelse($queues as $queue) + + + + {{ __('admin.ops_health.severity.'.$queue['severity']) }} + + + +
+ + {{ __('admin.ops_health.queue.size') }}: {{ $queue['size_label'] }} + + + {{ __('admin.ops_health.queue.failed') }}: {{ $queue['failed_label'] }} + + + {{ __('admin.ops_health.queue.utilization') }}: {{ $queue['utilization_label'] }} + +
+
+ @empty + + @endforelse +
- @endif + +
+ +
+ @forelse($alerts as $alert) + + + + {{ __('admin.ops_health.severity.'.$alert['severity']) }} + + + +
+ @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 + +
+
+ @empty + + @endforelse +
+
+
+
@endif diff --git a/tests/Feature/OpsHealthWidgetsTest.php b/tests/Feature/OpsHealthWidgetsTest.php new file mode 100644 index 0000000..54ae8d3 --- /dev/null +++ b/tests/Feature/OpsHealthWidgetsTest.php @@ -0,0 +1,67 @@ +setLocale('en'); + Cache::forget('storage:queue-health:last'); + + Livewire::test(QueueHealthWidget::class) + ->assertStatus(200) + ->assertSee(__('admin.ops_health.queue.no_snapshot')); + } + + public function test_queue_health_widget_renders_with_snapshot(): void + { + app()->setLocale('en'); + Cache::put('storage:queue-health:last', [ + 'generated_at' => now()->toIso8601String(), + 'connection' => 'redis', + 'queues' => [ + [ + 'queue' => 'default', + 'size' => 120, + 'failed' => 2, + 'severity' => 'warning', + 'limits' => [ + 'warning' => 100, + 'critical' => 200, + ], + ], + ], + 'alerts' => [ + [ + 'queue' => 'default', + 'type' => 'size', + 'severity' => 'warning', + 'size' => 120, + ], + ], + 'stalled_assets' => 1, + ], now()->addMinutes(5)); + + Livewire::test(QueueHealthWidget::class) + ->assertStatus(200) + ->assertSee('default') + ->assertSee(__('admin.ops_health.queue.alerts_heading')); + } + + public function test_upload_pipeline_widget_renders_without_snapshot(): void + { + app()->setLocale('en'); + Cache::forget('storage:monitor:last'); + + Livewire::test(UploadPipelineHealthWidget::class) + ->assertStatus(200) + ->assertSee(__('admin.ops_health.pipeline.no_snapshot')); + } +}