diff --git a/app/Filament/Clusters/DailyOps/Pages/JoinTokenAnalyticsDashboard.php b/app/Filament/Clusters/DailyOps/Pages/JoinTokenAnalyticsDashboard.php new file mode 100644 index 0000000..8bc26a4 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Pages/JoinTokenAnalyticsDashboard.php @@ -0,0 +1,127 @@ +components([ + Section::make() + ->schema([ + Select::make('range') + ->label(__('admin.join_token_analytics.filters.range')) + ->options(trans('admin.join_token_analytics.filters.range_options')) + ->default('24h') + ->native(false), + Select::make('event_id') + ->label(__('admin.join_token_analytics.filters.event')) + ->placeholder(__('admin.join_token_analytics.filters.event_placeholder')) + ->searchable() + ->getSearchResultsUsing(fn (string $search): array => $this->searchEvents($search)) + ->getOptionLabelUsing(fn ($value): ?string => $this->resolveEventLabel($value)) + ->native(false), + ]) + ->columns(2), + ]); + } + + private function searchEvents(string $search): array + { + return Event::query() + ->with('tenant') + ->when($search !== '', function ($query) use ($search) { + $query->where('slug', 'like', "%{$search}%") + ->orWhere('name->de', 'like', "%{$search}%") + ->orWhere('name->en', 'like', "%{$search}%"); + }) + ->orderByDesc('date') + ->limit(25) + ->get() + ->mapWithKeys(fn (Event $event) => [$event->id => $this->formatEventLabel($event)]) + ->all(); + } + + private function resolveEventLabel(mixed $value): ?string + { + if (! is_numeric($value)) { + return null; + } + + $event = Event::query() + ->with('tenant') + ->find((int) $value); + + return $event ? $this->formatEventLabel($event) : null; + } + + private function formatEventLabel(Event $event): string + { + $locale = app()->getLocale(); + $name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed'); + $tenant = $event->tenant?->name ?? __('admin.common.unnamed'); + $date = $event->date?->format('Y-m-d'); + + return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})"; + } +} diff --git a/app/Filament/Widgets/JoinTokenOverviewWidget.php b/app/Filament/Widgets/JoinTokenOverviewWidget.php new file mode 100644 index 0000000..949f12e --- /dev/null +++ b/app/Filament/Widgets/JoinTokenOverviewWidget.php @@ -0,0 +1,147 @@ +resolveFilters(); + $totals = $this->totalsByEventType($filters); + + $success = $this->sumTotals($totals, self::SUCCESS_EVENTS); + $failures = $this->sumTotals($totals, self::FAILURE_EVENTS); + $rateLimited = $this->sumTotals($totals, self::RATE_LIMIT_EVENTS); + $uploads = $this->sumTotals($totals, self::UPLOAD_EVENTS); + + $ratio = $success > 0 ? round(($failures / $success) * 100, 1) : null; + $ratioLabel = $ratio !== null ? "{$ratio}%" : __('admin.join_token_analytics.stats.no_data'); + + return [ + Stat::make(__('admin.join_token_analytics.stats.success'), number_format($success)) + ->color('success'), + Stat::make(__('admin.join_token_analytics.stats.failures'), number_format($failures)) + ->color($failures > 0 ? 'danger' : 'success'), + Stat::make(__('admin.join_token_analytics.stats.rate_limited'), number_format($rateLimited)) + ->color($rateLimited > 0 ? 'warning' : 'success'), + Stat::make(__('admin.join_token_analytics.stats.uploads'), number_format($uploads)) + ->color('primary'), + Stat::make(__('admin.join_token_analytics.stats.failure_ratio'), $ratioLabel) + ->color($ratio !== null && $ratio >= 50 ? 'danger' : 'warning'), + ]; + } + + private function totalsByEventType(array $filters): array + { + return $this->baseQuery($filters) + ->selectRaw('event_type, COUNT(*) as total') + ->groupBy('event_type') + ->get() + ->mapWithKeys(fn (EventJoinTokenEvent $event) => [$event->event_type => (int) $event->total]) + ->all(); + } + + private function sumTotals(array $totals, array $types): int + { + $sum = 0; + + foreach ($types as $type) { + $sum += (int) ($totals[$type] ?? 0); + } + + return $sum; + } + + private function baseQuery(array $filters): Builder + { + $query = EventJoinTokenEvent::query() + ->whereBetween('occurred_at', [$filters['start'], $filters['end']]); + + if ($filters['event_id']) { + $query->where('event_id', $filters['event_id']); + } + + return $query; + } + + private function resolveFilters(): array + { + $eventId = $this->pageFilters['event_id'] ?? null; + $eventId = is_numeric($eventId) ? (int) $eventId : null; + $range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h'; + + [$start, $end] = $this->resolveWindow($range, $eventId); + + return [ + 'event_id' => $eventId, + 'range' => $range, + 'start' => $start, + 'end' => $end, + ]; + } + + private function resolveWindow(string $range, ?int $eventId): array + { + $now = now(); + $start = match ($range) { + '2h' => $now->copy()->subHours(2), + '6h' => $now->copy()->subHours(6), + '12h' => $now->copy()->subHours(12), + '7d' => $now->copy()->subDays(7), + default => $now->copy()->subHours(24), + }; + $end = $now; + + if ($range === 'event_day' && $eventId) { + $eventDate = Event::query()->whereKey($eventId)->value('date'); + + if ($eventDate) { + $eventDay = Carbon::parse($eventDate); + $start = $eventDay->copy()->startOfDay(); + $end = $eventDay->copy()->endOfDay(); + } + } + + return [$start, $end]; + } +} diff --git a/app/Filament/Widgets/JoinTokenTopTokensWidget.php b/app/Filament/Widgets/JoinTokenTopTokensWidget.php new file mode 100644 index 0000000..76d5060 --- /dev/null +++ b/app/Filament/Widgets/JoinTokenTopTokensWidget.php @@ -0,0 +1,191 @@ +resolveFilters(); + + return $table + ->query(fn (): Builder => $this->buildQuery($filters)) + ->columns([ + Tables\Columns\TextColumn::make('token_preview') + ->label(__('admin.join_token_analytics.table.token')) + ->copyable() + ->copyMessage(__('admin.events.messages.join_link_copied')), + Tables\Columns\TextColumn::make('event_label') + ->label(__('admin.join_token_analytics.table.event')) + ->getStateUsing(fn (EventJoinToken $record) => $this->formatEventLabel($record->event)) + ->url(fn (EventJoinToken $record) => $record->event + ? EventResource::getUrl('view', ['record' => $record->event]) + : null) + ->openUrlInNewTab(), + Tables\Columns\TextColumn::make('tenant_label') + ->label(__('admin.join_token_analytics.table.tenant')) + ->getStateUsing(fn (EventJoinToken $record) => $record->event?->tenant?->name ?? __('admin.common.unnamed')), + Tables\Columns\TextColumn::make('success_total') + ->label(__('admin.join_token_analytics.table.success')) + ->numeric(), + Tables\Columns\TextColumn::make('failure_total') + ->label(__('admin.join_token_analytics.table.failures')) + ->numeric(), + Tables\Columns\TextColumn::make('rate_limited_total') + ->label(__('admin.join_token_analytics.table.rate_limited')) + ->numeric(), + Tables\Columns\TextColumn::make('upload_total') + ->label(__('admin.join_token_analytics.table.uploads')) + ->numeric(), + Tables\Columns\TextColumn::make('last_seen_at') + ->label(__('admin.join_token_analytics.table.last_seen')) + ->since() + ->placeholder('—'), + ]) + ->paginated(false); + } + + private function buildQuery(array $filters): Builder + { + $query = EventJoinToken::query() + ->with(['event.tenant']) + ->when($filters['event_id'], fn (Builder $builder, int $eventId) => $builder->where('event_id', $eventId)) + ->withCount([ + 'analytics as success_total' => function (Builder $builder) use ($filters) { + $this->applyAnalyticsFilters($builder, $filters) + ->whereIn('event_type', self::SUCCESS_EVENTS); + }, + 'analytics as failure_total' => function (Builder $builder) use ($filters) { + $this->applyAnalyticsFilters($builder, $filters) + ->whereIn('event_type', self::FAILURE_EVENTS); + }, + 'analytics as rate_limited_total' => function (Builder $builder) use ($filters) { + $this->applyAnalyticsFilters($builder, $filters) + ->whereIn('event_type', self::RATE_LIMIT_EVENTS); + }, + 'analytics as upload_total' => function (Builder $builder) use ($filters) { + $this->applyAnalyticsFilters($builder, $filters) + ->whereIn('event_type', self::UPLOAD_EVENTS); + }, + ]) + ->withMax([ + 'analytics as last_seen_at' => function (Builder $builder) use ($filters) { + $this->applyAnalyticsFilters($builder, $filters); + }, + ], 'occurred_at') + ->orderByDesc('failure_total') + ->orderByDesc('rate_limited_total') + ->orderByDesc('success_total') + ->limit(10); + + return $query; + } + + private function applyAnalyticsFilters(Builder $query, array $filters): Builder + { + return $query->whereBetween('occurred_at', [$filters['start'], $filters['end']]); + } + + private function resolveFilters(): array + { + $eventId = $this->pageFilters['event_id'] ?? null; + $eventId = is_numeric($eventId) ? (int) $eventId : null; + $range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h'; + + [$start, $end] = $this->resolveWindow($range, $eventId); + + return [ + 'event_id' => $eventId, + 'range' => $range, + 'start' => $start, + 'end' => $end, + ]; + } + + private function resolveWindow(string $range, ?int $eventId): array + { + $now = now(); + $start = match ($range) { + '2h' => $now->copy()->subHours(2), + '6h' => $now->copy()->subHours(6), + '12h' => $now->copy()->subHours(12), + '7d' => $now->copy()->subDays(7), + default => $now->copy()->subHours(24), + }; + $end = $now; + + if ($range === 'event_day' && $eventId) { + $eventDate = Event::query()->whereKey($eventId)->value('date'); + + if ($eventDate) { + $eventDay = Carbon::parse($eventDate); + $start = $eventDay->copy()->startOfDay(); + $end = $eventDay->copy()->endOfDay(); + } + } + + return [$start, $end]; + } + + private function formatEventLabel(?Event $event): string + { + if (! $event) { + return __('admin.common.unnamed'); + } + + $locale = app()->getLocale(); + $name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed'); + $tenant = $event->tenant?->name ?? __('admin.common.unnamed'); + $date = $event->date?->format('Y-m-d'); + + return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})"; + } + + private const SUCCESS_EVENTS = [ + 'access_granted', + 'gallery_access_granted', + ]; + + private const RATE_LIMIT_EVENTS = [ + 'token_rate_limited', + 'access_rate_limited', + 'download_rate_limited', + ]; + + private const FAILURE_EVENTS = [ + 'invalid_token', + 'token_expired', + 'token_revoked', + 'event_not_public', + 'gallery_expired', + 'token_rate_limited', + 'access_rate_limited', + 'download_rate_limited', + ]; + + private const UPLOAD_EVENTS = [ + 'upload_completed', + ]; +} diff --git a/app/Filament/Widgets/JoinTokenTrendWidget.php b/app/Filament/Widgets/JoinTokenTrendWidget.php new file mode 100644 index 0000000..f4f8337 --- /dev/null +++ b/app/Filament/Widgets/JoinTokenTrendWidget.php @@ -0,0 +1,178 @@ +resolveFilters(); + $events = $this->baseQuery($filters)->get(['event_type', 'occurred_at']); + $hourly = $filters['start']->diffInHours($filters['end']) <= 48; + $bucketFormat = $hourly ? 'Y-m-d H:00' : 'Y-m-d'; + $labelFormat = $hourly ? 'M d H:00' : 'M d'; + + $periodStart = $hourly ? $filters['start']->copy()->startOfHour() : $filters['start']->copy()->startOfDay(); + $period = CarbonPeriod::create($periodStart, $hourly ? '1 hour' : '1 day', $filters['end']); + + $grouped = $events->groupBy(fn (EventJoinTokenEvent $event) => $event->occurred_at?->format($bucketFormat)); + + $labels = []; + $success = []; + $failures = []; + $rateLimited = []; + $uploads = []; + + foreach ($period as $point) { + $key = $point->format($bucketFormat); + $bucket = $grouped->get($key, collect()); + + $labels[] = $point->translatedFormat($labelFormat); + $success[] = $bucket->whereIn('event_type', self::SUCCESS_EVENTS)->count(); + $failures[] = $bucket->whereIn('event_type', self::FAILURE_EVENTS)->count(); + $rateLimited[] = $bucket->whereIn('event_type', self::RATE_LIMIT_EVENTS)->count(); + $uploads[] = $bucket->whereIn('event_type', self::UPLOAD_EVENTS)->count(); + } + + return [ + 'datasets' => [ + [ + 'label' => __('admin.join_token_analytics.trend.success'), + 'data' => $success, + 'borderColor' => '#16a34a', + 'backgroundColor' => 'rgba(22, 163, 74, 0.2)', + 'tension' => 0.35, + 'fill' => false, + ], + [ + 'label' => __('admin.join_token_analytics.trend.failures'), + 'data' => $failures, + 'borderColor' => '#dc2626', + 'backgroundColor' => 'rgba(220, 38, 38, 0.2)', + 'tension' => 0.35, + 'fill' => false, + ], + [ + 'label' => __('admin.join_token_analytics.trend.rate_limited'), + 'data' => $rateLimited, + 'borderColor' => '#f59e0b', + 'backgroundColor' => 'rgba(245, 158, 11, 0.2)', + 'tension' => 0.35, + 'fill' => false, + ], + [ + 'label' => __('admin.join_token_analytics.trend.uploads'), + 'data' => $uploads, + 'borderColor' => '#2563eb', + 'backgroundColor' => 'rgba(37, 99, 235, 0.2)', + 'tension' => 0.35, + 'fill' => false, + ], + ], + 'labels' => $labels, + ]; + } + + protected function getType(): string + { + return 'line'; + } + + public function getHeading(): ?string + { + return __('admin.join_token_analytics.trend.heading'); + } + + private function baseQuery(array $filters): Builder + { + $query = EventJoinTokenEvent::query() + ->whereBetween('occurred_at', [$filters['start'], $filters['end']]); + + if ($filters['event_id']) { + $query->where('event_id', $filters['event_id']); + } + + return $query; + } + + private function resolveFilters(): array + { + $eventId = $this->pageFilters['event_id'] ?? null; + $eventId = is_numeric($eventId) ? (int) $eventId : null; + $range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h'; + + [$start, $end] = $this->resolveWindow($range, $eventId); + + return [ + 'event_id' => $eventId, + 'range' => $range, + 'start' => $start, + 'end' => $end, + ]; + } + + private function resolveWindow(string $range, ?int $eventId): array + { + $now = now(); + $start = match ($range) { + '2h' => $now->copy()->subHours(2), + '6h' => $now->copy()->subHours(6), + '12h' => $now->copy()->subHours(12), + '7d' => $now->copy()->subDays(7), + default => $now->copy()->subHours(24), + }; + $end = $now; + + if ($range === 'event_day' && $eventId) { + $eventDate = Event::query()->whereKey($eventId)->value('date'); + + if ($eventDate) { + $eventDay = Carbon::parse($eventDate); + $start = $eventDay->copy()->startOfDay(); + $end = $eventDay->copy()->endOfDay(); + } + } + + return [$start, $end]; + } + + private const SUCCESS_EVENTS = [ + 'access_granted', + 'gallery_access_granted', + ]; + + private const RATE_LIMIT_EVENTS = [ + 'token_rate_limited', + 'access_rate_limited', + 'download_rate_limited', + ]; + + private const FAILURE_EVENTS = [ + 'invalid_token', + 'token_expired', + 'token_revoked', + 'event_not_public', + 'gallery_expired', + 'token_rate_limited', + 'access_rate_limited', + 'download_rate_limited', + ]; + + private const UPLOAD_EVENTS = [ + 'upload_completed', + ]; +} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index 2b81e9f..dda0c44 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -7,13 +7,14 @@ use Closure; use Illuminate\Auth\Middleware\RedirectIfAuthenticated as BaseMiddleware; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Symfony\Component\HttpFoundation\Response; class RedirectIfAuthenticated extends BaseMiddleware { /** * Handle an incoming request. */ - public function handle(Request $request, Closure $next, ...$guards) + public function handle(Request $request, Closure $next, string ...$guards): Response { $guards = $guards === [] ? [null] : $guards; diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 04f2f36..743f492 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -108,13 +108,6 @@ class SuperAdminPanelProvider extends PanelProvider ->authMiddleware([ Authenticate::class, ]) - ->pages([ - Pages\Dashboard::class, - \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class, - \App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class, - \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class, - \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class, - ]) ->authGuard('super_admin'); // SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 8c60cc1..c244364 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -303,6 +303,52 @@ return [ 'unavailable' => 'Nicht verfügbar', ], ], + 'join_token_analytics' => [ + 'navigation' => [ + 'label' => 'Join-Token-Analytics', + ], + 'heading' => 'Join-Token-Analytics', + 'subheading' => 'Überwache Gastzugriffe und Join-Token-Aktivität über Events hinweg.', + 'filters' => [ + 'range' => 'Zeitraum', + 'event' => 'Event', + 'event_placeholder' => 'Alle Events', + 'range_options' => [ + '2h' => 'Letzte 2 Stunden', + '6h' => 'Letzte 6 Stunden', + '12h' => 'Letzte 12 Stunden', + '24h' => 'Letzte 24 Stunden', + '7d' => 'Letzte 7 Tage', + 'event_day' => 'Event-Tag', + ], + ], + 'stats' => [ + 'success' => 'Erfolge', + 'failures' => 'Fehlschläge', + 'rate_limited' => 'Rate-Limits', + 'uploads' => 'Uploads', + 'failure_ratio' => 'Fehlerquote', + 'no_data' => '—', + ], + 'trend' => [ + 'heading' => 'Join-Token-Aktivität', + 'success' => 'Erfolge', + 'failures' => 'Fehlschläge', + 'rate_limited' => 'Rate-Limits', + 'uploads' => 'Uploads', + ], + 'table' => [ + 'heading' => 'Top-Tokens', + 'token' => 'Token', + 'event' => 'Event', + 'tenant' => 'Mandant', + 'success' => 'Erfolge', + 'failures' => 'Fehlschläge', + 'rate_limited' => 'Rate-Limits', + 'uploads' => 'Uploads', + 'last_seen' => 'Zuletzt gesehen', + ], + ], 'events' => [ 'fields' => [ diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 46434c1..9ac936c 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -303,6 +303,52 @@ return [ 'unavailable' => 'Unavailable', ], ], + 'join_token_analytics' => [ + 'navigation' => [ + 'label' => 'Join token analytics', + ], + 'heading' => 'Join token analytics', + 'subheading' => 'Monitor guest access and join-token activity across events.', + 'filters' => [ + 'range' => 'Time range', + 'event' => 'Event', + 'event_placeholder' => 'All events', + 'range_options' => [ + '2h' => 'Last 2 hours', + '6h' => 'Last 6 hours', + '12h' => 'Last 12 hours', + '24h' => 'Last 24 hours', + '7d' => 'Last 7 days', + 'event_day' => 'Event day', + ], + ], + 'stats' => [ + 'success' => 'Success', + 'failures' => 'Failures', + 'rate_limited' => 'Rate limited', + 'uploads' => 'Uploads', + 'failure_ratio' => 'Failure ratio', + 'no_data' => '—', + ], + 'trend' => [ + 'heading' => 'Join-token activity', + 'success' => 'Success', + 'failures' => 'Failures', + 'rate_limited' => 'Rate limited', + 'uploads' => 'Uploads', + ], + 'table' => [ + 'heading' => 'Top tokens', + 'token' => 'Token', + 'event' => 'Event', + 'tenant' => 'Tenant', + 'success' => 'Success', + 'failures' => 'Failures', + 'rate_limited' => 'Rate limited', + 'uploads' => 'Uploads', + 'last_seen' => 'Last seen', + ], + ], 'events' => [ 'fields' => [ diff --git a/resources/views/filament/clusters/daily-ops/pages/join-token-analytics-dashboard.blade.php b/resources/views/filament/clusters/daily-ops/pages/join-token-analytics-dashboard.blade.php new file mode 100644 index 0000000..a741733 --- /dev/null +++ b/resources/views/filament/clusters/daily-ops/pages/join-token-analytics-dashboard.blade.php @@ -0,0 +1,3 @@ + + {{ $this->content }} + diff --git a/resources/views/filament/events/join-link.blade.php b/resources/views/filament/events/join-link.blade.php index 9c9874c..b284068 100644 --- a/resources/views/filament/events/join-link.blade.php +++ b/resources/views/filament/events/join-link.blade.php @@ -1,70 +1,81 @@ -
-
-
-
{{ __('admin.events.join_link.event_label') }}
-
{{ $event->name }}
-
-

+@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; + + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); + $tokensGrid = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); + $linkGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'md' => 2]); + $metricsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'sm' => 2, 'xl' => 4]); + $layoutsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'md' => 2]); +@endphp + +

+ + + + {{ __('admin.events.join_link.open_admin') }} + + + + {{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }} -

- - {{ __('admin.events.join_link.open_admin') }} - -
+ + @if ($tokens->isEmpty()) -
- {{ __('admin.events.join_link.no_tokens') }} -
+ @else -
+
@foreach ($tokens as $token) -
-
-
-
- {{ $token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']]) }} -
-
- {{ __('admin.events.join_link.token_usage', [ - 'usage' => $token['usage_count'], - 'limit' => $token['usage_limit'] ?? '∞', - ]) }} -
-
-
- @if ($token['is_active']) - - {{ __('admin.events.join_link.token_active') }} - - @else - - {{ __('admin.events.join_link.token_inactive') }} - - @endif -
-
+ + + @if ($token['is_active']) + + {{ __('admin.events.join_link.token_active') }} + + @else + + {{ __('admin.events.join_link.token_inactive') }} + + @endif + -
-
- {{ __('admin.events.join_link.link_label') }} -
-
- - {{ $token['url'] }} - - +
@@ -73,90 +84,88 @@ @endphp @if (!empty($analytics)) -
-
-
{{ __('admin.events.analytics.success_total') }}
-
+
+ + + {{ __('admin.events.analytics.success_total') }}: {{ number_format($analytics['success_total'] ?? 0) }} -
-
-
-
{{ __('admin.events.analytics.failure_total') }}
-
+ + + + + {{ __('admin.events.analytics.failure_total') }}: {{ number_format($analytics['failure_total'] ?? 0) }} -
-
-
-
{{ __('admin.events.analytics.rate_limited_total') }}
-
+ + + + + {{ __('admin.events.analytics.rate_limited_total') }}: {{ number_format($analytics['rate_limited_total'] ?? 0) }} + + + +
+ + {{ __('admin.events.analytics.recent_24h') }}: + {{ number_format($analytics['recent_24h'] ?? 0) }} + + @if (!empty($analytics['last_seen_at'])) + + {{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }} + + @endif
-
-
-
{{ __('admin.events.analytics.recent_24h') }}
-
- {{ number_format($analytics['recent_24h'] ?? 0) }} -
- @if (!empty($analytics['last_seen_at'])) -
- {{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }} -
- @endif -
+
@endif @if (!empty($token['layouts'])) -
-
- {{ __('admin.events.join_link.layouts_heading') }} -
-
+ +
@foreach ($token['layouts'] as $layout) -
-
- {{ $layout['name'] }} -
- @if (!empty($layout['subtitle'])) -
- {{ $layout['subtitle'] }} -
- @endif -
+ +
@foreach ($layout['download_urls'] as $format => $href) - {{ strtoupper($format) }} - + @endforeach
-
+ @endforeach
-
+
@elseif(!empty($token['layouts_url'])) - + + {{ __('admin.events.join_link.layouts_fallback') }} + @endif @if ($token['expires_at']) -
+ {{ __('admin.events.join_link.token_expiry', ['date' => \Carbon\Carbon::parse($token['expires_at'])->isoFormat('LLL')]) }} -
+ @endif -
+ @endforeach
@endif diff --git a/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php b/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php index effd5e6..9f949c8 100644 --- a/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php +++ b/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php @@ -1,13 +1,18 @@ +@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; + + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); +@endphp + -
-
- {{ __('filament-watermark.description') }} -
- {{ $this->form }} -
- + +
+ {{ $this->form }} + {{ __('filament-watermark.save') }}
-
+ diff --git a/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php b/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php index 40550a7..c2aee11 100644 --- a/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php +++ b/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php @@ -1,20 +1,48 @@ - -
- -
- @foreach($composes as $compose) -
-
-
-

{{ $compose['label'] }}

-

{{ $compose['compose_id'] }}

-
- - {{ ucfirst($compose['status'] ?? 'unknown') }} - -
+@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; -
+ $composeStatusColors = [ + 'done' => 'success', + 'deploying' => 'warning', + 'pending' => 'warning', + 'unreachable' => 'danger', + 'error' => 'danger', + 'failed' => 'danger', + ]; + + $composeStatusIcons = [ + 'done' => Heroicon::CheckCircle, + 'deploying' => Heroicon::ArrowPath, + 'pending' => Heroicon::Clock, + 'unreachable' => Heroicon::ExclamationTriangle, + 'error' => Heroicon::XCircle, + 'failed' => Heroicon::XCircle, + ]; + + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); + $composeGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'lg' => 2]); + $actionsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'sm' => 3]); + $logsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'lg' => 2]); +@endphp + + +
+ +
+ @forelse($composes as $compose) + + + + {{ ucfirst($compose['status'] ?? 'unknown') }} + + + +
Redeploy @@ -22,48 +50,79 @@ Stop @if($dokployWebUrl) - + Open in Dokploy @endif
-
- @endforeach + + @empty + + @endforelse
- -
- - - - - - - - - - - - @forelse($recentLogs as $log) - - - - - - - - @empty - - - - @endforelse - -
WhenUserTargetActionStatus
{{ $log['created_at'] }}{{ $log['user'] }}{{ $log['service_id'] }}{{ ucfirst($log['action']) }}{{ $log['status_code'] ?? '—' }}
No actions recorded yet.
-
- - View full log → - + + @if(empty($recentLogs)) + + @else +
+ @foreach($recentLogs as $log) + @php + $statusCode = $log['status_code'] ?? null; + $statusColor = 'gray'; + $statusIcon = Heroicon::QuestionMarkCircle; + + if (is_numeric($statusCode)) { + if ($statusCode >= 500) { + $statusColor = 'danger'; + $statusIcon = Heroicon::XCircle; + } elseif ($statusCode >= 400) { + $statusColor = 'danger'; + $statusIcon = Heroicon::ExclamationTriangle; + } elseif ($statusCode >= 300) { + $statusColor = 'warning'; + $statusIcon = Heroicon::ExclamationTriangle; + } elseif ($statusCode >= 200) { + $statusColor = 'success'; + $statusIcon = Heroicon::CheckCircle; + } + } + @endphp + + +
+ + {{ $log['user'] }} + + + {{ ucfirst($log['action']) }} + + + {{ $statusCode ?? '—' }} + +
+
+ @endforeach +
+ @endif + + + View full log +
diff --git a/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php b/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php index b0195aa..5db4406 100644 --- a/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php +++ b/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php @@ -1,12 +1,18 @@ +@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; + + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); +@endphp + -
-
+ +
{{ $this->form }} -
- - {{ __('admin.guest_policy.actions.save') }} - -
+ + {{ __('admin.guest_policy.actions.save') }} +
-
+ diff --git a/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php b/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php index c68de33..63a4f22 100644 --- a/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php +++ b/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php @@ -1,22 +1,25 @@ @php -use Illuminate\Support\Facades\Storage; + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\Support\Facades\Storage; + use Illuminate\View\ComponentAttributeBag; + + $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); @endphp -
-
+ +
{{ $this->form }} -
- - Speichern - -
+ + Speichern +
- @if($asset) -
-

Aktuelles Basis-Wasserzeichen

- Watermark -
- @endif -
+ + + @if($asset) + + Watermark + + @endif diff --git a/resources/views/filament/widgets/dokploy-platform-health.blade.php b/resources/views/filament/widgets/dokploy-platform-health.blade.php index 7f0858d..64bceb8 100644 --- a/resources/views/filament/widgets/dokploy-platform-health.blade.php +++ b/resources/views/filament/widgets/dokploy-platform-health.blade.php @@ -1,54 +1,90 @@ +@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; + + $statusColors = [ + 'done' => 'success', + 'deploying' => 'warning', + 'pending' => 'warning', + 'unreachable' => 'danger', + 'error' => 'danger', + 'failed' => 'danger', + ]; + + $statusIcons = [ + 'done' => Heroicon::CheckCircle, + 'deploying' => Heroicon::ArrowPath, + 'pending' => Heroicon::Clock, + 'unreachable' => Heroicon::ExclamationTriangle, + 'error' => Heroicon::XCircle, + 'failed' => Heroicon::XCircle, + ]; + + $serviceColors = [ + 'running' => 'success', + 'done' => 'success', + 'starting' => 'warning', + 'deploying' => 'warning', + 'unhealthy' => 'danger', + 'error' => 'danger', + 'failed' => 'danger', + ]; + + $cardsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'lg' => 2]); + $serviceGrid = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); +@endphp + -
+
@forelse($composes as $compose) -
-
-
-

{{ $compose['label'] }}

-

{{ $compose['name'] ?? '–' }}

-

{{ $compose['compose_id'] }}

-
- $compose['status'] === 'done', - 'bg-amber-100 text-amber-800' => in_array($compose['status'], ['deploying', 'pending']), - 'bg-rose-100 text-rose-800' => in_array($compose['status'], ['unreachable', 'error', 'failed']), - 'bg-slate-100 text-slate-600' => ! in_array($compose['status'], ['done', 'deploying', 'pending', 'unreachable', 'error', 'failed']), - ])> + + + {{ ucfirst($compose['status']) }} - -
+ + + {{ $compose['compose_id'] }} + + @if(isset($compose['error'])) -

{{ $compose['error'] }}

+ + {{ $compose['error'] }} + @else -
-

Services

+
@forelse($compose['services'] as $service) -
- {{ $service['name'] }} - in_array($service['status'], ['running', 'done']), - 'bg-amber-200/70 text-amber-900' => in_array($service['status'], ['starting', 'deploying']), - 'bg-rose-200/70 text-rose-900' => in_array($service['status'], ['error', 'failed', 'unhealthy']), - 'bg-slate-200/70 text-slate-900' => ! in_array($service['status'], ['running', 'done', 'starting', 'deploying', 'error', 'failed', 'unhealthy']), - ])> - {{ strtoupper($service['status'] ?? 'N/A') }} - -
+ + {{ $service['name'] }}: {{ strtoupper($service['status'] ?? 'N/A') }} + @empty -

No services reported.

+ + No services reported. + @endforelse + + Last deploy: + {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }} +
-

- Last deploy: {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }} -

@endif -
+ @empty -

No Dokploy compose stacks configured.

+ @endforelse
diff --git a/resources/views/filament/widgets/integrations-health.blade.php b/resources/views/filament/widgets/integrations-health.blade.php index 9438eb6..95358f2 100644 --- a/resources/views/filament/widgets/integrations-health.blade.php +++ b/resources/views/filament/widgets/integrations-health.blade.php @@ -1,76 +1,90 @@ +@php + use Filament\Support\Enums\GridDirection; + use Filament\Support\Icons\Heroicon; + use Illuminate\View\ComponentAttributeBag; + + $statusColors = [ + 'healthy' => 'success', + 'pending' => 'warning', + 'degraded' => 'danger', + 'unconfigured' => 'danger', + 'unknown' => 'gray', + ]; + + $statusIcons = [ + 'healthy' => Heroicon::CheckCircle, + 'pending' => Heroicon::Clock, + 'degraded' => Heroicon::ExclamationTriangle, + 'unconfigured' => Heroicon::XCircle, + 'unknown' => Heroicon::QuestionMarkCircle, + ]; + + $cardsGrid = (new ComponentAttributeBag())->grid(['default' => 1, 'lg' => 2]); + $metricsGrid = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); +@endphp + -
+
@forelse($providers as $provider) -
-
-
-

{{ $provider['label'] }}

-

- {{ $provider['config_label'] }}: - - {{ $provider['is_configured'] ? __('admin.integrations_health.configured') : __('admin.integrations_health.unconfigured') }} - -

-
- $provider['status'] === 'healthy', - 'bg-amber-100 text-amber-800' => $provider['status'] === 'pending', - 'bg-rose-100 text-rose-800' => in_array($provider['status'], ['degraded', 'unconfigured']), - 'bg-slate-100 text-slate-600' => $provider['status'] === 'unknown', - ])> + + + + {{ $provider['is_configured'] ? __('admin.integrations_health.configured') : __('admin.integrations_health.unconfigured') }} + + {{ $provider['status_label'] }} - -
+ + -
-
- {{ __('admin.integrations_health.last_received') }} - - {{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }} - -
-
- {{ __('admin.integrations_health.last_processed') }} - - {{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }} - -
-
- {{ __('admin.integrations_health.processing_lag') }} - - {{ $provider['processing_lag']['label'] ?? '—' }} - -
-
- {{ __('admin.integrations_health.pending_events') }} - {{ number_format($provider['pending_count']) }} -
-
- {{ __('admin.integrations_health.recent_failures') }} - {{ number_format($provider['recent_failures']) }} -
-
- {{ __('admin.integrations_health.queue_backlog') }} - {{ number_format($provider['queue_backlog']) }} -
-
- {{ __('admin.integrations_health.failed_jobs') }} - {{ number_format($provider['failed_jobs']) }} -
+
+ + {{ __('admin.integrations_health.last_received') }}: + {{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }} + + + {{ __('admin.integrations_health.last_processed') }}: + {{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }} + + + {{ __('admin.integrations_health.processing_lag') }}: + {{ $provider['processing_lag']['label'] ?? '—' }} + + + {{ __('admin.integrations_health.pending_events') }}: + {{ number_format($provider['pending_count']) }} + + + {{ __('admin.integrations_health.recent_failures') }}: + {{ number_format($provider['recent_failures']) }} + + + {{ __('admin.integrations_health.queue_backlog') }}: + {{ number_format($provider['queue_backlog']) }} + + + {{ __('admin.integrations_health.failed_jobs') }}: + {{ number_format($provider['failed_jobs']) }} + + @if(! empty(data_get($provider, 'last_failed.error_message'))) + + {{ __('admin.integrations_health.last_error') }}: + {{ data_get($provider, 'last_failed.error_message') }} + + @endif
- - @if(! empty(data_get($provider, 'last_failed.error_message'))) -
- {{ __('admin.integrations_health.last_error') }}: {{ data_get($provider, 'last_failed.error_message') }} -
- @endif -
+ @empty -

- {{ __('admin.integrations_health.empty') }} -

+ @endforelse
diff --git a/tests/Unit/SuperAdminNavigationGroupsTest.php b/tests/Unit/SuperAdminNavigationGroupsTest.php index 4e33332..c523dba 100644 --- a/tests/Unit/SuperAdminNavigationGroupsTest.php +++ b/tests/Unit/SuperAdminNavigationGroupsTest.php @@ -44,6 +44,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure', \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => 'admin.nav.infrastructure', \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => 'admin.nav.infrastructure', + \App\Filament\Clusters\DailyOps\Pages\JoinTokenAnalyticsDashboard::class => 'admin.nav.security', ]; foreach ($expectations as $resourceClass => $key) { @@ -59,6 +60,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class, \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class, \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => DailyOpsCluster::class, + \App\Filament\Clusters\DailyOps\Pages\JoinTokenAnalyticsDashboard::class => DailyOpsCluster::class, \App\Filament\Resources\TaskResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,