satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.'); } public static function canViewAny(): bool { $tenant = Tenant::current(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); return $resolver->isMember($user, $tenant) && $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canCreate(): bool { $tenant = Tenant::current(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); return $resolver->isMember($user, $tenant) && $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); } public static function getEloquentQuery(): Builder { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return parent::getEloquentQuery()->whereRaw('1 = 0'); } return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey()); } public static function form(Schema $schema): Schema { return $schema ->schema([ Forms\Components\TextInput::make('name') ->label('Backup name') ->default(fn () => now()->format('Y-m-d H:i:s').' backup') ->required(), ]); } public static function makeCreateAction(): Actions\CreateAction { $action = Actions\CreateAction::make() ->label('Create backup set'); UiEnforcement::forAction($action) ->requireCapability(Capabilities::TENANT_SYNC) ->apply(); return $action; } public static function table(Table $table): Table { return $table ->defaultSort('created_at', 'desc') ->paginated(TablePaginationProfiles::resource()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus)) ->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)), Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(), Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\TrashedFilter::make() ->label('Archived') ->placeholder('Active') ->trueLabel('All') ->falseLabel('Archived'), ]) ->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record])) ->actions([ static::primaryRelatedAction(), ActionGroup::make([ UiEnforcement::forAction( Actions\Action::make('restore') ->label('Restore') ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn (BackupSet $record): bool => $record->trashed()) ->action(function (BackupSet $record, AuditLogger $auditLogger) { $tenant = Filament::getTenant(); $record->restore(); $record->items()->withTrashed()->restore(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'backup.restored', resourceType: 'backup_set', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['name' => $record->name]] ); } Notification::make() ->title('Backup set restored') ->success() ->send(); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_MANAGE) ->apply(), UiEnforcement::forAction( Actions\Action::make('archive') ->label('Archive') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (BackupSet $record): bool => ! $record->trashed()) ->action(function (BackupSet $record, AuditLogger $auditLogger) { $tenant = Filament::getTenant(); $record->delete(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'backup.deleted', resourceType: 'backup_set', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['name' => $record->name]] ); } Notification::make() ->title('Backup set archived') ->success() ->send(); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_MANAGE) ->apply(), UiEnforcement::forAction( Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (BackupSet $record): bool => $record->trashed()) ->action(function (BackupSet $record, AuditLogger $auditLogger) { $tenant = Filament::getTenant(); if ($record->restoreRuns()->withTrashed()->exists()) { Notification::make() ->title('Cannot force delete backup set') ->body('Backup sets referenced by restore runs cannot be removed.') ->danger() ->send(); return; } if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'backup.force_deleted', resourceType: 'backup_set', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['name' => $record->name]] ); } $record->items()->withTrashed()->forceDelete(); $record->forceDelete(); Notification::make() ->title('Backup set permanently deleted') ->success() ->send(); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_DELETE) ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ UiEnforcement::forBulkAction( BulkAction::make('bulk_delete') ->label('Archive Backup Sets') ->icon('heroicon-o-archive-box-x-mark') ->color('danger') ->requiresConfirmation() ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return $isOnlyTrashed; }) ->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.') ->form(function (Collection $records) { if ($records->count() >= 10) { return [ Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', ]), ]; } return []; }) ->action(function (Collection $records) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); if (! $tenant instanceof Tenant) { return; } $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ $selection = app(BulkSelectionIdentity::class); $selectionIdentity = $selection->fromIds($ids); /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); $opRun = $runs->enqueueBulkOperation( tenant: $tenant, type: 'backup_set.delete', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], selectionIdentity: $selectionIdentity, dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { BulkBackupSetDeleteJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), backupSetIds: $ids, operationRun: $operationRun, ); }, initiator: $initiator, extraContext: [ 'backup_set_count' => $count, ], emitQueuedNotification: false, ); OperationUxPresenter::queuedToast('backup_set.delete') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), ) ->requireCapability(Capabilities::TENANT_MANAGE) ->apply(), UiEnforcement::forBulkAction( BulkAction::make('bulk_restore') ->label('Restore Backup Sets') ->icon('heroicon-o-arrow-uturn-left') ->color('success') ->requiresConfirmation() ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return ! $isOnlyTrashed; }) ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') ->action(function (Collection $records) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); if (! $tenant instanceof Tenant) { return; } $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ $selection = app(BulkSelectionIdentity::class); $selectionIdentity = $selection->fromIds($ids); /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); $opRun = $runs->enqueueBulkOperation( tenant: $tenant, type: 'backup_set.restore', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], selectionIdentity: $selectionIdentity, dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { BulkBackupSetRestoreJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), backupSetIds: $ids, operationRun: $operationRun, ); }, initiator: $initiator, extraContext: [ 'backup_set_count' => $count, ], emitQueuedNotification: false, ); OperationUxPresenter::queuedToast('backup_set.restore') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), ) ->requireCapability(Capabilities::TENANT_MANAGE) ->apply(), UiEnforcement::forBulkAction( BulkAction::make('bulk_force_delete') ->label('Force Delete Backup Sets') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return ! $isOnlyTrashed; }) ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') ->form(function (Collection $records) { if ($records->count() >= 10) { return [ Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', ]), ]; } return []; }) ->action(function (Collection $records) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); if (! $tenant instanceof Tenant) { return; } $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ $selection = app(BulkSelectionIdentity::class); $selectionIdentity = $selection->fromIds($ids); /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); $opRun = $runs->enqueueBulkOperation( tenant: $tenant, type: 'backup_set.force_delete', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], selectionIdentity: $selectionIdentity, dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { BulkBackupSetForceDeleteJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), backupSetIds: $ids, operationRun: $operationRun, ); }, initiator: $initiator, extraContext: [ 'backup_set_count' => $count, ], emitQueuedNotification: false, ); OperationUxPresenter::queuedToast('backup_set.force_delete') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), ) ->requireCapability(Capabilities::TENANT_DELETE) ->apply(), ])->label('More'), ]) ->emptyStateHeading('No backup sets') ->emptyStateDescription('Create a backup set to start protecting your configurations.') ->emptyStateIcon('heroicon-o-archive-box') ->emptyStateActions([ static::makeCreateAction(), ]); } public static function infolist(Schema $schema): Schema { return $schema ->schema([ Infolists\Components\ViewEntry::make('enterprise_detail') ->label('') ->view('filament.infolists.entries.enterprise-detail.layout') ->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray()) ->columnSpanFull(), ]); } public static function getRelations(): array { return [ BackupItemsRelationManager::class, ]; } public static function getPages(): array { return [ 'index' => Pages\ListBackupSets::route('/'), 'create' => Pages\CreateBackupSet::route('/create'), 'view' => Pages\ViewBackupSet::route('/{record}'), ]; } /** * @return array{label:?string,category:?string,restore:?string,risk:?string}|array */ private static function typeMeta(?string $type): array { if ($type === null) { return []; } $types = array_merge( config('tenantpilot.supported_policy_types', []), config('tenantpilot.foundation_types', []) ); return collect($types) ->firstWhere('type', $type) ?? []; } /** * @return list */ public static function relatedContextEntries(BackupSet $record): array { return app(RelatedNavigationResolver::class) ->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record); } private static function primaryRelatedAction(): Actions\Action { return Actions\Action::make('primary_drill_down') ->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record') ->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl) ->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false)) ->color('gray'); } private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry { return app(RelatedNavigationResolver::class) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record); } /** * Create a backup set via the domain service instead of direct model mass-assignment. */ public static function createBackupSet(array $data): BackupSet { /** @var Tenant $tenant */ $tenant = Tenant::current(); /** @var BackupService $service */ $service = app(BackupService::class); return $service->createBackupSet( tenant: $tenant, policyIds: $data['policy_ids'] ?? [], name: $data['name'] ?? null, actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, includeAssignments: $data['include_assignments'] ?? false, includeScopeTags: $data['include_scope_tags'] ?? false, ); } private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData { $factory = new EnterpriseDetailSectionFactory; $statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status); $metadata = is_array($record->metadata) ? $record->metadata : []; $metadataKeyCount = count($metadata); $relatedContext = static::relatedContextEntries($record); $isArchived = $record->trashed(); return EnterpriseDetailBuilder::make('backup_set', 'tenant') ->header(new SummaryHeaderData( title: (string) $record->name, subtitle: 'Backup set #'.$record->getKey(), statusBadges: [ $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), $factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'), ], keyFacts: [ $factory->keyFact('Items', $record->item_count), $factory->keyFact('Created by', $record->created_by), $factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)), $factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)), ], descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.', )) ->addSection( $factory->factsSection( id: 'lifecycle_overview', kind: 'core_details', title: 'Lifecycle overview', items: [ $factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Items', $record->item_count), $factory->keyFact('Created by', $record->created_by), $factory->keyFact('Archived', $isArchived), ], ), $factory->viewSection( id: 'related_context', kind: 'related_context', title: 'Related context', view: 'filament.infolists.entries.related-context', viewData: ['entries' => $relatedContext], emptyState: $factory->emptyState('No related context is available for this record.'), ), ) ->addSupportingCard( $factory->supportingFactsCard( kind: 'status', title: 'Recovery readiness', items: [ $factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Archived', $isArchived), $factory->keyFact('Metadata keys', $metadataKeyCount), ], ), $factory->supportingFactsCard( kind: 'timestamps', title: 'Timing', items: [ $factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)), $factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)), ], ), ) ->addTechnicalSection( $factory->technicalDetail( title: 'Technical detail', entries: [ $factory->keyFact('Metadata keys', $metadataKeyCount), $factory->keyFact('Archived', $isArchived), ], description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.', view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null, viewData: ['payload' => $metadata], emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'), ), ) ->build(); } private static function formatDetailTimestamp(mixed $value): string { if (! $value instanceof Carbon) { return '—'; } return $value->toDayDateTimeString(); } }