Add join token analytics dashboard and align Filament views
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-04 18:21:59 +01:00
parent 48b1cfde09
commit 15e19d4e8b
17 changed files with 1176 additions and 310 deletions

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Clusters\DailyOps\Pages;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Widgets\JoinTokenOverviewWidget;
use App\Filament\Widgets\JoinTokenTopTokensWidget;
use App\Filament\Widgets\JoinTokenTrendWidget;
use App\Models\Event;
use BackedEnum;
use Filament\Forms\Components\Select;
use Filament\Pages\Dashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use UnitEnum;
class JoinTokenAnalyticsDashboard extends Dashboard
{
use HasFiltersForm;
protected static ?string $cluster = DailyOpsCluster::class;
protected static string $routePath = 'join-token-analytics';
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-chart-bar';
protected static null|string|UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 12;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.security');
}
public static function getNavigationLabel(): string
{
return __('admin.join_token_analytics.navigation.label');
}
public function getHeading(): string
{
return __('admin.join_token_analytics.heading');
}
public function getSubheading(): ?string
{
return __('admin.join_token_analytics.subheading');
}
public function getColumns(): int|array
{
return 1;
}
public function getWidgets(): array
{
return [
JoinTokenOverviewWidget::class,
JoinTokenTrendWidget::class,
JoinTokenTopTokensWidget::class,
];
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->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})";
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Event;
use App\Models\EventJoinTokenEvent;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenOverviewWidget extends StatsOverviewWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full';
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',
];
protected function getStats(): array
{
$filters = $this->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];
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\EventResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use Filament\Tables;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenTopTokensWidget extends BaseWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 3;
protected int|string|array $columnSpan = 'full';
protected static ?string $heading = null;
public function getHeading(): ?string
{
return __('admin.join_token_analytics.table.heading');
}
public function table(Tables\Table $table): Tables\Table
{
$filters = $this->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',
];
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Event;
use App\Models\EventJoinTokenEvent;
use Carbon\CarbonPeriod;
use Filament\Widgets\ChartWidget;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenTrendWidget extends ChartWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
protected function getData(): array
{
$filters = $this->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',
];
}

View File

@@ -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;

View File

@@ -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