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\Auth\Middleware\RedirectIfAuthenticated as BaseMiddleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class RedirectIfAuthenticated extends BaseMiddleware
|
class RedirectIfAuthenticated extends BaseMiddleware
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* 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;
|
$guards = $guards === [] ? [null] : $guards;
|
||||||
|
|
||||||
|
|||||||
@@ -108,13 +108,6 @@ class SuperAdminPanelProvider extends PanelProvider
|
|||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
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');
|
->authGuard('super_admin');
|
||||||
|
|
||||||
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
||||||
|
|||||||
@@ -303,6 +303,52 @@ return [
|
|||||||
'unavailable' => 'Nicht verfügbar',
|
'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' => [
|
'events' => [
|
||||||
'fields' => [
|
'fields' => [
|
||||||
|
|||||||
@@ -303,6 +303,52 @@ return [
|
|||||||
'unavailable' => 'Unavailable',
|
'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' => [
|
'events' => [
|
||||||
'fields' => [
|
'fields' => [
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->content }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -1,70 +1,81 @@
|
|||||||
<div class="space-y-5">
|
@php
|
||||||
<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">
|
use Filament\Support\Enums\GridDirection;
|
||||||
<div class="flex flex-col gap-1">
|
use Filament\Support\Icons\Heroicon;
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{{ __('admin.events.join_link.event_label') }}</div>
|
use Illuminate\View\ComponentAttributeBag;
|
||||||
<div class="text-base font-semibold text-amber-900 dark:text-amber-100">{{ $event->name }}</div>
|
|
||||||
</div>
|
$stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column);
|
||||||
<p class="mt-3 text-xs leading-relaxed text-amber-700 dark:text-amber-200">
|
$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]) }}
|
{{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }}
|
||||||
</p>
|
</x-filament::badge>
|
||||||
<a
|
</x-filament::section>
|
||||||
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>
|
|
||||||
|
|
||||||
@if ($tokens->isEmpty())
|
@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">
|
<x-filament::empty-state
|
||||||
{{ __('admin.events.join_link.no_tokens') }}
|
:heading="__('admin.events.join_link.no_tokens')"
|
||||||
</div>
|
:icon="Heroicon::Key"
|
||||||
|
/>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-4">
|
<div {{ $tokensGrid }}>
|
||||||
@foreach ($tokens as $token)
|
@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">
|
<x-filament::card
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
:heading="$token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']])"
|
||||||
<div>
|
:description="__('admin.events.join_link.token_usage', [
|
||||||
<div class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
'usage' => $token['usage_count'],
|
||||||
{{ $token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']]) }}
|
'limit' => $token['usage_limit'] ?? '∞',
|
||||||
</div>
|
])"
|
||||||
<div class="text-xs text-slate-500 dark:text-slate-400">
|
>
|
||||||
{{ __('admin.events.join_link.token_usage', [
|
<x-slot name="afterHeader">
|
||||||
'usage' => $token['usage_count'],
|
@if ($token['is_active'])
|
||||||
'limit' => $token['usage_limit'] ?? '∞',
|
<x-filament::badge color="success" :icon="Heroicon::CheckCircle">
|
||||||
]) }}
|
{{ __('admin.events.join_link.token_active') }}
|
||||||
</div>
|
</x-filament::badge>
|
||||||
</div>
|
@else
|
||||||
<div>
|
<x-filament::badge color="gray" :icon="Heroicon::PauseCircle">
|
||||||
@if ($token['is_active'])
|
{{ __('admin.events.join_link.token_inactive') }}
|
||||||
<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>
|
||||||
{{ __('admin.events.join_link.token_active') }}
|
@endif
|
||||||
</span>
|
</x-slot>
|
||||||
@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>
|
|
||||||
|
|
||||||
<div class="mt-3 space-y-2">
|
<div {{ $stacked }}>
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
<x-filament::badge color="gray" :icon="Heroicon::Link">
|
||||||
{{ __('admin.events.join_link.link_label') }}
|
{{ __('admin.events.join_link.link_label') }}: {{ $token['url'] }}
|
||||||
</div>
|
</x-filament::badge>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div {{ $linkGrid }}>
|
||||||
<code class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-100">
|
<x-filament::button
|
||||||
{{ $token['url'] }}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
x-data
|
x-data
|
||||||
@click.prevent="navigator.clipboard.writeText('{{ $token['url'] }}')"
|
@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') }}
|
{{ __('admin.events.join_link.copy_link') }}
|
||||||
</button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,90 +84,88 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (!empty($analytics))
|
@if (!empty($analytics))
|
||||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div {{ $metricsGrid }}>
|
||||||
<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">
|
<x-filament::card compact>
|
||||||
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.success_total') }}</div>
|
<x-filament::badge color="success" :icon="Heroicon::CheckCircle">
|
||||||
<div class="mt-1 text-lg font-semibold">
|
{{ __('admin.events.analytics.success_total') }}:
|
||||||
{{ number_format($analytics['success_total'] ?? 0) }}
|
{{ number_format($analytics['success_total'] ?? 0) }}
|
||||||
</div>
|
</x-filament::badge>
|
||||||
</div>
|
</x-filament::card>
|
||||||
<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">
|
<x-filament::card compact>
|
||||||
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.failure_total') }}</div>
|
<x-filament::badge color="danger" :icon="Heroicon::XCircle">
|
||||||
<div class="mt-1 text-lg font-semibold">
|
{{ __('admin.events.analytics.failure_total') }}:
|
||||||
{{ number_format($analytics['failure_total'] ?? 0) }}
|
{{ number_format($analytics['failure_total'] ?? 0) }}
|
||||||
</div>
|
</x-filament::badge>
|
||||||
</div>
|
</x-filament::card>
|
||||||
<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">
|
<x-filament::card compact>
|
||||||
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.rate_limited_total') }}</div>
|
<x-filament::badge color="warning" :icon="Heroicon::ExclamationTriangle">
|
||||||
<div class="mt-1 text-lg font-semibold">
|
{{ __('admin.events.analytics.rate_limited_total') }}:
|
||||||
{{ number_format($analytics['rate_limited_total'] ?? 0) }}
|
{{ 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>
|
</x-filament::card>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (!empty($token['layouts']))
|
@if (!empty($token['layouts']))
|
||||||
<div class="mt-4 space-y-3">
|
<x-filament::section :heading="__('admin.events.join_link.layouts_heading')" :icon="Heroicon::QrCode">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
<div {{ $layoutsGrid }}>
|
||||||
{{ __('admin.events.join_link.layouts_heading') }}
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
|
||||||
@foreach ($token['layouts'] as $layout)
|
@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">
|
<x-filament::card
|
||||||
<div class="font-semibold text-slate-900 dark:text-slate-100">
|
:heading="$layout['name']"
|
||||||
{{ $layout['name'] }}
|
:description="$layout['subtitle'] ?? null"
|
||||||
</div>
|
>
|
||||||
@if (!empty($layout['subtitle']))
|
<div {{ $stacked }}>
|
||||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">
|
|
||||||
{{ $layout['subtitle'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
|
||||||
@foreach ($layout['download_urls'] as $format => $href)
|
@foreach ($layout['download_urls'] as $format => $href)
|
||||||
<a
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
href="{{ $href }}"
|
href="{{ $href }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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) }}
|
{{ strtoupper($format) }}
|
||||||
</a>
|
</x-filament::button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::card>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
@elseif(!empty($token['layouts_url']))
|
@elseif(!empty($token['layouts_url']))
|
||||||
<div class="mt-4">
|
<x-filament::button
|
||||||
<a
|
tag="a"
|
||||||
href="{{ $token['layouts_url'] }}"
|
href="{{ $token['layouts_url'] }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
||||||
{{ __('admin.events.join_link.layouts_fallback') }}
|
:icon="Heroicon::QrCode"
|
||||||
</a>
|
>
|
||||||
</div>
|
{{ __('admin.events.join_link.layouts_fallback') }}
|
||||||
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($token['expires_at'])
|
@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')]) }}
|
{{ __('admin.events.join_link.token_expiry', ['date' => \Carbon\Carbon::parse($token['expires_at'])->isoFormat('LLL')]) }}
|
||||||
</div>
|
</x-filament::badge>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-filament::card>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
<x-filament::page>
|
||||||
<div class="space-y-6">
|
<x-filament::section :description="__('filament-watermark.description')" :icon="Heroicon::Photo">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div {{ $stacked }}>
|
||||||
{{ __('filament-watermark.description') }}
|
{{ $this->form }}
|
||||||
</div>
|
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
|
||||||
{{ $this->form }}
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<x-filament::button wire:click="save" color="primary">
|
|
||||||
{{ __('filament-watermark.save') }}
|
{{ __('filament-watermark.save') }}
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
</x-filament::page>
|
</x-filament::page>
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
<x-filament-panels::page>
|
@php
|
||||||
<div class="space-y-6">
|
use Filament\Support\Enums\GridDirection;
|
||||||
<x-filament::section heading="Compose Controls">
|
use Filament\Support\Icons\Heroicon;
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
use Illuminate\View\ComponentAttributeBag;
|
||||||
@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>
|
|
||||||
|
|
||||||
<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'] }}')">
|
<x-filament::button size="sm" color="warning" wire:click="redeploy('{{ $compose['compose_id'] }}')">
|
||||||
Redeploy
|
Redeploy
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@@ -22,48 +50,79 @@
|
|||||||
Stop
|
Stop
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@if($dokployWebUrl)
|
@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
|
Open in Dokploy
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::card>
|
||||||
@endforeach
|
@empty
|
||||||
|
<x-filament::empty-state heading="No compose stacks configured." :icon="Heroicon::QuestionMarkCircle" />
|
||||||
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
<x-filament::section heading="Recent Actions">
|
<x-filament::section heading="Recent Actions" :icon="Heroicon::Clock">
|
||||||
<div class="overflow-x-auto">
|
@if(empty($recentLogs))
|
||||||
<table class="min-w-full text-sm">
|
<x-filament::empty-state heading="No actions recorded yet." :icon="Heroicon::Clock" />
|
||||||
<thead>
|
@else
|
||||||
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
|
<div {{ $logsGrid }}>
|
||||||
<th class="px-3 py-2">When</th>
|
@foreach($recentLogs as $log)
|
||||||
<th class="px-3 py-2">User</th>
|
@php
|
||||||
<th class="px-3 py-2">Target</th>
|
$statusCode = $log['status_code'] ?? null;
|
||||||
<th class="px-3 py-2">Action</th>
|
$statusColor = 'gray';
|
||||||
<th class="px-3 py-2">Status</th>
|
$statusIcon = Heroicon::QuestionMarkCircle;
|
||||||
</tr>
|
|
||||||
</thead>
|
if (is_numeric($statusCode)) {
|
||||||
<tbody class="divide-y divide-slate-100">
|
if ($statusCode >= 500) {
|
||||||
@forelse($recentLogs as $log)
|
$statusColor = 'danger';
|
||||||
<tr>
|
$statusIcon = Heroicon::XCircle;
|
||||||
<td class="px-3 py-2 text-slate-700 dark:text-slate-200">{{ $log['created_at'] }}</td>
|
} elseif ($statusCode >= 400) {
|
||||||
<td class="px-3 py-2 text-slate-600">{{ $log['user'] }}</td>
|
$statusColor = 'danger';
|
||||||
<td class="px-3 py-2 font-mono text-xs">{{ $log['service_id'] }}</td>
|
$statusIcon = Heroicon::ExclamationTriangle;
|
||||||
<td class="px-3 py-2">{{ ucfirst($log['action']) }}</td>
|
} elseif ($statusCode >= 300) {
|
||||||
<td class="px-3 py-2">{{ $log['status_code'] ?? '—' }}</td>
|
$statusColor = 'warning';
|
||||||
</tr>
|
$statusIcon = Heroicon::ExclamationTriangle;
|
||||||
@empty
|
} elseif ($statusCode >= 200) {
|
||||||
<tr>
|
$statusColor = 'success';
|
||||||
<td colspan="5" class="px-3 py-4 text-center text-slate-500">No actions recorded yet.</td>
|
$statusIcon = Heroicon::CheckCircle;
|
||||||
</tr>
|
}
|
||||||
@endforelse
|
}
|
||||||
</tbody>
|
@endphp
|
||||||
</table>
|
|
||||||
</div>
|
<x-filament::card :heading="$log['service_id']" :description="$log['created_at']">
|
||||||
<x-filament::link href="{{ route('filament.superadmin.resources.infrastructure-action-logs.index') }}" class="mt-3 inline-flex text-sm text-primary-600">
|
<div {{ $stacked }}>
|
||||||
View full log →
|
<x-filament::badge color="gray" :icon="Heroicon::User">
|
||||||
</x-filament::link>
|
{{ $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>
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
</x-filament-panels::page>
|
</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>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<x-filament::section :heading="__('admin.guest_policy.navigation.label')" :icon="Heroicon::AdjustmentsHorizontal">
|
||||||
<div class="rounded-xl bg-white p-6 shadow-sm">
|
<div {{ $stacked }}>
|
||||||
{{ $this->form }}
|
{{ $this->form }}
|
||||||
<div class="mt-4 flex justify-end">
|
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
|
||||||
<x-filament::button wire:click="save" color="primary">
|
{{ __('admin.guest_policy.actions.save') }}
|
||||||
{{ __('admin.guest_policy.actions.save') }}
|
</x-filament::button>
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
@php
|
@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
|
@endphp
|
||||||
|
|
||||||
<x-filament::page>
|
<x-filament::page>
|
||||||
<div class="space-y-6">
|
<x-filament::section :icon="Heroicon::Photo">
|
||||||
<div class="rounded-xl bg-white p-6 shadow-sm">
|
<div {{ $stacked }}>
|
||||||
{{ $this->form }}
|
{{ $this->form }}
|
||||||
<div class="mt-4 flex justify-end">
|
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
|
||||||
<x-filament::button wire:click="save" color="primary">
|
Speichern
|
||||||
Speichern
|
</x-filament::button>
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@if($asset)
|
</x-filament::section>
|
||||||
<div class="rounded-xl bg-white p-4 shadow-sm">
|
|
||||||
<p class="text-sm font-medium mb-2">Aktuelles Basis-Wasserzeichen</p>
|
@if($asset)
|
||||||
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" class="max-h-32 object-contain">
|
<x-filament::section heading="Aktuelles Basis-Wasserzeichen" :icon="Heroicon::Photo">
|
||||||
</div>
|
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" />
|
||||||
@endif
|
</x-filament::section>
|
||||||
</div>
|
@endif
|
||||||
</x-filament::page>
|
</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-widgets::widget>
|
||||||
<x-filament::section heading="Infra Status (Dokploy)">
|
<x-filament::section heading="Infra Status (Dokploy)">
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div {{ $cardsGrid }}>
|
||||||
@forelse($composes as $compose)
|
@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">
|
<x-filament::card
|
||||||
<div class="flex items-center justify-between">
|
:heading="$compose['label']"
|
||||||
<div>
|
:description="$compose['name'] ?? '–'"
|
||||||
<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>
|
<x-slot name="afterHeader">
|
||||||
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $compose['compose_id'] }}</p>
|
<x-filament::badge
|
||||||
</div>
|
:color="$statusColors[$compose['status']] ?? 'gray'"
|
||||||
<span @class([
|
:icon="$statusIcons[$compose['status']] ?? Heroicon::QuestionMarkCircle"
|
||||||
'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']),
|
|
||||||
])>
|
|
||||||
{{ ucfirst($compose['status']) }}
|
{{ ucfirst($compose['status']) }}
|
||||||
</span>
|
</x-filament::badge>
|
||||||
</div>
|
<x-filament::badge color="gray" :icon="Heroicon::Identification">
|
||||||
|
{{ $compose['compose_id'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
@if(isset($compose['error']))
|
@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
|
@else
|
||||||
<div class="mt-3 space-y-1">
|
<div {{ $serviceGrid }}>
|
||||||
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400">Services</p>
|
|
||||||
@forelse($compose['services'] as $service)
|
@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">
|
<x-filament::badge
|
||||||
<span>{{ $service['name'] }}</span>
|
:color="$serviceColors[$service['status']] ?? 'gray'"
|
||||||
<span @class([
|
:icon="array_key_exists($service['status'] ?? '', $serviceColors) ? Heroicon::Server : Heroicon::QuestionMarkCircle"
|
||||||
'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']),
|
{{ $service['name'] }}: {{ strtoupper($service['status'] ?? 'N/A') }}
|
||||||
'bg-amber-200/70 text-amber-900' => in_array($service['status'], ['starting', 'deploying']),
|
</x-filament::badge>
|
||||||
'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>
|
|
||||||
@empty
|
@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
|
@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>
|
</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
|
@endif
|
||||||
</div>
|
</x-filament::card>
|
||||||
@empty
|
@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
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</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-widgets::widget>
|
||||||
<x-filament::section heading="{{ __('admin.integrations_health.heading') }}">
|
<x-filament::section heading="{{ __('admin.integrations_health.heading') }}">
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div {{ $cardsGrid }}>
|
||||||
@forelse($providers as $provider)
|
@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">
|
<x-filament::card
|
||||||
<div class="flex items-center justify-between">
|
:heading="$provider['label']"
|
||||||
<div>
|
:description="$provider['config_label']"
|
||||||
<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">
|
<x-slot name="afterHeader">
|
||||||
{{ $provider['config_label'] }}:
|
<x-filament::badge :color="$provider['is_configured'] ? 'success' : 'danger'">
|
||||||
<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') }}
|
||||||
{{ $provider['is_configured'] ? __('admin.integrations_health.configured') : __('admin.integrations_health.unconfigured') }}
|
</x-filament::badge>
|
||||||
</span>
|
<x-filament::badge
|
||||||
</p>
|
:color="$statusColors[$provider['status']] ?? 'gray'"
|
||||||
</div>
|
:icon="$statusIcons[$provider['status']] ?? Heroicon::QuestionMarkCircle"
|
||||||
<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',
|
|
||||||
])>
|
|
||||||
{{ $provider['status_label'] }}
|
{{ $provider['status_label'] }}
|
||||||
</span>
|
</x-filament::badge>
|
||||||
</div>
|
</x-slot>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-2 text-xs text-slate-600 dark:text-slate-300">
|
<div {{ $metricsGrid }}>
|
||||||
<div class="flex items-center justify-between">
|
<x-filament::badge color="gray" :icon="Heroicon::Clock">
|
||||||
<span>{{ __('admin.integrations_health.last_received') }}</span>
|
{{ __('admin.integrations_health.last_received') }}:
|
||||||
<span class="font-semibold">
|
{{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }}
|
||||||
{{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }}
|
</x-filament::badge>
|
||||||
</span>
|
<x-filament::badge color="gray" :icon="Heroicon::ArrowPath">
|
||||||
</div>
|
{{ __('admin.integrations_health.last_processed') }}:
|
||||||
<div class="flex items-center justify-between">
|
{{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }}
|
||||||
<span>{{ __('admin.integrations_health.last_processed') }}</span>
|
</x-filament::badge>
|
||||||
<span class="font-semibold">
|
<x-filament::badge color="gray" :icon="Heroicon::Clock">
|
||||||
{{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }}
|
{{ __('admin.integrations_health.processing_lag') }}:
|
||||||
</span>
|
{{ $provider['processing_lag']['label'] ?? '—' }}
|
||||||
</div>
|
</x-filament::badge>
|
||||||
<div class="flex items-center justify-between">
|
<x-filament::badge color="gray" :icon="Heroicon::QueueList">
|
||||||
<span>{{ __('admin.integrations_health.processing_lag') }}</span>
|
{{ __('admin.integrations_health.pending_events') }}:
|
||||||
<span class="font-semibold">
|
{{ number_format($provider['pending_count']) }}
|
||||||
{{ $provider['processing_lag']['label'] ?? '—' }}
|
</x-filament::badge>
|
||||||
</span>
|
<x-filament::badge color="gray" :icon="Heroicon::ExclamationTriangle">
|
||||||
</div>
|
{{ __('admin.integrations_health.recent_failures') }}:
|
||||||
<div class="flex items-center justify-between">
|
{{ number_format($provider['recent_failures']) }}
|
||||||
<span>{{ __('admin.integrations_health.pending_events') }}</span>
|
</x-filament::badge>
|
||||||
<span class="font-semibold">{{ number_format($provider['pending_count']) }}</span>
|
<x-filament::badge color="gray" :icon="Heroicon::RectangleStack">
|
||||||
</div>
|
{{ __('admin.integrations_health.queue_backlog') }}:
|
||||||
<div class="flex items-center justify-between">
|
{{ number_format($provider['queue_backlog']) }}
|
||||||
<span>{{ __('admin.integrations_health.recent_failures') }}</span>
|
</x-filament::badge>
|
||||||
<span class="font-semibold">{{ number_format($provider['recent_failures']) }}</span>
|
<x-filament::badge color="gray" :icon="Heroicon::XCircle">
|
||||||
</div>
|
{{ __('admin.integrations_health.failed_jobs') }}:
|
||||||
<div class="flex items-center justify-between">
|
{{ number_format($provider['failed_jobs']) }}
|
||||||
<span>{{ __('admin.integrations_health.queue_backlog') }}</span>
|
</x-filament::badge>
|
||||||
<span class="font-semibold">{{ number_format($provider['queue_backlog']) }}</span>
|
@if(! empty(data_get($provider, 'last_failed.error_message')))
|
||||||
</div>
|
<x-filament::badge color="danger" :icon="Heroicon::ExclamationTriangle">
|
||||||
<div class="flex items-center justify-between">
|
{{ __('admin.integrations_health.last_error') }}:
|
||||||
<span>{{ __('admin.integrations_health.failed_jobs') }}</span>
|
{{ data_get($provider, 'last_failed.error_message') }}
|
||||||
<span class="font-semibold">{{ number_format($provider['failed_jobs']) }}</span>
|
</x-filament::badge>
|
||||||
</div>
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</x-filament::card>
|
||||||
@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>
|
|
||||||
@empty
|
@empty
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-300">
|
<x-filament::empty-state
|
||||||
{{ __('admin.integrations_health.empty') }}
|
:heading="__('admin.integrations_health.empty')"
|
||||||
</p>
|
:icon="Heroicon::QuestionMarkCircle"
|
||||||
|
/>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
|
|||||||
\App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure',
|
\App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure',
|
||||||
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => 'admin.nav.infrastructure',
|
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => 'admin.nav.infrastructure',
|
||||||
\App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::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) {
|
foreach ($expectations as $resourceClass => $key) {
|
||||||
@@ -59,6 +60,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
|
|||||||
\App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class,
|
\App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class,
|
||||||
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class,
|
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class,
|
||||||
\App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::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\TaskResource::class => WeeklyOpsCluster::class,
|
||||||
\App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class,
|
\App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class,
|
||||||
\App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,
|
\App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,
|
||||||
|
|||||||
Reference in New Issue
Block a user