schema([ Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { $tenantId = Tenant::current()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) ->orderByDesc('created_at') ->get() ->mapWithKeys(function (BackupSet $set) { $label = sprintf( '%s • %s items • %s', $set->name, $set->item_count ?? 0, optional($set->created_at)->format('Y-m-d H:i') ); return [$set->id => $label]; }); }) ->reactive() ->afterStateUpdated(function (Set $set): void { $set('backup_item_ids', []); $set('group_mapping', []); }) ->required(), Forms\Components\CheckboxList::make('backup_item_ids') ->label('Items to restore (optional)') ->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options']) ->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions']) ->columns(1) ->searchable() ->bulkToggleable() ->reactive() ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->helperText('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'), Section::make('Group mapping') ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') ->schema(function (Get $get): array { $backupSetId = $get('backup_set_id'); $selectedItemIds = $get('backup_item_ids'); $tenant = Tenant::current(); if (! $tenant || ! $backupSetId) { return []; } $unresolved = static::unresolvedGroups( backupSetId: $backupSetId, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, tenant: $tenant ); return array_map(function (array $group) use ($tenant): Forms\Components\Select { $groupId = $group['id']; $label = $group['label']; return Forms\Components\Select::make("group_mapping.{$groupId}") ->label($label) ->options([ 'SKIP' => 'Skip assignment', ]) ->searchable() ->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search)) ->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value)) ->required() ->helperText('Choose a target group or select Skip.'); }, $unresolved); }) ->visible(function (Get $get): bool { $backupSetId = $get('backup_set_id'); $selectedItemIds = $get('backup_item_ids'); $tenant = Tenant::current(); if (! $tenant || ! $backupSetId) { return false; } return static::unresolvedGroups( backupSetId: $backupSetId, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, tenant: $tenant ) !== []; }), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(), Tables\Columns\TextColumn::make('status')->badge(), Tables\Columns\TextColumn::make('started_at')->dateTime()->since(), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), ]) ->filters([ TrashedFilter::make() ->label('Archived') ->placeholder('Active') ->trueLabel('All') ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make(), ActionGroup::make([ Actions\Action::make('rerun') ->label('Rerun') ->icon('heroicon-o-arrow-path') ->color('primary') ->requiresConfirmation() ->visible(function (RestoreRun $record): bool { $backupSet = $record->backupSet; return $record->isDeletable() && $backupSet !== null && ! $backupSet->trashed(); }) ->action(function ( RestoreRun $record, RestoreService $restoreService, \App\Services\Intune\AuditLogger $auditLogger ) { $tenant = $record->tenant; $backupSet = $record->backupSet; if (! $tenant || ! $backupSet || $backupSet->trashed()) { Notification::make() ->title('Restore run cannot be rerun') ->body('Backup set is archived or unavailable.') ->warning() ->send(); return; } try { $newRun = $restoreService->execute( tenant: $tenant, backupSet: $backupSet, selectedItemIds: $record->requested_items ?? null, dryRun: (bool) $record->is_dry_run, actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, groupMapping: $record->group_mapping ?? [] ); } catch (\Throwable $throwable) { Notification::make() ->title('Restore run failed to start') ->body($throwable->getMessage()) ->danger() ->send(); return; } $auditLogger->log( tenant: $tenant, action: 'restore_run.rerun', resourceType: 'restore_run', resourceId: (string) $newRun->id, status: 'success', context: [ 'metadata' => [ 'original_restore_run_id' => $record->id, 'backup_set_id' => $backupSet->id, ], ] ); Notification::make() ->title('Restore run started') ->success() ->send(); }), Actions\Action::make('restore') ->label('Restore') ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn (RestoreRun $record) => $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { $record->restore(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'restore_run.restored', resourceType: 'restore_run', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] ); } Notification::make() ->title('Restore run restored') ->success() ->send(); }), Actions\Action::make('archive') ->label('Archive') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (RestoreRun $record) => ! $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { if (! $record->isDeletable()) { Notification::make() ->title('Restore run cannot be archived') ->body("Not deletable (status: {$record->status})") ->warning() ->send(); return; } $record->delete(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'restore_run.deleted', resourceType: 'restore_run', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] ); } Notification::make() ->title('Restore run archived') ->success() ->send(); }), Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (RestoreRun $record) => $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'restore_run.force_deleted', resourceType: 'restore_run', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] ); } $record->forceDelete(); Notification::make() ->title('Restore run permanently deleted') ->success() ->send(); }), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ BulkAction::make('bulk_delete') ->label('Archive Restore Runs') ->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; }) ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') ->form(function (Collection $records) { if ($records->count() >= 20) { 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(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk delete started') ->body("Deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->duration(8000) ->sendToDatabase($user) ->send(); BulkRestoreRunDeleteJob::dispatch($run->id); } else { BulkRestoreRunDeleteJob::dispatchSync($run->id); } }) ->deselectRecordsAfterCompletion(), BulkAction::make('bulk_restore') ->label('Restore Restore Runs') ->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()} restore runs?") ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') ->action(function (Collection $records) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk restore started') ->body("Restoring {$count} restore runs in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->duration(8000) ->sendToDatabase($user) ->send(); BulkRestoreRunRestoreJob::dispatch($run->id); } else { BulkRestoreRunRestoreJob::dispatchSync($run->id); } }) ->deselectRecordsAfterCompletion(), BulkAction::make('bulk_force_delete') ->label('Force Delete Restore Runs') ->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()} restore runs?") ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs 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) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk force delete started') ->body("Force deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->duration(8000) ->sendToDatabase($user) ->send(); BulkRestoreRunForceDeleteJob::dispatch($run->id); } else { BulkRestoreRunForceDeleteJob::dispatchSync($run->id); } }) ->deselectRecordsAfterCompletion(), ]), ]); } public static function infolist(Schema $schema): Schema { return $schema ->schema([ Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'), Infolists\Components\TextEntry::make('status')->badge(), Infolists\Components\TextEntry::make('is_dry_run') ->label('Dry-run') ->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No') ->badge(), Infolists\Components\TextEntry::make('requested_by'), Infolists\Components\TextEntry::make('started_at')->dateTime(), Infolists\Components\TextEntry::make('completed_at')->dateTime(), Infolists\Components\ViewEntry::make('preview') ->label('Preview') ->view('filament.infolists.entries.restore-preview') ->state(fn ($record) => $record->preview ?? []), Infolists\Components\ViewEntry::make('results') ->label('Results') ->view('filament.infolists.entries.restore-results') ->state(fn ($record) => $record->results ?? []), ]); } public static function getPages(): array { return [ 'index' => Pages\ListRestoreRuns::route('/'), 'create' => Pages\CreateRestoreRun::route('/create'), 'view' => Pages\ViewRestoreRun::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 array{options: array, descriptions: array} */ private static function restoreItemOptionData(?int $backupSetId): array { $tenant = Tenant::current(); if (! $tenant || ! $backupSetId) { return [ 'options' => [], 'descriptions' => [], ]; } static $cache = []; $cacheKey = $tenant->getKey().':'.$backupSetId; if (isset($cache[$cacheKey])) { return $cache[$cacheKey]; } $items = BackupItem::query() ->where('backup_set_id', $backupSetId) ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) ->where(function ($query) { $query->whereNull('policy_id') ->orWhereDoesntHave('policy') ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); }) ->with('policy:id,display_name') ->get() ->sortBy(function (BackupItem $item) { $meta = static::typeMeta($item->policy_type); $category = $meta['category'] ?? 'Policies'; $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; $name = strtolower($item->resolvedDisplayName()); return strtolower($categoryKey.'-'.$name); }); $options = []; $descriptions = []; foreach ($items as $item) { $meta = static::typeMeta($item->policy_type); $typeLabel = $meta['label'] ?? $item->policy_type; $category = $meta['category'] ?? 'Policies'; $restore = $meta['restore'] ?? 'enabled'; $platform = $item->platform ?? $meta['platform'] ?? null; $displayName = $item->resolvedDisplayName(); $identifier = $item->policy_identifier ?? null; $options[$item->id] = $displayName; $parts = array_filter([ $category, $typeLabel, $platform, "restore: {$restore}", $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, $identifier ? 'id: '.Str::limit($identifier, 24, '...') : null, ]); $descriptions[$item->id] = implode(' • ', $parts); } return $cache[$cacheKey] = [ 'options' => $options, 'descriptions' => $descriptions, ]; } public static function createRestoreRun(array $data): RestoreRun { /** @var Tenant $tenant */ $tenant = Tenant::current(); /** @var BackupSet $backupSet */ $backupSet = BackupSet::findOrFail($data['backup_set_id']); if ($backupSet->tenant_id !== $tenant->id) { abort(403, 'Backup set does not belong to the active tenant.'); } /** @var RestoreService $service */ $service = app(RestoreService::class); return $service->execute( tenant: $tenant, backupSet: $backupSet, selectedItemIds: $data['backup_item_ids'] ?? null, dryRun: (bool) ($data['is_dry_run'] ?? true), actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, groupMapping: $data['group_mapping'] ?? [], ); } /** * @param array|null $selectedItemIds * @return array */ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array { if (! $backupSetId) { return []; } $query = BackupItem::query()->where('backup_set_id', $backupSetId); if ($selectedItemIds !== null) { $query->whereIn('id', $selectedItemIds); } $items = $query->get(['assignments']); $assignments = []; $sourceNames = []; foreach ($items as $item) { if (! is_array($item->assignments) || $item->assignments === []) { continue; } foreach ($item->assignments as $assignment) { if (! is_array($assignment)) { continue; } $target = $assignment['target'] ?? []; $odataType = $target['@odata.type'] ?? ''; if (! in_array($odataType, [ '#microsoft.graph.groupAssignmentTarget', '#microsoft.graph.exclusionGroupAssignmentTarget', ], true)) { continue; } $groupId = $target['groupId'] ?? null; if (! is_string($groupId) || $groupId === '') { continue; } $assignments[] = $groupId; $displayName = $target['group_display_name'] ?? null; if (is_string($displayName) && $displayName !== '') { $sourceNames[$groupId] = $displayName; } } } $groupIds = array_values(array_unique($assignments)); if ($groupIds === []) { return []; } $graphOptions = $tenant->graphOptions(); $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); $resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); $unresolved = []; foreach ($groupIds as $groupId) { $group = $resolved[$groupId] ?? null; if (! is_array($group) || ! ($group['orphaned'] ?? false)) { continue; } $label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId); $unresolved[] = [ 'id' => $groupId, 'label' => $label, ]; } return $unresolved; } /** * @return array */ private static function targetGroupOptions(Tenant $tenant, string $search): array { if (mb_strlen($search) < 2) { return []; } try { $response = app(GraphClientInterface::class)->request( 'GET', 'groups', [ 'query' => [ '$filter' => sprintf( "securityEnabled eq true and startswith(displayName,'%s')", static::escapeOdataValue($search) ), '$select' => 'id,displayName', '$top' => 20, ], ] + $tenant->graphOptions() ); } catch (\Throwable) { return []; } if ($response->failed()) { return []; } return collect($response->data['value'] ?? []) ->filter(fn (array $group) => filled($group['id'] ?? null)) ->mapWithKeys(fn (array $group) => [ $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']), ]) ->all(); } private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string { if (! $groupId) { return $groupId; } if ($groupId === 'SKIP') { return 'Skip assignment'; } $graphOptions = $tenant->graphOptions(); $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); $resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions); $group = $resolved[$groupId] ?? null; return static::formatGroupLabel($group['displayName'] ?? null, $groupId); } private static function formatGroupLabel(?string $displayName, string $id): string { $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); return trim(($displayName ?: 'Security group').$suffix); } private static function escapeOdataValue(string $value): string { return str_replace("'", "''", $value); } }