'audit_log', 'surfaceType' => 'selected_record_monitoring', 'stateFields' => [ [ 'stateKey' => 'event', 'stateClass' => 'inspect', 'carrier' => 'query_param', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true, 'tenantSensitive' => false, 'invalidFallback' => 'clear_selection_and_continue', ], [ 'stateKey' => 'tenant_id', 'stateClass' => 'contextual_prefilter', 'carrier' => 'session', 'queryRole' => 'durable_restorable', 'shareable' => false, 'restorableOnRefresh' => true, 'tenantSensitive' => true, 'invalidFallback' => 'discard_and_continue', ], [ 'stateKey' => 'tableSearch', 'stateClass' => 'shareable_restorable', 'carrier' => 'session', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true, 'tenantSensitive' => false, 'invalidFallback' => 'discard_and_continue', ], ], 'hydrationRule' => [ 'precedenceOrder' => ['query', 'session', 'default'], 'appliesOnInitialMountOnly' => true, 'activeStateBecomesAuthoritativeAfterMount' => true, 'clearsOnTenantSwitch' => ['tenant_id', 'action', 'actor_label', 'resource_type'], 'invalidRequestedStateFallback' => 'clear_selection_and_continue', ], 'inspectContract' => [ 'primaryModel' => AuditLogModel::class, 'selectedStateKey' => 'selectedAuditLogId', 'openedBy' => ['query_param', 'inspect_action'], 'presentation' => 'inline_detail', 'shareable' => true, 'invalidSelectionFallback' => 'clear_selection_and_continue', ], 'shareableStateKeys' => ['event'], 'localOnlyStateKeys' => [], ]; public ?int $selectedAuditLogId = null; protected static bool $isDiscovered = false; protected static bool $shouldRegisterNavigation = false; protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; protected static ?string $navigationLabel = 'Audit Log'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list'; protected static ?string $slug = 'audit-log'; protected static ?string $title = 'Audit Log'; protected string $view = 'filament.pages.monitoring.audit-log'; /** * @var array|null */ private ?array $authorizedTenants = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::HistoryAudit) ->withDefaults(new ActionSurfaceDefaults( moreGroupLabel: 'More', exportIsDefaultBulkActionForReadOnly: false, )) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the Monitoring scope visible and expose selected-event detail actions.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Audit history is immutable and intentionally omits bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table exposes a clear-filters CTA when no audit events match the current view.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.'); } /** * @return array */ public static function monitoringPageStateContract(): array { return self::MONITORING_PAGE_STATE_CONTRACT; } public function mount(): void { $this->authorizePageAccess(); $requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null; app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request()); $this->mountInteractsWithTable(); if ($requestedEventId !== null) { $this->selectedAuditLogId = $this->resolveSelectedAuditLogId($requestedEventId); } } /** * @return array */ protected function getHeaderActions(): array { $actions = app(OperateHubShell::class)->headerActions( scopeActionName: 'operate_hub_scope_audit_log', returnActionName: 'operate_hub_return_audit_log', ); $navigationContext = CanonicalNavigationContext::fromRequest(request()); if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { array_splice($actions, 1, 0, [ Action::make('operate_hub_back_to_origin_audit_log') ->label($navigationContext->backLinkLabel) ->icon('heroicon-o-arrow-left') ->color('gray') ->url($navigationContext->backLinkUrl), ]); } $selectedAudit = $this->selectedAuditRecord(); $selectedAuditLink = $selectedAudit instanceof AuditLogModel ? $this->auditTargetLink($selectedAudit) : null; if ($selectedAudit instanceof AuditLogModel) { array_splice($actions, 1, 0, array_values(array_filter([ Action::make('close_selected_audit_event') ->label('Close details') ->icon('heroicon-o-x-mark') ->color('gray') ->url($this->auditLogUrl(['event' => null])), $selectedAuditLink !== null ? Action::make('open_selected_audit_target') ->label($selectedAuditLink['label']) ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->url($selectedAuditLink['url']) : null, ]))); } return $actions; } public function updatedTableFilters(): void { $this->normalizeSelectedAuditLogId(); } public function updatedTableSearch(): void { $this->normalizeSelectedAuditLogId(); } public function table(Table $table): Table { return $table ->query(fn (): Builder => $this->auditBaseQuery()) ->defaultSort('recorded_at', 'desc') ->paginated(TablePaginationProfiles::customPage()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->columns([ TextColumn::make('outcome') ->label('Outcome') ->badge() ->getStateUsing(fn (AuditLogModel $record): string => $record->normalizedOutcome()->value) ->formatStateUsing(fn (string $state): string => BadgeRenderer::label(BadgeDomain::AuditOutcome)($state)) ->color(fn (string $state): string => BadgeRenderer::color(BadgeDomain::AuditOutcome)($state)) ->icon(fn (string $state): ?string => BadgeRenderer::icon(BadgeDomain::AuditOutcome)($state)) ->iconColor(fn (string $state): ?string => BadgeRenderer::iconColor(BadgeDomain::AuditOutcome)($state)), TextColumn::make('summary') ->label('Event') ->getStateUsing(fn (AuditLogModel $record): string => $record->summaryText()) ->description(fn (AuditLogModel $record): string => AuditActionId::labelFor((string) $record->action)) ->searchable() ->wrap(), TextColumn::make('actor_label') ->label('Actor') ->getStateUsing(fn (AuditLogModel $record): string => $record->actorDisplayLabel()) ->description(fn (AuditLogModel $record): string => BadgeRenderer::label(BadgeDomain::AuditActorType)($record->actorSnapshot()->type->value)) ->searchable(), TextColumn::make('target_label') ->label('Target') ->getStateUsing(fn (AuditLogModel $record): string => $record->targetDisplayLabel() ?? 'No target snapshot') ->searchable() ->toggleable(), TextColumn::make('tenant.name') ->label('Tenant') ->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace') ->toggleable(), TextColumn::make('recorded_at') ->label('Recorded') ->since() ->sortable(), ]) ->filters([ SelectFilter::make('tenant_id') ->label('Tenant') ->options(fn (): array => $this->tenantFilterOptions()) ->default(fn (): ?string => $this->defaultTenantFilter()) ->searchable(), SelectFilter::make('action') ->label('Event type') ->options(fn (): array => $this->actionFilterOptions()) ->searchable(), SelectFilter::make('outcome') ->label('Outcome') ->options(FilterOptionCatalog::auditOutcomes()), SelectFilter::make('actor_label') ->label('Actor') ->options(fn (): array => $this->actorFilterOptions()) ->searchable(), SelectFilter::make('resource_type') ->label('Target type') ->options(fn (): array => $this->targetTypeFilterOptions()), FilterPresets::dateRange('recorded_at', 'Recorded', 'recorded_at'), ]) ->actions([ Action::make('inspect') ->label('Inspect event') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (AuditLogModel $record): string => $this->auditLogUrl(['event' => (int) $record->getKey()])), ]) ->bulkActions([]) ->emptyStateHeading('No audit events match this view') ->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.') ->emptyStateIcon('heroicon-o-funnel') ->emptyStateActions([ Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->action(function (): void { $this->selectedAuditLogId = null; $this->resetTable(); }), ]); } /** * @return array */ public function authorizedTenants(): array { if ($this->authorizedTenants !== null) { return $this->authorizedTenants; } $user = auth()->user(); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if (! $user instanceof User || ! is_numeric($workspaceId)) { return $this->authorizedTenants = []; } $tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel())) ->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId) ->filter(static fn (Tenant $tenant): bool => $tenant->isActive()) ->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey()) ->all(); return $this->authorizedTenants = $tenants; } private function authorizePageAccess(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspace = is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null; if (! $workspace instanceof Workspace) { throw new NotFoundHttpException; } $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $workspace)) { throw new NotFoundHttpException; } if (! $resolver->can($user, $workspace, Capabilities::AUDIT_VIEW)) { abort(403); } } private function auditBaseQuery(): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $authorizedTenantIds = array_map( static fn (Tenant $tenant): int => (int) $tenant->getKey(), $this->authorizedTenants(), ); return AuditLogModel::query() ->with(['tenant', 'workspace', 'operationRun']) ->forWorkspace((int) $workspaceId) ->where(function (Builder $query) use ($authorizedTenantIds): void { $query->whereNull('tenant_id'); if ($authorizedTenantIds !== []) { $query->orWhereIn('tenant_id', $authorizedTenantIds); } }) ->latestFirst(); } private function resolveAuditLog(int $auditLogId): AuditLogModel { $record = $this->auditBaseQuery() ->whereKey($auditLogId) ->first(); if (! $record instanceof AuditLogModel) { throw new NotFoundHttpException; } return $record; } public function selectedAuditRecord(): ?AuditLogModel { if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { return null; } $this->normalizeSelectedAuditLogId(); if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { return null; } try { return $this->resolveAuditLog($this->selectedAuditLogId); } catch (NotFoundHttpException) { return null; } } /** * @return array{label: string, url: string}|null */ public function selectedAuditTargetLink(): ?array { $record = $this->selectedAuditRecord(); if (! $record instanceof AuditLogModel) { return null; } return $this->auditTargetLink($record); } /** * @return array{label: string, url: string}|null */ private function auditTargetLink(AuditLogModel $record): ?array { return app(RelatedNavigationResolver::class)->auditTargetLink($record); } private function auditLogUrl(array $overrides = []): string { $parameters = array_merge( $this->navigationContext()?->toQuery() ?? [], ['event' => $this->selectedAuditLogId], $overrides, ); return route( 'admin.monitoring.audit-log', array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []), ); } private function navigationContext(): ?CanonicalNavigationContext { return CanonicalNavigationContext::fromRequest(request()); } private function normalizeSelectedAuditLogId(): void { if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { $this->selectedAuditLogId = null; return; } $this->selectedAuditLogId = $this->resolveSelectedAuditLogId($this->selectedAuditLogId); } private function resolveSelectedAuditLogId(int $auditLogId): ?int { try { $record = $this->resolveAuditLog($auditLogId); } catch (NotFoundHttpException) { return null; } return $this->selectedAuditVisible((int) $record->getKey()) ? (int) $record->getKey() : null; } private function selectedAuditVisible(int $auditLogId): bool { $record = $this->resolveAuditLog($auditLogId); return $this->matchesSelectedAuditFilters($record) && $this->matchesSelectedAuditSearch($record); } /** * @return array */ private function currentTableFiltersState(): array { $persisted = session()->get($this->getTableFiltersSessionKey(), []); return array_replace_recursive( is_array($persisted) ? $persisted : [], $this->tableFilters ?? [], ); } private function currentTableSearchState(): string { $search = trim((string) ($this->tableSearch ?? '')); if ($search !== '') { return $search; } $persisted = session()->get($this->getTableSearchSessionKey(), ''); return trim(is_string($persisted) ? $persisted : ''); } private function matchesSelectedAuditFilters(AuditLogModel $record): bool { $filters = $this->currentTableFiltersState(); $tenantFilter = data_get($filters, 'tenant_id.value'); if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) { return false; } $actionFilter = data_get($filters, 'action.value'); if (is_string($actionFilter) && $actionFilter !== '' && (string) $record->action !== $actionFilter) { return false; } $outcomeFilter = data_get($filters, 'outcome.value'); if (is_string($outcomeFilter) && $outcomeFilter !== '' && $record->normalizedOutcome()->value !== $outcomeFilter) { return false; } $actorFilter = data_get($filters, 'actor_label.value'); if (is_string($actorFilter) && $actorFilter !== '' && (string) $record->actor_label !== $actorFilter) { return false; } $resourceTypeFilter = data_get($filters, 'resource_type.value'); if (is_string($resourceTypeFilter) && $resourceTypeFilter !== '' && (string) $record->resource_type !== $resourceTypeFilter) { return false; } return true; } private function matchesSelectedAuditSearch(AuditLogModel $record): bool { $search = Str::lower($this->currentTableSearchState()); if ($search === '') { return true; } $haystack = Str::lower(implode(' ', [ $record->summaryText(), $record->actorDisplayLabel(), $record->targetDisplayLabel() ?? '', ])); return str_contains($haystack, $search); } /** * @return array */ private function tenantFilterOptions(): array { return collect($this->authorizedTenants()) ->mapWithKeys(static fn (Tenant $tenant): array => [ (string) $tenant->getKey() => $tenant->getFilamentName(), ]) ->all(); } private function defaultTenantFilter(): ?string { $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); if (! $activeTenant instanceof Tenant) { return null; } return array_key_exists((int) $activeTenant->getKey(), $this->authorizedTenants()) ? (string) $activeTenant->getKey() : null; } /** * @return array */ private function actionFilterOptions(): array { $values = (clone $this->auditBaseQuery()) ->reorder() ->select('action') ->distinct() ->orderBy('action') ->pluck('action') ->all(); return FilterOptionCatalog::auditActions($values); } /** * @return array */ private function actorFilterOptions(): array { return (clone $this->auditBaseQuery()) ->reorder() ->whereNotNull('actor_label') ->select('actor_label') ->distinct() ->orderBy('actor_label') ->pluck('actor_label', 'actor_label') ->all(); } /** * @return array */ private function targetTypeFilterOptions(): array { $values = (clone $this->auditBaseQuery()) ->reorder() ->whereNotNull('resource_type') ->select('resource_type') ->distinct() ->orderBy('resource_type') ->pluck('resource_type') ->all(); return FilterOptionCatalog::auditTargetTypes($values); } }