Refine ops health widget layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-01 21:35:22 +01:00
parent 2fc8232d57
commit 8b4950c79d
9 changed files with 295 additions and 111 deletions

View File

@@ -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)"}

View File

@@ -1 +1 @@
fotospiel-app-tym
fotospiel-app-z2k

View File

@@ -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');
}
}

View File

@@ -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'),
];

View File

@@ -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)',

View File

@@ -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)',

View File

@@ -1,9 +1,9 @@
@php
use Filament\Support\Icons\Heroicon;
@endphp
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section>
<p class="text-sm text-slate-600 dark:text-slate-300">
{{ __('admin.ops_health.help') }}
</p>
</x-filament::section>
</div>
<x-filament::section :icon="Heroicon::InformationCircle">
{{ __('admin.ops_health.help') }}
</x-filament::section>
</x-filament-panels::page>

View File

@@ -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
<x-filament-widgets::widget>
<x-filament::section
:heading="__('admin.ops_health.queue.heading')"
:description="__('admin.ops_health.queue.description')"
:icon="Heroicon::QueueList"
>
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-slate-500 dark:text-slate-300">
<div class="flex flex-wrap items-center gap-4">
<span>
{{ __('admin.ops_health.queue.connection') }}:
<span class="font-semibold text-slate-700 dark:text-slate-100">{{ $connection }}</span>
</span>
<span>{{ $snapshotLabel }}</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{{ __('admin.ops_health.queue.alerts_heading') }}: {{ number_format($alertCount) }}
</span>
@if($stalledAssets > 0)
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-800 dark:bg-amber-400/20 dark:text-amber-200">
{{ __('admin.ops_health.queue.stalled_assets', ['minutes' => $stalledMinutes]) }}:
{{ number_format($stalledAssets) }}
</span>
@endif
</div>
</div>
<x-slot name="afterHeader">
<x-filament::badge color="gray" :icon="Heroicon::Clock">
{{ $snapshotLabel }}
</x-filament::badge>
<x-filament::badge :color="$alertCount > 0 ? 'danger' : 'success'" :icon="Heroicon::BellAlert">
{{ __('admin.ops_health.queue.alerts_heading') }}: {{ number_format($alertCount) }}
</x-filament::badge>
@if($stalledAssets > 0)
<x-filament::badge color="warning" :icon="Heroicon::ExclamationTriangle">
{{ __('admin.ops_health.queue.stalled_assets', ['minutes' => $stalledMinutes]) }}:
{{ number_format($stalledAssets) }}
</x-filament::badge>
@endif
</x-slot>
@if($snapshotMissing)
<div class="mt-4 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 dark:border-white/10 dark:bg-slate-900/40 dark:text-slate-300">
{{ __('admin.ops_health.queue.no_snapshot') }}
</div>
<x-filament::empty-state
:heading="__('admin.ops_health.queue.no_snapshot')"
:description="__('admin.ops_health.queue.description')"
:icon="Heroicon::ExclamationTriangle"
/>
@else
<div class="mt-4 grid gap-3">
@forelse($queues as $queue)
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-100">{{ $queue['name'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ __('admin.ops_health.queue.thresholds') }}:
@if($queue['warning'] || $queue['critical'])
W {{ number_format($queue['warning']) }} / C {{ number_format($queue['critical']) }}
@else
-
@endif
</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-slate-500 dark:text-slate-400">
{{ __('admin.ops_health.queue.size') }}:
<span class="font-semibold text-slate-700 dark:text-slate-100">{{ $queue['size_label'] }}</span>
</span>
<span class="text-slate-500 dark:text-slate-400">
{{ __('admin.ops_health.queue.failed') }}:
<span class="font-semibold text-slate-700 dark:text-slate-100">{{ $queue['failed_label'] }}</span>
</span>
<span @class([
'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
'bg-emerald-100 text-emerald-800 dark:bg-emerald-400/20 dark:text-emerald-200' => $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']) }}
</span>
</div>
<div {{ $summaryGrid }}>
@foreach($statusSummary as $summary)
<x-filament::card>
<div {{ $summaryRow }}>
<x-filament::badge
:color="$severityColors[$summary['severity']] ?? 'gray'"
:icon="$severityIcons[$summary['severity']] ?? Heroicon::QuestionMarkCircle"
>
{{ $summary['label'] }}
</x-filament::badge>
<x-filament::badge color="gray">
{{ number_format($summary['count']) }}
</x-filament::badge>
</div>
</div>
@empty
<p class="text-sm text-slate-500 dark:text-slate-300">{{ __('admin.ops_health.queue.no_queues') }}</p>
@endforelse
</x-filament::card>
@endforeach
</div>
@if($alertCount > 0)
<div class="mt-4 grid gap-2">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.ops_health.queue.alerts_heading') }}
</p>
@foreach($alerts as $alert)
<div class="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200/70 bg-white/70 px-3 py-2 text-xs text-slate-600 dark:border-white/10 dark:bg-slate-900/50 dark:text-slate-200">
<div class="flex flex-wrap items-center gap-2">
@if($alert['queue'])
<span class="font-semibold text-slate-700 dark:text-slate-100">{{ $alert['queue'] }}</span>
@endif
<span class="text-slate-500 dark:text-slate-400">
@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
</span>
</div>
<span @class([
'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
'bg-emerald-100 text-emerald-800 dark:bg-emerald-400/20 dark:text-emerald-200' => $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']) }}
</span>
</div>
@endforeach
<div {{ $mainGrid }}>
<div {{ $queuesColumn }}>
<div {{ $stacked }}>
@forelse($queues as $queue)
<x-filament::card
:heading="$queue['name']"
:description="__('admin.ops_health.queue.thresholds') . ': ' . (($queue['warning'] || $queue['critical']) ? 'W ' . number_format($queue['warning']) . ' / C ' . number_format($queue['critical']) : '-')"
>
<x-slot name="afterHeader">
<x-filament::badge
:color="$severityColors[$queue['severity']] ?? 'gray'"
:icon="$severityIcons[$queue['severity']] ?? Heroicon::QuestionMarkCircle"
>
{{ __('admin.ops_health.severity.'.$queue['severity']) }}
</x-filament::badge>
</x-slot>
<div {{ $stacked }}>
<x-filament::badge color="gray" :icon="Heroicon::QueueList">
{{ __('admin.ops_health.queue.size') }}: {{ $queue['size_label'] }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::ExclamationCircle">
{{ __('admin.ops_health.queue.failed') }}: {{ $queue['failed_label'] }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::ArrowTrendingUp">
{{ __('admin.ops_health.queue.utilization') }}: {{ $queue['utilization_label'] }}
</x-filament::badge>
</div>
</x-filament::card>
@empty
<x-filament::empty-state
:heading="__('admin.ops_health.queue.no_queues')"
:icon="Heroicon::QueueList"
compact
/>
@endforelse
</div>
</div>
@endif
<div {{ $alertsColumn }}>
<x-filament::card :heading="__('admin.ops_health.queue.alerts_heading')">
<div {{ $stacked }}>
@forelse($alerts as $alert)
<x-filament::card compact>
<x-slot name="afterHeader">
<x-filament::badge
:color="$severityColors[$alert['severity']] ?? 'gray'"
:icon="$severityIcons[$alert['severity']] ?? Heroicon::QuestionMarkCircle"
>
{{ __('admin.ops_health.severity.'.$alert['severity']) }}
</x-filament::badge>
</x-slot>
<div {{ $stacked }}>
@if($alert['queue'])
<x-filament::badge color="gray" :icon="Heroicon::QueueList">
{{ $alert['queue'] }}
</x-filament::badge>
@endif
<x-filament::badge color="gray" :icon="Heroicon::BellAlert">
@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
</x-filament::badge>
</div>
</x-filament::card>
@empty
<x-filament::empty-state
:heading="__('admin.ops_health.queue.alerts_empty')"
:icon="Heroicon::BellAlert"
compact
/>
@endforelse
</div>
</x-filament::card>
</div>
</div>
@endif
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature;
use App\Filament\Widgets\QueueHealthWidget;
use App\Filament\Widgets\UploadPipelineHealthWidget;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Tests\TestCase;
class OpsHealthWidgetsTest extends TestCase
{
public function test_queue_health_widget_renders_without_snapshot(): void
{
app()->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'));
}
}