schema([ Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'), Infolists\Components\TextEntry::make('version_number')->label('Version'), Infolists\Components\TextEntry::make('policy_type') ->badge() ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)), Infolists\Components\TextEntry::make('platform') ->badge() ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform)) ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), Infolists\Components\TextEntry::make('created_by')->label('Actor'), Infolists\Components\TextEntry::make('captured_at')->dateTime(), Tabs::make() ->activeTab(1) ->persistTabInQueryString('tab') ->columnSpanFull() ->tabs([ Tab::make('Normalized settings') ->id('normalized-settings') ->schema([ Infolists\Components\ViewEntry::make('normalized_settings_catalog') ->view('filament.infolists.entries.normalized-settings') ->state(function (PolicyVersion $record) { $normalized = app(PolicyNormalizer::class)->normalize( is_array($record->snapshot) ? $record->snapshot : [], $record->policy_type ?? '', $record->platform ); $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); return $normalized; }) ->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), Infolists\Components\ViewEntry::make('normalized_settings_standard') ->view('filament.infolists.entries.policy-settings-standard') ->state(function (PolicyVersion $record) { $normalized = app(PolicyNormalizer::class)->normalize( is_array($record->snapshot) ? $record->snapshot : [], $record->policy_type ?? '', $record->platform ); $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); $normalized['policy_type'] = $record->policy_type; return $normalized; }) ->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), ]), Tab::make('Raw JSON') ->id('raw-json') ->schema([ Infolists\Components\ViewEntry::make('snapshot_pretty') ->view('filament.infolists.entries.snapshot-json') ->state(fn (PolicyVersion $record) => $record->snapshot ?? []), ]), Tab::make('Diff') ->id('diff') ->schema([ Infolists\Components\ViewEntry::make('normalized_diff') ->view('filament.infolists.entries.normalized-diff') ->state(function (PolicyVersion $record) { $normalizer = app(PolicyNormalizer::class); $diff = app(VersionDiff::class); $previous = $record->previous(); $from = $previous ? $normalizer->flattenForDiff($previous->snapshot ?? [], $previous->policy_type ?? '', $previous->platform) : []; $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform); $result = $diff->compare($from, $to); $result['policy_type'] = $record->policy_type; return $result; }), Infolists\Components\ViewEntry::make('diff_json') ->label('Raw diff (advanced)') ->view('filament.infolists.entries.snapshot-json') ->state(function (PolicyVersion $record) { $previous = $record->previous(); if (! $previous) { return ['summary' => 'No previous version']; } $diff = app(VersionDiff::class)->compare( $previous->snapshot ?? [], $record->snapshot ?? [] ); $filter = static fn (array $items): array => array_filter( $items, static fn (mixed $value, string $key): bool => ! str_contains($key, '@odata.context'), ARRAY_FILTER_USE_BOTH ); $added = $filter($diff['added'] ?? []); $removed = $filter($diff['removed'] ?? []); $changed = $filter($diff['changed'] ?? []); return [ 'summary' => [ 'added' => count($added), 'removed' => count($removed), 'changed' => count($changed), 'message' => sprintf( '%d added, %d removed, %d changed', count($added), count($removed), count($changed) ), ], 'added' => $added, 'removed' => $removed, 'changed' => $changed, ]; }), ]), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(), Tables\Columns\TextColumn::make('version_number')->sortable(), Tables\Columns\TextColumn::make('policy_type') ->badge() ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)), Tables\Columns\TextColumn::make('platform') ->badge() ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform)) ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), Tables\Columns\TextColumn::make('created_by')->label('Actor'), Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), ]) ->filters([ TrashedFilter::make() ->label('Archived') ->placeholder('Active') ->trueLabel('All') ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make(), Actions\ActionGroup::make([ Actions\Action::make('restore_via_wizard') ->label('Restore via Wizard') ->icon('heroicon-o-arrow-path-rounded-square') ->color('primary') ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') ->action(function (PolicyVersion $record) { $tenant = Tenant::current(); $user = auth()->user(); if (! $tenant || $record->tenant_id !== $tenant->id) { Notification::make() ->title('Policy version belongs to a different tenant') ->danger() ->send(); return; } $policy = $record->policy; if (! $policy) { Notification::make() ->title('Policy could not be found for this version') ->danger() ->send(); return; } $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => sprintf( 'Policy Version Restore • %s • v%d', $policy->display_name, $record->version_number ), 'created_by' => $user?->email, 'status' => 'completed', 'item_count' => 1, 'completed_at' => CarbonImmutable::now(), 'metadata' => [ 'source' => 'policy_version', 'policy_version_id' => $record->id, 'policy_version_number' => $record->version_number, 'policy_id' => $policy->id, ], ]); $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; $scopeTagIds = $scopeTags['ids'] ?? null; $scopeTagNames = $scopeTags['names'] ?? null; $backupItemMetadata = [ 'source' => 'policy_version', 'display_name' => $policy->display_name, 'policy_version_id' => $record->id, 'policy_version_number' => $record->version_number, 'version_captured_at' => $record->captured_at?->toIso8601String(), ]; if (is_array($scopeTagIds) && $scopeTagIds !== []) { $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; } if (is_array($scopeTagNames) && $scopeTagNames !== []) { $backupItemMetadata['scope_tag_names'] = $scopeTagNames; } $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, 'policy_version_id' => $record->id, 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), 'payload' => $record->snapshot ?? [], 'metadata' => $backupItemMetadata, 'assignments' => $record->assignments, ]); return redirect()->to(RestoreRunResource::getUrl('create', [ 'backup_set_id' => $backupSet->id, 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], ])); }), Actions\Action::make('archive') ->label('Archive') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => ! $record->trashed()) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { $record->delete(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'policy_version.deleted', resourceType: 'policy_version', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] ); } Notification::make() ->title('Policy version archived') ->success() ->send(); }), Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => $record->trashed()) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'policy_version.force_deleted', resourceType: 'policy_version', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] ); } $record->forceDelete(); Notification::make() ->title('Policy version permanently deleted') ->success() ->send(); }), Actions\Action::make('restore') ->label('Restore') ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => $record->trashed()) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { $record->restore(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'policy_version.restored', resourceType: 'policy_version', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] ); } Notification::make() ->title('Policy version restored') ->success() ->send(); }), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ BulkAction::make('bulk_prune_versions') ->label('Prune Versions') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return $isOnlyTrashed; }) ->form(function (Collection $records) { $fields = [ Forms\Components\TextInput::make('retention_days') ->label('Retention Days') ->helperText('Versions captured within the last N days will be skipped.') ->numeric() ->required() ->default(90) ->minValue(1), ]; if ($records->count() >= 20) { $fields[] = Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', ]); } return $fields; }) ->action(function (Collection $records, array $data) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $retentionDays = (int) ($data['retention_days'] ?? 90); 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: 'policy_version.prune', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], selectionIdentity: $selectionIdentity, dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void { BulkPolicyVersionPruneJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), policyVersionIds: $ids, retentionDays: $retentionDays, operationRun: $operationRun, ); }, initiator: $initiator, extraContext: [ 'policy_version_count' => $count, 'retention_days' => $retentionDays, ], emitQueuedNotification: false, ); if ($initiator instanceof User) { Notification::make() ->title('Policy version prune queued') ->body("Queued prune for {$count} policy versions.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->duration(8000) ->sendToDatabase($initiator); } OperationUxPresenter::queuedToast('policy_version.prune') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), BulkAction::make('bulk_restore_versions') ->label('Restore Versions') ->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()} policy versions?") ->modalDescription('Archived versions will be restored back to the active list. Active versions 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: 'policy_version.restore', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], selectionIdentity: $selectionIdentity, dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { BulkPolicyVersionRestoreJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), policyVersionIds: $ids, operationRun: $operationRun, ); }, initiator: $initiator, extraContext: [ 'policy_version_count' => $count, ], emitQueuedNotification: false, ); OperationUxPresenter::queuedToast('policy_version.restore') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), BulkAction::make('bulk_force_delete_versions') ->label('Force Delete Versions') ->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()} policy versions?") ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') ->form([ Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', ]), ]) ->action(function (Collection $records, array $data) { $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: 'policy_version.force_delete', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], selectionIdentity: $selectionIdentity, dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { BulkPolicyVersionForceDeleteJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), policyVersionIds: $ids, operationRun: $operationRun, ); }, initiator: $initiator, extraContext: [ 'policy_version_count' => $count, ], emitQueuedNotification: false, ); if ($initiator instanceof User) { Notification::make() ->title('Policy version force delete queued') ->body("Queued force delete for {$count} policy versions.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->duration(8000) ->sendToDatabase($initiator); } OperationUxPresenter::queuedToast('policy_version.force_delete') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), ]), ]); } public static function getEloquentQuery(): Builder { $tenantId = Tenant::current()->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->with('policy'); } public static function getPages(): array { return [ 'index' => Pages\ListPolicyVersions::route('/'), 'view' => Pages\ViewPolicyVersion::route('/{record}'), ]; } }