From 3cf4aa2cf4766b24600c0aca2141683430b41094 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 03:23:48 +0100 Subject: [PATCH] feat: restore run wizard phase 1 scaffold --- app/Filament/Resources/RestoreRunResource.php | 268 ++++++++++++++---- .../Pages/CreateRestoreRun.php | 8 + app/Models/RestoreRun.php | 25 +- app/Services/Intune/RestoreService.php | 20 +- app/Support/RestoreRunStatus.php | 73 +++++ specs/011-restore-run-wizard/tasks.md | 11 +- .../Filament/RestoreItemSelectionTest.php | 4 + tests/Feature/RestoreGroupMappingTest.php | 13 + .../Feature/RestoreRunWizardMetadataTest.php | 88 ++++++ 9 files changed, 444 insertions(+), 66 deletions(-) create mode 100644 app/Support/RestoreRunStatus.php create mode 100644 tests/Feature/RestoreRunWizardMetadataTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 3f7088d..d0f2d69 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -26,12 +26,14 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; +use Filament\Schemas\Components\Wizard\Step; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use UnitEnum; @@ -69,8 +71,10 @@ public static function form(Schema $schema): Schema }) ->reactive() ->afterStateUpdated(function (Set $set): void { - $set('backup_item_ids', []); + $set('scope_mode', 'all'); + $set('backup_item_ids', null); $set('group_mapping', []); + $set('is_dry_run', true); }) ->required(), Forms\Components\CheckboxList::make('backup_item_ids') @@ -137,6 +141,164 @@ public static function form(Schema $schema): Schema ]); } + /** + * @return array + */ + public static function getWizardSteps(): array + { + return [ + Step::make('Select Backup Set') + ->description('What are we restoring from?') + ->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('scope_mode', 'all'); + $set('backup_item_ids', null); + $set('group_mapping', []); + $set('is_dry_run', true); + }) + ->required(), + ]), + Step::make('Define Restore Scope') + ->description('What exactly should be restored?') + ->schema([ + Forms\Components\Radio::make('scope_mode') + ->label('Scope') + ->options([ + 'all' => 'All items (default)', + 'selected' => 'Selected items only', + ]) + ->default('all') + ->reactive() + ->afterStateUpdated(function (Set $set, $state): void { + $set('group_mapping', []); + + if ($state === 'all') { + $set('backup_item_ids', null); + } + }) + ->required(), + Forms\Components\CheckboxList::make('backup_item_ids') + ->label('Items to restore') + ->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', [])) + ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') + ->required(fn (Get $get): bool => $get('scope_mode') === 'selected') + ->helperText('Search by name, type, or ID. 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'); + $scopeMode = $get('scope_mode') ?? 'all'; + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return []; + } + + $selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null; + + if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) { + return []; + } + + $unresolved = static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: $scopeMode === 'selected' ? $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 (?string $value) => static::resolveTargetGroupLabel($tenant, $value)) + ->helperText('Choose a target group or select Skip.'); + }, $unresolved); + }) + ->visible(function (Get $get): bool { + $backupSetId = $get('backup_set_id'); + $scopeMode = $get('scope_mode') ?? 'all'; + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return false; + } + + $selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null; + + if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) { + return false; + } + + return static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null, + tenant: $tenant + ) !== []; + }), + ]), + Step::make('Safety & Conflict Checks') + ->description('Defensive checks (Phase 4)') + ->schema([ + Forms\Components\Placeholder::make('safety_checks_placeholder') + ->label('Status') + ->content('Safety & conflict checks will be added in Phase 4.'), + ]), + Step::make('Preview') + ->description('Dry-run preview (Phase 5)') + ->schema([ + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + Forms\Components\Placeholder::make('preview_placeholder') + ->label('Preview') + ->content('Preview diff summary will be added in Phase 5.'), + ]), + Step::make('Confirm & Execute') + ->description('Explicit confirmations (Phase 6)') + ->schema([ + Forms\Components\Placeholder::make('confirm_placeholder') + ->label('Execution') + ->content('Execution confirmations and gating will be added in Phase 6.'), + ]), + ]; + } + public static function table(Table $table): Table { return $table @@ -533,64 +695,61 @@ private static function restoreItemOptionData(?int $backupSetId): array ]; } - static $cache = []; - $cacheKey = $tenant->getKey().':'.$backupSetId; + $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId); - if (isset($cache[$cacheKey])) { - return $cache[$cacheKey]; - } + return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array { + $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', 'policyVersion:id,version_number,captured_at']) + ->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()); - $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', 'policyVersion:id,version_number,captured_at']) - ->get() - ->sortBy(function (BackupItem $item) { + 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'; - $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; - $name = strtolower($item->resolvedDisplayName()); + $restore = $meta['restore'] ?? 'enabled'; + $platform = $item->platform ?? $meta['platform'] ?? null; + $displayName = $item->resolvedDisplayName(); + $identifier = $item->policy_identifier ?? null; + $versionNumber = $item->policyVersion?->version_number; - return strtolower($categoryKey.'-'.$name); - }); + $options[$item->id] = $displayName; - $options = []; - $descriptions = []; + $parts = array_filter([ + $category, + $typeLabel, + $platform, + "restore: {$restore}", + $versionNumber ? "version: {$versionNumber}" : null, + $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, + $identifier ? 'id: '.Str::limit($identifier, 24, '...') : null, + ]); - 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; - $versionNumber = $item->policyVersion?->version_number; + $descriptions[$item->id] = implode(' • ', $parts); + } - $options[$item->id] = $displayName; - - $parts = array_filter([ - $category, - $typeLabel, - $platform, - "restore: {$restore}", - $versionNumber ? "version: {$versionNumber}" : null, - $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, - ]; + return [ + 'options' => $options, + 'descriptions' => $descriptions, + ]; + }); } public static function createRestoreRun(array $data): RestoreRun @@ -608,10 +767,15 @@ public static function createRestoreRun(array $data): RestoreRun /** @var RestoreService $service */ $service = app(RestoreService::class); + $scopeMode = $data['scope_mode'] ?? 'all'; + $selectedItemIds = ($scopeMode === 'selected') + ? ($data['backup_item_ids'] ?? null) + : null; + return $service->execute( tenant: $tenant, backupSet: $backupSet, - selectedItemIds: $data['backup_item_ids'] ?? null, + selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, dryRun: (bool) ($data['is_dry_run'] ?? true), actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2c22eb1..1ddc73c 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,13 +3,21 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; class CreateRestoreRun extends CreateRecord { + use HasWizard; + protected static string $resource = RestoreRunResource::class; + public function getSteps(): array + { + return RestoreRunResource::getWizardSteps(); + } + protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 28945c4..e4d3ce0 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\RestoreRunStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -35,17 +37,30 @@ public function backupSet(): BelongsTo return $this->belongsTo(BackupSet::class)->withTrashed(); } - public function scopeDeletable($query) + public function scopeDeletable(Builder $query): Builder { - return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); + return $query->whereIn('status', array_map( + static fn (RestoreRunStatus $status): string => $status->value, + [ + RestoreRunStatus::Draft, + RestoreRunStatus::Scoped, + RestoreRunStatus::Checked, + RestoreRunStatus::Previewed, + RestoreRunStatus::Completed, + RestoreRunStatus::Partial, + RestoreRunStatus::Failed, + RestoreRunStatus::Cancelled, + RestoreRunStatus::Aborted, + RestoreRunStatus::CompletedWithErrors, + ] + )); } public function isDeletable(): bool { - $status = strtolower(trim((string) $this->status)); - $status = str_replace([' ', '-'], '_', $status); + $status = RestoreRunStatus::fromString($this->status); - return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); + return $status?->isDeletable() ?? false; } // Group mapping helpers diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index d5082b1..2b8aaef 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); @@ -191,11 +195,21 @@ public function execute( ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); + $wizardMetadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()), + ]; + $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, @@ -205,7 +219,7 @@ public function execute( 'requested_items' => $selectedItemIds, 'preview' => $preview, 'started_at' => CarbonImmutable::now(), - 'metadata' => [], + 'metadata' => $wizardMetadata, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); @@ -740,12 +754,12 @@ public function execute( 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), - 'metadata' => [ + 'metadata' => array_merge($restoreRun->metadata ?? [], [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, 'total' => $totalCount, 'foundations_skipped' => $foundationSkipped, - ], + ]), ]); $this->auditLogger->log( diff --git a/app/Support/RestoreRunStatus.php b/app/Support/RestoreRunStatus.php new file mode 100644 index 0000000..727c4e5 --- /dev/null +++ b/app/Support/RestoreRunStatus.php @@ -0,0 +1,73 @@ + in_array($next, [self::Scoped, self::Cancelled], true), + self::Scoped => in_array($next, [self::Checked, self::Cancelled], true), + self::Checked => in_array($next, [self::Previewed, self::Cancelled], true), + self::Previewed => in_array($next, [self::Queued, self::Cancelled], true), + self::Pending => in_array($next, [self::Queued, self::Running, self::Cancelled], true), + self::Queued => in_array($next, [self::Running, self::Cancelled], true), + self::Running => in_array($next, [self::Completed, self::Partial, self::Failed, self::Cancelled], true), + self::Completed, + self::Partial, + self::Failed, + self::Cancelled, + self::Aborted, + self::CompletedWithErrors => false, + }; + } + + public function isDeletable(): bool + { + return in_array($this, [ + self::Draft, + self::Scoped, + self::Checked, + self::Previewed, + self::Completed, + self::Partial, + self::Failed, + self::Cancelled, + self::Aborted, + self::CompletedWithErrors, + ], true); + } +} diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 323e85b..a8066f8 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -7,13 +7,13 @@ ## Phase 0 — Specs (this PR) - [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. ## Phase 1 — Data Model + Status Semantics -- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). -- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). -- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit. +- [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). +- [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). +- [x] T004 Freeze `environment` + `highlander_label` at run creation for audit. ## Phase 2 — Filament Wizard (Create Restore Run) -- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). -- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. +- [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). +- [x] T006 Ensure changing `backup_set_id` resets downstream wizard state. - [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. ## Phase 3 — Restore Scope UX @@ -40,4 +40,3 @@ ## Phase 7 — Tests + Formatting - [ ] T020 Add Pest tests for preview summary generation. - [ ] T021 Run `./vendor/bin/pint --dirty`. - [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). - diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index a3ce170..63ec789 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -84,6 +84,10 @@ ->fillForm([ 'backup_set_id' => $backupSet->id, ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + ]) ->assertSee('Policy Display') ->assertDontSee('Ignored Policy') ->assertSee('Scope Tag Alpha') diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 2397796..260cc99 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -80,6 +80,10 @@ Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], ]) ->assertFormFieldVisible('group_mapping.source-group-1'); @@ -150,12 +154,21 @@ Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], 'group_mapping' => [ 'source-group-1' => 'target-group-1', ], + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->fillForm([ 'is_dry_run' => true, ]) + ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php new file mode 100644 index 0000000..1f81df3 --- /dev/null +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -0,0 +1,88 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => ['id' => 'policy-1'], + 'metadata' => [ + 'displayName' => 'Backup Policy One', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'tester@example.com', + 'name' => 'Tester', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => true, + ]) + ->goToNextWizardStep() + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->metadata)->toHaveKeys([ + 'scope_mode', + 'environment', + 'highlander_label', + 'failed', + 'non_applied', + 'total', + 'foundations_skipped', + ]); + + expect($run->metadata['scope_mode'])->toBe('selected'); + expect($run->metadata['environment'])->toBe('test'); + expect($run->metadata['highlander_label'])->toBe('Tenant One'); +});