Add join token analytics dashboard and align Filament views
This commit is contained in:
@@ -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})";
|
||||
}
|
||||
}
|
||||
147
app/Filament/Widgets/JoinTokenOverviewWidget.php
Normal file
147
app/Filament/Widgets/JoinTokenOverviewWidget.php
Normal 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];
|
||||
}
|
||||
}
|
||||
191
app/Filament/Widgets/JoinTokenTopTokensWidget.php
Normal file
191
app/Filament/Widgets/JoinTokenTopTokensWidget.php
Normal 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',
|
||||
];
|
||||
}
|
||||
178
app/Filament/Widgets/JoinTokenTrendWidget.php
Normal file
178
app/Filament/Widgets/JoinTokenTrendWidget.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->content }}
|
||||
</x-filament-panels::page>
|
||||
@@ -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">
|
||||
@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"
|
||||
color="warning"
|
||||
size="sm"
|
||||
:icon="Heroicon::ArrowTopRightOnSquare"
|
||||
>
|
||||
{{ __('admin.events.join_link.open_admin') }}
|
||||
</x-filament::button>
|
||||
</x-slot>
|
||||
|
||||
<x-filament::badge color="warning" :icon="Heroicon::ExclamationTriangle">
|
||||
{{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
{{ __('admin.events.join_link.open_admin') }}
|
||||
</a>
|
||||
</div>
|
||||
</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', [
|
||||
'usage' => $token['usage_count'],
|
||||
'limit' => $token['usage_limit'] ?? '∞',
|
||||
]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@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">
|
||||
{{ __('admin.events.join_link.token_active') }}
|
||||
</span>
|
||||
@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">
|
||||
{{ __('admin.events.join_link.token_inactive') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<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'] ?? '∞',
|
||||
])"
|
||||
>
|
||||
<x-slot name="afterHeader">
|
||||
@if ($token['is_active'])
|
||||
<x-filament::badge color="success" :icon="Heroicon::CheckCircle">
|
||||
{{ __('admin.events.join_link.token_active') }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" :icon="Heroicon::PauseCircle">
|
||||
{{ __('admin.events.join_link.token_inactive') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</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) }}
|
||||
</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) }}
|
||||
</x-filament::badge>
|
||||
@if (!empty($analytics['last_seen_at']))
|
||||
<x-filament::badge color="gray" :icon="Heroicon::Eye">
|
||||
{{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</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">
|
||||
{{ number_format($analytics['recent_24h'] ?? 0) }}
|
||||
</div>
|
||||
@if (!empty($analytics['last_seen_at']))
|
||||
<div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }}
|
||||
</div>
|
||||
@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
|
||||
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"
|
||||
>
|
||||
{{ __('admin.events.join_link.layouts_fallback') }}
|
||||
</a>
|
||||
</div>
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="{{ $token['layouts_url'] }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="warning"
|
||||
size="xs"
|
||||
:icon="Heroicon::QrCode"
|
||||
>
|
||||
{{ __('admin.events.join_link.layouts_fallback') }}
|
||||
</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
|
||||
|
||||
@@ -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>
|
||||
{{ $this->form }}
|
||||
<div class="mt-4 flex justify-end">
|
||||
<x-filament::button wire:click="save" color="primary">
|
||||
<x-filament::section :description="__('filament-watermark.description')" :icon="Heroicon::Photo">
|
||||
<div {{ $stacked }}>
|
||||
{{ $this->form }}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
</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::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::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>
|
||||
|
||||
@@ -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">
|
||||
{{ __('admin.guest_policy.actions.save') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
|
||||
{{ __('admin.guest_policy.actions.save') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -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">
|
||||
Speichern
|
||||
</x-filament::button>
|
||||
</div>
|
||||
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
|
||||
Speichern
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@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>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if($asset)
|
||||
<x-filament::section heading="Aktuelles Basis-Wasserzeichen" :icon="Heroicon::Photo">
|
||||
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" />
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</x-filament::page>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}">
|
||||
{{ $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::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') }}
|
||||
</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">
|
||||
{{ 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">
|
||||
{{ 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">
|
||||
{{ $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 {{ $metricsGrid }}>
|
||||
<x-filament::badge color="gray" :icon="Heroicon::Clock">
|
||||
{{ __('admin.integrations_health.last_received') }}:
|
||||
{{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }}
|
||||
</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() ?? '—' }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" :icon="Heroicon::Clock">
|
||||
{{ __('admin.integrations_health.processing_lag') }}:
|
||||
{{ $provider['processing_lag']['label'] ?? '—' }}
|
||||
</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')))
|
||||
<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>
|
||||
|
||||
@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>
|
||||
@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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user