Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
36c9de3ddd feat: productize restore wizard preview safety gates 2026-05-24 23:43:01 +02:00
Ahmed Darrazi
b4cf61d8c7 spec: add Spec 332 restore run preview productization 2026-05-24 23:42:49 +02:00
8 changed files with 824 additions and 160 deletions

View File

@ -72,6 +72,7 @@
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step; use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Exceptions\Halt;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
@ -707,6 +708,49 @@ public static function getWizardSteps(): array
]), ]),
Step::make('Preview') Step::make('Preview')
->description('Dry-run 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('Technical blocker present')
->body('Resolve the technical blockers before proceeding to confirmation.')
->danger()
->send();
throw new Halt();
}
})
->schema([ ->schema([
Forms\Components\Hidden::make('preview_summary') Forms\Components\Hidden::make('preview_summary')
->default(null), ->default(null),
@ -793,11 +837,22 @@ public static function getWizardSteps(): array
$policiesChanged = (int) ($summary['policies_changed'] ?? 0); $policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 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() Notification::make()
->title('Preview generated') ->title('Preview generated')
->body("Policies: {$policiesChanged}/{$policiesTotal} changed") ->body($previewBody)
->status($policiesChanged > 0 ? 'warning' : 'success') ->status($previewStatus)
->send(); ->send();
}), }),
Actions\Action::make('clear_restore_preview') Actions\Action::make('clear_restore_preview')

View File

@ -26,6 +26,7 @@ public static function primaryNextAction(?string $action): string
'generate_preview' => 'Generate a preview for the current scope.', 'generate_preview' => 'Generate a preview for the current scope.',
'regenerate_preview' => 'Regenerate the 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.', '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.', 'review_warnings' => 'Review the warnings before real execution.',
'execute' => 'Queue the real restore execution.', 'execute' => 'Queue the real restore execution.',
'review_preview' => 'Review the preview evidence before claiming recovery or queueing execution.', 'review_preview' => 'Review the preview evidence before claiming recovery or queueing execution.',

View File

@ -13,9 +13,15 @@
$checksIntegrity = $checksIntegrity ?? []; $checksIntegrity = $checksIntegrity ?? [];
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : []; $checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
$executionReadiness = $executionReadiness ?? [];
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
$safetyAssessment = $safetyAssessment ?? []; $safetyAssessment = $safetyAssessment ?? [];
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : []; $safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
$currentScope = $currentScope ?? [];
$currentScope = is_array($currentScope) ? $currentScope : [];
$ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null); $ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null);
$ranAtLabel = null; $ranAtLabel = null;
@ -37,9 +43,24 @@
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0); $assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0); $scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0); $diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
$integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.'; $integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.';
$nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview'; $nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview';
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'generate_preview'); $nextAction = is_string($nextAction) ? $nextAction : 'generate_preview';
$previewIsCurrent = ($previewIntegrity['state'] ?? null) === 'current';
$checksAreCurrent = ($checksIntegrity['state'] ?? null) === 'current';
$executionAllowed = (bool) ($executionReadiness['allowed'] ?? false);
if ($previewIsCurrent && $checksAreCurrent && $executionAllowed && $nextAction === 'execute') {
$nextAction = 'review_and_confirm';
}
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction($nextAction);
$scopeMode = ($currentScope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all';
$scopeLabel = $scopeMode === 'selected' ? 'All selected restore items' : 'All restore items';
$limitedKeys = static function (array $items, int $limit = 8): array { $limitedKeys = static function (array $items, int $limit = 8): array {
$keys = array_keys($items); $keys = array_keys($items);
@ -49,10 +70,118 @@
return array_slice($keys, 0, $limit); return array_slice($keys, 0, $limit);
}; };
$sourceSelected = (int) ($currentScope['backup_set_id'] ?? 0) > 0;
$targetSelected = $executionReadiness !== [];
$validationComplete = $checksAreCurrent;
$previewComplete = $previewIsCurrent;
$technicalBlocked = ! $executionAllowed;
$gatesTotal = 7;
$gatesComplete = count(array_filter([
$sourceSelected,
$targetSelected,
$validationComplete,
$previewComplete,
]));
$nextGateLabel = match (true) {
! $sourceSelected => 'Source required',
! $targetSelected => 'Target required',
! $validationComplete => 'Validation required',
! $previewComplete => 'Preview required',
$technicalBlocked => 'Technical blocker present',
default => 'Confirmation required',
};
$executionLabel = $technicalBlocked ? 'Blocked' : 'Unavailable';
$executionSummary = $technicalBlocked
? 'Execution is blocked until the technical prerequisites are healthy again.'
: 'Execution is unavailable until required safety gates are complete.';
$gateTone = static function (string $status): string {
return match ($status) {
'complete' => 'success',
'required' => 'warning',
'blocked' => 'danger',
default => 'gray',
};
};
$gateBadge = static function (string $status): string {
return match ($status) {
'complete' => 'Complete',
'required' => 'Required',
'blocked' => 'Blocked',
default => 'Unavailable',
};
};
$gateStatus = static function (bool $complete, bool $required = false, bool $blocked = false): string {
if ($blocked) {
return 'blocked';
}
if ($complete) {
return 'complete';
}
return $required ? 'required' : 'unavailable';
};
$safetyGates = [
[
'step' => 1,
'label' => 'Source selected',
'summary' => 'A usable source backup is selected for this restore draft.',
'status' => $gateStatus($sourceSelected, required: true),
],
[
'step' => 2,
'label' => 'Target selected',
'summary' => 'Target environment is the route-bound managed environment.',
'status' => $gateStatus($targetSelected, required: true),
],
[
'step' => 3,
'label' => 'Validation',
'summary' => $validationComplete
? 'Checks evidence is current for the selected restore scope.'
: 'Run checks for the current scope before confirmation.',
'status' => $gateStatus($validationComplete, required: true),
],
[
'step' => 4,
'label' => 'Preview',
'summary' => $previewComplete
? 'Preview evidence is current for the selected restore scope.'
: 'Generate a preview for the current scope before confirmation.',
'status' => $gateStatus($previewComplete, required: true),
],
[
'step' => 5,
'label' => 'Confirmation',
'summary' => 'Explicit confirmation required before real execution.',
'status' => $gateStatus(false, required: true, blocked: $technicalBlocked),
],
[
'step' => 6,
'label' => 'Execution',
'summary' => $executionSummary,
'status' => $gateStatus(false, blocked: $technicalBlocked),
],
[
'step' => 7,
'label' => 'Post-run evidence',
'summary' => 'Post-run evidence is unavailable before execution.',
'status' => 'unavailable',
],
];
@endphp @endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field"> <x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-12" x-data="{ safetyGatesOpen: false }">
<div class="space-y-4 lg:col-span-8">
<x-filament::section <x-filament::section
heading="Preview" heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'" :description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
@ -86,14 +215,37 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'"> <x-filament::badge :color="$policiesTotal <= 0 ? 'gray' : ($policiesChanged > 0 ? 'warning' : 'success')">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed @if ($policiesTotal <= 0)
No policies in scope
@elseif ($policiesChanged <= 0)
No policy changes
@elseif ($policiesChanged === 1)
1 policy will be updated
@else
{{ $policiesChanged }} policies will be updated
@endif
</x-filament::badge>
<x-filament::badge color="gray">
{{ $policiesTotal }} {{ \Illuminate\Support\Str::plural('policy', $policiesTotal) }} reviewed
</x-filament::badge> </x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'"> <x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed @if ($assignmentsChanged <= 0)
No assignment changes
@elseif ($assignmentsChanged === 1)
1 assignment will be updated
@else
{{ $assignmentsChanged }} assignments will be updated
@endif
</x-filament::badge> </x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'"> <x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed @if ($scopeTagsChanged <= 0)
No scope tag changes
@elseif ($scopeTagsChanged === 1)
1 scope tag will be updated
@else
{{ $scopeTagsChanged }} scope tags will be updated
@endif
</x-filament::badge> </x-filament::badge>
@if ($diffsOmitted > 0) @if ($diffsOmitted > 0)
<x-filament::badge color="gray"> <x-filament::badge color="gray">
@ -121,10 +273,13 @@
@foreach ($diffs as $entry) @foreach ($diffs as $entry)
@php @php
$entry = is_array($entry) ? $entry : []; $entry = is_array($entry) ? $entry : [];
$name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item'; $nameRaw = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
$type = $entry['policy_type'] ?? 'type'; $nameRaw = is_string($nameRaw) ? $nameRaw : 'Item';
$platform = $entry['platform'] ?? 'platform'; $name = (string) \Illuminate\Support\Str::of($nameRaw)
->headline()
->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker');
$action = $entry['action'] ?? 'update'; $action = $entry['action'] ?? 'update';
$action = in_array($action, ['create', 'update'], true) ? $action : 'update';
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : []; $diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : []; $diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
$added = (int) ($diffSummary['added'] ?? 0); $added = (int) ($diffSummary['added'] ?? 0);
@ -134,15 +289,16 @@
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false); $scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false); $diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false); $diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []); $changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []); $addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []); $removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);
@endphp @endphp
<x-filament::section :heading="$name" :description="sprintf('%s • %s', $type, $platform)" collapsible :collapsed="true"> <x-filament::section :heading="$name" description="Policy change preview" collapsible :collapsed="true">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm"> <x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
{{ $action }} {{ \Illuminate\Support\Str::headline((string) $action) }}
</x-filament::badge> </x-filament::badge>
<x-filament::badge color="success" size="sm"> <x-filament::badge color="success" size="sm">
{{ $added }} added {{ $added }} added
@ -170,6 +326,27 @@
@endif @endif
</div> </div>
<div class="mt-3 grid gap-1 text-sm text-gray-700 dark:text-gray-200">
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">Scope:</span>
<span class="text-gray-600 dark:text-gray-300">{{ $scopeLabel }}</span>
</div>
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">Change type:</span>
<span class="text-gray-600 dark:text-gray-300">{{ \Illuminate\Support\Str::headline((string) $action) }}</span>
</div>
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">Impact:</span>
<span class="text-gray-600 dark:text-gray-300">
@if ($added === 0 && $removed === 0 && $changed === 0 && ! $assignmentsDelta && ! $scopeTagsDelta)
No policy changes detected in preview.
@else
Changes detected in preview. They will apply during restore execution.
@endif
</span>
</div>
</div>
@if ($diffOmitted) @if ($diffOmitted)
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300"> <div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
Diff details omitted due to preview limits. Narrow scope to see more items in detail. Diff details omitted due to preview limits. Narrow scope to see more items in detail.
@ -225,4 +402,68 @@
</div> </div>
@endif @endif
</div> </div>
<div class="space-y-4 lg:col-span-4">
<x-filament::section heading="Restore safety status">
<div class="space-y-3 text-sm text-gray-700 dark:text-gray-200">
<div class="grid gap-1">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $gatesComplete }} of {{ $gatesTotal }} gates complete
</div>
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">Next gate:</span>
<span class="text-gray-600 dark:text-gray-300">{{ $nextGateLabel }}</span>
</div>
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">Execution:</span>
<span class="text-gray-600 dark:text-gray-300">{{ $executionLabel }}</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $executionSummary }}
</div>
</div>
<button
type="button"
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
x-on:click="safetyGatesOpen = ! safetyGatesOpen"
x-text="safetyGatesOpen ? 'Hide safety gates' : 'View safety gates'"
>
View safety gates
</button>
<div class="space-y-2" x-show="safetyGatesOpen" x-cloak>
@foreach ($safetyGates as $gate)
@php
$status = (string) ($gate['status'] ?? 'unavailable');
$tone = $gateTone($status);
$badge = $gateBadge($status);
@endphp
<div class="rounded-lg border border-gray-200 bg-white px-3 py-3 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<div class="flex h-7 w-7 items-center justify-center rounded-full border border-gray-200 text-xs font-semibold text-gray-700 dark:border-white/10 dark:text-gray-200">
{{ $gate['step'] ?? '•' }}
</div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ $gate['label'] ?? 'Gate' }}
</div>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $gate['summary'] ?? '' }}
</div>
</div>
<x-filament::badge :color="$tone" size="sm">
{{ $badge }}
</x-filament::badge>
</div>
</div>
@endforeach
</div>
</div>
</x-filament::section>
</div>
</div>
</x-dynamic-component> </x-dynamic-component>

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->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)));
}
it('keeps safety gates collapsed by default on the restore preview step', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'policy-preview-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config 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 Preview 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' => [],
'metadata' => [],
]);
bindFailHardGraphClient();
$redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
$redirectPath = parse_url($redirectBase, PHP_URL_PATH) ?: '/admin';
$redirect = $redirectPath
.'?backup_set_id='.(int) $backupSet->getKey()
.'&scope_mode=selected'
.'&backup_item_ids='.(int) $backupItem->getKey();
visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, $redirect))
->resize(1920, 1200)
->waitForText('Select Backup Set')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Next')
->waitForText('Define Restore Scope')
->click('Next')
->waitForText('Run checks')
->click('Run checks')
->waitForText('No group-based assignments detected.')
->click('Next')
->waitForText('Generate preview')
->click('Generate preview')
->waitForText('Policy change preview')
->assertSee('Review the preview and complete confirmation before execution can be queued.')
->assertSee('View safety gates')
->assertDontSee('Hide safety gates')
->assertSee('Execution: Unavailable');
});

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\ManagedEnvironment;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows confirmation guidance when preview and checks are complete', function (): void {
$tenant = ManagedEnvironment::factory()->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('Resolve the technical blockers before real execution.')
->assertSeeText('Policy change preview')
->assertSeeText('BitLocker Require')
->assertSeeText('No policy changes')
->assertSeeText('1 policy reviewed')
->assertDontSeeText('deviceCompliancePolicy')
->assertDontSeeText('deviceCompliancePolicy • all');
});

View File

@ -0,0 +1,64 @@
# Implementation Plan: Spec 332 - Restore Run Preview Productization (Wizard Safety Gates)
- Branch: `332-product-process-flow-system-v1`
- Date: 2026-05-24
- Spec: `specs/332-restore-run-preview-productization/spec.md`
## Summary
Productize the Restore Run wizard preview step so it remains decision-first and truthfully gated:
- Block navigation to confirmation until checks + preview are current and execution is technically allowed.
- Collapse “safety gates” detail by default; show concise guidance first.
- Improve preview toast copy so it communicates real meaning (no scope, no changes, changes).
## Affected Surfaces / Files
- Wizard logic:
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
- Copy:
- `apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php`
- Preview component:
- `apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php`
- Tests:
- `apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php`
- `apps/platform/tests/Browser/Spec332RestoreRunWizardPreviewSmokeTest.php`
## Technical Approach
1. **Wizard gate enforcement**
- Add `afterValidation` gate on the Preview step.
- Evaluate existing restore safety state (`wizardSafetyState`) to check:
- preview integrity is current
- checks integrity is current
- execution readiness is allowed
- Block navigation using `Filament\Support\Exceptions\Halt` and a clear Notification message.
2. **Decision-first preview UI**
- Keep safety details collapsed by default, with an explicit “View safety gates” affordance.
- Ensure primary preview content remains readable (avoid noisy type/platform copy in the main list).
3. **Tests**
- Feature test: confirmation guidance copy and preview readability.
- Browser smoke: run checks + generate preview, then assert gates are collapsed and execution is shown as unavailable.
## Validation Commands
Narrow first:
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/RestoreRunPreviewProductizationTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/RestorePreviewTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php --compact`
Browser smoke:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec332RestoreRunWizardPreviewSmokeTest.php --compact`
Formatting:
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
- `git diff --check`
## Dependencies
- Spec 334 (nested Filament/Livewire context hardening) must be present on the branch to avoid tenantless Livewire update crashes during wizard smoke validation.

View File

@ -0,0 +1,96 @@
# Feature Specification: Spec 332 - Restore Run Preview Productization (Wizard Safety Gates)
- Feature Branch: `332-product-process-flow-system-v1`
- Created: 2026-05-24
- Status: Draft
- Input: parked WIP ("spec-332-restore-productization-blocked-by-livewire-context") + repo implementation + tests
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Restore wizard preview and confirmation gates were not productized enough: operators could reach confirmation without current preview/checks, and the preview step exposed too much gate detail by default.
- **Today's failure**: Operators can misinterpret wizard progress as readiness. In addition, Livewire update lifecycles previously caused context loss crashes (addressed by Spec 334), blocking stable browser smoke validation for this flow.
- **User-visible improvement**: Preview step is decision-first: safe guidance is visible, “safety gates” details are collapsed by default, and progression to confirmation is blocked unless checks + preview are current and execution is technically allowed.
- **Smallest enterprise-capable version**: Add wizard step gating + copy improvements + one feature test + one browser smoke test. No tenancy rewrite, no restore domain redesign, no new persisted entities.
- **Explicit non-goals**: No new restore risk engine, no new preview diff format, no new global trust framework, no new workflow beyond the existing wizard steps.
- **Permanent complexity imported**: Small amount of wizard step logic (`afterValidation` halt), UI copy tweaks, and two tests (Feature + Browser).
- **Why now**: Restore is high-risk and operator-critical; readiness must be truthful and stable to proceed with restore flow productization.
- **Why not local**: Wizard gating and preview surface are shared operator behavior; leaving it implicit causes repeated operator confusion and regressions.
- **Approval class**: Core Enterprise
- **Red flags triggered**: UI surface behavior change (wizard). Defense: bounded change with tests + browser smoke.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 9/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.
- Preview/check data remains wizard/restore-run owned, derived by existing resolvers.
- **RBAC**:
- Tenant membership required.
- Existing restore capabilities remain the authority; this spec does not change 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 preview + confirmation gates.
- **Design depth**: Manual Review Required (operator-critical, risky workflow).
- **Repo-truth level**: repo-verified (feature + browser tests).
- **New pattern required**: none; reuse existing RestoreSafety resolver state, improve decision-first copy + gating.
- **Screenshot required**: no (covered by dedicated browser smoke test assertions).
- **Dangerous-action review required**: yes; “execute restore” remains gated and this spec tightens readiness gating.
- **Coverage files updated or explicitly not needed**: `N/A - no UI audit registry update in this change set; scope is covered via browser smoke + feature tests`.
## Goals
1. Block wizard progression to confirmation unless:
- safety checks are current for the selected scope
- preview is current for the selected scope
- execution is technically allowed (no technical blockers)
2. Improve preview-step decision-first messaging:
- guidance for “review and confirm” when preview + checks are complete
- safety gate details collapsed by default (operator can expand)
3. Keep the restore preview surface readable:
- avoid noisy type/platform strings in the primary preview list presentation
## Non-Goals
- No changes to restore execution behavior, queue orchestration, or Graph contract paths.
- No new “trust framework” outside restore wizard surfaces.
- No new persisted state families or tables.
## Implementation Notes
- Gating is enforced in the wizard using Filaments step lifecycle (`afterValidation`) and `Halt` to prevent navigation.
- Notifications are used to explain why progression is blocked (checks required, preview required, technical blocker).
- Preview notification copy is adjusted to be user-meaningful (“No policy changes detected” vs raw counts).
## Testing / Lane / Runtime Impact
- **Test purpose / classification**: Feature + Browser smoke
- **Validation lanes**: confidence + browser
- **New tests**:
- `apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php`
- `apps/platform/tests/Browser/Spec332RestoreRunWizardPreviewSmokeTest.php`
## Acceptance Criteria
- Wizard cannot proceed from Preview → Confirmation when checks are missing/stale, preview is missing/stale, or execution is technically blocked.
- Preview step shows “View safety gates” by default (collapsed), and does not default-open the full gates panel.
- Confirmation guidance text is visible when preview + checks are complete.
- Feature test and browser smoke test pass.

View File

@ -0,0 +1,23 @@
# Tasks: Spec 332 - Restore Run Preview Productization (Wizard Safety Gates)
**Input**: `specs/332-restore-run-preview-productization/spec.md`, `specs/332-restore-run-preview-productization/plan.md`
## Phase 1: Restore parked WIP
- [x] Base work on updated `platform-dev` (done by branching from `platform-dev`).
- [x] Restore parked 332 WIP changes (applied from stash).
## Phase 2: Implement wizard gating + preview productization
- [x] Add Preview-step `afterValidation` gate to block navigation when checks/preview are not current or execution is technically blocked.
- [x] Improve preview generated toast copy (no scope / no changes / changes).
- [x] Ensure preview surface stays decision-first and safety gates are collapsed by default.
- [x] Add next-action copy for `review_and_confirm`.
## Phase 3: Tests + formatting
- [x] Add feature regression test: `apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php`.
- [x] Add browser smoke: `apps/platform/tests/Browser/Spec332RestoreRunWizardPreviewSmokeTest.php`.
- [x] Run targeted restore tests.
- [x] Run browser smoke test.
- [x] Run `pint` and `git diff --check`.