feat: productize restore wizard preview safety gates

This commit is contained in:
Ahmed Darrazi 2026-05-24 23:43:01 +02:00
parent b4cf61d8c7
commit 36c9de3ddd
5 changed files with 641 additions and 160 deletions

View File

@ -72,6 +72,7 @@
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;
@ -707,6 +708,49 @@ public static function getWizardSteps(): array
]),
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('Technical blocker present')
->body('Resolve the technical blockers before proceeding to confirmation.')
->danger()
->send();
throw new Halt();
}
})
->schema([
Forms\Components\Hidden::make('preview_summary')
->default(null),
@ -793,11 +837,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')

View File

@ -26,6 +26,7 @@ public static function primaryNextAction(?string $action): string
'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.',

View File

@ -13,9 +13,15 @@
$checksIntegrity = $checksIntegrity ?? [];
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
$executionReadiness = $executionReadiness ?? [];
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
$safetyAssessment = $safetyAssessment ?? [];
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
$currentScope = $currentScope ?? [];
$currentScope = is_array($currentScope) ? $currentScope : [];
$ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null);
$ranAtLabel = null;
@ -37,9 +43,24 @@
$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');
$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 {
$keys = array_keys($items);
@ -49,180 +70,400 @@
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
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4">
<x-filament::section
heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
{{ $integritySpec->label }}
</x-filament::badge>
@if (($checksIntegrity['state'] ?? null) === 'current')
<x-filament::badge color="success" size="sm">
Checks current
<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
heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
{{ $integritySpec->label }}
</x-filament::badge>
@endif
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
<x-filament::badge color="warning" size="sm">
Calm readiness suppressed
@if (($checksIntegrity['state'] ?? null) === 'current')
<x-filament::badge color="success" size="sm">
Checks current
</x-filament::badge>
@endif
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
<x-filament::badge color="warning" size="sm">
Calm readiness suppressed
</x-filament::badge>
@endif
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<div class="font-medium">What the preview proves</div>
<div class="mt-1">{{ $integritySummary }}</div>
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Primary next step
</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
{{ $nextActionLabel }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesTotal <= 0 ? 'gray' : ($policiesChanged > 0 ? 'warning' : 'success')">
@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>
@endif
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<div class="font-medium">What the preview proves</div>
<div class="mt-1">{{ $integritySummary }}</div>
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Primary next step
</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
{{ $nextActionLabel }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
</x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
{{ $policiesTotal }} {{ \Illuminate\Support\Str::plural('policy', $policiesTotal) }} reviewed
</x-filament::badge>
@endif
</div>
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
<div class="text-xs text-amber-800 dark:text-amber-200">
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
@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 :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
@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>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
</x-filament::badge>
@endif
</div>
@endif
</div>
</x-filament::section>
@if ($diffs === [])
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300">
No preview diff is recorded for this scope yet.
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
<div class="text-xs text-amber-800 dark:text-amber-200">
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
</div>
@endif
</div>
</x-filament::section>
@else
<div class="space-y-3">
@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
<x-filament::section :heading="$name" :description="sprintf('%s • %s', $type, $platform)" collapsible :collapsed="true">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
{{ $action }}
</x-filament::badge>
<x-filament::badge color="success" size="sm">
{{ $added }} added
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
{{ $removed }} removed
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ $changed }} changed
</x-filament::badge>
@if ($assignmentsDelta)
@if ($diffs === [])
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300">
No preview diff is recorded for this scope yet.
</div>
</x-filament::section>
@else
<div class="space-y-3">
@foreach ($diffs as $entry)
@php
$entry = is_array($entry) ? $entry : [];
$nameRaw = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
$nameRaw = is_string($nameRaw) ? $nameRaw : 'Item';
$name = (string) \Illuminate\Support\Str::of($nameRaw)
->headline()
->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker');
$action = $entry['action'] ?? 'update';
$action = in_array($action, ['create', 'update'], true) ? $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
<x-filament::section :heading="$name" description="Policy change preview" collapsible :collapsed="true">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
{{ \Illuminate\Support\Str::headline((string) $action) }}
</x-filament::badge>
<x-filament::badge color="success" size="sm">
{{ $added }} added
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
{{ $removed }} removed
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
assignments
</x-filament::badge>
@endif
@if ($scopeTagsDelta)
<x-filament::badge color="warning" size="sm">
scope tags
</x-filament::badge>
@endif
@if ($diffTruncated)
<x-filament::badge color="gray" size="sm">
truncated
{{ $changed }} changed
</x-filament::badge>
@if ($assignmentsDelta)
<x-filament::badge color="warning" size="sm">
assignments
</x-filament::badge>
@endif
@if ($scopeTagsDelta)
<x-filament::badge color="warning" size="sm">
scope tags
</x-filament::badge>
@endif
@if ($diffTruncated)
<x-filament::badge color="gray" size="sm">
truncated
</x-filament::badge>
@endif
</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)
<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.
</div>
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
@if ($changedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Changed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($changedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($addedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Added keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($addedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($removedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Removed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($removedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
</x-filament::section>
@endforeach
</div>
@endif
</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>
@if ($diffOmitted)
<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.
<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>
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
@if ($changedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Changed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($changedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($addedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Added keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($addedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($removedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Removed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($removedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
</x-filament::section>
@endforeach
</div>
@endif
@endforeach
</div>
</div>
</x-filament::section>
</div>
</div>
</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');
});