diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource.php b/apps/platform/app/Filament/Resources/RestoreRunResource.php index 290bcea3..3f8534de 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource.php @@ -8,6 +8,7 @@ use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes; use App\Filament\Resources\RestoreRunResource\Pages; +use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter; use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunRestoreJob; @@ -30,11 +31,13 @@ use App\Services\Intune\RestoreService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartResult; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\BackupQuality\BackupQualityResolver; +use App\Support\BackupQuality\BackupQualitySummary; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Filament\FilterOptionCatalog; @@ -67,11 +70,13 @@ use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Schemas\Components\Grid; 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\Support\Exceptions\Halt; use Filament\Tables; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; @@ -80,6 +85,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -173,8 +179,7 @@ public static function form(Schema $schema): Schema ->reactive() ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->helperText(fn (): string => static::restoreItemQualityHelperText()), - Section::make('Group mapping') - ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') + Section::make('Resolve target mappings') ->schema(function (Get $get): array { $backupSetId = $get('backup_set_id'); $selectedItemIds = $get('backup_item_ids'); @@ -190,52 +195,66 @@ public static function form(Schema $schema): Schema tenant: $tenant ); - $groupCacheQuery = EntraGroup::query()->where('managed_environment_id', $tenant->getKey()); - $hasCachedGroups = $groupCacheQuery->exists(); + return [ + Forms\Components\ViewField::make('restore_mapping_resolver_summary_form') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-mapping-resolver-summary') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)), + ...array_map(function (array $group) use ($tenant): Forms\Components\TextInput { + $groupId = $group['id']; + $sourceDisplayName = is_string($group['displayName'] ?? null) ? $group['displayName'] : null; + $sourceLabel = filled($sourceDisplayName) ? $sourceDisplayName : 'Unknown source group'; - $stalenessDays = (int) config('directory_groups.staleness_days', 30); - $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); - $latestSeen = $groupCacheQuery->max('last_seen_at'); - $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); - - $cacheNotice = match (true) { - ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', - $isStale => "Cached groups may be stale (>{$stalenessDays} days). Consider running \"Sync Groups\".", - default => null, - }; - - return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { - $groupId = $group['id']; - $label = $group['label']; - - return Forms\Components\TextInput::make("group_mapping.{$groupId}") - ->label($label) - ->placeholder('SKIP or target group Object ID (GUID)') - ->rules([new SkipOrUuidRule]) - ->required() - ->suffixAction( - Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) - ->icon('heroicon-o-magnifying-glass') - ->iconButton() - ->tooltip('Select from Directory cache') - ->modalHeading('Select from Directory cache') - ->modalWidth('5xl') - ->modalSubmitAction(false) - ->modalCancelActionLabel('Close') - ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ - 'sourceGroupId' => $groupId, - ])) - ) - ->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') - ->hintAction( - Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel())) - ->visible(fn (): bool => $cacheNotice !== null) - ); - }, $unresolved); + return Forms\Components\TextInput::make("group_mapping.{$groupId}") + ->label($sourceLabel) + ->placeholder('Target group Object ID (GUID)') + ->rules([new SkipOrUuidRule]) + ->live(onBlur: true) + ->helperText(fn (Get $get): HtmlString => static::groupMappingIdentityHelperText( + tenant: $tenant, + sourceGroupId: $groupId, + rawValue: $get("group_mapping.{$groupId}"), + )) + ->hintActions([ + Actions\Action::make('skip_assignment_'.str_replace('-', '_', $groupId)) + ->label('Skip assignment') + ->icon('heroicon-o-minus-circle') + ->color('gray') + ->link() + ->visible(fn (Get $get): bool => strtoupper(trim((string) $get("group_mapping.{$groupId}"))) !== 'SKIP') + ->action(function (Get $get, Set $set) use ($groupId): void { + $set("group_mapping.{$groupId}", 'SKIP', shouldCallUpdatedHooks: true); + static::touchWizardDraftAfterGroupMappingChange($get, $set); + }), + Actions\Action::make('undo_skip_assignment_'.str_replace('-', '_', $groupId)) + ->label('Undo skip') + ->icon('heroicon-o-arrow-uturn-left') + ->color('gray') + ->link() + ->visible(fn (Get $get): bool => strtoupper(trim((string) $get("group_mapping.{$groupId}"))) === 'SKIP') + ->action(function (Get $get, Set $set) use ($groupId): void { + $set("group_mapping.{$groupId}", null, shouldCallUpdatedHooks: true); + static::touchWizardDraftAfterGroupMappingChange($get, $set); + }), + ]) + ->required() + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Find target group for this assignment') + ->modalHeading('Resolve target group mapping') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Enter object ID manually') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + 'sourceGroupDisplayName' => $sourceDisplayName, + 'tenantId' => (int) $tenant->getKey(), + ])) + ); + }, $unresolved), + ]; }) ->visible(function (Get $get): bool { $backupSetId = $get('backup_set_id'); @@ -251,7 +270,9 @@ public static function form(Schema $schema): Schema selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, tenant: $tenant ) !== []; - }), + }) + ->collapsible() + ->collapsed(), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true), @@ -319,8 +340,12 @@ public static function getWizardSteps(): array { return [ Step::make('Select Backup Set') - ->description('What are we restoring from? Backup quality is visible here before safety checks run.') + ->description('Choose a source and review restore safety from the start of the workflow.') ->schema([ + Forms\Components\ViewField::make('restore_safety_decision') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-safety-decision') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)), Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(fn () => static::restoreBackupSetOptions()) @@ -353,9 +378,57 @@ public static function getWizardSteps(): array $set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']); }) ->required(), + Forms\Components\ViewField::make('restore_backup_quality_summary') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-backup-quality-summary') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)), + Grid::make([ + 'default' => 1, + 'xl' => 12, + ]) + ->schema([ + Forms\Components\ViewField::make('restore_safety_gates') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-safety-gates') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)) + ->columnSpan([ + 'default' => 1, + 'xl' => 8, + ]), + Forms\Components\ViewField::make('restore_proof_aside') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-proof-aside') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)) + ->columnSpan([ + 'default' => 1, + 'xl' => 4, + ]), + ]), ]), Step::make('Define Restore Scope') - ->description('What exactly should be restored? Item quality hints appear here before restore risk checks.') + ->description('Define restore scope and dependency mapping. Safety status stays compact here.') + ->afterValidation(function (Get $get, \Filament\Schemas\Components\Wizard\Step $component): void { + $contract = static::restoreWizardViewData($get, currentStep: 2, compactFlow: true); + $mappingResolver = is_array($contract['mappingResolver'] ?? null) ? $contract['mappingResolver'] : []; + + if (! ($mappingResolver['requiresResolution'] ?? false)) { + return; + } + + $wizard = $component->getContainer()->getParentComponent(); + + if ($wizard instanceof \Filament\Schemas\Components\Wizard) { + $wizard->goToStep($component->getKey()); + } + + Notification::make() + ->title('Mappings required') + ->body('Resolve required mappings before validation can run.') + ->danger() + ->send(); + + throw new Halt; + }) ->schema([ Forms\Components\Radio::make('scope_mode') ->label('Scope') @@ -479,8 +552,36 @@ public static function getWizardSteps(): array ->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)), ]) ->helperText(fn (): string => static::restoreItemQualityHelperText()), - Section::make('Group mapping') - ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') + Forms\Components\ViewField::make('restore_scope_summary') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-scope-summary') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)), + Section::make('Resolve target mappings') + ->description(function (Get $get): string { + $contract = static::restoreWizardViewData($get, currentStep: 2, compactFlow: true); + $mappingResolver = is_array($contract['mappingResolver'] ?? null) ? $contract['mappingResolver'] : []; + + $resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0); + $totalCount = (int) ($mappingResolver['totalCount'] ?? 0); + $unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0); + $skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0); + $manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0); + + $parts = [ + "{$resolvedCount} of {$totalCount} mappings resolved", + "{$unresolvedCount} unresolved", + "{$skippedCount} skipped", + ]; + + if ($manualFallbackCount > 0) { + $parts[] = "{$manualFallbackCount} manual fallback"; + } + + return implode(' · ', $parts); + }) + ->extraAttributes([ + 'data-testid' => 'restore-run-mapping-resolver-section', + ]) ->schema(function (Get $get): array { $backupSetId = $get('backup_set_id'); $scopeMode = $get('scope_mode') ?? 'all'; @@ -503,64 +604,89 @@ public static function getWizardSteps(): array tenant: $tenant ); - $groupCacheQuery = EntraGroup::query()->where('managed_environment_id', $tenant->getKey()); - $hasCachedGroups = $groupCacheQuery->exists(); + $rows = []; - $stalenessDays = (int) config('directory_groups.staleness_days', 30); - $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); - $latestSeen = $groupCacheQuery->max('last_seen_at'); - $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); - - $cacheNotice = match (true) { - ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', - $isStale => "Cached groups may be stale (>{$stalenessDays} days). Consider running \"Sync Groups\".", - default => null, - }; - - return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { + foreach ($unresolved as $group) { $groupId = $group['id']; - $label = $group['label']; + $sourceDisplayName = is_string($group['displayName'] ?? null) ? $group['displayName'] : null; + $sourceLabel = filled($sourceDisplayName) ? $sourceDisplayName : 'Unknown source group'; + $safeGroupToken = str_replace('-', '_', $groupId); + $statePath = "group_mapping.{$groupId}"; - return Forms\Components\TextInput::make("group_mapping.{$groupId}") - ->label($label) - ->placeholder('SKIP or target group Object ID (GUID)') + $isSkipped = fn (Get $get): bool => strtoupper(trim((string) $get($statePath))) === 'SKIP'; + + $rows[] = Forms\Components\ViewField::make("group_mapping_skipped_{$safeGroupToken}") + ->label($sourceLabel) + ->view('filament.forms.components.restore-run-group-mapping-skipped') + ->viewData(fn (Get $get): array => [ + 'identityHtml' => static::groupMappingIdentityHelperText( + tenant: $tenant, + sourceGroupId: $groupId, + rawValue: $get($statePath), + ), + ]) + ->visible($isSkipped) + ->hintActions([ + Actions\Action::make("undo_skip_assignment_{$safeGroupToken}") + ->label('Undo skip') + ->icon('heroicon-o-arrow-uturn-left') + ->color('gray') + ->link() + ->action(function (Get $get, Set $set) use ($groupId): void { + $set("group_mapping.{$groupId}", null, shouldCallUpdatedHooks: true); + static::touchWizardDraftAfterGroupMappingChange($get, $set); + }), + ]); + + $rows[] = Forms\Components\TextInput::make($statePath) + ->label($sourceLabel) + ->placeholder('Target group Object ID (GUID)') ->rules([new SkipOrUuidRule]) - ->reactive() - ->afterStateUpdated(function (Set $set, Get $get): void { - $set('is_dry_run', true); - $set('acknowledged_impact', false); - $set('tenant_confirm', null); - - $draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get)); - - $set('scope_basis', $draft['scope_basis']); - $set('check_invalidation_reasons', $draft['check_invalidation_reasons']); - $set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']); - }) - ->required() + ->live(onBlur: true) + ->afterStateUpdated(fn (Set $set, Get $get) => static::touchWizardDraftAfterGroupMappingChange($get, $set)) + ->helperText(fn (Get $get): HtmlString => static::groupMappingIdentityHelperText( + tenant: $tenant, + sourceGroupId: $groupId, + rawValue: $get($statePath), + )) + ->hintActions([ + Actions\Action::make("skip_assignment_{$safeGroupToken}") + ->label('Skip assignment') + ->icon('heroicon-o-minus-circle') + ->color('gray') + ->link() + ->action(function (Get $get, Set $set) use ($groupId): void { + $set("group_mapping.{$groupId}", 'SKIP', shouldCallUpdatedHooks: true); + static::touchWizardDraftAfterGroupMappingChange($get, $set); + }), + ]) + ->markAsRequired() + ->visible(fn (Get $get): bool => ! $isSkipped($get)) + ->dehydratedWhenHidden() ->suffixAction( - Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + Actions\Action::make("select_from_directory_cache_{$safeGroupToken}") ->icon('heroicon-o-magnifying-glass') ->iconButton() - ->tooltip('Select from Directory cache') - ->modalHeading('Select from Directory cache') + ->tooltip('Find target group for this assignment') + ->modalHeading('Resolve target group mapping') ->modalWidth('5xl') ->modalSubmitAction(false) - ->modalCancelActionLabel('Close') + ->modalCancelActionLabel('Enter object ID manually') ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ 'sourceGroupId' => $groupId, + 'sourceGroupDisplayName' => $sourceDisplayName, + 'tenantId' => (int) $tenant->getKey(), ])) - ) - ->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') - ->hintAction( - Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel())) - ->visible(fn (): bool => $cacheNotice !== null) ); - }, $unresolved); + } + + return [ + Forms\Components\ViewField::make('restore_mapping_resolver_summary') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-mapping-resolver-summary') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)), + ...$rows, + ]; }) ->visible(function (Get $get): bool { $backupSetId = $get('backup_set_id'); @@ -583,10 +709,85 @@ public static function getWizardSteps(): array selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null, tenant: $tenant ) !== []; - }), + }) + ->collapsible() + ->collapsed(), + Grid::make([ + 'default' => 1, + 'xl' => 12, + ]) + ->schema([ + Forms\Components\ViewField::make('restore_safety_status_scope') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-safety-gates') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)) + ->columnSpan([ + 'default' => 1, + 'xl' => 8, + ]), + Forms\Components\ViewField::make('restore_proof_scope') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-proof-aside') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)) + ->columnSpan([ + 'default' => 1, + 'xl' => 4, + ]), + ]), ]), Step::make('Safety & Conflict Checks') - ->description('Is this dangerous?') + ->description('Validate impact before execution.') + ->afterValidation(function (Get $get, \Filament\Schemas\Components\Wizard\Step $component): void { + $tenant = static::resolveTenantContextForCurrentPanel(); + + $wizard = $component->getContainer()->getParentComponent(); + + if ($tenant instanceof ManagedEnvironment) { + $resolution = app(ProviderConnectionResolver::class)->resolveDefault($tenant, 'microsoft'); + + if (! $resolution->resolved) { + if ($wizard instanceof \Filament\Schemas\Components\Wizard) { + $wizard->goToStep($component->getKey()); + } + + Notification::make() + ->title('Validation blocked') + ->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.') + ->danger() + ->actions([ + \Filament\Actions\Action::make('review_provider_connection') + ->label('Review provider connection') + ->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true) + ->button(), + ]) + ->send(); + + throw new Halt; + } + } + + $state = static::wizardSafetyState(static::draftDataSnapshot($get)); + $checksIntegrity = $state['checksIntegrity'] ?? []; + $blockingCount = is_array($checksIntegrity) + ? (int) ($checksIntegrity['blocking_count'] ?? 0) + : 0; + + if ($blockingCount <= 0) { + return; + } + + if ($wizard instanceof \Filament\Schemas\Components\Wizard) { + $wizard->goToStep($component->getKey()); + } + + Notification::make() + ->title('Validation blocked') + ->body('Resolve the blocking validation issues before moving to preview.') + ->danger() + ->send(); + + throw new Halt; + }) ->schema([ Forms\Components\Hidden::make('scope_basis') ->default(null), @@ -602,17 +803,41 @@ public static function getWizardSteps(): array ->label('Checks') ->default([]) ->view('filament.forms.components.restore-run-checks') - ->viewData(fn (Get $get): array => [ - 'summary' => $get('check_summary'), - 'ranAt' => $get('checks_ran_at'), - ...static::wizardSafetyState(static::draftDataSnapshot($get)), - ]) + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true)) ->hintActions([ Actions\Action::make('run_restore_checks') ->label('Run checks') ->icon('heroicon-o-shield-check') ->color('gray') ->visible(fn (Get $get): bool => filled($get('backup_set_id'))) + ->disabled(function (): bool { + $tenant = static::resolveTenantContextForCurrentPanel(); + + if (! $tenant instanceof ManagedEnvironment) { + return true; + } + + $resolution = app(\App\Services\Providers\ProviderConnectionResolver::class) + ->resolveDefault($tenant, 'microsoft'); + + return ! $resolution->resolved; + }) + ->tooltip(function (): ?string { + $tenant = static::resolveTenantContextForCurrentPanel(); + + if (! $tenant instanceof ManagedEnvironment) { + return 'Validation blocked'; + } + + $resolution = app(\App\Services\Providers\ProviderConnectionResolver::class) + ->resolveDefault($tenant, 'microsoft'); + + if ($resolution->resolved) { + return null; + } + + return 'Validation blocked. Provider credentials are not available for this environment.'; + }) ->action(function (Get $get, Set $set): void { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -620,6 +845,25 @@ public static function getWizardSteps(): array return; } + $providerResolution = app(\App\Services\Providers\ProviderConnectionResolver::class) + ->resolveDefault($tenant, 'microsoft'); + + if (! $providerResolution->resolved) { + Notification::make() + ->title('Validation blocked') + ->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.') + ->danger() + ->actions([ + \Filament\Actions\Action::make('review_provider_connection') + ->label('Review provider connection') + ->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true) + ->button(), + ]) + ->send(); + + return; + } + $backupSetId = $get('backup_set_id'); if (! $backupSetId) { @@ -631,7 +875,7 @@ public static function getWizardSteps(): array if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) { Notification::make() ->title('Unable to run checks') - ->body('Backup set is not available for the active tenant.') + ->body('Backup set is not available for the active environment.') ->danger() ->send(); @@ -648,12 +892,36 @@ public static function getWizardSteps(): array $groupMapping = static::normalizeGroupMapping($get('group_mapping')); $checker = app(RestoreRiskChecker::class); - $outcome = $checker->check( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: $selectedItemIds, - groupMapping: $groupMapping, - ); + try { + $outcome = $checker->check( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + groupMapping: $groupMapping, + ); + } catch (\App\Services\Providers\ProviderConfigurationRequiredException) { + Notification::make() + ->title('Validation blocked') + ->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.') + ->danger() + ->actions([ + \Filament\Actions\Action::make('review_provider_connection') + ->label('Review provider connection') + ->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true) + ->button(), + ]) + ->send(); + + return; + } catch (\Throwable) { + Notification::make() + ->title('Unable to run checks') + ->body('Validation could not run due to an unexpected error.') + ->danger() + ->send(); + + return; + } $ranAt = now('UTC')->toIso8601String(); $draft = [ @@ -681,9 +949,25 @@ public static function getWizardSteps(): array $set('is_dry_run', true, shouldCallUpdatedHooks: true); } + $bodyParts = []; + + if ($blockers > 0) { + $bodyParts[] = $blockers.' '.Str::plural('blocker', $blockers); + } + + if ($warnings > 0) { + $bodyParts[] = $warnings.' '.Str::plural('warning', $warnings); + } + + $notificationTitle = match (true) { + $blockers > 0 => 'Safety checks finished with blockers', + $warnings > 0 => 'Safety checks finished with warnings', + default => 'Safety checks completed', + }; + Notification::make() - ->title('Safety checks completed') - ->body("Blocking: {$blockers} • Warnings: {$warnings}") + ->title($notificationTitle) + ->body($bodyParts === [] ? 'No blockers or warnings' : implode(' · ', $bodyParts)) ->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success')) ->send(); }), @@ -704,9 +988,74 @@ public static function getWizardSteps(): array }), ]) ->helperText('Run checks after defining scope and mapping missing groups.'), + Grid::make([ + 'default' => 1, + 'xl' => 12, + ]) + ->schema([ + Forms\Components\ViewField::make('restore_safety_status_checks') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-safety-gates') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true)) + ->columnSpan([ + 'default' => 1, + 'xl' => 8, + ]), + Forms\Components\ViewField::make('restore_proof_checks') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-proof-aside') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true)) + ->columnSpan([ + 'default' => 1, + 'xl' => 4, + ]), + ]), ]), Step::make('Preview') ->description('Dry-run preview') + ->afterValidation(function (Get $get): void { + $state = static::wizardSafetyState(static::draftDataSnapshot($get)); + $previewIntegrity = $state['previewIntegrity'] ?? []; + $checksIntegrity = $state['checksIntegrity'] ?? []; + $executionReadiness = $state['executionReadiness'] ?? []; + + $previewIsCurrent = is_array($previewIntegrity) + && ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT; + $checksAreCurrent = is_array($checksIntegrity) + && ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT; + $executionAllowed = is_array($executionReadiness) + && (bool) ($executionReadiness['allowed'] ?? false); + + if (! $checksAreCurrent) { + Notification::make() + ->title('Safety checks required') + ->body('Run the safety checks for the current scope before proceeding to confirmation.') + ->warning() + ->send(); + + throw new Halt; + } + + if (! $previewIsCurrent) { + Notification::make() + ->title('Preview required') + ->body('Generate a preview for the current scope before proceeding to confirmation.') + ->warning() + ->send(); + + throw new Halt; + } + + if (! $executionAllowed) { + Notification::make() + ->title('Execution blocked') + ->body('Review prerequisites before proceeding to confirmation.') + ->danger() + ->send(); + + throw new Halt; + } + }) ->schema([ Forms\Components\Hidden::make('preview_summary') ->default(null), @@ -724,7 +1073,7 @@ public static function getWizardSteps(): array ->viewData(fn (Get $get): array => [ 'summary' => $get('preview_summary'), 'ranAt' => $get('preview_ran_at'), - ...static::wizardSafetyState(static::draftDataSnapshot($get)), + ...static::restoreWizardViewData($get, currentStep: 4, compactFlow: true), ]) ->hintActions([ Actions\Action::make('run_restore_preview') @@ -750,7 +1099,7 @@ public static function getWizardSteps(): array if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) { Notification::make() ->title('Unable to generate preview') - ->body('Backup set is not available for the active tenant.') + ->body('Backup set is not available for the active environment.') ->danger() ->send(); @@ -793,11 +1142,22 @@ public static function getWizardSteps(): array $policiesChanged = (int) ($summary['policies_changed'] ?? 0); $policiesTotal = (int) ($summary['policies_total'] ?? 0); + $previewBody = match (true) { + $policiesTotal <= 0 => 'No policies in scope.', + $policiesChanged <= 0 => 'No policy changes detected.', + $policiesChanged === 1 => '1 policy will be updated during execution.', + default => "{$policiesChanged} policies will be updated during execution.", + }; + $previewStatus = match (true) { + $policiesTotal <= 0 => 'info', + $policiesChanged > 0 => 'warning', + default => 'success', + }; Notification::make() ->title('Preview generated') - ->body("Policies: {$policiesChanged}/{$policiesTotal} changed") - ->status($policiesChanged > 0 ? 'warning' : 'success') + ->body($previewBody) + ->status($previewStatus) ->send(); }), Actions\Action::make('clear_restore_preview') @@ -821,88 +1181,36 @@ public static function getWizardSteps(): array Step::make('Confirm & Execute') ->description('Point of no return') ->schema([ - Forms\Components\Placeholder::make('confirm_environment') - ->label('Environment') - ->content(fn (): string => app()->environment('production') ? 'prod' : 'test'), - Forms\Components\Placeholder::make('confirm_tenant_label') - ->label('ManagedEnvironment hard-confirm label') - ->content(function (): string { - $tenant = static::resolveTenantContextForCurrentPanel(); - - if (! $tenant) { - return ''; - } - - $tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id; - - return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); - }), - Forms\Components\Placeholder::make('confirm_execution_readiness') - ->label('Technical startability') - ->content(function (Get $get): string { - $state = static::wizardSafetyState(static::draftDataSnapshot($get)); - $readiness = $state['executionReadiness']; - - if (! is_array($readiness)) { - return 'Execution readiness is unavailable.'; - } - - return (string) ($readiness['display_summary'] ?? 'Execution readiness is unavailable.'); - }), - Forms\Components\Placeholder::make('confirm_safety_readiness') - ->label('Safety readiness') - ->content(function (Get $get): string { - $state = static::wizardSafetyState(static::draftDataSnapshot($get)); - $assessment = $state['safetyAssessment']; - - if (! is_array($assessment)) { - return 'Safety readiness is unavailable.'; - } - - return (string) ($assessment['summary'] ?? 'Safety readiness is unavailable.'); - }), - Forms\Components\Placeholder::make('confirm_primary_next_step') - ->label('Primary next step') - ->content(function (Get $get): string { - $state = static::wizardSafetyState(static::draftDataSnapshot($get)); - $assessment = $state['safetyAssessment']; - - if (! is_array($assessment)) { - return 'Review the current scope and safety evidence.'; - } - - return RestoreSafetyCopy::primaryNextAction( - is_string($assessment['primary_next_action'] ?? null) - ? $assessment['primary_next_action'] - : 'review_scope' - ); - }), + Forms\Components\ViewField::make('restore_confirm_panel') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-confirm-panel') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true)), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true) ->reactive() ->disabled(function (Get $get): bool { - $state = static::wizardSafetyState(static::draftDataSnapshot($get)); - $readiness = $state['executionReadiness']; + $contract = static::restoreWizardViewData($get, currentStep: 5, compactFlow: true); + $readiness = $contract['executionReadiness'] ?? null; return ! is_array($readiness) || ! (bool) ($readiness['allowed'] ?? false); }) ->helperText(function (Get $get): string { - $state = static::wizardSafetyState(static::draftDataSnapshot($get)); - $assessment = $state['safetyAssessment']; + $contract = static::restoreWizardViewData($get, currentStep: 5, compactFlow: true); + $decisionCard = is_array($contract['decisionCard'] ?? null) ? $contract['decisionCard'] : []; - if (! is_array($assessment)) { - return 'Turn OFF to queue a real execution. Execution requires checks, preview, and confirmations.'; + if (! is_array($decisionCard)) { + return 'Turn OFF to queue real execution. Execution requires checks, preview, and confirmation.'; } - return (string) ($assessment['summary'] ?? 'Turn OFF to queue a real execution. Execution requires checks, preview, and confirmations.'); + return (string) ($decisionCard['impact'] ?? 'Turn OFF to queue real execution. Execution requires checks, preview, and confirmation.'); }), Forms\Components\Checkbox::make('acknowledged_impact') ->label('I reviewed the impact (checks + preview)') ->accepted() ->visible(fn (Get $get): bool => $get('is_dry_run') === false), Forms\Components\TextInput::make('tenant_confirm') - ->label('Type the tenant label to confirm execution') + ->label('Type the environment label to confirm execution') ->required(fn (Get $get): bool => $get('is_dry_run') === false) ->visible(fn (Get $get): bool => $get('is_dry_run') === false) ->in(function (): array { @@ -917,7 +1225,7 @@ public static function getWizardSteps(): array return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())]; }) ->validationMessages([ - 'in' => 'ManagedEnvironment hard-confirm does not match.', + 'in' => 'Confirmation label does not match.', ]) ->helperText(function (): string { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -931,6 +1239,28 @@ public static function getWizardSteps(): array return "Type: {$expected}"; }), + Grid::make([ + 'default' => 1, + 'xl' => 12, + ]) + ->schema([ + Forms\Components\ViewField::make('restore_safety_status_confirm') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-safety-gates') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true)) + ->columnSpan([ + 'default' => 1, + 'xl' => 8, + ]), + Forms\Components\ViewField::make('restore_proof_confirm') + ->hiddenLabel() + ->view('filament.forms.components.restore-run-proof-aside') + ->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true)) + ->columnSpan([ + 'default' => 1, + 'xl' => 4, + ]), + ]), ]), ]; } @@ -1688,6 +2018,588 @@ private static function backupItemQualitySummary(BackupItem $backupItem): \App\S return app(BackupQualityResolver::class)->forBackupItem($backupItem); } + private static function restoreWizardViewData(Get $get, int $currentStep, bool $compactFlow = false): array + { + $tenant = static::resolveTenantContextForCurrentPanel(); + $user = auth()->user(); + + return RestoreRunCreatePresenter::contract( + data: static::draftDataSnapshot($get), + currentStep: $currentStep, + compactFlow: $compactFlow, + tenant: $tenant instanceof ManagedEnvironment ? $tenant : null, + user: $user instanceof User ? $user : null, + ); + } + + /** + * @param array $data + * @return array{ + * decisionCard: array, + * backupQualityCard: array, + * processFlow: array, + * proofAside: array, + * mappingResolver: array, + * diagnosticsDisclosure: array + * } + */ + private static function restoreWizardPresentationState(array $data): array + { + static $memo = []; + + $tenant = static::resolveTenantContextForCurrentPanel(); + $user = auth()->user(); + $draft = static::synchronizeRestoreSafetyDraft($data); + + $cacheKey = md5(serialize([ + 'tenant_id' => $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null, + 'user_id' => $user instanceof User ? (int) $user->getKey() : null, + 'draft' => $draft, + ])); + + if (array_key_exists($cacheKey, $memo)) { + return $memo[$cacheKey]; + } + + $wizardSafetyState = static::wizardSafetyState($draft); + $currentScope = is_array($wizardSafetyState['currentScope'] ?? null) ? $wizardSafetyState['currentScope'] : []; + $previewIntegrity = is_array($wizardSafetyState['previewIntegrity'] ?? null) ? $wizardSafetyState['previewIntegrity'] : []; + $checksIntegrity = is_array($wizardSafetyState['checksIntegrity'] ?? null) ? $wizardSafetyState['checksIntegrity'] : []; + $executionReadiness = is_array($wizardSafetyState['executionReadiness'] ?? null) ? $wizardSafetyState['executionReadiness'] : []; + $safetyAssessment = is_array($wizardSafetyState['safetyAssessment'] ?? null) ? $wizardSafetyState['safetyAssessment'] : []; + + $backupSetId = is_numeric($currentScope['backup_set_id'] ?? null) + ? (int) $currentScope['backup_set_id'] + : null; + $scopeMode = ($currentScope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all'; + $selectedItemIds = array_values(array_filter( + is_array($currentScope['selected_item_ids'] ?? null) ? $currentScope['selected_item_ids'] : [], + static fn (mixed $itemId): bool => is_int($itemId) || (is_string($itemId) && ctype_digit($itemId)) + )); + $groupMapping = static::normalizeGroupMapping($currentScope['group_mapping'] ?? []); + $groupMappingProgress = static::groupMappingProgressValues($currentScope['group_mapping'] ?? []); + + $backupSet = static::restoreWizardSelectedBackupSet($backupSetId, $tenant); + $backupQuality = $backupSet instanceof BackupSet + ? static::backupSetQualitySummary($backupSet) + : null; + + $selectedItemCount = count($selectedItemIds); + $unresolvedGroups = ($tenant instanceof ManagedEnvironment) && $backupSetId !== null + ? static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null, + tenant: $tenant, + ) + : []; + $unresolvedGroupCount = count($unresolvedGroups); + $resolvedGroupCount = 0; + $skippedGroupCount = 0; + $pendingGroupCount = 0; + $manualFallbackCount = 0; + $mappingValues = []; + $resolvedTargetIds = []; + + foreach ($unresolvedGroups as $group) { + $groupId = is_string($group['id'] ?? null) ? $group['id'] : null; + $value = $groupId !== null ? trim((string) ($groupMappingProgress[$groupId] ?? '')) : ''; + + if ($groupId !== null) { + $mappingValues[$groupId] = $value; + } + + if ($value === '') { + $pendingGroupCount++; + + continue; + } + + if (strtoupper($value) === 'SKIP') { + $skippedGroupCount++; + + continue; + } + + if (! Str::isUuid($value)) { + $pendingGroupCount++; + + continue; + } + + $resolvedGroupCount++; + $resolvedTargetIds[] = strtolower($value); + } + + $cachedTargetLookup = []; + + if ($tenant instanceof ManagedEnvironment && $resolvedTargetIds !== []) { + $cachedTargetIds = EntraGroup::query() + ->where('managed_environment_id', $tenant->getKey()) + ->whereIn('entra_id', array_values(array_unique($resolvedTargetIds))) + ->pluck('entra_id') + ->map(static fn (string $entraId): string => strtolower($entraId)) + ->values() + ->all(); + + $cachedTargetLookup = array_fill_keys($cachedTargetIds, true); + } + + foreach ($mappingValues as $value) { + if ($value === '' || strtoupper($value) === 'SKIP' || ! Str::isUuid($value)) { + continue; + } + + if (! array_key_exists(strtolower($value), $cachedTargetLookup)) { + $manualFallbackCount++; + } + } + + $mappedGroupCount = $resolvedGroupCount + $skippedGroupCount; + + $hasUsableSource = $backupSet instanceof BackupSet + && $backupQuality instanceof BackupQualitySummary + && $backupQuality->totalItems > 0 + && ($backupQuality->totalItems - $backupQuality->metadataOnlyCount) > 0; + $targetSelected = $tenant instanceof ManagedEnvironment; + $checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT; + $previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT; + $executionTechnicallyAllowed = (bool) ($executionReadiness['allowed'] ?? false); + $scopeDefined = $backupSetId !== null && ($scopeMode === 'all' || $selectedItemCount > 0); + $scopeDependencyResolved = $scopeDefined + && $mappedGroupCount >= $unresolvedGroupCount + && ($checksAreCurrent || $previewIsCurrent); + $executionAvailableAfterConfirmation = $executionTechnicallyAllowed && $checksAreCurrent && $previewIsCurrent; + + $scopeDescription = static::restoreWizardScopeDescription( + scopeMode: $scopeMode, + selectedItemCount: $selectedItemCount, + unresolvedGroupCount: $unresolvedGroupCount, + backupSetSelected: $backupSet instanceof BackupSet, + ); + + $sourceSummary = match (true) { + ! ($backupSet instanceof BackupSet) => 'Select a backup set with usable captured items before judging restore viability.', + ! ($backupQuality instanceof BackupQualitySummary) => 'Backup quality is unavailable for the selected source.', + $backupQuality->totalItems === 0 => 'The selected backup does not contain any captured items yet.', + ! $hasUsableSource => 'The selected backup does not contain a usable captured item yet.', + default => 'A usable source backup is selected for this restore draft.', + }; + + $scopeDependencySummary = match (true) { + ! $scopeDefined => 'Define the restore scope before validation can prove the current draft.', + $unresolvedGroupCount > $mappedGroupCount => sprintf( + 'Resolve %d remaining group mapping%s before validation can prove the current draft.', + $unresolvedGroupCount - $mappedGroupCount, + ($unresolvedGroupCount - $mappedGroupCount) === 1 ? '' : 's', + ), + $scopeDependencyResolved => 'Scope and dependency mapping are resolved for the current draft.', + default => 'Validation has not yet confirmed the current scope and dependency mapping.', + }; + + $executionGateSummary = match (true) { + ! $scopeDefined => 'Define the restore scope before validation can run.', + $unresolvedGroupCount > $mappedGroupCount => 'Resolve required mappings before validation can run.', + ! $executionTechnicallyAllowed => 'Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.', + $executionAvailableAfterConfirmation => 'Execution becomes available after explicit confirmation. Post-run evidence starts only after execution.', + default => 'Execution and post-run evidence remain unavailable until required safety gates are complete.', + }; + + $decisionCard = static::restoreWizardDecisionCard( + backupSet: $backupSet, + backupQuality: $backupQuality, + hasUsableSource: $hasUsableSource, + checksIntegrity: $checksIntegrity, + previewIntegrity: $previewIntegrity, + executionReadiness: $executionReadiness, + safetyAssessment: $safetyAssessment, + ); + + if ($backupSet instanceof BackupSet && $hasUsableSource && (! $scopeDependencyResolved || ! $checksAreCurrent || ! $previewIsCurrent)) { + $decisionCard['nextAction'] = 'Continue to scope and resolve required mappings.'; + } elseif ($backupSet instanceof BackupSet && ! $executionTechnicallyAllowed) { + $decisionCard['nextAction'] = 'Review prerequisites before execution.'; + } + + $backupQualityCard = static::restoreWizardBackupQualityCard($backupQuality, $hasUsableSource); + + $processFlow = [ + 'compact' => false, + 'title' => 'Restore safety gates', + 'gatesTotal' => 7, + 'gatesComplete' => count(array_filter([ + $hasUsableSource, + $targetSelected, + $scopeDependencyResolved, + $checksAreCurrent, + $previewIsCurrent, + ])), + 'nextGate' => match (true) { + ! $hasUsableSource => 'Usable source selected', + ! $targetSelected => 'Target selected', + ! $scopeDependencyResolved => 'Scope/dependency mapping', + ! $checksAreCurrent => 'Validation', + ! $previewIsCurrent => 'Preview', + ! $executionTechnicallyAllowed => 'Execution prerequisites', + default => 'Confirmation', + }, + 'executionLabel' => $executionAvailableAfterConfirmation ? 'Available after confirmation' : 'Unavailable', + 'executionSummary' => $executionGateSummary, + 'steps' => [ + [ + 'step' => 1, + 'label' => 'Usable source selected', + 'summary' => $sourceSummary, + 'status' => static::restoreWizardGateStatus($hasUsableSource, required: true), + ], + [ + 'step' => 2, + 'label' => 'Target selected', + 'summary' => 'Target environment is the route-bound managed environment.', + 'status' => static::restoreWizardGateStatus($targetSelected, required: true), + ], + [ + 'step' => 3, + 'label' => 'Scope/dependency mapping', + 'summary' => $scopeDependencySummary, + 'status' => static::restoreWizardGateStatus($scopeDependencyResolved, required: true), + ], + [ + 'step' => 4, + 'label' => 'Validation', + 'summary' => $checksAreCurrent + ? 'Checks evidence is current for the selected restore scope.' + : 'Run checks for the current scope before confirmation.', + 'status' => static::restoreWizardGateStatus($checksAreCurrent, required: true), + ], + [ + 'step' => 5, + 'label' => 'Preview', + 'summary' => $previewIsCurrent + ? 'Preview evidence is current for the selected restore scope.' + : 'Generate a preview for the current scope before confirmation.', + 'status' => static::restoreWizardGateStatus($previewIsCurrent, required: true), + ], + [ + 'step' => 6, + 'label' => 'Confirmation', + 'summary' => $executionAvailableAfterConfirmation + ? 'Explicit confirmation is the next required gate before real execution.' + : 'Confirmation stays unavailable until validation and preview evidence are current.', + 'status' => static::restoreWizardGateStatus(false, required: $executionAvailableAfterConfirmation), + ], + [ + 'step' => 7, + 'label' => 'Execution and evidence', + 'summary' => $executionGateSummary, + 'status' => static::restoreWizardGateStatus( + false, + required: $executionAvailableAfterConfirmation, + blocked: ! $executionTechnicallyAllowed, + ), + ], + ], + ]; + + $proofAside = [ + 'title' => 'Restore Proof', + 'items' => static::restoreWizardProofItems( + backupSet: $backupSet, + tenant: $tenant, + user: $user instanceof User ? $user : null, + backupQuality: $backupQuality, + hasUsableSource: $hasUsableSource, + scopeDefined: $scopeDefined, + scopeDescription: $scopeDescription, + ), + ]; + + $groupSyncUrl = $tenant instanceof ManagedEnvironment + ? EntraGroupResource::getUrl('index', tenant: $tenant) + : null; + $groupSyncOperationsUrl = $tenant instanceof ManagedEnvironment + ? OperationRunLinks::index($tenant, operationType: 'directory.groups.sync') + : null; + + $groupCacheQuery = $tenant instanceof ManagedEnvironment + ? EntraGroup::query()->where('managed_environment_id', $tenant->getKey()) + : null; + $hasCachedGroups = $groupCacheQuery?->exists() ?? false; + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery?->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + $groupCacheNotice = match (true) { + $unresolvedGroupCount === 0 => null, + ! $hasCachedGroups => 'No cached directory groups are available for this environment. Open group sync, then return to this mapping.', + $isStale => "Cached directory groups may be stale (>{$stalenessDays} days). Review group sync freshness before choosing a target.", + default => null, + }; + + return $memo[$cacheKey] = [ + 'decisionCard' => $decisionCard, + 'backupQualityCard' => $backupQualityCard, + 'processFlow' => $processFlow, + 'proofAside' => $proofAside, + 'mappingResolver' => [ + 'resolvedCount' => $resolvedGroupCount, + 'totalCount' => $unresolvedGroupCount, + 'unresolvedCount' => $pendingGroupCount, + 'skippedCount' => $skippedGroupCount, + 'manualFallbackCount' => $manualFallbackCount, + 'requiresResolution' => $pendingGroupCount > 0, + 'requirementLabel' => $pendingGroupCount > 0 + ? 'Resolve required mappings before validation can run.' + : 'Mappings are resolved for the current draft.', + 'explanation' => 'Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.', + 'cacheNotice' => $groupCacheNotice, + 'groupSyncUrl' => $groupCacheNotice !== null ? $groupSyncUrl : null, + 'groupSyncOperationsUrl' => $groupCacheNotice !== null ? $groupSyncOperationsUrl : null, + ], + 'diagnosticsDisclosure' => [ + 'label' => 'Diagnostics - Collapsed', + 'summary' => 'Diagnostics stay collapsed by default. Raw payload is intentionally hidden on the create wizard surface unless explicit support review is needed.', + ], + ]; + } + + private static function restoreWizardSelectedBackupSet(?int $backupSetId, ?ManagedEnvironment $tenant): ?BackupSet + { + if (! ($tenant instanceof ManagedEnvironment) || $backupSetId === null || $backupSetId <= 0) { + return null; + } + + return BackupSet::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->with([ + 'items' => fn ($query) => $query->select([ + 'id', + 'backup_set_id', + 'payload', + 'metadata', + 'assignments', + ]), + ]) + ->find($backupSetId); + } + + /** + * @param array $checksIntegrity + * @param array $previewIntegrity + * @param array $executionReadiness + * @param array $safetyAssessment + * @return array + */ + private static function restoreWizardDecisionCard( + ?BackupSet $backupSet, + ?BackupQualitySummary $backupQuality, + bool $hasUsableSource, + array $checksIntegrity, + array $previewIntegrity, + array $executionReadiness, + array $safetyAssessment, + ): array { + $checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT; + $previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT; + $executionTechnicallyAllowed = (bool) ($executionReadiness['allowed'] ?? false); + $safetyState = is_string($safetyAssessment['state'] ?? null) ? $safetyAssessment['state'] : null; + $assessmentNextAction = is_string($safetyAssessment['primary_next_action'] ?? null) + ? $safetyAssessment['primary_next_action'] + : 'review_scope'; + + $status = match (true) { + ! ($backupSet instanceof BackupSet) => 'Source required', + ! $hasUsableSource => 'Source unavailable', + $safetyState !== null => RestoreSafetyCopy::safetyStateLabel($safetyState), + default => 'Unavailable', + }; + + $reason = match (true) { + ! ($backupSet instanceof BackupSet) => 'No backup set is selected yet.', + $backupQuality instanceof BackupQualitySummary && $backupQuality->totalItems === 0 => $backupQuality->summaryMessage, + $backupQuality instanceof BackupQualitySummary && ! $hasUsableSource => 'The selected backup captures metadata only or otherwise lacks a usable payload for restore.', + ! $executionTechnicallyAllowed => (string) ($executionReadiness['display_summary'] ?? 'Execution prerequisites are unavailable.'), + ! $checksAreCurrent => (string) ($checksIntegrity['display_summary'] ?? 'Checks evidence is not current for the selected scope.'), + ! $previewIsCurrent => (string) ($previewIntegrity['display_summary'] ?? 'Preview evidence is not current for the selected scope.'), + default => (string) ($safetyAssessment['summary'] ?? 'Restore safety state is available for the selected scope.'), + }; + + $impact = match (true) { + ! ($backupSet instanceof BackupSet) => 'Restore safety cannot be judged until a source backup is selected.', + ! $hasUsableSource && $backupQuality instanceof BackupQualitySummary => $backupQuality->positiveClaimBoundary, + ! $executionTechnicallyAllowed => 'Provider readiness or restore prerequisites currently prevent real execution.', + ! $checksAreCurrent || ! $previewIsCurrent => 'Confirmation and real execution must stay blocked until current validation and preview evidence exist.', + ($safetyAssessment['state'] ?? null) === 'ready_with_caution' => 'Execution can start, but calm safety claims stay suppressed until warnings are reviewed.', + default => 'Execution can move toward confirmation, but recovery is not yet verified before post-run evidence exists.', + }; + + $primaryNextAction = match (true) { + ! ($backupSet instanceof BackupSet) => 'Select a backup set to establish the restore source.', + ! $hasUsableSource && $backupQuality instanceof BackupQualitySummary => $backupQuality->nextAction, + default => RestoreSafetyCopy::primaryNextAction($assessmentNextAction), + }; + + $tone = match (true) { + ! ($backupSet instanceof BackupSet) => 'gray', + ! $hasUsableSource => 'warning', + ($safetyAssessment['state'] ?? null) === 'ready' => 'success', + ($safetyAssessment['state'] ?? null) === 'ready_with_caution' => 'warning', + default => 'danger', + }; + + return [ + 'title' => 'Restore Safety', + 'statusLabel' => 'Status', + 'reasonLabel' => 'Reason', + 'impactLabel' => 'Impact', + 'nextActionLabel' => 'Primary next action', + 'status' => $status, + 'reason' => $reason, + 'impact' => $impact, + 'nextAction' => $primaryNextAction, + 'helperText' => 'This create flow does not prove recoverability before execution and post-run evidence exist.', + 'tone' => $tone, + ]; + } + + /** + * @return array + */ + private static function restoreWizardBackupQualityCard(?BackupQualitySummary $backupQuality, bool $hasUsableSource): array + { + if (! $backupQuality instanceof BackupQualitySummary) { + return [ + 'available' => false, + 'status' => 'Select a backup set to inspect input quality.', + 'summary' => 'Backup quality hints describe input strength only.', + 'nextAction' => 'Select a backup set to inspect item counts and degradations.', + 'positiveClaimBoundary' => 'Input quality signals do not prove that execution is safe or that recovery is verified.', + 'counts' => [], + ]; + } + + $status = match (true) { + ! $hasUsableSource => 'No usable captured source yet', + $backupQuality->hasDegradations() => 'Usable source with degradations', + default => 'Usable source available', + }; + + return [ + 'available' => true, + 'status' => $status, + 'summary' => $backupQuality->summaryMessage, + 'nextAction' => $backupQuality->nextAction, + 'positiveClaimBoundary' => $backupQuality->positiveClaimBoundary, + 'counts' => [ + ['label' => 'Item count', 'value' => $backupQuality->totalItems], + ['label' => 'Degraded items', 'value' => $backupQuality->degradedItemCount], + ['label' => 'Metadata-only items', 'value' => $backupQuality->metadataOnlyCount], + ['label' => 'Assignment issues', 'value' => $backupQuality->assignmentIssueCount], + ['label' => 'Orphaned assignments', 'value' => $backupQuality->orphanedAssignmentCount], + ], + ]; + } + + private static function restoreWizardScopeDescription( + string $scopeMode, + int $selectedItemCount, + int $unresolvedGroupCount, + bool $backupSetSelected, + ): string { + if (! $backupSetSelected) { + return 'Restore scope is unavailable until a backup set is selected.'; + } + + $scopeDescription = $scopeMode === 'selected' + ? ($selectedItemCount > 0 + ? $selectedItemCount.' selected '.Str::plural('item', $selectedItemCount).' currently define the restore scope.' + : 'Select at least one backup item to define a narrowed restore scope.') + : 'Current restore scope includes all captured items in the selected backup set.'; + + if ($unresolvedGroupCount > 0) { + return $scopeDescription.' '.$unresolvedGroupCount.' group '.($unresolvedGroupCount === 1 ? 'mapping remains' : 'mappings remain').' for review.'; + } + + return $scopeDescription.' No unresolved group-based dependencies are currently detected.'; + } + + /** + * @return list + */ + private static function restoreWizardProofItems( + ?BackupSet $backupSet, + ?ManagedEnvironment $tenant, + ?User $user, + ?BackupQualitySummary $backupQuality, + bool $hasUsableSource, + bool $scopeDefined, + string $scopeDescription, + ): array { + $sourceDescription = match (true) { + ! ($backupSet instanceof BackupSet) => 'No backup source is recorded yet.', + $backupQuality instanceof BackupQualitySummary => implode(' • ', array_filter([ + $backupSet->name, + $backupQuality->compactSummary, + ])), + default => (string) ($backupSet->name ?? 'Selected backup source'), + }; + + return [ + [ + 'label' => 'Source backup', + 'value' => ! ($backupSet instanceof BackupSet) ? 'Pending' : ($hasUsableSource ? 'Complete' : 'Unavailable'), + 'description' => $sourceDescription, + 'tone' => ! ($backupSet instanceof BackupSet) ? 'gray' : ($hasUsableSource ? 'success' : 'warning'), + ], + [ + 'label' => 'Target environment', + 'value' => $tenant instanceof ManagedEnvironment ? 'Complete' : 'Unavailable', + 'description' => $tenant instanceof ManagedEnvironment + ? (string) ($tenant->name ?? $tenant->managed_environment_id ?? $tenant->getKey()) + : 'Target environment is unavailable.', + 'tone' => $tenant instanceof ManagedEnvironment ? 'success' : 'warning', + ], + [ + 'label' => 'Requested by', + 'value' => $user instanceof User ? 'Recorded' : 'Unavailable', + 'description' => $user instanceof User + ? (string) ($user->email ?? $user->name ?? 'Authenticated operator') + : 'The current requestor is unavailable.', + 'tone' => $user instanceof User ? 'success' : 'warning', + ], + [ + 'label' => 'Restore scope', + 'value' => $scopeDefined ? 'Complete' : 'Pending', + 'description' => $scopeDescription, + 'tone' => $scopeDefined ? 'success' : 'warning', + ], + [ + 'label' => 'Operation proof', + 'value' => 'Unavailable', + 'description' => 'Operation proof is unavailable before execution.', + 'tone' => 'gray', + ], + [ + 'label' => 'Post-run evidence', + 'value' => 'Unavailable', + 'description' => 'Post-run evidence is unavailable before execution.', + 'tone' => 'gray', + ], + ]; + } + + private static function restoreWizardGateStatus(bool $complete, bool $required = false, bool $blocked = false): string + { + if ($blocked) { + return 'blocked'; + } + + if ($complete) { + return 'complete'; + } + + return $required ? 'required' : 'unavailable'; + } + public static function createRestoreRun(array $data): RestoreRun { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -1712,7 +2624,7 @@ public static function createRestoreRun(array $data): RestoreRun $backupSet = BackupSet::findOrFail($data['backup_set_id']); if ($backupSet->managed_environment_id !== $tenant->id) { - abort(403, 'Backup set does not belong to the active tenant.'); + abort(403, 'Backup set does not belong to the active environment.'); } /** @var RestoreService $service */ @@ -2246,10 +3158,12 @@ private static function wizardSafetyState(array $data): array 'checksIntegrity' => $checksIntegrity->toArray(), 'executionReadiness' => null, 'safetyAssessment' => null, + 'providerConnectionsUrl' => null, ]; } $assessment = $resolver->safetyAssessment($tenant, $user, $data); + $providerResolution = app(ProviderConnectionResolver::class)->resolveDefault($tenant, 'microsoft'); return [ 'currentScope' => $scope, @@ -2257,6 +3171,11 @@ private static function wizardSafetyState(array $data): array 'checksIntegrity' => $checksIntegrity->toArray(), 'executionReadiness' => $assessment->executionReadiness->toArray(), 'safetyAssessment' => $assessment->toArray(), + 'providerResolution' => [ + 'resolved' => $providerResolution->resolved, + 'reasonCode' => $providerResolution->effectiveReasonCode(), + ], + 'providerConnectionsUrl' => \App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ]; } @@ -2342,7 +3261,7 @@ private static function restoreSafetyResolver(): RestoreSafetyResolver /** * @param array|null $selectedItemIds - * @return array + * @return array */ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, ManagedEnvironment $tenant): array { @@ -2402,17 +3321,88 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem } $resolver = app(EntraGroupLabelResolver::class); - $cached = $resolver->lookupMany($tenant, $groupIds); + $descriptions = $resolver->describeMany($tenant, $groupIds, $sourceNames); - return array_map(function (string $groupId) use ($sourceNames, $cached): array { - $cachedName = $cached[strtolower($groupId)] ?? null; - $fallbackName = $cachedName ?? ($sourceNames[$groupId] ?? null); + $unresolved = []; - return [ + foreach ($groupIds as $groupId) { + $description = $descriptions[$groupId] ?? null; + + if (is_array($description) && (bool) ($description['resolved'] ?? false)) { + continue; + } + + $displayName = is_array($description) ? ($description['display_name'] ?? null) : null; + + $unresolved[] = [ 'id' => $groupId, - 'label' => EntraGroupLabelResolver::formatLabel($fallbackName, $groupId), + 'displayName' => is_string($displayName) && $displayName !== '' ? $displayName : null, ]; - }, $groupIds); + } + + return $unresolved; + } + + private static function groupMappingIdentityHelperText(?ManagedEnvironment $tenant, string $sourceGroupId, mixed $rawValue): HtmlString + { + $value = is_string($rawValue) ? trim($rawValue) : ''; + + $lines = [ + 'Source ID: '.$sourceGroupId, + ]; + + if ($value === '') { + return new HtmlString(implode('
', array_map('e', $lines))); + } + + if (strtoupper($value) === 'SKIP') { + $lines[] = 'Skipped'; + $lines[] = 'This assignment will not be restored.'; + + return new HtmlString(implode('
', array_map('e', $lines))); + } + + if (! Str::isUuid($value)) { + $lines[] = 'Invalid group object ID (GUID).'; + + return new HtmlString(implode('
', array_map('e', $lines))); + } + + $normalizedTargetId = strtolower($value); + $targetDisplayName = null; + + if ($tenant instanceof ManagedEnvironment) { + $targetDisplayName = EntraGroup::query() + ->where('managed_environment_id', $tenant->getKey()) + ->where('entra_id', $normalizedTargetId) + ->value('display_name'); + } + + if (is_string($targetDisplayName) && $targetDisplayName !== '') { + $lines[] = 'Target group: '.$targetDisplayName; + $lines[] = 'Target ID: '.$value; + + return new HtmlString(implode('
', array_map('e', $lines))); + } + + $lines[] = 'Manual target object ID'; + $lines[] = $value; + $lines[] = 'Badge: Manual fallback'; + + return new HtmlString(implode('
', array_map('e', $lines))); + } + + private static function touchWizardDraftAfterGroupMappingChange(Get $get, Set $set): void + { + $set('is_dry_run', true); + $set('acknowledged_impact', false); + $set('tenant_confirm', null); + + $draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get)); + + $set('scope_basis', $draft['scope_basis']); + $set('check_invalidation_reasons', $draft['check_invalidation_reasons']); + $set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']); } /** @@ -2450,6 +3440,76 @@ private static function groupMappingPlaceholders(?int $backupSetId, string $scop return $placeholders; } + /** + * @return array + */ + private static function groupMappingProgressValues(mixed $mapping): array + { + if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) { + $mapping = $mapping->toArray(); + } + + if ($mapping instanceof \stdClass) { + $mapping = (array) $mapping; + } + + if (! is_array($mapping)) { + return []; + } + + $result = []; + + if (array_key_exists('group_mapping', $mapping)) { + $nested = $mapping['group_mapping']; + + if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) { + $nested = $nested->toArray(); + } + + if ($nested instanceof \stdClass) { + $nested = (array) $nested; + } + + if (is_array($nested)) { + $mapping = $nested; + } + } + + foreach ($mapping as $key => $value) { + if (! is_string($key) || $key === '') { + continue; + } + + $sourceGroupId = str_starts_with($key, 'group_mapping.') + ? substr($key, strlen('group_mapping.')) + : $key; + + if ($sourceGroupId === '') { + continue; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_array($value) || $value instanceof \stdClass) { + $value = (array) $value; + $value = $value['value'] ?? $value['id'] ?? null; + } + + if (is_string($value)) { + $trimmed = trim($value); + $result[$sourceGroupId] = $trimmed !== '' ? $trimmed : null; + + continue; + } + + $result[$sourceGroupId] = null; + } + + return $result; + } + /** * @return array */ diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php new file mode 100644 index 00000000..e3e254ae --- /dev/null +++ b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php @@ -0,0 +1,1019 @@ + $data + * @return array + */ + public static function contract( + array $data, + int $currentStep, + bool $compactFlow, + ?ManagedEnvironment $tenant, + ?User $user, + ): array { + $draft = self::synchronizeRestoreSafetyDraft($data); + + $resolver = app(RestoreSafetyResolver::class); + $scope = $resolver->scopeFingerprintFromData($draft)->toArray(); + $previewIntegrity = $resolver->previewIntegrityFromData($draft)->toArray(); + $checksIntegrity = $resolver->checksIntegrityFromData($draft)->toArray(); + + $executionReadiness = null; + $safetyAssessment = null; + $providerResolution = null; + $providerConnectionsUrl = null; + + if ($tenant instanceof ManagedEnvironment && $user instanceof User) { + $assessment = $resolver->safetyAssessment($tenant, $user, $draft); + $executionReadiness = $assessment->executionReadiness->toArray(); + $safetyAssessment = $assessment->toArray(); + + $provider = app(ProviderConnectionResolver::class)->resolveDefault($tenant, 'microsoft'); + $providerResolution = [ + 'resolved' => $provider->resolved, + 'reasonCode' => $provider->effectiveReasonCode(), + ]; + $providerConnectionsUrl = \App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant); + } + + $backupSetId = is_numeric($scope['backup_set_id'] ?? null) ? (int) $scope['backup_set_id'] : null; + $scopeMode = ($scope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all'; + $selectedItemIds = array_values(array_filter( + is_array($scope['selected_item_ids'] ?? null) ? $scope['selected_item_ids'] : [], + static fn (mixed $itemId): bool => is_int($itemId) || (is_string($itemId) && ctype_digit($itemId)) + )); + $selectedItemCount = count($selectedItemIds); + + $backupSet = self::restoreWizardSelectedBackupSet($backupSetId, $tenant); + $backupQuality = $backupSet instanceof BackupSet + ? app(BackupQualityResolver::class)->summarizeBackupSet($backupSet) + : null; + + $unresolvedGroups = ($tenant instanceof ManagedEnvironment) && $backupSetId !== null + ? self::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null, + tenant: $tenant, + ) + : []; + $unresolvedGroupCount = count($unresolvedGroups); + + $groupMappingProgress = self::groupMappingProgressValues($scope['group_mapping'] ?? []); + $resolvedGroupCount = 0; + $skippedGroupCount = 0; + $pendingGroupCount = 0; + $manualFallbackCount = 0; + $mappingValues = []; + $resolvedTargetIds = []; + + foreach ($unresolvedGroups as $group) { + $groupId = is_string($group['id'] ?? null) ? $group['id'] : null; + $value = $groupId !== null ? trim((string) ($groupMappingProgress[$groupId] ?? '')) : ''; + + if ($groupId !== null) { + $mappingValues[$groupId] = $value; + } + + if ($value === '') { + $pendingGroupCount++; + + continue; + } + + if (strtoupper($value) === 'SKIP') { + $skippedGroupCount++; + + continue; + } + + if (! Str::isUuid($value)) { + $pendingGroupCount++; + + continue; + } + + $resolvedGroupCount++; + $resolvedTargetIds[] = strtolower($value); + } + + $cachedTargetLookup = []; + + if ($tenant instanceof ManagedEnvironment && $resolvedTargetIds !== []) { + $cachedTargetIds = EntraGroup::query() + ->where('managed_environment_id', $tenant->getKey()) + ->whereIn('entra_id', array_values(array_unique($resolvedTargetIds))) + ->pluck('entra_id') + ->map(static fn (string $entraId): string => strtolower($entraId)) + ->values() + ->all(); + + $cachedTargetLookup = array_fill_keys($cachedTargetIds, true); + } + + foreach ($mappingValues as $value) { + if ($value === '' || strtoupper($value) === 'SKIP' || ! Str::isUuid($value)) { + continue; + } + + if (! array_key_exists(strtolower($value), $cachedTargetLookup)) { + $manualFallbackCount++; + } + } + + $mappedGroupCount = $resolvedGroupCount + $skippedGroupCount; + + $hasUsableSource = $backupSet instanceof BackupSet + && $backupQuality instanceof BackupQualitySummary + && $backupQuality->totalItems > 0 + && ($backupQuality->totalItems - $backupQuality->metadataOnlyCount) > 0; + $targetSelected = $tenant instanceof ManagedEnvironment; + $checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT; + $previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT; + $executionTechnicallyAllowed = is_array($executionReadiness) + ? (bool) ($executionReadiness['allowed'] ?? false) + : false; + $scopeDefined = $backupSetId !== null && ($scopeMode === 'all' || $selectedItemCount > 0); + $scopeDependencyResolved = $scopeDefined + && $mappedGroupCount >= $unresolvedGroupCount + && ($checksAreCurrent || $previewIsCurrent); + $executionAvailableAfterConfirmation = $executionTechnicallyAllowed && $checksAreCurrent && $previewIsCurrent; + + $scopeDescription = self::restoreWizardScopeDescription( + scopeMode: $scopeMode, + selectedItemCount: $selectedItemCount, + unresolvedGroupCount: $unresolvedGroupCount, + backupSetSelected: $backupSet instanceof BackupSet, + ); + + $sourceSummary = match (true) { + ! ($backupSet instanceof BackupSet) => 'Select a backup set with usable captured items before judging restore viability.', + ! ($backupQuality instanceof BackupQualitySummary) => 'Backup quality is unavailable for the selected source.', + $backupQuality->totalItems === 0 => 'The selected backup does not contain any captured items yet.', + ! $hasUsableSource => 'The selected backup does not contain a usable captured item yet.', + default => 'A usable source backup is selected for this restore draft.', + }; + + $scopeDependencySummary = match (true) { + ! $scopeDefined => 'Define the restore scope before validation can prove the current draft.', + $unresolvedGroupCount > $mappedGroupCount => sprintf( + 'Resolve %d remaining group mapping%s before validation can prove the current draft.', + $unresolvedGroupCount - $mappedGroupCount, + ($unresolvedGroupCount - $mappedGroupCount) === 1 ? '' : 's', + ), + $scopeDependencyResolved => 'Scope and dependency mapping are resolved for the current draft.', + default => 'Validation has not yet confirmed the current scope and dependency mapping.', + }; + + $executionGateSummary = match (true) { + ! $scopeDefined => 'Define the restore scope before validation can run.', + $unresolvedGroupCount > $mappedGroupCount => 'Resolve required mappings before validation can run.', + ! $executionTechnicallyAllowed => 'Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.', + $executionAvailableAfterConfirmation => 'Execution becomes available after explicit confirmation. Post-run evidence starts only after execution.', + default => 'Execution and post-run evidence remain unavailable until required safety gates are complete.', + }; + + $decisionCard = self::restoreWizardDecisionCard( + backupSet: $backupSet, + backupQuality: $backupQuality, + hasUsableSource: $hasUsableSource, + checksIntegrity: $checksIntegrity, + previewIntegrity: $previewIntegrity, + executionReadiness: is_array($executionReadiness) ? $executionReadiness : [], + safetyAssessment: is_array($safetyAssessment) ? $safetyAssessment : [], + ); + + if ($backupSet instanceof BackupSet && $hasUsableSource && (! $scopeDependencyResolved || ! $checksAreCurrent || ! $previewIsCurrent)) { + $decisionCard['nextAction'] = 'Continue to scope and resolve required mappings.'; + } elseif ($backupSet instanceof BackupSet && ! $executionTechnicallyAllowed) { + $decisionCard['nextAction'] = 'Review prerequisites before execution.'; + } + + $backupQualityCard = self::restoreWizardBackupQualityCard($backupQuality, $hasUsableSource); + + $nextGate = match (true) { + ! $hasUsableSource => 'Usable source selected', + ! $targetSelected => 'Target selected', + ! $scopeDependencyResolved => 'Scope/dependency mapping', + ! $previewIsCurrent => 'Preview', + ! $executionTechnicallyAllowed => 'Execution prerequisites', + default => 'Confirmation', + }; + + $processFlow = [ + 'compact' => $compactFlow, + 'title' => 'Restore safety gates', + 'gatesTotal' => 7, + 'gatesComplete' => count(array_filter([ + $hasUsableSource, + $targetSelected, + $scopeDependencyResolved, + $checksAreCurrent, + $previewIsCurrent, + ])), + 'nextGate' => $nextGate, + 'executionLabel' => $executionAvailableAfterConfirmation ? 'Available after confirmation' : 'Unavailable', + 'executionSummary' => $executionGateSummary, + 'steps' => [ + [ + 'step' => 1, + 'label' => 'Usable source selected', + 'summary' => $sourceSummary, + 'status' => self::restoreWizardGateStatus($hasUsableSource, required: true), + ], + [ + 'step' => 2, + 'label' => 'Target selected', + 'summary' => 'Target environment is the route-bound managed environment.', + 'status' => self::restoreWizardGateStatus($targetSelected, required: true), + ], + [ + 'step' => 3, + 'label' => 'Scope/dependency mapping', + 'summary' => $scopeDependencySummary, + 'status' => self::restoreWizardGateStatus($scopeDependencyResolved, required: true), + ], + [ + 'step' => 4, + 'label' => 'Validation', + 'summary' => $checksAreCurrent + ? 'Checks evidence is current for the selected restore scope.' + : 'Run checks for the current scope before confirmation.', + 'status' => self::restoreWizardGateStatus($checksAreCurrent, required: true), + ], + [ + 'step' => 5, + 'label' => 'Preview', + 'summary' => $previewIsCurrent + ? 'Preview evidence is current for the selected restore scope.' + : 'Generate a preview for the current scope before confirmation.', + 'status' => self::restoreWizardGateStatus($previewIsCurrent, required: true), + ], + [ + 'step' => 6, + 'label' => 'Confirmation', + 'summary' => $executionAvailableAfterConfirmation + ? 'Explicit confirmation is the next required gate before real execution.' + : 'Confirmation stays unavailable until validation and preview evidence are current.', + 'status' => self::restoreWizardGateStatus(false, required: $executionAvailableAfterConfirmation), + ], + [ + 'step' => 7, + 'label' => 'Execution and evidence', + 'summary' => $executionGateSummary, + 'status' => self::restoreWizardGateStatus( + false, + required: $executionAvailableAfterConfirmation, + blocked: ! $executionTechnicallyAllowed, + ), + ], + ], + ]; + + $proofItems = self::restoreWizardProofItems( + backupSet: $backupSet, + tenant: $tenant, + user: $user, + backupQuality: $backupQuality, + hasUsableSource: $hasUsableSource, + scopeDefined: $scopeDefined, + scopeDescription: $scopeDescription, + ); + + $groupSyncUrl = $tenant instanceof ManagedEnvironment + ? EntraGroupResource::getUrl('index', tenant: $tenant) + : null; + $groupSyncOperationsUrl = $tenant instanceof ManagedEnvironment + ? OperationRunLinks::index($tenant, operationType: 'directory.groups.sync') + : null; + + $groupCacheQuery = $tenant instanceof ManagedEnvironment + ? EntraGroup::query()->where('managed_environment_id', $tenant->getKey()) + : null; + $hasCachedGroups = $groupCacheQuery?->exists() ?? false; + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery?->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + $groupCacheNotice = match (true) { + $unresolvedGroupCount === 0 => null, + ! $hasCachedGroups => 'No cached directory groups are available for this environment. Open group sync, then return to this mapping.', + $isStale => "Cached directory groups may be stale (>{$stalenessDays} days). Review group sync freshness before choosing a target.", + default => null, + }; + + $mappingResolver = [ + 'resolvedCount' => $resolvedGroupCount, + 'totalCount' => $unresolvedGroupCount, + 'unresolvedCount' => $pendingGroupCount, + 'skippedCount' => $skippedGroupCount, + 'manualFallbackCount' => $manualFallbackCount, + 'requiresResolution' => $pendingGroupCount > 0, + 'requirementLabel' => $pendingGroupCount > 0 + ? 'Resolve required mappings before validation can run.' + : 'Mappings are resolved for the current draft.', + 'explanation' => 'Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.', + 'cacheNotice' => $groupCacheNotice, + 'groupSyncUrl' => $groupCacheNotice !== null ? $groupSyncUrl : null, + 'groupSyncOperationsUrl' => $groupCacheNotice !== null ? $groupSyncOperationsUrl : null, + ]; + + $validationSummary = self::validationSummary( + checkSummary: is_array($draft['check_summary'] ?? null) ? $draft['check_summary'] : [], + checkResults: is_array($draft['check_results'] ?? null) ? $draft['check_results'] : [], + checksIntegrity: $checksIntegrity, + executionReadiness: is_array($executionReadiness) ? $executionReadiness : [], + safetyAssessment: is_array($safetyAssessment) ? $safetyAssessment : [], + providerResolution: is_array($providerResolution) ? $providerResolution : [], + providerConnectionsUrl: $providerConnectionsUrl, + ); + + $previewSummary = self::previewSummary( + previewSummary: is_array($draft['preview_summary'] ?? null) ? $draft['preview_summary'] : [], + previewDiffs: is_array($draft['preview_diffs'] ?? null) ? $draft['preview_diffs'] : [], + previewIntegrity: $previewIntegrity, + checksIntegrity: $checksIntegrity, + executionReadiness: is_array($executionReadiness) ? $executionReadiness : [], + safetyAssessment: is_array($safetyAssessment) ? $safetyAssessment : [], + scope: $scope, + ); + + $canContinue = match ($currentStep) { + 1 => $backupSet instanceof BackupSet, + 2 => ! (bool) ($mappingResolver['requiresResolution'] ?? false), + 3 => (int) ($validationSummary['blockingCount'] ?? 0) <= 0 && ! (bool) ($validationSummary['providerCredentialBlocked'] ?? false), + 4 => (bool) ($previewSummary['canProceedToConfirm'] ?? false), + 5 => true, + default => true, + }; + + $blockedReason = match ($currentStep) { + 1 => $backupSet instanceof BackupSet ? null : 'Select a backup set to continue.', + 2 => (bool) ($mappingResolver['requiresResolution'] ?? false) ? (string) ($mappingResolver['requirementLabel'] ?? 'Resolve required mappings to continue.') : null, + 3 => (int) ($validationSummary['blockingCount'] ?? 0) > 0 ? 'Resolve validation blockers before moving to preview.' : null, + 4 => (string) ($previewSummary['blockedReason'] ?? null), + default => null, + }; + + return [ + // Required product UI contract. + 'current_step' => $currentStep, + 'decision_status' => (string) ($decisionCard['status'] ?? 'Unavailable'), + 'reason' => (string) ($decisionCard['reason'] ?? 'Restore safety reason is unavailable.'), + 'impact' => (string) ($decisionCard['impact'] ?? 'Restore impact is unavailable.'), + 'primary_next_action' => (string) ($decisionCard['nextAction'] ?? 'Review the current restore state.'), + 'gates' => is_array($processFlow['steps'] ?? null) ? $processFlow['steps'] : [], + 'proof_items' => $proofItems, + 'backup_quality_summary' => $backupQualityCard, + 'mapping_summary' => $mappingResolver, + 'validation_summary' => $validationSummary, + 'preview_summary' => $previewSummary, + 'can_continue' => $canContinue, + 'blocked_reason' => $blockedReason, + 'diagnostics_state' => [ + 'collapsedByDefault' => true, + 'label' => 'Diagnostics - Collapsed', + 'summary' => 'Diagnostics stay collapsed by default. Raw payload is intentionally hidden on the create wizard surface unless explicit support review is needed.', + ], + // Component backing data (still passive in views). + 'decisionCard' => $decisionCard, + 'processFlow' => $processFlow, + 'proofAside' => [ + 'title' => 'Restore Proof', + 'items' => $proofItems, + ], + 'validationSummary' => $validationSummary, + 'previewSummary' => $previewSummary, + 'mappingResolver' => $mappingResolver, + 'currentScope' => $scope, + 'previewIntegrity' => $previewIntegrity, + 'checksIntegrity' => $checksIntegrity, + 'executionReadiness' => $executionReadiness, + 'safetyAssessment' => $safetyAssessment, + 'providerResolution' => $providerResolution, + 'providerConnectionsUrl' => $providerConnectionsUrl, + 'diagnosticsDisclosure' => [ + 'label' => 'Diagnostics - Collapsed', + 'summary' => 'Diagnostics stay collapsed by default. Raw payload is intentionally hidden on the create wizard surface unless explicit support review is needed.', + ], + ]; + } + + /** + * @param array $data + * @return array + */ + private static function synchronizeRestoreSafetyDraft(array $data): array + { + $resolver = app(RestoreSafetyResolver::class); + $scope = $resolver->scopeFingerprintFromData($data); + + $data['scope_basis'] = $resolver->scopeBasisFromData($data); + $data['check_invalidation_reasons'] = $resolver->invalidationReasonsForBasis( + currentScope: $scope, + basis: is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null, + explicitReasons: $data['check_invalidation_reasons'] ?? null, + ); + $data['preview_invalidation_reasons'] = $resolver->invalidationReasonsForBasis( + currentScope: $scope, + basis: is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null, + explicitReasons: $data['preview_invalidation_reasons'] ?? null, + ); + + return $data; + } + + private static function restoreWizardSelectedBackupSet(?int $backupSetId, ?ManagedEnvironment $tenant): ?BackupSet + { + if (! ($tenant instanceof ManagedEnvironment) || $backupSetId === null || $backupSetId <= 0) { + return null; + } + + return BackupSet::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->with([ + 'items' => fn ($query) => $query->select([ + 'id', + 'backup_set_id', + 'payload', + 'metadata', + 'assignments', + ]), + ]) + ->find($backupSetId); + } + + /** + * @param array|null $selectedItemIds + * @return array + */ + private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, ManagedEnvironment $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 []; + } + + $resolver = app(EntraGroupLabelResolver::class); + $descriptions = $resolver->describeMany($tenant, $groupIds, $sourceNames); + + $unresolved = []; + + foreach ($groupIds as $groupId) { + $description = $descriptions[$groupId] ?? null; + + if (is_array($description) && (bool) ($description['resolved'] ?? false)) { + continue; + } + + $displayName = is_array($description) ? ($description['display_name'] ?? null) : null; + + $unresolved[] = [ + 'id' => $groupId, + 'displayName' => is_string($displayName) && $displayName !== '' ? $displayName : null, + ]; + } + + return $unresolved; + } + + /** + * @return array + */ + private static function groupMappingProgressValues(mixed $mapping): array + { + if ($mapping instanceof Arrayable) { + $mapping = $mapping->toArray(); + } + + if ($mapping instanceof \stdClass) { + $mapping = (array) $mapping; + } + + if (! is_array($mapping)) { + return []; + } + + $result = []; + + if (array_key_exists('group_mapping', $mapping)) { + $nested = $mapping['group_mapping']; + + if ($nested instanceof Arrayable) { + $nested = $nested->toArray(); + } + + if ($nested instanceof \stdClass) { + $nested = (array) $nested; + } + + if (is_array($nested)) { + $mapping = $nested; + } + } + + foreach ($mapping as $key => $value) { + if (! is_string($key) || $key === '') { + continue; + } + + $sourceGroupId = str_starts_with($key, 'group_mapping.') + ? substr($key, strlen('group_mapping.')) + : $key; + + if ($sourceGroupId === '') { + continue; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_array($value) || $value instanceof \stdClass) { + $value = (array) $value; + $value = $value['value'] ?? $value['id'] ?? null; + } + + if (is_string($value)) { + $trimmed = trim($value); + $result[$sourceGroupId] = $trimmed !== '' ? $trimmed : null; + + continue; + } + + $result[$sourceGroupId] = null; + } + + return $result; + } + + private static function restoreWizardScopeDescription( + string $scopeMode, + int $selectedItemCount, + int $unresolvedGroupCount, + bool $backupSetSelected, + ): string { + if (! $backupSetSelected) { + return 'Restore scope is unavailable until a backup set is selected.'; + } + + $scopeDescription = $scopeMode === 'selected' + ? ($selectedItemCount > 0 + ? $selectedItemCount.' selected '.Str::plural('item', $selectedItemCount).' currently define the restore scope.' + : 'Select at least one backup item to define a narrowed restore scope.') + : 'Current restore scope includes all captured items in the selected backup set.'; + + if ($unresolvedGroupCount > 0) { + return $scopeDescription.' '.$unresolvedGroupCount.' group '.($unresolvedGroupCount === 1 ? 'mapping remains' : 'mappings remain').' for review.'; + } + + return $scopeDescription.' No unresolved group-based dependencies are currently detected.'; + } + + /** + * @return list + */ + private static function restoreWizardProofItems( + ?BackupSet $backupSet, + ?ManagedEnvironment $tenant, + ?User $user, + ?BackupQualitySummary $backupQuality, + bool $hasUsableSource, + bool $scopeDefined, + string $scopeDescription, + ): array { + $sourceDescription = match (true) { + ! ($backupSet instanceof BackupSet) => 'No backup source is recorded yet.', + $backupQuality instanceof BackupQualitySummary => implode(' • ', array_filter([ + $backupSet->name, + $backupQuality->compactSummary, + ])), + default => (string) ($backupSet->name ?? 'Selected backup source'), + }; + + return [ + [ + 'label' => 'Source backup', + 'value' => ! ($backupSet instanceof BackupSet) ? 'Pending' : ($hasUsableSource ? 'Complete' : 'Unavailable'), + 'description' => $sourceDescription, + 'tone' => ! ($backupSet instanceof BackupSet) ? 'gray' : ($hasUsableSource ? 'success' : 'warning'), + ], + [ + 'label' => 'Target environment', + 'value' => $tenant instanceof ManagedEnvironment ? 'Complete' : 'Unavailable', + 'description' => $tenant instanceof ManagedEnvironment + ? (string) ($tenant->name ?? $tenant->managed_environment_id ?? $tenant->getKey()) + : 'Target environment is unavailable.', + 'tone' => $tenant instanceof ManagedEnvironment ? 'success' : 'warning', + ], + [ + 'label' => 'Requested by', + 'value' => $user instanceof User ? 'Recorded' : 'Unavailable', + 'description' => $user instanceof User + ? (string) ($user->email ?? $user->name ?? 'Authenticated operator') + : 'The current requestor is unavailable.', + 'tone' => $user instanceof User ? 'success' : 'warning', + ], + [ + 'label' => 'Restore scope', + 'value' => $scopeDefined ? 'Complete' : 'Pending', + 'description' => $scopeDescription, + 'tone' => $scopeDefined ? 'success' : 'warning', + ], + [ + 'label' => 'Operation proof', + 'value' => 'Unavailable', + 'description' => 'Operation proof is unavailable before execution.', + 'tone' => 'gray', + ], + [ + 'label' => 'Post-run evidence', + 'value' => 'Unavailable', + 'description' => 'Post-run evidence is unavailable before execution.', + 'tone' => 'gray', + ], + ]; + } + + private static function restoreWizardGateStatus(bool $complete, bool $required = false, bool $blocked = false): string + { + if ($blocked) { + return 'blocked'; + } + + if ($complete) { + return 'complete'; + } + + return $required ? 'required' : 'unavailable'; + } + + /** + * @param array $checksIntegrity + * @param array $previewIntegrity + * @param array $executionReadiness + * @param array $safetyAssessment + * @return array + */ + private static function restoreWizardDecisionCard( + ?BackupSet $backupSet, + ?BackupQualitySummary $backupQuality, + bool $hasUsableSource, + array $checksIntegrity, + array $previewIntegrity, + array $executionReadiness, + array $safetyAssessment, + ): array { + $checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT; + $previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT; + $executionTechnicallyAllowed = (bool) ($executionReadiness['allowed'] ?? false); + $safetyState = is_string($safetyAssessment['state'] ?? null) ? $safetyAssessment['state'] : null; + $assessmentNextAction = is_string($safetyAssessment['primary_next_action'] ?? null) + ? $safetyAssessment['primary_next_action'] + : 'review_scope'; + + $status = match (true) { + ! ($backupSet instanceof BackupSet) => 'Source required', + ! $hasUsableSource => 'Source unavailable', + $safetyState !== null => RestoreSafetyCopy::safetyStateLabel($safetyState), + default => 'Unavailable', + }; + + $reason = match (true) { + ! ($backupSet instanceof BackupSet) => 'No backup set is selected yet.', + $backupQuality instanceof BackupQualitySummary && $backupQuality->totalItems === 0 => $backupQuality->summaryMessage, + $backupQuality instanceof BackupQualitySummary && ! $hasUsableSource => 'The selected backup captures metadata only or otherwise lacks a usable payload for restore.', + ! $executionTechnicallyAllowed => (string) ($executionReadiness['display_summary'] ?? 'Execution prerequisites are unavailable.'), + ! $checksAreCurrent => (string) ($checksIntegrity['display_summary'] ?? 'Checks evidence is not current for the selected scope.'), + ! $previewIsCurrent => (string) ($previewIntegrity['display_summary'] ?? 'Preview evidence is not current for the selected scope.'), + default => (string) ($safetyAssessment['summary'] ?? 'Restore safety state is available for the selected scope.'), + }; + + $impact = match (true) { + ! ($backupSet instanceof BackupSet) => 'Restore safety cannot be judged until a source backup is selected.', + ! $hasUsableSource && $backupQuality instanceof BackupQualitySummary => $backupQuality->positiveClaimBoundary, + ! $executionTechnicallyAllowed => 'Provider readiness or restore prerequisites currently prevent real execution.', + ! $checksAreCurrent || ! $previewIsCurrent => 'Confirmation and real execution must stay blocked until current validation and preview evidence exist.', + ($safetyAssessment['state'] ?? null) === 'ready_with_caution' => 'Execution can start, but calm safety claims stay suppressed until warnings are reviewed.', + default => 'Execution can move toward confirmation, but recovery is not yet verified before post-run evidence exists.', + }; + + $primaryNextAction = match (true) { + ! ($backupSet instanceof BackupSet) => 'Select a backup set to establish the restore source.', + ! $hasUsableSource && $backupQuality instanceof BackupQualitySummary => $backupQuality->nextAction, + default => RestoreSafetyCopy::primaryNextAction($assessmentNextAction), + }; + + $tone = match (true) { + ! ($backupSet instanceof BackupSet) => 'gray', + ! $hasUsableSource => 'warning', + ($safetyAssessment['state'] ?? null) === 'ready' => 'success', + ($safetyAssessment['state'] ?? null) === 'ready_with_caution' => 'warning', + default => 'danger', + }; + + return [ + 'title' => 'Restore Safety', + 'statusLabel' => 'Status', + 'reasonLabel' => 'Reason', + 'impactLabel' => 'Impact', + 'nextActionLabel' => 'Primary next action', + 'status' => $status, + 'reason' => $reason, + 'impact' => $impact, + 'nextAction' => $primaryNextAction, + 'helperText' => 'This create flow does not prove recoverability before execution and post-run evidence exist.', + 'tone' => $tone, + ]; + } + + /** + * @return array + */ + private static function restoreWizardBackupQualityCard(?BackupQualitySummary $backupQuality, bool $hasUsableSource): array + { + if (! $backupQuality instanceof BackupQualitySummary) { + return [ + 'available' => false, + 'status' => 'Select a backup set to inspect input quality.', + 'summary' => 'Backup quality hints describe input strength only.', + 'nextAction' => 'Select a backup set to inspect item counts and degradations.', + 'positiveClaimBoundary' => 'Input quality signals do not prove that execution is safe or that recovery is verified.', + 'counts' => [], + ]; + } + + $status = match (true) { + $backupQuality->totalItems === 0 => 'No captured items', + ! $hasUsableSource => 'Degraded input', + $backupQuality->degradedItemCount > 0 => 'Degraded input', + default => 'Available', + }; + + $summary = match (true) { + $backupQuality->totalItems === 0 => $backupQuality->summaryMessage, + ! $hasUsableSource => $backupQuality->summaryMessage, + default => 'Backup quality hints describe input strength only.', + }; + + return [ + 'available' => true, + 'status' => $status, + 'summary' => $summary, + 'nextAction' => $backupQuality->nextAction, + 'positiveClaimBoundary' => $backupQuality->positiveClaimBoundary, + 'counts' => [ + ['label' => 'Items', 'value' => $backupQuality->totalItems], + ['label' => 'Degraded', 'value' => $backupQuality->degradedItemCount], + ['label' => 'Metadata-only', 'value' => $backupQuality->metadataOnlyCount], + ['label' => 'Assignment issues', 'value' => $backupQuality->assignmentIssueCount], + ['label' => 'Orphaned assignments', 'value' => $backupQuality->orphanedAssignmentCount], + ], + ]; + } + + /** + * @param array $checkSummary + * @param array $checkResults + * @param array $checksIntegrity + * @param array $executionReadiness + * @param array $safetyAssessment + * @param array $providerResolution + * @return array + */ + private static function validationSummary( + array $checkSummary, + array $checkResults, + array $checksIntegrity, + array $executionReadiness, + array $safetyAssessment, + array $providerResolution, + ?string $providerConnectionsUrl, + ): array { + $blocking = (int) ($checkSummary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0)); + $warning = (int) ($checkSummary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0)); + $safe = (int) ($checkSummary['safe'] ?? 0); + + $providerCredentialBlocked = array_key_exists('resolved', $providerResolution) + ? ! (bool) ($providerResolution['resolved'] ?? true) + : false; + + $integritySpec = BadgeRenderer::spec( + BadgeDomain::RestoreCheckSeverity, + $checksIntegrity['state'] ?? 'not_run' + ); + + $nextAction = is_string($safetyAssessment['primary_next_action'] ?? null) + ? $safetyAssessment['primary_next_action'] + : 'rerun_checks'; + + $startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning'; + + $grouped = [ + 'blocking' => [], + 'warning' => [], + 'safe' => [], + ]; + + foreach ($checkResults as $result) { + if (! is_array($result)) { + continue; + } + + $severity = is_string($result['severity'] ?? null) ? $result['severity'] : 'safe'; + $bucket = in_array($severity, ['blocking', 'warning', 'safe'], true) ? $severity : 'safe'; + $grouped[$bucket][] = $result; + } + + return [ + 'integritySpec' => $integritySpec, + 'integritySummary' => (string) ($checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.'), + 'ranAt' => $checksIntegrity['ran_at'] ?? null, + 'blockingCount' => $blocking, + 'warningCount' => $warning, + 'safeCount' => $safe, + 'nextActionLabel' => RestoreSafetyCopy::primaryNextAction($nextAction), + 'executionAllowed' => (bool) ($executionReadiness['allowed'] ?? false), + 'executionReadinessSummary' => (string) ($executionReadiness['display_summary'] ?? 'Execution prerequisites are unavailable.'), + 'executionReadinessTone' => $startabilityTone, + 'providerCredentialBlocked' => $providerCredentialBlocked, + 'providerConnectionsUrl' => $providerConnectionsUrl, + 'invalidationReasons' => is_array($checksIntegrity['invalidation_reasons'] ?? null) + ? $checksIntegrity['invalidation_reasons'] + : [], + 'groupedResults' => $grouped, + ]; + } + + /** + * @param array $previewSummary + * @param array $previewDiffs + * @param array $previewIntegrity + * @param array $checksIntegrity + * @param array $executionReadiness + * @param array $safetyAssessment + * @param array $scope + * @return array + */ + private static function previewSummary( + array $previewSummary, + array $previewDiffs, + array $previewIntegrity, + array $checksIntegrity, + array $executionReadiness, + array $safetyAssessment, + array $scope, + ): array { + $integritySpec = BadgeRenderer::spec( + BadgeDomain::RestorePreviewDecision, + $previewIntegrity['state'] ?? 'not_generated' + ); + + $previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT; + $checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT; + $executionAllowed = (bool) ($executionReadiness['allowed'] ?? false); + + $primaryNextAction = is_string($safetyAssessment['primary_next_action'] ?? null) + ? $safetyAssessment['primary_next_action'] + : 'generate_preview'; + + if ($previewIsCurrent && $checksAreCurrent && $executionAllowed && $primaryNextAction === 'execute') { + $primaryNextAction = 'review_and_confirm'; + } + + $needsAttention = []; + $unchanged = []; + + foreach ($previewDiffs as $entry) { + if (! is_array($entry)) { + continue; + } + + $diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : []; + $diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : []; + + $added = (int) ($diffSummary['added'] ?? 0); + $removed = (int) ($diffSummary['removed'] ?? 0); + $changed = (int) ($diffSummary['changed'] ?? 0); + $assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false); + $scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false); + + $isUnchanged = $added === 0 && $removed === 0 && $changed === 0 && ! $assignmentsDelta && ! $scopeTagsDelta; + + if ($isUnchanged) { + $unchanged[] = $entry; + + continue; + } + + $needsAttention[] = $entry; + } + + $canProceedToConfirm = $checksAreCurrent && $previewIsCurrent && $executionAllowed; + + $blockedReason = match (true) { + $canProceedToConfirm => null, + ! $checksAreCurrent => 'Run safety checks for the current scope before confirmation.', + ! $previewIsCurrent => 'Generate a preview for the current scope before confirmation.', + ! $executionAllowed => 'Review prerequisites before confirmation.', + default => 'Complete required gates before confirmation.', + }; + + $scopeMode = ($scope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all'; + + return [ + 'integritySpec' => $integritySpec, + 'integritySummary' => (string) ($previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.'), + 'generatedAt' => $previewIntegrity['generated_at'] ?? null, + 'previewSummary' => $previewSummary, + 'needsAttentionDiffs' => $needsAttention, + 'unchangedDiffs' => $unchanged, + 'scopeMode' => $scopeMode, + 'scopeLabel' => $scopeMode === 'selected' ? 'All selected restore items' : 'All restore items', + 'primaryNextActionLabel' => RestoreSafetyCopy::primaryNextAction($primaryNextAction), + 'canProceedToConfirm' => $canProceedToConfirm, + 'blockedReason' => $blockedReason, + ]; + } +} diff --git a/apps/platform/app/Livewire/EntraGroupCachePickerTable.php b/apps/platform/app/Livewire/EntraGroupCachePickerTable.php index 257eb9e6..be40d758 100644 --- a/apps/platform/app/Livewire/EntraGroupCachePickerTable.php +++ b/apps/platform/app/Livewire/EntraGroupCachePickerTable.php @@ -5,6 +5,7 @@ use App\Filament\Resources\EntraGroupResource; use App\Models\EntraGroup; use App\Models\ManagedEnvironment; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunLinks; use Filament\Actions\Action; use Filament\Tables\Columns\TextColumn; @@ -18,14 +19,39 @@ class EntraGroupCachePickerTable extends TableComponent { public string $sourceGroupId; - public function mount(string $sourceGroupId): void + public ?string $sourceGroupDisplayName = null; + + public ?int $tenantId = null; + + public bool $hasCachedGroups = false; + + public ?string $groupSyncUrl = null; + + public ?string $groupSyncOperationsUrl = null; + + public function mount(string $sourceGroupId, ?string $sourceGroupDisplayName = null, ?int $tenantId = null): void { $this->sourceGroupId = $sourceGroupId; + $this->sourceGroupDisplayName = filled($sourceGroupDisplayName) ? $sourceGroupDisplayName : null; + + $this->tenantId = is_int($tenantId) && $tenantId > 0 ? $tenantId : null; + + $tenant = $this->resolveTenantContext(); + + if ($tenant instanceof ManagedEnvironment) { + $this->tenantId = (int) $tenant->getKey(); + + $this->hasCachedGroups = EntraGroup::query() + ->where('managed_environment_id', $tenant->getKey()) + ->exists(); + $this->groupSyncUrl = EntraGroupResource::getUrl('index', tenant: $tenant); + $this->groupSyncOperationsUrl = OperationRunLinks::index($tenant, operationType: 'directory.groups.sync'); + } } public function table(Table $table): Table { - $tenantId = ManagedEnvironment::current()?->getKey(); + $tenantId = $this->tenantId ?? $this->resolveTenantContext()?->getKey(); $query = EntraGroup::query(); @@ -143,18 +169,9 @@ public function table(Table $table): Table $this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id); }), ]) - ->emptyStateHeading('No cached groups found') - ->emptyStateDescription('Run “Sync Groups” first, then come back here.') - ->emptyStateActions([ - Action::make('open_groups') - ->label('Directory Groups') - ->icon('heroicon-o-user-group') - ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: ManagedEnvironment::current())), - Action::make('open_sync_runs') - ->label('Operations') - ->icon('heroicon-o-clock') - ->url(fn (): string => OperationRunLinks::index(ManagedEnvironment::current())), - ]); + ->emptyStateHeading('No cached directory groups match your search') + ->emptyStateDescription('Try a broader search, or sync directory groups if the cache is incomplete for this environment.') + ->emptyStateActions([]); } public function render(): View @@ -200,4 +217,25 @@ private function groupTypeColor(string $type): string default => 'gray', }; } + + private function resolveTenantContext(): ?ManagedEnvironment + { + if ($this->tenantId !== null) { + $tenant = ManagedEnvironment::query()->find($this->tenantId); + + if ($tenant instanceof ManagedEnvironment) { + return $tenant; + } + } + + $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); + + if ($tenant instanceof ManagedEnvironment) { + return $tenant; + } + + $tenant = ManagedEnvironment::current(); + + return $tenant instanceof ManagedEnvironment ? $tenant : null; + } } diff --git a/apps/platform/app/Rules/SkipOrUuidRule.php b/apps/platform/app/Rules/SkipOrUuidRule.php index 57aa15ca..02264b74 100644 --- a/apps/platform/app/Rules/SkipOrUuidRule.php +++ b/apps/platform/app/Rules/SkipOrUuidRule.php @@ -12,8 +12,10 @@ public function __construct(public bool $allowSkip = true) {} public function validate(string $attribute, mixed $value, Closure $fail): void { + $message = 'Enter a valid group object ID (GUID), or use Skip assignment.'; + if (! is_string($value)) { - $fail('Please enter SKIP or a valid UUID.'); + $fail($message); return; } @@ -21,7 +23,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $value = trim($value); if ($value === '') { - $fail('Please enter SKIP or a valid UUID.'); + $fail($message); return; } @@ -31,7 +33,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } if (! Str::isUuid($value)) { - $fail('Please enter SKIP or a valid UUID.'); + $fail($message); } } } diff --git a/apps/platform/app/Services/Directory/EntraGroupLabelResolver.php b/apps/platform/app/Services/Directory/EntraGroupLabelResolver.php index 603a459a..c49797c8 100644 --- a/apps/platform/app/Services/Directory/EntraGroupLabelResolver.php +++ b/apps/platform/app/Services/Directory/EntraGroupLabelResolver.php @@ -112,7 +112,7 @@ public function lookupMany(ManagedEnvironment $tenant, array $groupIds): array public static function formatLabel(?string $displayName, string $id): string { - $name = filled($displayName) ? $displayName : 'Unresolved'; + $name = filled($displayName) ? $displayName : 'Unknown group'; return sprintf('%s (%s)', trim($name), self::shortToken($id)); } diff --git a/apps/platform/app/Services/Intune/RestoreRiskChecker.php b/apps/platform/app/Services/Intune/RestoreRiskChecker.php index 632358d5..860fe5ff 100644 --- a/apps/platform/app/Services/Intune/RestoreRiskChecker.php +++ b/apps/platform/app/Services/Intune/RestoreRiskChecker.php @@ -4,9 +4,9 @@ use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\ManagedEnvironment; use App\Models\Policy; use App\Models\PolicyVersion; -use App\Models\ManagedEnvironment; use App\Services\Graph\GroupResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver; use Carbon\CarbonImmutable; @@ -365,8 +365,8 @@ private function checkMetadataOnlySnapshots(Collection $policyItems): ?array $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; $message = $hasRestoreEnabled - ? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.' - : 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.'; + ? 'Some selected items were captured as metadata-only. Restore execution is blocked until provider connectivity is restored.' + : 'Some selected items were captured as metadata-only. Execution remains preview-only, and payload completeness is limited.'; return [ 'code' => 'metadata_only', diff --git a/apps/platform/app/Support/BackupQuality/BackupQualityResolver.php b/apps/platform/app/Support/BackupQuality/BackupQualityResolver.php index e70b91cf..d3d95820 100644 --- a/apps/platform/app/Support/BackupQuality/BackupQualityResolver.php +++ b/apps/platform/app/Support/BackupQuality/BackupQualityResolver.php @@ -474,6 +474,6 @@ private function defaultSetCompactSummary(int $totalItems): string private function positiveClaimBoundary(): string { - return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.'; + return 'Input quality signals do not prove that execution is safe or that recovery is verified.'; } } diff --git a/apps/platform/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php b/apps/platform/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php index ed2b6cf5..739eeb88 100644 --- a/apps/platform/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php +++ b/apps/platform/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php @@ -18,9 +18,9 @@ public function spec(mixed $value): BadgeSpec 'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'), 'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'), 'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'), - 'current' => new BadgeSpec('Current checks', 'success', 'heroicon-m-check-circle', 'success'), + 'current' => new BadgeSpec('Latest check result', 'info', 'heroicon-m-clock', 'info'), 'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'), - 'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'), + 'stale' => new BadgeSpec('Stale evidence', 'gray', 'heroicon-m-clock', 'gray'), 'not_run' => new BadgeSpec('Not run', 'gray', 'heroicon-m-eye-slash', 'gray'), default => BadgeSpec::unknown(), } ?? BadgeSpec::unknown(); diff --git a/apps/platform/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php b/apps/platform/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php index e1b3df67..c40c4db0 100644 --- a/apps/platform/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php +++ b/apps/platform/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php @@ -23,7 +23,7 @@ public function spec(mixed $value): BadgeSpec 'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'), 'current' => new BadgeSpec('Current basis', 'success', 'heroicon-m-check-circle', 'success'), 'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'), - 'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'), + 'stale' => new BadgeSpec('Stale evidence', 'gray', 'heroicon-m-clock', 'gray'), 'not_generated' => new BadgeSpec('Not generated', 'gray', 'heroicon-m-eye-slash', 'gray'), default => BadgeSpec::unknown(), } ?? BadgeSpec::unknown(); diff --git a/apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php b/apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php index c811e99a..97d6214b 100644 --- a/apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php +++ b/apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php @@ -22,10 +22,11 @@ public static function safetyStateLabel(?string $state): string public static function primaryNextAction(?string $action): string { return match ($action) { - 'resolve_blockers' => 'Resolve the technical blockers before real execution.', + 'resolve_blockers' => 'Review prerequisites before execution.', 'generate_preview' => 'Generate a preview for the current scope.', 'regenerate_preview' => 'Regenerate the preview for the current scope.', 'rerun_checks' => 'Run the safety checks again for the current scope.', + 'review_and_confirm' => 'Review the preview and complete confirmation before execution can be queued.', 'review_warnings' => 'Review the warnings before real execution.', 'execute' => 'Queue the real restore execution.', 'review_preview' => 'Review the preview evidence before claiming recovery or queueing execution.', @@ -58,9 +59,9 @@ public static function recoveryBoundary(?string $boundary): string { return match ($boundary) { 'preview_only_no_execution_proven' => 'No execution was performed from this record.', - 'execution_failed_no_recovery_claim' => 'ManagedEnvironment recovery is not proven.', - 'run_completed_not_recovery_proven' => 'ManagedEnvironment-wide recovery is not proven.', - default => 'ManagedEnvironment-wide recovery is not proven.', + 'execution_failed_no_recovery_claim' => 'Target environment recovery is not proven.', + 'run_completed_not_recovery_proven' => 'Target environment recovery is not proven.', + default => 'Target environment recovery is not proven.', }; } diff --git a/apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php b/apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php index 32845d54..caa3e612 100644 --- a/apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php +++ b/apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php @@ -6,13 +6,14 @@ use App\Contracts\Hardening\WriteGateInterface; use App\Exceptions\Hardening\ProviderAccessHardeningRequired; -use App\Models\RestoreRun; use App\Models\ManagedEnvironment; +use App\Models\RestoreRun; use App\Models\User; use App\Services\Auth\CapabilityResolver; +use App\Services\Providers\ProviderConnectionResolver; +use App\Support\Auth\Capabilities; use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthResolver; -use App\Support\Auth\Capabilities; final readonly class RestoreSafetyResolver { @@ -22,6 +23,7 @@ public function __construct( private CapabilityResolver $capabilityResolver, private WriteGateInterface $writeGate, private TenantBackupHealthResolver $backupHealthResolver, + private ProviderConnectionResolver $providerConnections, ) {} /** @@ -274,6 +276,14 @@ public function executionReadiness(ManagedEnvironment $tenant, User $user, array $blockingReasons[] = 'missing_capability'; } + if (! $dryRun) { + $providerResolution = $this->providerConnections->resolveDefault($tenant, 'microsoft'); + + if (! $providerResolution->resolved) { + $blockingReasons[] = $providerResolution->effectiveReasonCode(); + } + } + if (! $dryRun) { try { $this->writeGate->evaluate($tenant, 'restore.execute'); @@ -294,8 +304,8 @@ public function executionReadiness(ManagedEnvironment $tenant, User $user, array $allowed = $blockingReasons === []; $displaySummary = $allowed - ? 'The platform can start a restore for this tenant once the operator chooses to proceed.' - : 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.'; + ? 'Restore execution can start for this environment once the operator chooses to proceed.' + : 'Provider readiness or restore prerequisites currently prevent real execution.'; return new ExecutionReadinessState( allowed: $allowed, @@ -324,7 +334,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $ positiveClaimSuppressed: true, primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked', primaryNextAction: 'resolve_blockers', - summary: 'Real execution is blocked until the technical prerequisites are healthy again.', + summary: 'Restore execution is blocked until required prerequisites are healthy again.', ); } @@ -337,7 +347,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $ positiveClaimSuppressed: true, primaryIssueCode: $previewIntegrity->state, primaryNextAction: 'regenerate_preview', - summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.', + summary: 'Execution could start, but the preview basis is not current enough to support a calm go signal.', ); } @@ -350,7 +360,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $ positiveClaimSuppressed: true, primaryIssueCode: $checksIntegrity->state, primaryNextAction: 'rerun_checks', - summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.', + summary: 'Execution could start, but the checks basis is not current enough to support a calm go signal.', ); } @@ -428,7 +438,7 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte state: RestoreResultAttention::STATE_NOT_EXECUTED, followUpRequired: false, primaryCauseFamily: 'none', - summary: 'This record proves preview truth, not tenant recovery.', + summary: 'This record proves preview truth, not environment recovery.', primaryNextAction: 'review_preview', recoveryClaimBoundary: 'preview_only_no_execution_proven', tone: 'gray', @@ -475,7 +485,7 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte state: RestoreResultAttention::STATE_COMPLETED, followUpRequired: false, primaryCauseFamily: 'none', - summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.', + summary: 'The restore completed without visible follow-up, but this still does not prove environment-wide recovery.', primaryNextAction: 'review_result', recoveryClaimBoundary: 'run_completed_not_recovery_proven', tone: 'success', @@ -500,6 +510,7 @@ public function dashboardRecoveryEvidence(ManagedEnvironment $tenant): array { $backupHealth = $this->backupHealthResolver->assess($tenant); $relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant); + return $this->dashboardRecoveryEvidencePayload( backupHealth: $backupHealth, relevantRun: $relevantRestoreHistory['run'], diff --git a/apps/platform/resources/views/filament/forms/components/partials/restore-run-process-flow-panel.blade.php b/apps/platform/resources/views/filament/forms/components/partials/restore-run-process-flow-panel.blade.php new file mode 100644 index 00000000..5a9bcff8 --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/partials/restore-run-process-flow-panel.blade.php @@ -0,0 +1,149 @@ +@php + $processFlow = is_array($processFlow ?? null) ? $processFlow : []; + $steps = is_array($processFlow['steps'] ?? null) ? $processFlow['steps'] : []; + $compact = (bool) ($processFlow['compact'] ?? false); + + $badgeTone = static function (string $status): string { + return match ($status) { + 'complete' => 'success', + 'required' => 'warning', + 'blocked' => 'danger', + default => 'gray', + }; + }; + + $badgeLabel = static function (string $status): string { + return match ($status) { + 'complete' => 'Complete', + 'required' => 'Required', + 'blocked' => 'Blocked', + default => 'Unavailable', + }; + }; +@endphp + +@if ($compact) +
+ +
+
+
+ {{ $processFlow['gatesComplete'] ?? 0 }}/{{ $processFlow['gatesTotal'] ?? count($steps) }} gates complete +
+
+ Next gate: + {{ $processFlow['nextGate'] ?? 'Unavailable' }} +
+
+ Execution: + {{ $processFlow['executionLabel'] ?? 'Unavailable' }} +
+
+ {{ $processFlow['executionSummary'] ?? 'Execution remains unavailable until required safety gates are complete.' }} +
+
+ + + + +
+
+
+@else + +@endif diff --git a/apps/platform/resources/views/filament/forms/components/partials/restore-run-proof-panel.blade.php b/apps/platform/resources/views/filament/forms/components/partials/restore-run-proof-panel.blade.php new file mode 100644 index 00000000..78fa4642 --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/partials/restore-run-proof-panel.blade.php @@ -0,0 +1,56 @@ +@php + $proofAside = is_array($proofAside ?? null) ? $proofAside : []; + $items = is_array($proofAside['items'] ?? null) ? $proofAside['items'] : []; + $diagnosticsDisclosure = is_array($diagnosticsDisclosure ?? null) ? $diagnosticsDisclosure : []; + + $badgeClasses = static function (string $tone): string { + return match ($tone) { + 'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300', + 'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300', + 'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300', + default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', + }; + }; +@endphp + + diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-backup-quality-summary.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-backup-quality-summary.blade.php new file mode 100644 index 00000000..97a31cdd --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-backup-quality-summary.blade.php @@ -0,0 +1,49 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + $backupQualityCard = is_array($backupQualityCard ?? null) ? $backupQualityCard : []; + $counts = is_array($backupQualityCard['counts'] ?? null) ? $backupQualityCard['counts'] : []; + + $statusBadgeClasses = static function (bool $available): string { + return $available + ? 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300' + : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300'; + }; +@endphp + + + +
+
+

+ Backup quality summary +

+ + {{ $backupQualityCard['status'] ?? 'Unavailable' }} + +
+ +

+ {{ $backupQualityCard['summary'] ?? 'Backup quality hints describe input strength only.' }} +

+ + @if ($counts !== []) +
+ @foreach ($counts as $count) +
+
{{ $count['label'] ?? 'Count' }}
+
{{ $count['value'] ?? 0 }}
+
+ @endforeach +
+ @endif + +
+ {{ $backupQualityCard['positiveClaimBoundary'] ?? 'Input quality signals do not prove that execution is safe or that recovery is verified.' }} +
+ +
+ {{ $backupQualityCard['nextAction'] ?? 'Inspect item-level backup detail before continuing.' }} +
+
+
+
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php index 7fce0fc3..4b6cc6de 100644 --- a/apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php +++ b/apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -1,26 +1,35 @@ @php $fieldWrapperView = $getFieldWrapperView(); - $results = $getState() ?? []; - $results = is_array($results) ? $results : []; + $validationSummary = is_array($validationSummary ?? null) ? $validationSummary : []; + $validationSummary = $validationSummary !== [] + ? $validationSummary + : (is_array($validation_summary ?? null) ? $validation_summary : []); - $summary = $summary ?? []; - $summary = is_array($summary) ? $summary : []; + $integritySpec = $validationSummary['integritySpec'] ?? null; - $checksIntegrity = $checksIntegrity ?? []; - $checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : []; + if (! $integritySpec instanceof \App\Support\Badges\BadgeSpec) { + $integritySpec = \App\Support\Badges\BadgeSpec::unknown(); + } - $executionReadiness = $executionReadiness ?? []; - $executionReadiness = is_array($executionReadiness) ? $executionReadiness : []; + $blocking = (int) ($validationSummary['blockingCount'] ?? 0); + $warning = (int) ($validationSummary['warningCount'] ?? 0); + $safe = (int) ($validationSummary['safeCount'] ?? 0); - $safetyAssessment = $safetyAssessment ?? []; - $safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : []; + $integritySummary = (string) ($validationSummary['integritySummary'] ?? 'Run checks for the current scope before real execution.'); + $nextActionLabel = (string) ($validationSummary['nextActionLabel'] ?? 'Run the safety checks again for the current scope.'); - $blocking = (int) ($summary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0)); - $warning = (int) ($summary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0)); - $safe = (int) ($summary['safe'] ?? 0); + $startabilitySummary = (string) ($validationSummary['executionReadinessSummary'] ?? 'Execution prerequisites are unavailable.'); + $startabilityTone = (string) ($validationSummary['executionReadinessTone'] ?? ((bool) ($validationSummary['executionAllowed'] ?? false) ? 'success' : 'warning')); - $ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null); + $providerCredentialBlocked = (bool) ($validationSummary['providerCredentialBlocked'] ?? false); + $providerConnectionsUrl = $validationSummary['providerConnectionsUrl'] ?? null; + $providerConnectionsUrl = is_string($providerConnectionsUrl) && $providerConnectionsUrl !== '' ? $providerConnectionsUrl : null; + + $invalidationReasons = is_array($validationSummary['invalidationReasons'] ?? null) ? $validationSummary['invalidationReasons'] : []; + $groupedResults = is_array($validationSummary['groupedResults'] ?? null) ? $validationSummary['groupedResults'] : []; + + $ranAt = $validationSummary['ranAt'] ?? null; $ranAtLabel = null; if (is_string($ranAt) && $ranAt !== '') { @@ -31,27 +40,44 @@ } } - $severitySpec = static function (?string $severity): \App\Support\Badges\BadgeSpec { - return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity); - }; - - $integritySpec = $severitySpec($checksIntegrity['state'] ?? 'not_run'); - $integritySummary = $checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.'; - $nextAction = $safetyAssessment['primary_next_action'] ?? 'rerun_checks'; - $nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'rerun_checks'); - $startabilitySummary = $executionReadiness['display_summary'] ?? 'Execution readiness is unavailable.'; - $startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning'; - $limitedList = static function (array $items, int $limit = 5): array { - if (count($items) <= $limit) { - return $items; - } - - return array_slice($items, 0, $limit); - }; + $resultsPresent = collect($groupedResults) + ->filter(fn ($bucket) => is_array($bucket) && $bucket !== []) + ->isNotEmpty(); @endphp
+ @if ($providerCredentialBlocked) + +
+
+ Validation blocked +
+
+ Provider credentials are not available for this environment. +
+
+ Restore checks cannot run until the provider connection is repaired. +
+ + @if ($providerConnectionsUrl) +
+ + Review provider connection + +
+ @endif +
+
+ @endif + label }} - {{ (bool) ($executionReadiness['allowed'] ?? false) ? 'Technically startable' : 'Technical blocker present' }} + {{ $startabilityTone === 'success' ? 'Prerequisites healthy' : 'Execution blocked' }} - @if (($safetyAssessment['state'] ?? null) === 'ready_with_caution') - - Ready with caution - - @elseif (($safetyAssessment['state'] ?? null) === 'ready') - - Ready - - @endif
-
What the current checks prove
+
What validation proves
{{ $integritySummary }}
-
- Technical startability: {{ $startabilitySummary }} +
+ Execution prerequisites +
+
+ {{ $startabilitySummary }}
Primary next step @@ -90,81 +110,82 @@
- - {{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }} + + {{ $blocking }} blockers - - {{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }} + + {{ $warning }} warnings - - {{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }} + + {{ $safe }} safe
- @if (($checksIntegrity['invalidation_reasons'] ?? []) !== []) + @if ($invalidationReasons !== [])
- Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $checksIntegrity['invalidation_reasons'])) }} + Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $invalidationReasons)) }}
@endif
- @if ($results === []) + @if (! $resultsPresent)
No checks have been recorded for this scope yet.
@else -
- @foreach ($results as $result) +
+ @foreach ([ + 'blocking' => ['label' => 'Blockers', 'tone' => 'danger'], + 'warning' => ['label' => 'Warnings', 'tone' => 'warning'], + 'safe' => ['label' => 'Safe checks', 'tone' => 'success'], + ] as $bucket => $bucketMeta) @php - $severity = is_array($result) ? ($result['severity'] ?? 'safe') : 'safe'; - $title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check'; - $message = is_array($result) ? ($result['message'] ?? null) : null; - $meta = is_array($result) ? ($result['meta'] ?? []) : []; - $meta = is_array($meta) ? $meta : []; - $unmappedGroups = $meta['unmapped'] ?? []; - $unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : []; - $spec = $severitySpec($severity); + $bucketResults = is_array($groupedResults[$bucket] ?? null) ? $groupedResults[$bucket] : []; @endphp - -
-
-
- {{ $title }} + @if ($bucketResults !== []) + +
+
+ {{ $bucketMeta['label'] }}
- @if (is_string($message) && $message !== '') -
- {{ $message }} + + {{ count($bucketResults) }} + +
+ +
+ @foreach ($bucketResults as $result) + @php + $title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check'; + $message = is_array($result) ? ($result['message'] ?? null) : null; + @endphp + +
+
+
+
+ {{ is_string($title) ? $title : 'Check' }} +
+ @if (is_string($message) && $message !== '') +
+ {{ $message }} +
+ @endif +
+ + + {{ $bucketMeta['label'] }} + +
- @endif + @endforeach
- - - {{ $spec->label }} - -
- - @if ($unmappedGroups !== []) -
-
- Unmapped groups -
-
    - @foreach ($unmappedGroups as $group) - @php - $label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null; - @endphp - @if (is_string($label) && $label !== '') -
  • {{ $label }}
  • - @endif - @endforeach -
-
- @endif -
+ + @endif @endforeach
@endif diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php new file mode 100644 index 00000000..87005ad2 --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php @@ -0,0 +1,70 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + + $decisionCard = is_array($decisionCard ?? null) ? $decisionCard : []; + $processFlow = is_array($processFlow ?? null) ? $processFlow : []; + + $statusTone = (string) ($decisionCard['tone'] ?? 'gray'); + + $statusBadgeClasses = static function (string $tone): string { + return match ($tone) { + 'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300', + 'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300', + 'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300', + default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', + }; + }; +@endphp + + + +
+
+
+ {{ $decisionCard['title'] ?? 'Restore Safety' }} +
+ + {{ $decisionCard['status'] ?? 'Unavailable' }} + +
+ +
+
+
+ Primary next step +
+
+ {{ $decisionCard['nextAction'] ?? 'Review the current restore state.' }} +
+
+ +
+
+ Next gate +
+
+ {{ $processFlow['nextGate'] ?? 'Unavailable' }} +
+
+ +
+
+ Execution +
+
+ {{ $processFlow['executionLabel'] ?? 'Unavailable' }} +
+ @if (filled($processFlow['executionSummary'] ?? null)) +
+ {{ $processFlow['executionSummary'] }} +
+ @endif +
+
+ +
+ Confirmation does not claim recovery. Operation proof and post-run evidence remain unavailable until execution starts. +
+
+
+
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-group-mapping-skipped.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-group-mapping-skipped.blade.php new file mode 100644 index 00000000..545a706f --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-group-mapping-skipped.blade.php @@ -0,0 +1,12 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + $identityHtml = $identityHtml ?? null; +@endphp + + +
+ @if ($identityHtml) + {!! $identityHtml !!} + @endif +
+
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-mapping-resolver-summary.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-mapping-resolver-summary.blade.php new file mode 100644 index 00000000..0742122c --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-mapping-resolver-summary.blade.php @@ -0,0 +1,98 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); +@endphp + + + @php + $mappingResolver = is_array($mappingResolver ?? null) ? $mappingResolver : []; + $resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0); + $totalCount = (int) ($mappingResolver['totalCount'] ?? 0); + $unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0); + $skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0); + $manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0); + $requirementLabel = (string) ($mappingResolver['requirementLabel'] ?? 'Required before validation can run'); + $explanation = (string) ($mappingResolver['explanation'] ?? 'Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.'); + $cacheNotice = (string) ($mappingResolver['cacheNotice'] ?? ''); + $groupSyncUrl = $mappingResolver['groupSyncUrl'] ?? null; + $groupSyncOperationsUrl = $mappingResolver['groupSyncOperationsUrl'] ?? null; + @endphp + +
+
+
+ + {{ $resolvedCount }} of {{ $totalCount }} mappings resolved + + + {{ $unresolvedCount }} unresolved + + + {{ $skippedCount }} skipped + + @if ($manualFallbackCount > 0) + + {{ $manualFallbackCount }} manual fallback + + @endif +
+ +
+

+ {{ $requirementLabel }} +

+

+ {{ $explanation }} +

+
+ + @if ($cacheNotice !== '') +
+ {{ $cacheNotice }} +
+ @endif +
+ +
+ + Hide mapping details + + + @if (filled($groupSyncUrl)) + + Open group sync + + @endif + + @if (filled($groupSyncOperationsUrl)) + + View group sync operations + + @endif +
+
+
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php index c3fcfc6d..da6a06ee 100644 --- a/apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php +++ b/apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php @@ -1,228 +1,228 @@ @php $fieldWrapperView = $getFieldWrapperView(); - $diffs = $getState() ?? []; - $diffs = is_array($diffs) ? $diffs : []; + $previewState = is_array($previewSummary ?? null) ? $previewSummary : []; + $previewState = $previewState !== [] + ? $previewState + : (is_array($preview_summary ?? null) ? $preview_summary : []); - $summary = $summary ?? []; - $summary = is_array($summary) ? $summary : []; + $integritySpec = $previewState['integritySpec'] ?? null; - $previewIntegrity = $previewIntegrity ?? []; - $previewIntegrity = is_array($previewIntegrity) ? $previewIntegrity : []; + if (! $integritySpec instanceof \App\Support\Badges\BadgeSpec) { + $integritySpec = \App\Support\Badges\BadgeSpec::unknown(); + } - $checksIntegrity = $checksIntegrity ?? []; - $checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : []; + $generatedAt = $previewState['generatedAt'] ?? null; + $generatedAtLabel = null; - $safetyAssessment = $safetyAssessment ?? []; - $safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : []; - - $ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null); - $ranAtLabel = null; - - if (is_string($ranAt) && $ranAt !== '') { + if (is_string($generatedAt) && $generatedAt !== '') { try { - $ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i'); + $generatedAtLabel = \Carbon\CarbonImmutable::parse($generatedAt)->format('Y-m-d H:i'); } catch (\Throwable) { - $ranAtLabel = $ranAt; + $generatedAtLabel = $generatedAt; } } - $integritySpec = \App\Support\Badges\BadgeRenderer::spec( - \App\Support\Badges\BadgeDomain::RestorePreviewDecision, - $previewIntegrity['state'] ?? 'not_generated' - ); + $integritySummary = (string) ($previewState['integritySummary'] ?? 'Generate a preview before real execution.'); + $scopeLabel = (string) ($previewState['scopeLabel'] ?? 'Restore scope'); + $primaryNextActionLabel = (string) ($previewState['primaryNextActionLabel'] ?? 'Review the current scope and safety evidence.'); + + $summary = is_array($previewState['previewSummary'] ?? null) ? $previewState['previewSummary'] : []; + $needsAttentionDiffs = is_array($previewState['needsAttentionDiffs'] ?? null) ? $previewState['needsAttentionDiffs'] : []; + $unchangedDiffs = is_array($previewState['unchangedDiffs'] ?? null) ? $previewState['unchangedDiffs'] : []; $policiesTotal = (int) ($summary['policies_total'] ?? 0); $policiesChanged = (int) ($summary['policies_changed'] ?? 0); $assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0); $scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0); $diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0); - $integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.'; - $nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview'; - $nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'generate_preview'); - $limitedKeys = static function (array $items, int $limit = 8): array { - $keys = array_keys($items); - if (count($keys) <= $limit) { - return $keys; + $reviewedCount = count($needsAttentionDiffs) + count($unchangedDiffs); + + $policyLabel = static function (array $entry): string { + $displayName = $entry['display_name'] ?? $entry['displayName'] ?? null; + $identifier = $entry['policy_identifier'] ?? $entry['policyIdentifier'] ?? null; + + if (is_string($displayName) && trim($displayName) !== '') { + return (string) \Illuminate\Support\Str::of(trim($displayName)) + ->headline() + ->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker'); } - return array_slice($keys, 0, $limit); + if (is_string($identifier) && trim($identifier) !== '') { + return (string) \Illuminate\Support\Str::of(trim($identifier)) + ->headline() + ->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker'); + } + + return 'Policy'; + }; + + $policyActionLabel = static function (array $entry): string { + $action = $entry['action'] ?? null; + + return match ((string) $action) { + 'create' => 'Create', + 'delete' => 'Delete', + 'update' => 'Update', + default => 'Review', + }; }; @endphp -
- -
-
- - {{ $integritySpec->label }} - - @if (($checksIntegrity['state'] ?? null) === 'current') - - Checks current +
+
+ +
+
+ + {{ $integritySpec->label }} - @endif - @if (($safetyAssessment['state'] ?? null) === 'ready_with_caution') - - Calm readiness suppressed + + {{ $scopeLabel }} - @endif -
- -
-
What the preview proves
-
{{ $integritySummary }}
-
- Primary next step
-
- {{ $nextActionLabel }} + +
+
What the preview proves
+
{{ $integritySummary }}
+
+ Primary next step +
+
+ {{ $primaryNextActionLabel }} +
-
-
- - {{ $policiesChanged }}/{{ $policiesTotal }} policies changed - - - {{ $assignmentsChanged }} assignments changed - - - {{ $scopeTagsChanged }} scope tags changed - - @if ($diffsOmitted > 0) - - {{ $diffsOmitted }} diffs omitted (limit) - - @endif -
- - @if (($previewIntegrity['invalidation_reasons'] ?? []) !== []) -
- Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }} +
+
+
Policies reviewed
+
+ {{ $reviewedCount }} {{ \Illuminate\Support\Str::plural('policy', $reviewedCount) }} reviewed +
+
+
+
Policies changed
+
+ {{ $policiesChanged }} +
+
+
+
Assignments changed
+
+ {{ $assignmentsChanged }} +
+
+
+
Scope tags changed
+
+ {{ $scopeTagsChanged }} +
+
- @endif -
- - - @if ($diffs === []) - -
- No preview diff is recorded for this scope yet.
- @else -
- @foreach ($diffs as $entry) - @php - $entry = is_array($entry) ? $entry : []; - $name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item'; - $type = $entry['policy_type'] ?? 'type'; - $platform = $entry['platform'] ?? 'platform'; - $action = $entry['action'] ?? 'update'; - $diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : []; - $diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : []; - $added = (int) ($diffSummary['added'] ?? 0); - $removed = (int) ($diffSummary['removed'] ?? 0); - $changed = (int) ($diffSummary['changed'] ?? 0); - $assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false); - $scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false); - $diffOmitted = (bool) ($entry['diff_omitted'] ?? false); - $diffTruncated = (bool) ($entry['diff_truncated'] ?? false); - $changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []); - $addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []); - $removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []); - @endphp - -
- - {{ $action }} - - - {{ $added }} added - - - {{ $removed }} removed - - - {{ $changed }} changed - - @if ($assignmentsDelta) - - assignments - - @endif - @if ($scopeTagsDelta) - - scope tags - - @endif - @if ($diffTruncated) - - truncated - - @endif + +
+ @if ($policiesTotal === 0) +
+ No policies are included in this preview yet.
+ @elseif ($needsAttentionDiffs === [] && $unchangedDiffs !== []) +
+ No policy changes +
+ @endif - @if ($diffOmitted) -
- Diff details omitted due to preview limits. Narrow scope to see more items in detail. + @if ($diffsOmitted > 0) +
+ {{ $diffsOmitted }} {{ \Illuminate\Support\Str::plural('policy diff', $diffsOmitted) }} omitted due to preview limits. Narrow scope to review more. +
+ @endif + + @if ($needsAttentionDiffs !== []) +
+
+ Needs attention
- @elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== []) -
- @if ($changedKeys !== []) -
-
- Changed keys (sample) -
-
    - @foreach ($changedKeys as $key) -
  • - {{ $key }} -
  • - @endforeach -
-
- @endif - @if ($addedKeys !== []) -
-
- Added keys (sample) -
-
    - @foreach ($addedKeys as $key) -
  • - {{ $key }} -
  • - @endforeach -
-
- @endif - @if ($removedKeys !== []) -
-
- Removed keys (sample) -
-
    - @foreach ($removedKeys as $key) -
  • - {{ $key }} -
  • - @endforeach -
-
- @endif +
+ + + + + + + + + @foreach ($needsAttentionDiffs as $entry) + @php + $entry = is_array($entry) ? $entry : []; + @endphp + + + + + @endforeach + +
PolicyAction
+ {{ $policyLabel($entry) }} + + {{ $policyActionLabel($entry) }} +
- @endif - - @endforeach -
- @endif +
+ @endif + + @if ($unchangedDiffs !== []) +
+
+ Unchanged +
+
+ + + + + + + + + @foreach ($unchangedDiffs as $entry) + @php + $entry = is_array($entry) ? $entry : []; + @endphp + + + + + @endforeach + +
PolicyResult
+ {{ $policyLabel($entry) }} + + No policy changes +
+
+
+ @endif +
+ +
+ +
+ @include('filament.forms.components.partials.restore-run-process-flow-panel', [ + 'processFlow' => $processFlow ?? [], + ]) + + @include('filament.forms.components.partials.restore-run-proof-panel', [ + 'proofAside' => $proofAside ?? [], + 'diagnosticsDisclosure' => $diagnosticsDisclosure ?? [], + ]) +
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-proof-aside.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-proof-aside.blade.php new file mode 100644 index 00000000..a9c463cb --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-proof-aside.blade.php @@ -0,0 +1,10 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); +@endphp + + + @include('filament.forms.components.partials.restore-run-proof-panel', [ + 'proofAside' => $proofAside ?? [], + 'diagnosticsDisclosure' => $diagnosticsDisclosure ?? [], + ]) + diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php new file mode 100644 index 00000000..f8c11937 --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php @@ -0,0 +1,62 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + $decisionCard = is_array($decisionCard ?? null) ? $decisionCard : []; + + $statusBadgeClasses = static function (string $tone): string { + return match ($tone) { + 'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300', + 'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300', + 'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300', + default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', + }; + }; +@endphp + + + +
+
+
+

+ {{ $decisionCard['title'] ?? 'Restore Safety' }} +

+ + {{ $decisionCard['status'] ?? 'Unavailable' }} + +
+ +
+
+
{{ $decisionCard['statusLabel'] ?? 'Status' }}
+
{{ $decisionCard['status'] ?? 'Unavailable' }}
+
+ +
+
{{ $decisionCard['reasonLabel'] ?? 'Reason' }}
+
{{ $decisionCard['reason'] ?? 'Restore safety reason is unavailable.' }}
+
+ +
+
{{ $decisionCard['impactLabel'] ?? 'Impact' }}
+
{{ $decisionCard['impact'] ?? 'Restore impact is unavailable.' }}
+
+
+
+ + +
+
+
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-safety-gates.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-safety-gates.blade.php new file mode 100644 index 00000000..2a53064f --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-safety-gates.blade.php @@ -0,0 +1,9 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); +@endphp + + + @include('filament.forms.components.partials.restore-run-process-flow-panel', [ + 'processFlow' => $processFlow ?? [], + ]) + diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-scope-summary.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-scope-summary.blade.php new file mode 100644 index 00000000..ac779dba --- /dev/null +++ b/apps/platform/resources/views/filament/forms/components/restore-run-scope-summary.blade.php @@ -0,0 +1,98 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + + $currentScope = is_array($currentScope ?? null) ? $currentScope : []; + $mappingResolver = is_array($mappingResolver ?? null) ? $mappingResolver : []; + + $scopeMode = ($currentScope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all'; + $selectedItems = is_array($currentScope['selected_item_ids'] ?? null) ? $currentScope['selected_item_ids'] : []; + $selectedCount = count($selectedItems); + + $resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0); + $totalCount = (int) ($mappingResolver['totalCount'] ?? 0); + $unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0); + $skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0); + $manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0); + + $blockedReason = $blocked_reason ?? null; + $blockedReason = is_string($blockedReason) && $blockedReason !== '' ? $blockedReason : null; + + $canContinue = $can_continue ?? null; + $canContinue = is_bool($canContinue) ? $canContinue : null; +@endphp + + + +
+
+
+
+ Selected scope +
+
+ {{ $scopeMode === 'selected' ? "{$selectedCount} selected item" . ($selectedCount === 1 ? '' : 's') : 'All items (default)' }} +
+
+ Scope changes invalidate checks and preview evidence. +
+
+ +
+
+ Dependency mappings +
+
+ @if ($totalCount === 0) + No mappings required + @else + {{ $resolvedCount }} of {{ $totalCount }} mappings resolved + @endif +
+ @if ($totalCount > 0) +
+ {{ $unresolvedCount }} unresolved + · + {{ $skippedCount }} skipped + @if ($manualFallbackCount > 0) + · + {{ $manualFallbackCount }} manual fallback + @endif +
+ @endif +
+ +
+
+ Next +
+
+ {{ $canContinue === true ? 'Ready to continue' : 'Blocked' }} +
+ @if ($blockedReason) +
+ {{ $blockedReason }} +
+ @endif +
+
+ + @if ($totalCount > 0) +
+ +
+ @endif +
+
+
diff --git a/apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php b/apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php index 3475afbc..d0a16cb1 100644 --- a/apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php +++ b/apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php @@ -1,3 +1,3 @@
- +
diff --git a/apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php b/apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php index b7a760be..63ab07fd 100644 --- a/apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php +++ b/apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php @@ -1,3 +1,68 @@ -
- {{ $this->table }} +
+
+

+ Source group +

+

+ {{ $sourceGroupDisplayName ?? 'Unknown source group' }} +

+

+ Source ID: {{ $sourceGroupId }} +

+
+ + @if ($hasCachedGroups) +
+ {{ $this->table }} +
+ @else +
+
+
+

+ No directory group cache available +

+

+ TenantPilot needs cached directory groups before target mappings can be selected. +

+

+ Sync directory groups, then return to this mapping. +

+

+ If you already know the target object ID, close this picker and enter it manually in the mapping field. +

+
+ +
+ @if (filled($groupSyncUrl)) + + Open group sync + + @endif + + @if (filled($groupSyncOperationsUrl)) + + View group sync operations + + @endif +
+
+
+ @endif
diff --git a/apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowScreenshotsTest.php b/apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowScreenshotsTest.php new file mode 100644 index 00000000..62e35649 --- /dev/null +++ b/apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowScreenshotsTest.php @@ -0,0 +1,382 @@ +browser()->timeout(60_000); + +uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); + +function spec332RestoreWizardScreenshot(string $name): string +{ + return 'spec332-restore-create-'.$name; +} + +function spec332CopyBrowserScreenshot(string $name, ?string $targetFilename = null): void +{ + $filename = spec332RestoreWizardScreenshot($name).'.png'; + $source = base_path('tests/Browser/Screenshots/'.$filename); + $targetDirectory = repo_path('specs/332-product-process-flow-system-v1/artifacts/screenshots'); + $targetFilename ??= $filename; + + if (is_dir($targetDirectory) && ! is_writable($targetDirectory)) { + return; + } + + if (! is_dir($targetDirectory)) { + @mkdir($targetDirectory, 0755, true); + } + + if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) { + return; + } + + if (! is_file($source)) { + $source = \Pest\Browser\Support\Screenshot::path($filename); + } + + if (is_file($source)) { + @copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$targetFilename); + } +} + +function spec332RestoreWizardScreenshotsLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string +{ + return route('admin.local.smoke-login', array_filter([ + 'email' => $user->email, + 'tenant' => $tenant->external_id, + 'workspace' => $tenant->workspace->slug, + 'redirect' => $redirect, + ], static fn (?string $value): bool => filled($value))); +} + +function spec332ScreenshotsTenant(): array +{ + $tenant = ManagedEnvironment::factory()->create([ + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + ensureDefaultProviderConnection($tenant, 'microsoft'); + bindFailHardGraphClient(); + + return [$user, $tenant]; +} + +function spec332ScreenshotsRedirect(ManagedEnvironment $tenant): string +{ + $redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant); + + return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin'; +} + +function spec332ScreenshotsSelectBackupSet($page, BackupSet $backupSet): void +{ + $selected = $page->script(<< { + const select = document.getElementById('form.backup_set_id'); + + if (! select) { + return false; + } + + select.value = '{$backupSet->getKey()}'; + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + + return true; +})() +JS); + + expect($selected)->toBeTrue(); +} + +function spec332ScreenshotsWizardNext($page): void +{ + $clicked = $page->script(<<<'JS' +(() => { + const footer = document.querySelector('.fi-sc-wizard-footer'); + + if (! footer) { + return false; + } + + const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]'); + + if (! nextTrigger) { + return false; + } + + if (nextTrigger.classList.contains('fi-hidden')) { + return false; + } + + nextTrigger.scrollIntoView({ block: 'center' }); + nextTrigger.click(); + + return true; +})() +JS); + + expect($clicked)->toBeTrue(); +} + +function spec332ScreenshotsUsableBackupFixture(ManagedEnvironment $tenant): BackupSet +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-screenshots-policy-usable', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Screenshots Policy', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => [ + 'foo' => 'current', + ], + 'metadata' => [], + 'assignments' => [], + 'scope_tags' => [], + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Screenshots Usable Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => [ + 'foo' => 'backup', + 'displayName' => 'Spec332 Screenshots Policy', + ], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Screenshots Policy', + ], + ]); + + return $backupSet; +} + +function spec332ScreenshotsUnresolvedGroupBackupFixture(ManagedEnvironment $tenant): BackupSet +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-screenshots-policy-group', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Screenshots Group Mapping Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Screenshots Group Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => [ + 'foo' => 'backup', + 'displayName' => 'Spec332 Screenshots Group Mapping Policy', + ], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => '11111111-1111-1111-1111-111111111111', + 'group_display_name' => 'Spec332 Missing Group', + ], + ]], + 'metadata' => [ + 'displayName' => 'Spec332 Screenshots Group Mapping Policy', + ], + ]); + + return $backupSet; +} + +function spec332ScreenshotsMetadataOnlyFixture(ManagedEnvironment $tenant): BackupSet +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-screenshots-policy-metadata-only', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Screenshots Metadata Only Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Screenshots Metadata-only Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => [], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Screenshots Metadata Only Policy', + 'snapshot_source' => 'metadata_only', + 'warnings' => ['metadata only fallback'], + ], + ]); + + return $backupSet; +} + +it('captures step 1 screenshot with a degraded backup selected', function (): void { + [$user, $tenant] = spec332ScreenshotsTenant(); + $backupSet = spec332ScreenshotsMetadataOnlyFixture($tenant); + + $page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332ScreenshotsSelectBackupSet($page, $backupSet); + + $page->waitForText('The selected backup does not contain a usable captured item yet.'); + $page->script('window.scrollTo(0, 0);'); + $page->screenshot(true, spec332RestoreWizardScreenshot('step-1-backup-selected')); + spec332CopyBrowserScreenshot('step-1-backup-selected', 'step-1-backup-selected.png'); +}); + +it('captures step 2 screenshots for mapping summary + resolver expansion', function (): void { + [$user, $tenant] = spec332ScreenshotsTenant(); + $backupSet = spec332ScreenshotsUnresolvedGroupBackupFixture($tenant); + + EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'display_name' => 'Spec332 Cached Target Group', + 'last_seen_at' => now(), + ]); + + $page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332ScreenshotsSelectBackupSet($page, $backupSet); + + $page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.'); + spec332ScreenshotsWizardNext($page); + + $page->waitForText('Resolve target mappings'); + $page->script('window.scrollTo(0, 0);'); + $page->screenshot(true, spec332RestoreWizardScreenshot('step-2-scope-default')); + spec332CopyBrowserScreenshot('step-2-scope-default', 'step-2-scope-default.png'); + + $page->click('[data-testid="restore-run-open-mapping-resolver"]') + ->waitForText('Resolve mappings'); + + $page->script('window.scrollTo(0, 0);'); + $page->screenshot(true, spec332RestoreWizardScreenshot('step-2-resolver-expanded')); + spec332CopyBrowserScreenshot('step-2-resolver-expanded', 'step-2-resolver-expanded.png'); +}); + +it('captures step 3 screenshot when validation is blocked', function (): void { + [$user, $tenant] = spec332ScreenshotsTenant(); + $backupSet = spec332ScreenshotsMetadataOnlyFixture($tenant); + + $page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332ScreenshotsSelectBackupSet($page, $backupSet); + + $page->waitForText('The selected backup does not contain a usable captured item yet.'); + spec332ScreenshotsWizardNext($page); + $page->waitForText('Define Restore Scope'); + spec332ScreenshotsWizardNext($page); + $page->waitForText('Safety & Conflict Checks'); + + $page->waitForText('Run checks') + ->click('Run checks') + ->waitForText('Snapshot completeness'); + + spec332ScreenshotsWizardNext($page); + $page->waitForText('Validation blocked'); + + $page->script('window.scrollTo(0, 0);'); + $page->screenshot(true, spec332RestoreWizardScreenshot('step-3-validation-blocked')); + spec332CopyBrowserScreenshot('step-3-validation-blocked', 'step-3-validation-blocked.png'); +}); + +it('captures step 4 and 5 screenshots after preview generation', function (): void { + [$user, $tenant] = spec332ScreenshotsTenant(); + $backupSet = spec332ScreenshotsUsableBackupFixture($tenant); + + $page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332ScreenshotsSelectBackupSet($page, $backupSet); + + $page->waitForText('A usable source backup is selected for this restore draft.'); + spec332ScreenshotsWizardNext($page); + $page->waitForText('Define Restore Scope'); + spec332ScreenshotsWizardNext($page); + $page->waitForText('Safety & Conflict Checks'); + + $page->waitForText('Run checks') + ->click('Run checks') + ->waitForText('No group-based assignments detected.'); + + spec332ScreenshotsWizardNext($page); + + $page->waitForText('Generate preview') + ->click('Generate preview') + ->waitForText('Policy change preview'); + + $page->script('window.scrollTo(0, 0);'); + $page->screenshot(true, spec332RestoreWizardScreenshot('step-4-preview-generated')); + spec332CopyBrowserScreenshot('step-4-preview-generated', 'step-4-preview-generated.png'); + + spec332ScreenshotsWizardNext($page); + $page->waitForText('Confirm & Execute'); + + $page->script('window.scrollTo(0, 0);'); + $page->screenshot(true, spec332RestoreWizardScreenshot('step-5-confirm-ready')); + spec332CopyBrowserScreenshot('step-5-confirm-ready', 'step-5-confirm-ready.png'); +}); diff --git a/apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php b/apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php new file mode 100644 index 00000000..9292590c --- /dev/null +++ b/apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php @@ -0,0 +1,605 @@ +browser()->timeout(30_000); + +uses(RefreshDatabase::class); + +function spec332RestoreWizardSmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string +{ + return route('admin.local.smoke-login', array_filter([ + 'email' => $user->email, + 'tenant' => $tenant->external_id, + 'workspace' => $tenant->workspace->slug, + 'redirect' => $redirect, + ], static fn (?string $value): bool => filled($value))); +} + +function spec332BrowserTenant(): array +{ + $tenant = ManagedEnvironment::factory()->create([ + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + ensureDefaultProviderConnection($tenant, 'microsoft'); + bindFailHardGraphClient(); + + return [$user, $tenant]; +} + +function spec332BrowserRedirect(ManagedEnvironment $tenant): string +{ + $redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant); + + return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin'; +} + +function spec332BrowserSelectBackupSet($page, BackupSet $backupSet): void +{ + $selected = $page->script(<< { + const select = document.getElementById('form.backup_set_id'); + + if (! select) { + return false; + } + + select.value = '{$backupSet->getKey()}'; + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + + return true; +})() +JS); + + expect($selected)->toBeTrue(); +} + +function spec332BrowserElementIsVisibleScript(string $testId): string +{ + return << { + const element = document.querySelector('[data-testid="{$testId}"]'); + + if (! element) { + return false; + } + + const style = window.getComputedStyle(element); + + return style.display !== 'none' + && style.visibility !== 'hidden' + && ! element.hidden + && Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); +})() +JS; +} + +function spec332BrowserWizardNext($page): void +{ + $clicked = $page->script(<<<'JS' +(() => { + const footer = document.querySelector('.fi-sc-wizard-footer'); + + if (! footer) { + return false; + } + + const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]'); + + if (! nextTrigger) { + return false; + } + + if (nextTrigger.classList.contains('fi-hidden')) { + return false; + } + + nextTrigger.scrollIntoView({ block: 'center' }); + nextTrigger.click(); + + return true; +})() +JS); + + expect($clicked)->toBeTrue(); +} + +function spec332BrowserUsableBackupFixture(ManagedEnvironment $tenant): array +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-browser-policy-usable', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Browser Policy', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => [ + 'foo' => 'current', + ], + 'metadata' => [], + 'assignments' => [], + 'scope_tags' => [], + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Browser Usable Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => [ + 'foo' => 'backup', + 'displayName' => 'Spec332 Browser Policy', + ], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Browser Policy', + ], + ]); + + return [$backupSet, $backupItem]; +} + +function spec332BrowserUnresolvedGroupFixture(ManagedEnvironment $tenant): array +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-browser-policy-group', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Group Mapping Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Browser Group Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => [ + 'foo' => 'backup', + ], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => '11111111-1111-1111-1111-111111111111', + 'group_display_name' => 'Spec332 Missing Group', + ], + ]], + 'metadata' => [ + 'displayName' => 'Spec332 Group Mapping Policy', + ], + ]); + + return [$backupSet, $backupItem]; +} + +function spec332BrowserMetadataOnlyFixture(ManagedEnvironment $tenant): array +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-browser-policy-metadata-only', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Metadata Only Browser Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Browser Metadata-only Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => [], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Metadata Only Browser Policy', + 'snapshot_source' => 'metadata_only', + 'warnings' => ['metadata only fallback'], + ], + ]); + + return [$backupSet, $backupItem]; +} + +it('shows the full product process flow on step 1', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUsableBackupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('A usable source backup is selected for this restore draft.') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->assertSee('Restore Safety') + ->assertSee('Backup quality summary') + ->assertSee('Restore safety gates') + ->assertSee('Restore Proof') + ->assertSee('Diagnostics - Collapsed') + ->assertSee('Continue to scope and resolve required mappings.') + ->assertSee('Validate impact before execution.') + ->assertDontSee('Technical startability') + ->assertDontSee('write-gate') + ->assertDontSee('hard-blocker') + ->assertDontSee('Is this dangerous?') + ->assertDontSee('tenant-wide recoverability') + ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), true) + ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), false) + ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true) + ->assertSee('A usable source backup is selected for this restore draft.'); +}); + +it('shows compact restore safety status by default on step 2', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUsableBackupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('A usable source backup is selected for this restore draft.'); + spec332BrowserWizardNext($page); + + $page->waitForText('2/7 gates complete') + ->assertSee('Restore safety status') + ->assertSee('2/7 gates complete') + ->assertSee('View safety gates') + ->assertDontSee('Hide safety gates') + ->assertSee('Restore Proof') + ->assertSee('Diagnostics - Collapsed') + ->assertDontSee('Technical startability') + ->assertDontSee('write-gate') + ->assertDontSee('hard-blocker') + ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), true) + ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), false) + ->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true) + ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true); +}); + +it('keeps group mapping details collapsed until explicitly opened on step 2', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.'); + spec332BrowserWizardNext($page); + + $page->waitForText('Resolve target mappings') + ->assertSee('Scope summary') + ->assertSee('Resolve mappings') + ->assertSee('0 of 1 mappings resolved') + ->assertSee('Resolve target mappings') + ->assertSee('Restore safety status') + ->assertSee('Restore Proof') + ->assertSee('Diagnostics - Collapsed') + ->assertSee('Resolve required mappings before validation can run.') + ->assertDontSee('Paste the target Entra ID group Object ID (GUID).') + ->assertScript(<<<'JS' +(() => { + const section = Array.from(document.querySelectorAll('.fi-section')).find((element) => + element.textContent?.includes('Resolve target mappings') + ); + + return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'false'; +})() +JS, true) + ->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true); + + $page->click('[data-testid="restore-run-open-mapping-resolver"]'); + + $page->waitForText('0 of 1 mappings resolved') + ->assertSee('0 of 1 mappings resolved') + ->assertSee('1 unresolved') + ->assertSee('0 skipped') + ->assertSee('Resolve required mappings before validation can run.') + ->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.') + ->assertSee('Hide mapping details') + ->assertDontSee('Return to scope summary') + ->assertDontSee('Paste the target Entra ID group Object ID (GUID).') + ->assertScript(<<<'JS' +(() => { + const section = Array.from(document.querySelectorAll('.fi-section')).find((element) => + element.textContent?.includes('Resolve target mappings') + ); + + return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'true'; +})() +JS, true); +}); + +it('shows a task-specific empty picker when the directory group cache is unavailable on step 2', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.'); + spec332BrowserWizardNext($page); + + $page->waitForText('Resolve target mappings'); + + $pickerOpened = $page->script(<<<'JS' +(() => { + const header = Array.from(document.querySelectorAll('.fi-section-header')).find((element) => + element.textContent?.includes('Resolve target mappings') + ); + + header?.click(); + + const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]'); + + pickerButton?.click(); + + return Boolean(pickerButton); +})() +JS); + + expect($pickerOpened)->toBeTrue(); + + $page->waitForText('Resolve target group mapping') + ->assertSee('Source group') + ->assertSee('Spec332 Missing Group') + ->assertSee('Source ID: 11111111-1111-1111-1111-111111111111') + ->assertSee('No directory group cache available') + ->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.') + ->assertSee('Sync directory groups, then return to this mapping.') + ->assertSee('Open group sync') + ->assertSee('View group sync operations') + ->assertScript(<<<'JS' +(() => { + const modalContent = document.querySelector('[data-testid="restore-group-picker-modal-content"]'); + + if (! modalContent) { + return false; + } + + const actionLabels = Array.from(modalContent.querySelectorAll('a, button')) + .map((element) => element.textContent?.trim() ?? '') + .filter(Boolean); + + return ! actionLabels.includes('Directory Groups') + && ! actionLabels.includes('Operations') + && ! modalContent.textContent?.includes('No cached groups found') + && ! modalContent.textContent?.includes('No groups found in tenant') + && ! modalContent.textContent?.includes('Search groups…'); +})() +JS, true); +}); + +it('shows cached directory group results in the picker when cache exists on step 2', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); + + EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'display_name' => 'Spec332 Cached Target Group', + 'last_seen_at' => now(), + ]); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.'); + spec332BrowserWizardNext($page); + + $page->waitForText('Resolve target mappings') + ->click('[data-testid="restore-run-open-mapping-resolver"]'); + + $pickerOpened = $page->script(<<<'JS' +(() => { + const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]'); + + pickerButton?.click(); + + return Boolean(pickerButton); +})() +JS); + + expect($pickerOpened)->toBeTrue(); + + $page->waitForText('Resolve target group mapping') + ->assertSee('Spec332 Cached Target Group') + ->assertDontSee('No directory group cache available'); +}); + +it('blocks next on step 2 when required mappings are unresolved', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.'); + spec332BrowserWizardNext($page); + + $page->waitForText('Resolve target mappings'); + spec332BrowserWizardNext($page); + + $page->waitForText('Mappings required') + ->assertDontSee('field is required.') + ->assertSee('Resolve required mappings before validation can run.') + ->assertSee('Define Restore Scope'); +}); + +it('blocks next on step 3 when validation has blockers', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserMetadataOnlyFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('The selected backup does not contain a usable captured item yet.'); + spec332BrowserWizardNext($page); + $page->waitForText('Define Restore Scope'); + spec332BrowserWizardNext($page); + $page->waitForText('Safety & Conflict Checks'); + + $page->waitForText('Run checks') + ->click('Run checks') + ->waitForText('Snapshot completeness'); + + spec332BrowserWizardNext($page); + + $page + ->waitForText('Validation blocked') + ->assertSee('Resolve the blocking validation issues before moving to preview.') + ->assertSee('Safety & Conflict Checks'); +}); + +it('keeps preview decision-first on step 4 while safety gates stay compact', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUsableBackupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('A usable source backup is selected for this restore draft.'); + spec332BrowserWizardNext($page); + $page->waitForText('Define Restore Scope'); + spec332BrowserWizardNext($page); + $page->waitForText('Safety & Conflict Checks'); + + $page->waitForText('Run checks') + ->click('Run checks') + ->waitForText('No group-based assignments detected.'); + + spec332BrowserWizardNext($page); + + $page->waitForText('Generate preview') + ->click('Generate preview') + ->waitForText('Policy change preview') + ->assertSee('Review the preview and complete confirmation before execution can be queued.') + ->assertSee('Restore safety status') + ->assertSee('View safety gates') + ->assertDontSee('Hide safety gates') + ->assertSee('Restore Proof') + ->assertSee('Operation proof') + ->assertSee('Diagnostics - Collapsed') + ->assertDontSee('tenant-wide recovery is proven') + ->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact\"]") !== null', true) + ->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-full\"]") === null', true) + ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true); +}); + +it('shows confirm step readiness after preview and checks are current', function (): void { + [$user, $tenant] = spec332BrowserTenant(); + [$backupSet] = spec332BrowserUsableBackupFixture($tenant); + + $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); + + $page->resize(1920, 1200) + ->waitForText('Select Backup Set'); + + spec332BrowserSelectBackupSet($page, $backupSet); + + $page->waitForText('A usable source backup is selected for this restore draft.'); + spec332BrowserWizardNext($page); + $page->waitForText('Define Restore Scope'); + spec332BrowserWizardNext($page); + $page->waitForText('Safety & Conflict Checks'); + + $page->waitForText('Run checks') + ->click('Run checks') + ->waitForText('No group-based assignments detected.'); + + spec332BrowserWizardNext($page); + + $page->waitForText('Generate preview') + ->click('Generate preview') + ->waitForText('Policy change preview'); + + spec332BrowserWizardNext($page); + + $page + ->waitForText('Confirm & Execute') + ->assertSee('Confirmation summary') + ->assertSee('Available after confirmation') + ->assertSee('Confirmation does not claim recovery.') + ->assertSee('Restore Proof') + ->assertSee('Operation proof') + ->assertSee('Post-run evidence') + ->assertDontSee('tenant-wide recovery is proven'); +}); diff --git a/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php b/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php index 12ea48a3..802af8b5 100644 --- a/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php +++ b/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php @@ -20,10 +20,9 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; -use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\Rbac\UiTooltips; +use App\Support\RestoreSafety\RestoreResultAttention; use Carbon\CarbonImmutable; -use Filament\Facades\Filament; use Filament\Widgets\StatsOverviewWidget\Stat; use Illuminate\Support\Facades\Gate; use Livewire\Livewire; @@ -125,7 +124,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, ]), 'Weakened', 'The restore did not complete successfully. Follow-up is still required.', - 'ManagedEnvironment recovery is not proven.', + 'Target environment recovery is not proven.', RestoreResultAttention::STATE_FAILED, ], 'partial history' => [ @@ -138,7 +137,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, ]), 'Weakened', 'The restore reached a terminal state, but some items or assignments still need follow-up.', - 'ManagedEnvironment-wide recovery is not proven.', + 'Target environment recovery is not proven.', RestoreResultAttention::STATE_PARTIAL, ], 'follow-up history' => [ @@ -151,7 +150,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, ]), 'Weakened', 'The restore completed, but follow-up remains for skipped or non-applied work.', - 'ManagedEnvironment-wide recovery is not proven.', + 'Target environment recovery is not proven.', RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, ], 'calm completed history' => [ @@ -164,7 +163,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, ]), 'No recent issues visible', 'Recent executed restore history exists without a current follow-up signal.', - 'ManagedEnvironment-wide recovery is not proven.', + 'Target environment recovery is not proven.', 'no_recent_issues_visible', ], ]); @@ -249,12 +248,12 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, ]); expect($stats['High severity findings'])->toMatchArray([ - 'value' => '2', - 'url' => FindingResource::getUrl('index', [ - 'tab' => 'needs_action', - 'high_severity' => 1, - ], panel: 'admin', tenant: $tenant), - ]) + 'value' => '2', + 'url' => FindingResource::getUrl('index', [ + 'tab' => 'needs_action', + 'high_severity' => 1, + ], panel: 'admin', tenant: $tenant), + ]) ->and($stats['Overdue findings']['value'])->toBe('0') ->and($stats['Overdue findings']['url'])->toBe(FindingResource::getUrl('index', [ 'tab' => 'overdue', @@ -435,7 +434,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, expect($recoveryStat['description']) ->toContain('No executed restore history is visible in the latest tenant restore records.') - ->toContain('ManagedEnvironment-wide recovery is not proven.'); + ->toContain('Target environment recovery is not proven.'); }); it('surfaces weak and calm restore history on the recovery evidence KPI', function ( diff --git a/apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php b/apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php index 8224cd7a..446b7c33 100644 --- a/apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php +++ b/apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php @@ -23,10 +23,9 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; -use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\Rbac\UiTooltips; +use App\Support\RestoreSafety\RestoreResultAttention; use Carbon\CarbonImmutable; -use Filament\Facades\Filament; use Illuminate\Support\Facades\Gate; use Livewire\Livewire; @@ -707,7 +706,7 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme ->assertSee('Backups are recent and healthy') ->assertSee('No recent restore issues visible') ->assertSee('Recent executed restore history exists without a current follow-up signal.') - ->assertSee('ManagedEnvironment-wide recovery is not proven.') + ->assertSee('Target environment recovery is not proven.') ->assertDontSee('Recovery evidence is unvalidated') ->assertDontSee('Recent restore failed'); }); diff --git a/apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php b/apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php index d13a0fc6..c9225204 100644 --- a/apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php @@ -106,7 +106,7 @@ ->assertSee('Follow-up required') ->assertSee('Review skipped or non-applied items before closing the run.') ->assertSee('No dominant cause recorded') - ->assertSee('ManagedEnvironment-wide recovery is not proven.') + ->assertSee('Target environment recovery is not proven.') ->assertDontSee('review_skipped_items') ->assertDontSee('run_completed_not_recovery_proven'); }); diff --git a/apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php b/apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php new file mode 100644 index 00000000..36580dd8 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php @@ -0,0 +1,75 @@ +create([ + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + ensureDefaultProviderConnection($tenant, 'microsoft'); + + /** @var RestoreSafetyResolver $resolver */ + $resolver = app(RestoreSafetyResolver::class); + + $data = [ + 'backup_set_id' => 10, + 'scope_mode' => 'selected', + 'backup_item_ids' => [1], + 'group_mapping' => [], + 'check_summary' => ['blocking' => 0, 'warning' => 0, 'safe' => 1], + 'check_results' => [['code' => 'safe', 'severity' => 'safe']], + 'checks_ran_at' => now('UTC')->toIso8601String(), + 'preview_summary' => [ + 'generated_at' => now('UTC')->toIso8601String(), + 'policies_total' => 1, + 'policies_changed' => 0, + 'assignments_changed' => 0, + 'scope_tags_changed' => 0, + ], + 'preview_diffs' => [[ + 'policy_identifier' => 'policy-1', + 'display_name' => 'Bitlocker Require', + 'policy_type' => 'deviceCompliancePolicy', + 'platform' => 'all', + 'action' => 'update', + 'assignments_changed' => false, + 'scope_tags_changed' => false, + 'diff' => [ + 'summary' => ['added' => 0, 'removed' => 0, 'changed' => 0], + 'changed' => [], + 'added' => [], + 'removed' => [], + ], + ]], + 'preview_ran_at' => now('UTC')->toIso8601String(), + ]; + + $data['check_basis'] = $resolver->checksBasisFromData($data); + $data['preview_basis'] = $resolver->previewBasisFromData($data); + $data = App\Filament\Resources\RestoreRunResource::synchronizeRestoreSafetyDraft($data); + + setAdminPanelContext($tenant); + + Livewire::actingAs($user) + ->test(CreateRestoreRun::class) + ->set('data', $data) + ->goToWizardStep(4) + ->assertSeeText('Review the preview and complete confirmation before execution can be queued.') + ->assertDontSeeText('Review prerequisites before execution.') + ->assertSeeText('Policy change preview') + ->assertSeeText('BitLocker Require') + ->assertSeeText('No policy changes') + ->assertSeeText('1 policy reviewed') + ->assertDontSeeText('deviceCompliancePolicy') + ->assertDontSeeText('deviceCompliancePolicy • all'); +}); diff --git a/apps/platform/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php b/apps/platform/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php index 9b0b60af..1e767ed7 100644 --- a/apps/platform/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php +++ b/apps/platform/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php @@ -39,7 +39,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName ->assertSee('Select Backup Set'); }); -test('restore wizard group mapping renders DB-only with manual GUID UX', function () { +test('restore wizard group mapping renders DB-only with resolver-mode UX', function () { $tenant = ManagedEnvironment::factory()->create(); [$user] = createUserWithTenant($tenant); @@ -49,7 +49,6 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName ]); $groupId = '11111111-2222-3333-4444-555555555555'; - $expectedMasked = '…'.substr($groupId, -8); BackupItem::factory()->create([ 'managed_environment_id' => $tenant->getKey(), @@ -72,7 +71,10 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName ]) ->get($url) ->assertOk() - ->assertSee($expectedMasked) - ->assertSee('Paste the target Entra ID group Object ID') - ->assertSee('Use SKIP to omit the assignment.'); + ->assertSee('Example Group') + ->assertSee('Source ID: '.$groupId) + ->assertSee('Resolve target mappings') + ->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback.') + ->assertDontSee('Paste the target Entra ID group Object ID') + ->assertDontSee('Use SKIP to omit the assignment.'); }); diff --git a/apps/platform/tests/Feature/Filament/Spec332ProductProcessFlowSystemTest.php b/apps/platform/tests/Feature/Filament/Spec332ProductProcessFlowSystemTest.php new file mode 100644 index 00000000..aa730130 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Spec332ProductProcessFlowSystemTest.php @@ -0,0 +1,556 @@ +create([ + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + ensureDefaultProviderConnection($tenant, 'microsoft'); + + return [$user, $tenant]; +} + +function spec332UsableBackupFixture(ManagedEnvironment $tenant): array +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-policy-usable', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Device Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Usable Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + 'displayName' => 'Spec332 Device Policy', + 'settings' => ['foo' => 'bar'], + ], + 'metadata' => [ + 'displayName' => 'Spec332 Device Policy', + ], + 'assignments' => [], + ]); + + return [$backupSet, $backupItem]; +} + +function spec332EmptyBackupFixture(ManagedEnvironment $tenant): BackupSet +{ + return BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Empty Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); +} + +function spec332MetadataOnlyBackupFixture(ManagedEnvironment $tenant): array +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-policy-metadata-only', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Metadata Only Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Metadata-only Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Metadata Only Policy', + 'snapshot_source' => 'metadata_only', + 'warnings' => ['metadata only fallback'], + ], + 'assignments' => [], + ]); + + return [$backupSet, $backupItem]; +} + +function spec332UnresolvedGroupBackupFixture(ManagedEnvironment $tenant): array +{ + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-policy-group', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Group Mapping Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Group Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + 'displayName' => 'Spec332 Group Mapping Policy', + ], + 'metadata' => [ + 'displayName' => 'Spec332 Group Mapping Policy', + ], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => '11111111-1111-1111-1111-111111111111', + 'group_display_name' => 'Spec332 Missing Group', + ], + ]], + ]); + + return [$backupSet, $backupItem]; +} + +function spec332WizardComponent($user, ManagedEnvironment $tenant): \Livewire\Features\SupportTesting\Testable +{ + setAdminPanelContext($tenant); + + return Livewire::actingAs($user)->test(CreateRestoreRun::class); +} + +function spec332CurrentPreviewData(BackupSet $backupSet, BackupItem $backupItem): array +{ + /** @var RestoreSafetyResolver $resolver */ + $resolver = app(RestoreSafetyResolver::class); + + $data = [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'scope_mode' => 'selected', + 'backup_item_ids' => [(int) $backupItem->getKey()], + 'group_mapping' => [], + 'check_summary' => ['blocking' => 0, 'warning' => 0, 'safe' => 1], + 'check_results' => [['code' => 'safe', 'severity' => 'safe']], + 'checks_ran_at' => now('UTC')->toIso8601String(), + 'preview_summary' => [ + 'generated_at' => now('UTC')->toIso8601String(), + 'policies_total' => 1, + 'policies_changed' => 0, + 'assignments_changed' => 0, + 'scope_tags_changed' => 0, + 'raw_payload_marker' => 'spec332 raw payload should stay hidden', + ], + 'preview_diffs' => [[ + 'policy_identifier' => 'spec332-policy-usable', + 'display_name' => 'Spec332 Device Policy', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'action' => 'update', + 'assignments_changed' => false, + 'scope_tags_changed' => false, + 'diff' => [ + 'summary' => ['added' => 0, 'removed' => 0, 'changed' => 0], + 'changed' => [], + 'added' => [], + 'removed' => [], + ], + ]], + 'preview_ran_at' => now('UTC')->toIso8601String(), + ]; + + $data['check_basis'] = $resolver->checksBasisFromData($data); + $data['preview_basis'] = $resolver->previewBasisFromData($data); + + return RestoreRunResource::synchronizeRestoreSafetyDraft($data); +} + +it('keeps only the reconciled spec 332 directory under the active product process flow path', function (): void { + $spec332Directories = collect(glob(repo_path('specs/332-*')) ?: []) + ->map(static fn (string $path): string => basename($path)) + ->sort() + ->values() + ->all(); + + expect($spec332Directories)->toBe([ + '332-product-process-flow-system-v1', + ]) + ->and(is_dir(repo_path('specs/332-product-process-flow-system-v1')))->toBeTrue() + ->and(is_dir(repo_path('specs/332-restore-run-preview-productization')))->toBeFalse() + ->and((string) file_get_contents(repo_path('specs/332-product-process-flow-system-v1/spec.md'))) + ->toContain('Product Process Flow System v1') + ->toContain('Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path'); +}); + +it('renders the full product process flow on step 1 for a usable backup source', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UsableBackupFixture($tenant); + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->assertSee('Restore Safety') + ->assertSee('Backup quality summary') + ->assertSee('Restore safety gates') + ->assertSee('Restore Proof') + ->assertSee('Diagnostics - Collapsed') + ->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.') + ->assertSee('A usable source backup is selected for this restore draft.') + ->assertSee('Continue to scope and resolve required mappings.') + ->assertSee('Validate impact before execution.') + ->assertSee('This create flow does not prove recoverability before execution and post-run evidence exist.') + ->assertDontSee('Technical startability') + ->assertDontSee('write-gate') + ->assertDontSee('hard-blocker') + ->assertDontSee('Is this dangerous?') + ->assertDontSee('tenant-wide recoverability') + ->assertSeeHtml('data-step-label="Usable source selected"') + ->assertSeeHtml('data-proof-label="Operation proof"'); + + expect($component->html())->toContain('data-testid="restore-run-process-flow-full"'); +}); + +it('does not mark usable source as complete when the backup has no captured items', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + $backupSet = spec332EmptyBackupFixture($tenant); + + spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.') + ->assertSeeHtml('data-step-label="Usable source selected"') + ->assertSeeHtml('data-step-status="required"'); +}); + +it('renders compact restore safety status on step 2 while keeping restore proof visible', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UsableBackupFixture($tenant); + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->assertSee('Restore safety status') + ->assertSee('2/7 gates complete') + ->assertSee('View safety gates') + ->assertSee('Restore Proof') + ->assertSee('Requested by') + ->assertSee('Diagnostics - Collapsed') + ->assertSeeHtml('data-testid="restore-run-process-flow-compact"'); + + expect($component->html())->toContain('data-testid="restore-run-process-flow-compact"'); +}); + +it('keeps group mapping details collapsed by default on step 2 until the resolver is opened explicitly', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UnresolvedGroupBackupFixture($tenant); + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->assertSee('Scope summary') + ->assertSee('Resolve mappings') + ->assertSee('Resolve target mappings') + ->assertSee('Restore safety status') + ->assertSee('Restore Proof') + ->assertSee('0 of 1 mappings resolved') + ->assertSee('1 unresolved') + ->assertSee('0 skipped') + ->assertSee('Resolve required mappings before validation can run.') + ->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.') + ->assertDontSee('Paste the target Entra ID group Object ID (GUID).'); + + $html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5); + + expect(preg_match('/isCollapsed:\s*true[\s\S]{0,2200}Resolve target mappings/', $html))->toBe(1); +}); + +it('shows cached target group identity in mapping helper text when a cached target is selected', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UnresolvedGroupBackupFixture($tenant); + + $sourceGroupId = '11111111-1111-1111-1111-111111111111'; + $targetGroupId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + + EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => $targetGroupId, + 'display_name' => 'Spec332 Cached Target Group', + ]); + + spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->fillForm([ + 'group_mapping' => [ + $sourceGroupId => $targetGroupId, + ], + ]) + ->assertSee('Target group: Spec332 Cached Target Group') + ->assertSee('Target ID: '.$targetGroupId); +}); + +it('labels manual GUID mapping as a manual fallback and counts it in the resolver summary', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UnresolvedGroupBackupFixture($tenant); + + $sourceGroupId = '11111111-1111-1111-1111-111111111111'; + $targetGroupId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->fillForm([ + 'group_mapping' => [ + $sourceGroupId => $targetGroupId, + ], + ]) + ->assertSee('1 of 1 mappings resolved') + ->assertSee('0 unresolved') + ->assertSee('0 skipped') + ->assertSee('1 manual fallback') + ->assertSee('Manual target object ID') + ->assertSee('Badge: Manual fallback'); +}); + +it('does not treat invalid GUID values as resolved in the resolver summary', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UnresolvedGroupBackupFixture($tenant); + + $sourceGroupId = '11111111-1111-1111-1111-111111111111'; + + spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->fillForm([ + 'group_mapping' => [ + $sourceGroupId => 'not-a-guid', + ], + ]) + ->assertSee('0 of 1 mappings resolved') + ->assertSee('1 unresolved') + ->assertSee('Invalid group object ID (GUID).'); +}); + +it('supports skipping and undoing a mapping assignment on step 2', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UnresolvedGroupBackupFixture($tenant); + + $sourceGroupId = '11111111-1111-1111-1111-111111111111'; + $sourceGroupToken = '11111111_1111_1111_1111_111111111111'; + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->assertFormComponentActionVisible("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}") + ->callFormComponentAction("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}") + ->assertSet("data.group_mapping.{$sourceGroupId}", 'SKIP') + ->assertFormFieldHidden("group_mapping.{$sourceGroupId}") + ->assertFormFieldVisible("group_mapping_skipped_{$sourceGroupToken}") + ->assertSee('1 skipped') + ->assertSee('This assignment will not be restored.') + ->assertFormComponentActionVisible("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}") + ->callFormComponentAction("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}") + ->assertSet("data.group_mapping.{$sourceGroupId}", null) + ->assertFormFieldVisible("group_mapping.{$sourceGroupId}"); + + expect($component)->not->toBeNull(); +}); + +it('blocks progression out of step 2 while required mappings are unresolved', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet] = spec332UnresolvedGroupBackupFixture($tenant); + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2); + + $component + ->goToNextWizardStep() + ->assertWizardCurrentStep(2) + ->assertNotified('Mappings required') + ->assertSee('Resolve required mappings before validation can run.'); +}); + +it('productizes the empty target group picker when step 2 needs dependency mapping', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet, $backupItem] = spec332UnresolvedGroupBackupFixture($tenant); + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->assertWizardCurrentStep(2); + + $component + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [(int) $backupItem->getKey()], + ]); + + /** @var \Filament\Schemas\Schema $schema */ + $schema = $component->instance()->form; + $field = $schema->getComponentByStatePath('group_mapping.11111111-1111-1111-1111-111111111111'); + $action = collect($field?->getSuffixActions() ?? []) + ->first(fn ($candidate) => $candidate->getName() === 'select_from_directory_cache_11111111_1111_1111_1111_111111111111'); + + expect($field)->not->toBeNull() + ->and($action)->not->toBeNull() + ->and($action->getModalHeading())->toBe('Resolve target group mapping'); +}); + +it('shows a product-safe blocked state for checks when provider credentials are missing', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false); + [$backupSet] = spec332UsableBackupFixture($tenant); + + $component = spec332WizardComponent($user, $tenant) + ->fillForm([ + 'backup_set_id' => (int) $backupSet->getKey(), + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->assertWizardCurrentStep(3) + ->assertSee('Validation blocked') + ->assertSee('Provider credentials are not available for this environment.') + ->assertSee('Restore checks cannot run until the provider connection is repaired.') + ->assertSee('Review provider connection') + ->assertDontSee('Provider credentials are missing'); + + $component + ->goToNextWizardStep() + ->assertWizardCurrentStep(3) + ->assertNotified('Validation blocked'); + + expect($component->html())->not->toContain('Exception'); +}); + +it('keeps preview decision-first while showing compact safety status and restore proof', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet, $backupItem] = spec332UsableBackupFixture($tenant); + $data = spec332CurrentPreviewData($backupSet, $backupItem); + + $component = spec332WizardComponent($user, $tenant) + ->set('data', $data) + ->goToWizardStep(4) + ->assertWizardCurrentStep(4) + ->assertSee('Review the preview and complete confirmation before execution can be queued.') + ->assertDontSee('Review prerequisites before execution.') + ->assertSee('Restore safety status') + ->assertSee('Next gate:') + ->assertSee('Confirmation') + ->assertSee('Restore Proof') + ->assertSee('Operation proof') + ->assertSee('Post-run evidence') + ->assertSee('Diagnostics - Collapsed') + ->assertDontSee('spec332 raw payload should stay hidden') + ->assertDontSee('tenant-wide recovery is proven'); +}); + +it('keeps confirm step locked when execution prerequisites are unavailable', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false); + [$backupSet, $backupItem] = spec332UsableBackupFixture($tenant); + $data = spec332CurrentPreviewData($backupSet, $backupItem); + + $component = spec332WizardComponent($user, $tenant) + ->set('data', $data) + ->goToWizardStep(5) + ->assertWizardCurrentStep(5) + ->assertSee('Confirmation summary') + ->assertSee('Execution') + ->assertSee('Unavailable') + ->assertSee('Review prerequisites before execution.') + ->assertSee('Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.') + ->assertSee('Confirmation does not claim recovery.') + ->assertFormFieldDisabled('is_dry_run'); + + expect($component->html())->not->toContain('Operation proof is complete'); +}); + +it('shows confirm step readiness when execution prerequisites are healthy', function (): void { + [$user, $tenant] = spec332ProductProcessFlowTenant(); + [$backupSet, $backupItem] = spec332UsableBackupFixture($tenant); + $data = spec332CurrentPreviewData($backupSet, $backupItem); + + spec332WizardComponent($user, $tenant) + ->set('data', $data) + ->goToWizardStep(5) + ->assertWizardCurrentStep(5) + ->assertSee('Confirmation summary') + ->assertSee('Execution') + ->assertSee('Available after confirmation') + ->assertSee('Confirmation does not claim recovery.') + ->assertFormFieldEnabled('is_dry_run'); +}); diff --git a/apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php b/apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php index 0e1c317f..c3aa8136 100644 --- a/apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php +++ b/apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php @@ -5,8 +5,8 @@ use App\Filament\Pages\EnvironmentDashboard; use App\Filament\Resources\ManagedEnvironmentResource; use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments; -use App\Models\Policy; use App\Models\ManagedEnvironment; +use App\Models\Policy; use App\Models\User; use App\Support\BackupHealth\BackupFreshnessEvaluation; use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation; @@ -19,8 +19,6 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; -use function Pest\Laravel\mock; - uses(RefreshDatabase::class); afterEach(function (): void { @@ -122,7 +120,7 @@ function tenantRegistryRecoveryEvidence( return [ 'overview_state' => $overviewState, 'summary' => $summary, - 'claim_boundary' => 'ManagedEnvironment-wide recovery is not proven.', + 'claim_boundary' => 'Target environment recovery is not proven.', 'reason' => $reason, 'latest_relevant_restore_run' => null, 'latest_relevant_attention' => null, diff --git a/apps/platform/tests/Feature/Operations/RestoreLinkedOperationDetailTest.php b/apps/platform/tests/Feature/Operations/RestoreLinkedOperationDetailTest.php index 0cb603a7..1b037532 100644 --- a/apps/platform/tests/Feature/Operations/RestoreLinkedOperationDetailTest.php +++ b/apps/platform/tests/Feature/Operations/RestoreLinkedOperationDetailTest.php @@ -67,6 +67,6 @@ ->test(TenantlessOperationRunViewer::class, ['run' => $operationRun]) ->assertSee('Restore continuation') ->assertSee('Follow-up required') - ->assertSee('ManagedEnvironment-wide recovery is not proven.') + ->assertSee('Target environment recovery is not proven.') ->assertSee('Open restore run'); }); diff --git a/apps/platform/tests/Feature/RestoreGroupMappingTest.php b/apps/platform/tests/Feature/RestoreGroupMappingTest.php index 7c1049de..031dcffc 100644 --- a/apps/platform/tests/Feature/RestoreGroupMappingTest.php +++ b/apps/platform/tests/Feature/RestoreGroupMappingTest.php @@ -1,11 +1,13 @@ assertFormFieldVisible('group_mapping.source-group-1'); }); +test('restore group mapping picker productizes the empty directory cache state', function () { + $tenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => 'tenant-1', + 'name' => 'ManagedEnvironment One', + 'metadata' => [], + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + + $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); + $this->actingAs($user); + setAdminPanelContext($tenant); + + $component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [ + 'sourceGroupId' => '00000000-0000-0000-0000-d908d2dd', + 'sourceGroupDisplayName' => 'ADSyncOperators', + 'tenantId' => (int) $tenant->getKey(), + ]); + + $component + ->assertSee('Source group') + ->assertSee('ADSyncOperators') + ->assertSee('Source ID: 00000000-0000-0000-0000-d908d2dd') + ->assertSee('No directory group cache available') + ->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.') + ->assertSee('Sync directory groups, then return to this mapping.') + ->assertSee('Open group sync') + ->assertSee('View group sync operations') + ->assertDontSee('No cached groups found') + ->assertDontSee('Directory Groups') + ->assertDontSee('No groups found in tenant') + ->assertDontSee('Search groups…') + ->assertDontSee('Stale'); + + $html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5); + $expectedGroupSyncUrl = \App\Filament\Resources\EntraGroupResource::getUrl('index', tenant: $tenant); + $expectedOperationsUrl = \App\Support\OperationRunLinks::index($tenant, operationType: 'directory.groups.sync'); + + expect($html) + ->toContain('data-testid="restore-group-picker-empty-state"') + ->toContain('href="'.$expectedGroupSyncUrl.'"') + ->toContain('href="'.$expectedOperationsUrl.'"') + ->toContain('target="_blank"') + ->not->toMatch('/>\s*Operations\s*create([ + 'managed_environment_id' => 'tenant-1', + 'name' => 'ManagedEnvironment One', + 'metadata' => [], + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + + $otherTenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => 'tenant-2', + 'name' => 'ManagedEnvironment Two', + 'metadata' => [], + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + + $cached = EntraGroup::factory()->for($tenant)->create([ + 'display_name' => 'Spec332 Cached Group', + 'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + ]); + + EntraGroup::factory()->for($otherTenant)->create([ + 'display_name' => 'Foreign Cached Group', + 'entra_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + ]); + + $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); + $this->actingAs($user); + setAdminPanelContext($tenant); + + $component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [ + 'sourceGroupId' => '00000000-0000-0000-0000-d908d2dd', + 'sourceGroupDisplayName' => 'ADSyncOperators', + 'tenantId' => (int) $tenant->getKey(), + ]); + + $component + ->assertSee('Source group') + ->assertSee('ADSyncOperators') + ->assertSee('Source ID: 00000000-0000-0000-0000-d908d2dd') + ->assertSee('Spec332 Cached Group') + ->assertDontSee('Foreign Cached Group') + ->assertDontSee('No directory group cache available'); + + $html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5); + + expect($html) + ->toContain('data-testid="restore-group-picker-table"'); +}); + test('restore wizard persists group mapping selections', function () { $tenant = ManagedEnvironment::factory()->create([ 'managed_environment_id' => 'tenant-1', diff --git a/apps/platform/tests/Feature/RestoreRiskChecksWizardTest.php b/apps/platform/tests/Feature/RestoreRiskChecksWizardTest.php index 66f28b63..e3f34fd6 100644 --- a/apps/platform/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/apps/platform/tests/Feature/RestoreRiskChecksWizardTest.php @@ -3,9 +3,9 @@ use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\ManagedEnvironment; use App\Models\Policy; use App\Models\RestoreRun; -use App\Models\ManagedEnvironment; use App\Models\User; use App\Services\Graph\GroupResolver; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -93,7 +93,8 @@ ]) ->goToNextWizardStep() ->assertFormComponentActionVisible('check_results', 'run_restore_checks') - ->callFormComponentAction('check_results', 'run_restore_checks'); + ->callFormComponentAction('check_results', 'run_restore_checks') + ->assertNotified('Safety checks finished with blockers'); $summary = $component->get('data.check_summary'); $results = $component->get('data.check_results'); diff --git a/apps/platform/tests/Feature/RestoreRunIdempotencyTest.php b/apps/platform/tests/Feature/RestoreRunIdempotencyTest.php index 4ee12e66..023a92f0 100644 --- a/apps/platform/tests/Feature/RestoreRunIdempotencyTest.php +++ b/apps/platform/tests/Feature/RestoreRunIdempotencyTest.php @@ -66,6 +66,7 @@ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $this->actingAs($user); setAdminPanelContext($tenant); + ensureDefaultProviderConnection($tenant, 'microsoft'); $data = [ 'backup_set_id' => $backupSet->id, diff --git a/apps/platform/tests/Unit/Badges/RestoreRunBadgesTest.php b/apps/platform/tests/Unit/Badges/RestoreRunBadgesTest.php index 22834752..ae81df8f 100644 --- a/apps/platform/tests/Unit/Badges/RestoreRunBadgesTest.php +++ b/apps/platform/tests/Unit/Badges/RestoreRunBadgesTest.php @@ -63,8 +63,8 @@ expect($safe->color)->toBe('success'); $current = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'current'); - expect($current->label)->toBe('Current checks'); - expect($current->color)->toBe('success'); + expect($current->label)->toBe('Latest check result'); + expect($current->color)->toBe('info'); $invalidated = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'invalidated'); expect($invalidated->label)->toBe('Invalidated'); diff --git a/apps/platform/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php b/apps/platform/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php index 1e177f34..7f9e74ed 100644 --- a/apps/platform/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php +++ b/apps/platform/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php @@ -7,11 +7,11 @@ uses(RefreshDatabase::class); -it('formats unresolved labels using an ellipsis + last 8 chars', function () { +it('formats unknown labels using an ellipsis + last 8 chars', function () { $id = '11111111-2222-3333-4444-555555555555'; expect(EntraGroupLabelResolver::formatLabel(null, $id)) - ->toBe('Unresolved (…55555555)'); + ->toBe('Unknown group (…55555555)'); }); it('resolves labels from the tenant cache (tenant-scoped)', function () { @@ -46,5 +46,5 @@ $resolver = app(EntraGroupLabelResolver::class); expect($resolver->resolveOne($tenant, 'group-123')) - ->toBe('Unresolved (group123)'); + ->toBe('Unknown group (group123)'); }); diff --git a/apps/platform/tests/Unit/Filament/RestoreRunCreatePresenterDeterminismTest.php b/apps/platform/tests/Unit/Filament/RestoreRunCreatePresenterDeterminismTest.php new file mode 100644 index 00000000..fc763080 --- /dev/null +++ b/apps/platform/tests/Unit/Filament/RestoreRunCreatePresenterDeterminismTest.php @@ -0,0 +1,193 @@ +actingAs($user); + ensureDefaultProviderConnection($tenant, 'microsoft'); + + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-presenter-policy', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Presenter Policy', + 'platform' => 'windows', + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Presenter Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Metadata Only Policy', + 'snapshot_source' => 'metadata_only', + 'warnings' => ['metadata only fallback'], + ], + ]); + + $wizardData = [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'scope_mode' => 'all', + 'backup_item_ids' => [], + 'group_mapping' => [], + ]; + + $first = RestoreRunCreatePresenter::contract( + data: $wizardData, + currentStep: 1, + compactFlow: false, + tenant: $tenant, + user: $user, + ); + + $firstSummary = data_get($first, 'processFlow.steps.0.summary'); + + expect($firstSummary) + ->toBeString() + ->toContain('does not contain a usable captured item yet'); + + $backupItem->update([ + 'payload' => [ + 'id' => 'spec332-presenter-policy', + 'displayName' => 'Spec332 Presenter Policy', + 'settings' => ['foo' => 'bar'], + ], + 'metadata' => [ + 'displayName' => 'Spec332 Presenter Policy', + ], + ]); + + $second = RestoreRunCreatePresenter::contract( + data: $wizardData, + currentStep: 1, + compactFlow: false, + tenant: $tenant, + user: $user, + ); + + $secondSummary = data_get($second, 'processFlow.steps.0.summary'); + + expect($secondSummary) + ->toBeString() + ->toContain('A usable source backup is selected for this restore draft.') + ->not->toContain('does not contain a usable captured item yet'); +}); + +it('does not leak presenter state between independent restore draft contracts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + ensureDefaultProviderConnection($tenant, 'microsoft'); + + $policy = Policy::create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'external_id' => 'spec332-presenter-policy-b', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Spec332 Presenter Policy B', + 'platform' => 'windows', + 'metadata' => [], + ]); + + $metadataOnlyBackup = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Presenter Metadata Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $metadataOnlyBackup->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Metadata Only Policy', + 'snapshot_source' => 'metadata_only', + ], + ]); + + $first = RestoreRunCreatePresenter::contract( + data: [ + 'backup_set_id' => (int) $metadataOnlyBackup->getKey(), + 'scope_mode' => 'all', + 'backup_item_ids' => [], + 'group_mapping' => [], + ], + currentStep: 1, + compactFlow: false, + tenant: $tenant, + user: $user, + ); + + expect(data_get($first, 'processFlow.steps.0.summary')) + ->toBeString() + ->toContain('does not contain a usable captured item yet'); + + $usableBackup = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec332 Presenter Usable Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + BackupItem::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $usableBackup->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + 'id' => 'spec332-presenter-policy-b', + 'displayName' => 'Spec332 Presenter Policy B', + 'settings' => ['foo' => 'bar'], + ], + 'assignments' => [], + 'metadata' => [ + 'displayName' => 'Spec332 Presenter Policy B', + ], + ]); + + $second = RestoreRunCreatePresenter::contract( + data: [ + 'backup_set_id' => (int) $usableBackup->getKey(), + 'scope_mode' => 'all', + 'backup_item_ids' => [], + 'group_mapping' => [], + ], + currentStep: 1, + compactFlow: false, + tenant: $tenant, + user: $user, + ); + + expect(data_get($second, 'processFlow.steps.0.summary')) + ->toBeString() + ->toContain('A usable source backup is selected for this restore draft.') + ->not->toContain('does not contain a usable captured item yet'); +}); diff --git a/apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php b/apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php index f08b454c..b0dd7fc5 100644 --- a/apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php +++ b/apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php @@ -149,7 +149,7 @@ expect($overview['overview_state'])->toBe('weakened') ->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestFailed->getKey()) ->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_FAILED) - ->and($overview['claim_boundary'])->toBe('ManagedEnvironment recovery is not proven.') + ->and($overview['claim_boundary'])->toBe('Target environment recovery is not proven.') ->and($overview['reason'])->toBe(RestoreResultAttention::STATE_FAILED); }); diff --git a/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-1-backup-selected.png b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-1-backup-selected.png new file mode 100644 index 00000000..bf686f4d Binary files /dev/null and b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-1-backup-selected.png differ diff --git a/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-2-resolver-expanded.png b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-2-resolver-expanded.png new file mode 100644 index 00000000..0ed5c5ac Binary files /dev/null and b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-2-resolver-expanded.png differ diff --git a/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-2-scope-default.png b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-2-scope-default.png new file mode 100644 index 00000000..6bb039dd Binary files /dev/null and b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-2-scope-default.png differ diff --git a/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-3-validation-blocked.png b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-3-validation-blocked.png new file mode 100644 index 00000000..7d4f96d9 Binary files /dev/null and b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-3-validation-blocked.png differ diff --git a/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-4-preview-generated.png b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-4-preview-generated.png new file mode 100644 index 00000000..d2d84dae Binary files /dev/null and b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-4-preview-generated.png differ diff --git a/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-5-confirm-ready.png b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-5-confirm-ready.png new file mode 100644 index 00000000..9287072e Binary files /dev/null and b/specs/332-product-process-flow-system-v1/artifacts/screenshots/step-5-confirm-ready.png differ diff --git a/specs/332-product-process-flow-system-v1/plan.md b/specs/332-product-process-flow-system-v1/plan.md new file mode 100644 index 00000000..614ca304 --- /dev/null +++ b/specs/332-product-process-flow-system-v1/plan.md @@ -0,0 +1,90 @@ +# Implementation Plan: Spec 332 - Product Process Flow System v1 + +- Branch: `332-product-process-flow-system-v1` +- Date: 2026-05-25 +- Spec: `specs/332-product-process-flow-system-v1/spec.md` + +## Reconciliation Note + +Spec 332 was reconciled from the narrower `restore-run-preview-productization` path into the intended `product-process-flow-system-v1` scope. The previous path underrepresented the actual product/process-flow goal and caused restore safety state to drift too late into Preview. + +## Summary + +Implement a reusable Product Process Flow pattern and apply it first to Restore Run Create: + +- Step 1 shows the full restore safety/product process flow, decision card, backup quality summary, restore proof aside, and collapsed diagnostics. +- Step 2 and later keep step-specific content primary while exposing compact restore safety status. +- Step 2 keeps dependency mapping in an explicit resolver mode with progress copy, shared guidance, and blocked progression until required mappings are resolved. +- The Step 2 dependency-mapping picker uses task-specific cache-empty guidance instead of a generic directory-cache table empty state. +- Preview stays decision-first. +- Confirm stays high-friction before execution. + +## Affected Surfaces / Files + +- Wizard logic: + - `apps/platform/app/Filament/Resources/RestoreRunResource.php` +- Restore safety copy: + - `apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php` +- Product Process Flow views: + - `apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php` + - `apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php` + - shared/new restore-run process-flow partials as needed +- Group picker modal + component: + - `apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php` + - `apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php` + - `apps/platform/app/Livewire/EntraGroupCachePickerTable.php` +- Tests: + - `apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php` + - restore-run create wizard feature tests covering step 1 through confirm + - `apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php` + +## Technical Approach + +1. **Spec reconciliation** + - Keep only `specs/332-product-process-flow-system-v1`. + - Update spec/plan/tasks so Product Process Flow is the primary scope and Restore Preview productization is one slice of that scope. + +2. **Shared Product Process Flow state** + - Reuse existing `wizardSafetyState`, restore safety assessment, preview integrity, execution readiness, and backup quality summary helpers. + - Add or extract presentation helpers/partials so Step 1 full flow and Step 2+ compact status read from the same underlying state. + +3. **Step 1 contract** + - Restore Safety decision card above the selector. + - Backup set selector and backup quality summary. + - Full vertical `Restore safety gates`. + - Restore Proof aside with diagnostics collapsed. + +4. **Later-step contract** + - Keep step-specific content primary. + - Render compact Restore Safety Status plus proof context where useful. + - Productize Step 2 resolver mode around collapsed-by-default mapping details, progress copy, blocked progression, and cache-empty follow-up. + - Preserve gate enforcement with `afterValidation()` and `Halt`. + +5. **Preview and confirm** + - Preview remains decision-first and truthfully reflects gate state. + - Confirm preserves high-friction confirmation without implying completed recovery proof. + +6. **Tests** + - Feature tests for full Step 1, compact Step 2+, blocked validation, truthful preview copy, proof persistence, collapsed diagnostics, and no false recovery-proof claims. + - Browser smoke through Steps 1-4. + +## Validation Commands + +Narrow first: + +- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament --filter=Spec332 --compact` +- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/RestoreRun* --compact` + +Browser smoke: + +- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php --compact` + +Formatting: + +- `cd apps/platform && ./vendor/bin/sail pint --dirty` +- `git diff --check` + +## Dependencies + +- Existing restore safety / preview / backup quality resolvers remain the source of truth. +- Spec 334 (nested Filament/Livewire context hardening) must remain present so browser smoke can run without tenantless Livewire context failures. diff --git a/specs/332-product-process-flow-system-v1/spec.md b/specs/332-product-process-flow-system-v1/spec.md new file mode 100644 index 00000000..aea6e7ff --- /dev/null +++ b/specs/332-product-process-flow-system-v1/spec.md @@ -0,0 +1,193 @@ +# Feature Specification: Spec 332 - Product Process Flow System v1 + +- Feature Branch: `332-product-process-flow-system-v1` +- Created: 2026-05-24 +- Updated: 2026-05-25 +- Status: Draft +- Input: restore-run create wizard drift review + repo implementation + tests + +## Reconciliation Note + +Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path into the intended `specs/332-product-process-flow-system-v1` scope. The previous path underrepresented the actual product/process-flow goal and caused restore safety state to drift too late into Preview. + +Restore Preview productization remains part of Spec 332, but it is only one consumer slice. The primary deliverable is a reusable Product Process Flow pattern with Restore Run Create as the first consumer. + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Restore safety, proof, and gate progression became concentrated too late in the wizard. Step 1 drifted toward a plain backup selector, while meaningful restore readiness moved mostly into Preview. +- **Today's failure**: Operators cannot judge restore viability from the start of the flow. This weakens safe decision-making, obscures the next blocked gate, and hides proof/evidence structure until late in a high-risk workflow. +- **User-visible improvement**: Restore Run shows a reusable Product Process Flow from the first step: a decision card, backup quality summary, full vertical restore safety gates, restore proof aside, and collapsed diagnostics. Later steps show compact safety status, while Preview remains decision-first. +- **Smallest enterprise-capable version**: Introduce a reusable Product Process Flow pattern and wire it into the existing Restore Run create wizard. Reuse existing restore safety, preview, and backup quality resolvers. No new persisted entities. +- **Explicit non-goals**: No new restore execution engine, no new queue orchestration, no new Graph contract layer, no new risk taxonomy, and no new route family. +- **Permanent complexity imported**: Shared wizard presentation pattern, bounded Blade/UI state, step gating, and focused feature/browser regression coverage. +- **Why now**: Restore is high-risk and operator-critical. Safety/proof state must be truthful from step 1, not deferred until preview. +- **Why not local**: The flow pattern is reusable across risky multi-step workflows. Leaving it embedded only in Preview encourages repeated drift and inconsistent operator guidance. +- **Approval class**: Core Enterprise +- **Red flags triggered**: UI surface behavior change (wizard), high-risk operator workflow, evidence/proof messaging. Defense: bounded reuse, explicit copy rules, feature tests, browser smoke. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant (environment-bound restore wizard) +- **Primary Routes**: + - `/admin/workspaces/{workspace}/environments/{environment}/restore-runs/create` +- **Data Ownership**: + - Uses existing `RestoreRun` draft state; no new tables. + - Safety, proof, preview, diagnostics, and backup quality state remain derived from existing restore/backup resolvers. +- **RBAC**: + - Tenant membership required. + - Existing restore capabilities remain the authority; this spec does not change authorization policy rules. + +## UI Surface Impact *(mandatory — UI-COV-001)* + +- [ ] No UI surface impact +- [x] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [x] New modal/drawer/wizard/action added +- [x] New table/form/state added +- [ ] Customer-facing surface changed +- [x] Dangerous action changed +- [x] Status/evidence/review presentation changed +- [ ] Workspace/environment context presentation changed + +## UI/Productization Coverage *(mandatory)* + +- **Route/page/surface**: Restore Run create wizard, with Product Process Flow pattern applied across Step 1 through Confirm. +- **Design depth**: Manual Review Required (operator-critical, risky workflow). +- **Repo-truth level**: repo-verified (feature + browser tests). +- **New pattern required**: yes; reusable Product Process Flow pattern using existing restore safety / proof / backup quality state. +- **Screenshot required**: no (covered by dedicated browser smoke assertions). +- **Dangerous-action review required**: yes; execute restore remains high-friction and must not imply recovery proof before execution/post-run evidence exists. +- **Coverage files updated or explicitly not needed**: `N/A - scope is covered via focused feature tests + browser smoke`. + +## Goals + +1. Introduce a reusable Product Process Flow pattern for risky multi-step product workflows. +2. Apply that pattern to Restore Run Create without changing the underlying restore domain model. +3. Show restore safety from the start of the wizard, not only in Preview. +4. Keep Preview decision-first, but no longer as the only place where safety/proof state appears. +5. Keep Confirm high-friction before execution. +6. Prevent false recovery-proof claims before execution/post-run evidence exists. + +## Product Contract + +### Step 1: Select Backup Set + +Render, in this order: + +1. Restore Safety decision card + - Status + - Reason + - Impact + - Primary next action +2. Backup set selector +3. Backup quality summary + - item count + - degraded items + - metadata-only items + - assignment issues + - orphaned assignments + - clear copy that input quality does not prove that execution is safe or that recovery is verified +4. Full vertical Product Process Flow + - Title: `Restore safety gates` + - Steps: + - Usable source selected + - Target selected + - Scope/dependency mapping + - Validation + - Preview + - Confirmation + - Execution and evidence when represented separately +5. Restore Proof aside + - Source backup + - Target environment + - Requested by + - Restore scope + - Operation proof + - Post-run evidence + - Diagnostics +6. Diagnostics collapsed by default + +### Step 2 and later + +- Show step-specific content first. +- Show compact Restore Safety Status instead of the full seven-gate flow by default. +- Keep `Restore Proof` available where useful. +- Keep dependency mapping details hidden by default until the resolver is opened explicitly. +- When dependency mapping is expanded, present it as `Resolve target mappings` with resolver progress, one shared explanation, and no repeated per-row helper copy. +- Block Next while required mappings remain unresolved. +- When dependency mapping opens the target group picker without cached directory groups, show a task-specific empty state: + - modal heading: `Resolve target group mapping` + - source group context remains visible + - empty state copy explains cache availability, not tenant group absence + - primary CTA uses `Open group sync` unless direct in-modal sync is explicitly supported + - search/filter controls do not dominate the modal when the cache is empty +- Show: + - completed gates count (for example `2/7 gates complete`) + - next gate + - execution available/unavailable state + - `View safety gates` + +### Step 3: Validate + +- Validation blockers remain explicit. +- Next is blocked when validation is blocked. + +### Step 4: Preview + +- Preview remains decision-first. +- Compact safety status is shown by default. +- Preview copy must reflect actual gate state and avoid false blocker language once Preview is complete. + +### Step 5: Confirm + +- Confirmation remains high-friction before execution. +- No false recovery-proof claim is shown before execution/post-run evidence exists. + +## Non-Goals + +- No changes to restore execution behavior, queue orchestration, or Graph contract paths. +- No new persisted state families, tables, or enum taxonomies. +- No separate “trust framework” outside the Product Process Flow pattern and Restore Run consumer. + +## Implementation Notes + +- Reuse existing restore safety, preview integrity, execution readiness, and backup quality resolvers. +- Prefer shared Product Process Flow presentation primitives over one-off Preview-only copy. +- Keep diagnostics collapsed by default and avoid raw payload presentation in the default wizard path. +- Gating continues to use Filament wizard step lifecycle hooks and `Halt` where appropriate. + +## Testing / Lane / Runtime Impact + +- **Test purpose / classification**: Feature + Browser smoke +- **Validation lanes**: confidence + browser +- **Updated tests**: + - Step 1 renders full Product Process Flow + - Step 1 renders Restore Safety decision card + - Step 1 renders Restore Proof aside + - Step 1 usable source gate reflects actual backup contents +- Step 2+ render compact safety status +- Step 2 resolver mode stays explicit and blocks progression while required mappings remain unresolved +- Step 2 empty group picker renders task-specific cache-empty guidance + - Preview remains decision-first + - Diagnostics remain collapsed + - No false recovery-proof claims are visible + +## Acceptance Criteria + +- Only one Spec 332 directory exists in the repo: `specs/332-product-process-flow-system-v1`. +- Step 1 is not a bare backup selector page. +- Step 1 renders the full Product Process Flow, Restore Safety decision card, backup quality summary, Restore Proof aside, and collapsed diagnostics. +- A backup with usable captured items marks the usable source gate complete. +- A backup with zero/no captured items does not mark the usable source gate complete. +- Step 2 and later render compact Restore Safety Status by default, not the full gate flow. +- Step 2 hides mapping details by default and only shows resolver details after explicit expansion. +- Step 2 blocks Next until required mappings are resolved. +- The Step 2 group mapping picker shows task-specific cache-empty guidance and does not imply the tenant has no groups. +- Preview remains decision-first and is not the only place where safety/proof state appears. +- Confirm remains high-friction before execution. +- No false recovery-proof claim appears before execution/post-run evidence exists. +- Feature tests and browser smoke pass. diff --git a/specs/332-product-process-flow-system-v1/tasks.md b/specs/332-product-process-flow-system-v1/tasks.md new file mode 100644 index 00000000..8361caba --- /dev/null +++ b/specs/332-product-process-flow-system-v1/tasks.md @@ -0,0 +1,49 @@ +# Tasks: Spec 332 - Product Process Flow System v1 + +**Input**: `specs/332-product-process-flow-system-v1/spec.md`, `specs/332-product-process-flow-system-v1/plan.md` + +## Reconciliation + +- [x] Keep only one Spec 332 directory in the repo: `specs/332-product-process-flow-system-v1`. +- [x] Document that Spec 332 was reconciled from the narrower `restore-run-preview-productization` path into the intended Product Process Flow scope. + +## Phase 1: Product Process Flow pattern + +- [x] Reuse existing restore safety, preview integrity, execution readiness, and backup quality state as the Product Process Flow source of truth. +- [x] Add the shared Step 1 full-flow presentation for Restore Run Create. +- [x] Add the shared compact later-step safety status presentation. + +## Phase 2: Restore Run wizard contract + +- [x] Step 1 renders Restore Safety decision card. +- [x] Step 1 renders backup selector plus backup quality summary. +- [x] Step 1 renders the full vertical `Restore safety gates` flow. +- [x] Step 1 renders Restore Proof aside with diagnostics collapsed. +- [x] Step 2+ render compact Restore Safety Status instead of the full gate flow by default. +- [x] Step 2 hides mapping details by default and only reveals an explicit `Resolve target mappings` resolver when opened. +- [x] Step 2 resolver shows progress copy and blocks Next until required mappings are resolved. +- [x] Step 2 group mapping picker uses task-specific cache-empty guidance and source-group context instead of generic directory-cache empty copy. +- [x] Step 3 blocks Next when validation blockers exist. +- [x] Step 4 remains decision-first and reflects actual gate state. +- [x] Step 5 remains high-friction before execution. +- [x] No false recovery-proof claims appear before execution/post-run evidence exists. + +## Phase 3: Tests + formatting + +- [x] Add/update feature tests for: + - only one Spec 332 directory exists + - Step 1 full Product Process Flow + - Step 1 usable source gate truthfulness + - Step 2 compact safety status + - Step 2 explicit resolver mode and blocked progression + - Step 2 empty group-picker productized empty state + - Step 3 blocked progression + - Step 4 truthful preview copy + - Restore Proof persistence + - diagnostics collapsed + - no false recovery-proof claims +- [x] Add/update browser smoke for Step 1, Step 2 default, Step 2 resolver, Step 2 empty picker, Step 3 blocked, and Step 4 preview. +- [x] Run targeted Spec 332 and Restore Run create wizard tests. +- [x] Run browser smoke. +- [x] Run `pint --dirty`. +- [x] Run `git diff --check`.