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

View File

@@ -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' => [

View File

@@ -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' => [

View File

@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->content }}
</x-filament-panels::page>

View File

@@ -1,70 +1,81 @@
<div class="space-y-5">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/60 dark:bg-amber-500/10 dark:text-amber-100">
<div class="flex flex-col gap-1">
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{{ __('admin.events.join_link.event_label') }}</div>
<div class="text-base font-semibold text-amber-900 dark:text-amber-100">{{ $event->name }}</div>
</div>
<p class="mt-3 text-xs leading-relaxed text-amber-700 dark:text-amber-200">
{{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }}
</p>
<a
@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
<div {{ $stacked }}>
<x-filament::section
:heading="__('admin.events.join_link.event_label')"
:description="$event->name"
:icon="Heroicon::InformationCircle"
>
<x-slot name="afterHeader">
<x-filament::button
tag="a"
href="{{ url('/event-admin/events/' . $event->slug) }}"
target="_blank"
rel="noreferrer"
class="mt-3 inline-flex items-center gap-2 rounded bg-amber-600 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:hover:bg-amber-500"
color="warning"
size="sm"
:icon="Heroicon::ArrowTopRightOnSquare"
>
{{ __('admin.events.join_link.open_admin') }}
</a>
</div>
</x-filament::button>
</x-slot>
<x-filament::badge color="warning" :icon="Heroicon::ExclamationTriangle">
{{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }}
</x-filament::badge>
</x-filament::section>
@if ($tokens->isEmpty())
<div class="rounded border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/60 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('admin.events.join_link.no_tokens') }}
</div>
<x-filament::empty-state
:heading="__('admin.events.join_link.no_tokens')"
:icon="Heroicon::Key"
/>
@else
<div class="space-y-4">
<div {{ $tokensGrid }}>
@foreach ($tokens as $token)
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-800 dark:text-slate-100">
{{ $token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']]) }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.token_usage', [
<x-filament::card
:heading="$token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']])"
:description="__('admin.events.join_link.token_usage', [
'usage' => $token['usage_count'],
'limit' => $token['usage_limit'] ?? '∞',
]) }}
</div>
</div>
<div>
])"
>
<x-slot name="afterHeader">
@if ($token['is_active'])
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200">
<x-filament::badge color="success" :icon="Heroicon::CheckCircle">
{{ __('admin.events.join_link.token_active') }}
</span>
</x-filament::badge>
@else
<span class="rounded-full bg-slate-200 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
<x-filament::badge color="gray" :icon="Heroicon::PauseCircle">
{{ __('admin.events.join_link.token_inactive') }}
</span>
</x-filament::badge>
@endif
</div>
</div>
</x-slot>
<div class="mt-3 space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.link_label') }}
</div>
<div class="flex flex-wrap items-center gap-3">
<code class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-100">
{{ $token['url'] }}
</code>
<button
<div {{ $stacked }}>
<x-filament::badge color="gray" :icon="Heroicon::Link">
{{ __('admin.events.join_link.link_label') }}: {{ $token['url'] }}
</x-filament::badge>
<div {{ $linkGrid }}>
<x-filament::button
x-data
@click.prevent="navigator.clipboard.writeText('{{ $token['url'] }}')"
class="rounded border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
color="gray"
size="xs"
:icon="Heroicon::ClipboardDocument"
>
{{ __('admin.events.join_link.copy_link') }}
</button>
</x-filament::button>
</div>
</div>
@@ -73,90 +84,88 @@
@endphp
@if (!empty($analytics))
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.success_total') }}</div>
<div class="mt-1 text-lg font-semibold">
<div {{ $metricsGrid }}>
<x-filament::card compact>
<x-filament::badge color="success" :icon="Heroicon::CheckCircle">
{{ __('admin.events.analytics.success_total') }}:
{{ number_format($analytics['success_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.failure_total') }}</div>
<div class="mt-1 text-lg font-semibold">
</x-filament::badge>
</x-filament::card>
<x-filament::card compact>
<x-filament::badge color="danger" :icon="Heroicon::XCircle">
{{ __('admin.events.analytics.failure_total') }}:
{{ number_format($analytics['failure_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.rate_limited_total') }}</div>
<div class="mt-1 text-lg font-semibold">
</x-filament::badge>
</x-filament::card>
<x-filament::card compact>
<x-filament::badge color="warning" :icon="Heroicon::ExclamationTriangle">
{{ __('admin.events.analytics.rate_limited_total') }}:
{{ number_format($analytics['rate_limited_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.recent_24h') }}</div>
<div class="mt-1 text-lg font-semibold">
</x-filament::badge>
</x-filament::card>
<x-filament::card compact>
<div {{ $stacked }}>
<x-filament::badge color="gray" :icon="Heroicon::Clock">
{{ __('admin.events.analytics.recent_24h') }}:
{{ number_format($analytics['recent_24h'] ?? 0) }}
</div>
</x-filament::badge>
@if (!empty($analytics['last_seen_at']))
<div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
<x-filament::badge color="gray" :icon="Heroicon::Eye">
{{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }}
</div>
</x-filament::badge>
@endif
</div>
</x-filament::card>
</div>
@endif
@if (!empty($token['layouts']))
<div class="mt-4 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.layouts_heading') }}
</div>
<div class="grid gap-3 md:grid-cols-2">
<x-filament::section :heading="__('admin.events.join_link.layouts_heading')" :icon="Heroicon::QrCode">
<div {{ $layoutsGrid }}>
@foreach ($token['layouts'] as $layout)
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
<div class="font-semibold text-slate-900 dark:text-slate-100">
{{ $layout['name'] }}
</div>
@if (!empty($layout['subtitle']))
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{{ $layout['subtitle'] }}
</div>
@endif
<div class="mt-2 flex flex-wrap gap-2">
<x-filament::card
:heading="$layout['name']"
:description="$layout['subtitle'] ?? null"
>
<div {{ $stacked }}>
@foreach ($layout['download_urls'] as $format => $href)
<a
<x-filament::button
tag="a"
href="{{ $href }}"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 rounded border border-amber-300 bg-amber-100 px-2 py-1 text-[11px] font-medium text-amber-800 transition hover:bg-amber-200 dark:border-amber-500/50 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/20"
color="warning"
size="xs"
:icon="Heroicon::ArrowDownTray"
>
{{ strtoupper($format) }}
</a>
</x-filament::button>
@endforeach
</div>
</div>
</x-filament::card>
@endforeach
</div>
</div>
</x-filament::section>
@elseif(!empty($token['layouts_url']))
<div class="mt-4">
<a
<x-filament::button
tag="a"
href="{{ $token['layouts_url'] }}"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 underline decoration-dotted hover:text-amber-800 dark:text-amber-300"
color="warning"
size="xs"
:icon="Heroicon::QrCode"
>
{{ __('admin.events.join_link.layouts_fallback') }}
</a>
</div>
</x-filament::button>
@endif
@if ($token['expires_at'])
<div class="mt-4 text-xs text-slate-500 dark:text-slate-400">
<x-filament::badge color="gray" :icon="Heroicon::Clock">
{{ __('admin.events.join_link.token_expiry', ['date' => \Carbon\Carbon::parse($token['expires_at'])->isoFormat('LLL')]) }}
</div>
</x-filament::badge>
@endif
</div>
</x-filament::card>
@endforeach
</div>
@endif

View File

@@ -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
<x-filament::page>
<div class="space-y-6">
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ __('filament-watermark.description') }}
</div>
<x-filament::section :description="__('filament-watermark.description')" :icon="Heroicon::Photo">
<div {{ $stacked }}>
{{ $this->form }}
<div class="mt-4 flex justify-end">
<x-filament::button wire:click="save" color="primary">
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
{{ __('filament-watermark.save') }}
</x-filament::button>
</div>
</div>
</x-filament::section>
</x-filament::page>

View File

@@ -1,20 +1,48 @@
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section heading="Compose Controls">
<div class="grid gap-4 md:grid-cols-2">
@foreach($composes as $compose)
<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 items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $compose['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['compose_id'] }}</p>
</div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100">
{{ ucfirst($compose['status'] ?? 'unknown') }}
</span>
</div>
@php
use Filament\Support\Enums\GridDirection;
use Filament\Support\Icons\Heroicon;
use Illuminate\View\ComponentAttributeBag;
<div class="mt-4 flex flex-wrap gap-2">
$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
<x-filament-panels::page>
<div {{ $stacked }}>
<x-filament::section heading="Compose Controls" :icon="Heroicon::RocketLaunch">
<div {{ $composeGrid }}>
@forelse($composes as $compose)
<x-filament::card :heading="$compose['label']" :description="$compose['compose_id']">
<x-slot name="afterHeader">
<x-filament::badge
:color="$composeStatusColors[$compose['status']] ?? 'gray'"
:icon="$composeStatusIcons[$compose['status']] ?? Heroicon::QuestionMarkCircle"
>
{{ ucfirst($compose['status'] ?? 'unknown') }}
</x-filament::badge>
</x-slot>
<div {{ $actionsGrid }}>
<x-filament::button size="sm" color="warning" wire:click="redeploy('{{ $compose['compose_id'] }}')">
Redeploy
</x-filament::button>
@@ -22,48 +50,79 @@
Stop
</x-filament::button>
@if($dokployWebUrl)
<x-filament::button tag="a" size="sm" color="gray" href="{{ rtrim($dokployWebUrl, '/') }}" target="_blank">
<x-filament::button
tag="a"
size="sm"
color="gray"
href="{{ rtrim($dokployWebUrl, '/') }}"
target="_blank"
:icon="Heroicon::ArrowTopRightOnSquare"
>
Open in Dokploy
</x-filament::button>
@endif
</div>
</div>
@endforeach
</x-filament::card>
@empty
<x-filament::empty-state heading="No compose stacks configured." :icon="Heroicon::QuestionMarkCircle" />
@endforelse
</div>
</x-filament::section>
<x-filament::section heading="Recent Actions">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
<th class="px-3 py-2">When</th>
<th class="px-3 py-2">User</th>
<th class="px-3 py-2">Target</th>
<th class="px-3 py-2">Action</th>
<th class="px-3 py-2">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse($recentLogs as $log)
<tr>
<td class="px-3 py-2 text-slate-700 dark:text-slate-200">{{ $log['created_at'] }}</td>
<td class="px-3 py-2 text-slate-600">{{ $log['user'] }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ $log['service_id'] }}</td>
<td class="px-3 py-2">{{ ucfirst($log['action']) }}</td>
<td class="px-3 py-2">{{ $log['status_code'] ?? '—' }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-3 py-4 text-center text-slate-500">No actions recorded yet.</td>
</tr>
@endforelse
</tbody>
</table>
<x-filament::section heading="Recent Actions" :icon="Heroicon::Clock">
@if(empty($recentLogs))
<x-filament::empty-state heading="No actions recorded yet." :icon="Heroicon::Clock" />
@else
<div {{ $logsGrid }}>
@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
<x-filament::card :heading="$log['service_id']" :description="$log['created_at']">
<div {{ $stacked }}>
<x-filament::badge color="gray" :icon="Heroicon::User">
{{ $log['user'] }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::CommandLine">
{{ ucfirst($log['action']) }}
</x-filament::badge>
<x-filament::badge :color="$statusColor" :icon="$statusIcon">
{{ $statusCode ?? '—' }}
</x-filament::badge>
</div>
<x-filament::link href="{{ route('filament.superadmin.resources.infrastructure-action-logs.index') }}" class="mt-3 inline-flex text-sm text-primary-600">
View full log
</x-filament::link>
</x-filament::card>
@endforeach
</div>
@endif
<x-filament::button
tag="a"
href="{{ route('filament.superadmin.resources.infrastructure-action-logs.index') }}"
color="gray"
size="sm"
:icon="Heroicon::ArrowTopRightOnSquare"
>
View full log
</x-filament::button>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@@ -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
<x-filament-panels::page>
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm">
<x-filament::section :heading="__('admin.guest_policy.navigation.label')" :icon="Heroicon::AdjustmentsHorizontal">
<div {{ $stacked }}>
{{ $this->form }}
<div class="mt-4 flex justify-end">
<x-filament::button wire:click="save" color="primary">
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
{{ __('admin.guest_policy.actions.save') }}
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@@ -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
<x-filament::page>
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm">
<x-filament::section :icon="Heroicon::Photo">
<div {{ $stacked }}>
{{ $this->form }}
<div class="mt-4 flex justify-end">
<x-filament::button wire:click="save" color="primary">
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
Speichern
</x-filament::button>
</div>
</div>
</x-filament::section>
@if($asset)
<div class="rounded-xl bg-white p-4 shadow-sm">
<p class="text-sm font-medium mb-2">Aktuelles Basis-Wasserzeichen</p>
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" class="max-h-32 object-contain">
</div>
<x-filament::section heading="Aktuelles Basis-Wasserzeichen" :icon="Heroicon::Photo">
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" />
</x-filament::section>
@endif
</div>
</x-filament::page>

View File

@@ -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
<x-filament-widgets::widget>
<x-filament::section heading="Infra Status (Dokploy)">
<div class="grid gap-4 md:grid-cols-2">
<div {{ $cardsGrid }}>
@forelse($composes as $compose)
<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 items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $compose['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['name'] ?? '' }}</p>
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $compose['compose_id'] }}</p>
</div>
<span @class([
'rounded-full px-3 py-1 text-xs font-semibold',
'bg-emerald-100 text-emerald-800' => $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']),
])>
<x-filament::card
:heading="$compose['label']"
:description="$compose['name'] ?? ''"
>
<x-slot name="afterHeader">
<x-filament::badge
:color="$statusColors[$compose['status']] ?? 'gray'"
:icon="$statusIcons[$compose['status']] ?? Heroicon::QuestionMarkCircle"
>
{{ ucfirst($compose['status']) }}
</span>
</div>
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::Identification">
{{ $compose['compose_id'] }}
</x-filament::badge>
</x-slot>
@if(isset($compose['error']))
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $compose['error'] }}</p>
<x-filament::badge color="danger" :icon="Heroicon::ExclamationTriangle">
{{ $compose['error'] }}
</x-filament::badge>
@else
<div class="mt-3 space-y-1">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400">Services</p>
<div {{ $serviceGrid }}>
@forelse($compose['services'] as $service)
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
<span>{{ $service['name'] }}</span>
<span @class([
'rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide',
'bg-emerald-200/70 text-emerald-900' => 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') }}
</span>
</div>
<x-filament::badge
:color="$serviceColors[$service['status']] ?? 'gray'"
:icon="array_key_exists($service['status'] ?? '', $serviceColors) ? Heroicon::Server : Heroicon::QuestionMarkCircle"
>
{{ $service['name'] }}: {{ strtoupper($service['status'] ?? 'N/A') }}
</x-filament::badge>
@empty
<p class="text-xs text-slate-500 dark:text-slate-400">No services reported.</p>
<x-filament::badge color="gray" :icon="Heroicon::QuestionMarkCircle">
No services reported.
</x-filament::badge>
@endforelse
<x-filament::badge color="gray" :icon="Heroicon::Clock">
Last deploy:
{{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }}
</x-filament::badge>
</div>
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
Last deploy: {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }}
</p>
@endif
</div>
</x-filament::card>
@empty
<p class="text-sm text-slate-500 dark:text-slate-300">No Dokploy compose stacks configured.</p>
<x-filament::empty-state
heading="No Dokploy compose stacks configured."
:icon="Heroicon::QuestionMarkCircle"
/>
@endforelse
</div>
</x-filament::section>

View File

@@ -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
<x-filament-widgets::widget>
<x-filament::section heading="{{ __('admin.integrations_health.heading') }}">
<div class="grid gap-4 md:grid-cols-2">
<div {{ $cardsGrid }}>
@forelse($providers as $provider)
<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 items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-100">{{ $provider['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ $provider['config_label'] }}:
<span class="font-semibold {{ $provider['is_configured'] ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400' }}">
<x-filament::card
:heading="$provider['label']"
:description="$provider['config_label']"
>
<x-slot name="afterHeader">
<x-filament::badge :color="$provider['is_configured'] ? 'success' : 'danger'">
{{ $provider['is_configured'] ? __('admin.integrations_health.configured') : __('admin.integrations_health.unconfigured') }}
</span>
</p>
</div>
<span @class([
'rounded-full px-3 py-1 text-xs font-semibold',
'bg-emerald-100 text-emerald-800' => $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',
])>
</x-filament::badge>
<x-filament::badge
:color="$statusColors[$provider['status']] ?? 'gray'"
:icon="$statusIcons[$provider['status']] ?? Heroicon::QuestionMarkCircle"
>
{{ $provider['status_label'] }}
</span>
</div>
</x-filament::badge>
</x-slot>
<div class="mt-4 grid gap-2 text-xs text-slate-600 dark:text-slate-300">
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.last_received') }}</span>
<span class="font-semibold">
<div {{ $metricsGrid }}>
<x-filament::badge color="gray" :icon="Heroicon::Clock">
{{ __('admin.integrations_health.last_received') }}:
{{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.last_processed') }}</span>
<span class="font-semibold">
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::ArrowPath">
{{ __('admin.integrations_health.last_processed') }}:
{{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.processing_lag') }}</span>
<span class="font-semibold">
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::Clock">
{{ __('admin.integrations_health.processing_lag') }}:
{{ $provider['processing_lag']['label'] ?? '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.pending_events') }}</span>
<span class="font-semibold">{{ number_format($provider['pending_count']) }}</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.recent_failures') }}</span>
<span class="font-semibold">{{ number_format($provider['recent_failures']) }}</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.queue_backlog') }}</span>
<span class="font-semibold">{{ number_format($provider['queue_backlog']) }}</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.failed_jobs') }}</span>
<span class="font-semibold">{{ number_format($provider['failed_jobs']) }}</span>
</div>
</div>
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::QueueList">
{{ __('admin.integrations_health.pending_events') }}:
{{ number_format($provider['pending_count']) }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::ExclamationTriangle">
{{ __('admin.integrations_health.recent_failures') }}:
{{ number_format($provider['recent_failures']) }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::RectangleStack">
{{ __('admin.integrations_health.queue_backlog') }}:
{{ number_format($provider['queue_backlog']) }}
</x-filament::badge>
<x-filament::badge color="gray" :icon="Heroicon::XCircle">
{{ __('admin.integrations_health.failed_jobs') }}:
{{ number_format($provider['failed_jobs']) }}
</x-filament::badge>
@if(! empty(data_get($provider, 'last_failed.error_message')))
<div class="mt-3 rounded-lg bg-rose-50 px-3 py-2 text-xs text-rose-700 dark:bg-rose-900/30 dark:text-rose-200">
{{ __('admin.integrations_health.last_error') }}: {{ data_get($provider, 'last_failed.error_message') }}
</div>
<x-filament::badge color="danger" :icon="Heroicon::ExclamationTriangle">
{{ __('admin.integrations_health.last_error') }}:
{{ data_get($provider, 'last_failed.error_message') }}
</x-filament::badge>
@endif
</div>
</x-filament::card>
@empty
<p class="text-sm text-slate-500 dark:text-slate-300">
{{ __('admin.integrations_health.empty') }}
</p>
<x-filament::empty-state
:heading="__('admin.integrations_health.empty')"
:icon="Heroicon::QuestionMarkCircle"
/>
@endforelse
</div>
</x-filament::section>

View File

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