Compare commits
2 Commits
f967db7983
...
36c9de3ddd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c9de3ddd | ||
|
|
b4cf61d8c7 |
@ -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')
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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,180 +70,400 @@
|
|||||||
|
|
||||||
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 }">
|
||||||
<x-filament::section
|
<div class="space-y-4 lg:col-span-8">
|
||||||
heading="Preview"
|
<x-filament::section
|
||||||
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
|
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">
|
<div class="space-y-3">
|
||||||
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
<div class="flex flex-wrap gap-2">
|
||||||
{{ $integritySpec->label }}
|
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
||||||
</x-filament::badge>
|
{{ $integritySpec->label }}
|
||||||
@if (($checksIntegrity['state'] ?? null) === 'current')
|
|
||||||
<x-filament::badge color="success" size="sm">
|
|
||||||
Checks current
|
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
@endif
|
@if (($checksIntegrity['state'] ?? null) === 'current')
|
||||||
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
|
<x-filament::badge color="success" size="sm">
|
||||||
<x-filament::badge color="warning" size="sm">
|
Checks current
|
||||||
Calm readiness suppressed
|
</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>
|
</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">
|
<x-filament::badge color="gray">
|
||||||
{{ $diffsOmitted }} diffs omitted (limit)
|
{{ $policiesTotal }} {{ \Illuminate\Support\Str::plural('policy', $policiesTotal) }} reviewed
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
@endif
|
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
|
||||||
</div>
|
@if ($assignmentsChanged <= 0)
|
||||||
|
No assignment changes
|
||||||
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
|
@elseif ($assignmentsChanged === 1)
|
||||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
1 assignment will be updated
|
||||||
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
|
@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>
|
</div>
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
@if ($diffs === [])
|
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
|
||||||
<x-filament::section>
|
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
|
||||||
No preview diff is recorded for this scope yet.
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</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">
|
@if ($diffs === [])
|
||||||
<div class="flex flex-wrap gap-2">
|
<x-filament::section>
|
||||||
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ $action }}
|
No preview diff is recorded for this scope yet.
|
||||||
</x-filament::badge>
|
</div>
|
||||||
<x-filament::badge color="success" size="sm">
|
</x-filament::section>
|
||||||
{{ $added }} added
|
@else
|
||||||
</x-filament::badge>
|
<div class="space-y-3">
|
||||||
<x-filament::badge color="danger" size="sm">
|
@foreach ($diffs as $entry)
|
||||||
{{ $removed }} removed
|
@php
|
||||||
</x-filament::badge>
|
$entry = is_array($entry) ? $entry : [];
|
||||||
<x-filament::badge color="warning" size="sm">
|
$nameRaw = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
|
||||||
{{ $changed }} changed
|
$nameRaw = is_string($nameRaw) ? $nameRaw : 'Item';
|
||||||
</x-filament::badge>
|
$name = (string) \Illuminate\Support\Str::of($nameRaw)
|
||||||
@if ($assignmentsDelta)
|
->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">
|
<x-filament::badge color="warning" size="sm">
|
||||||
assignments
|
{{ $changed }} changed
|
||||||
</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>
|
</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
|
@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>
|
||||||
|
<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)
|
<button
|
||||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
type="button"
|
||||||
Diff details omitted due to preview limits. Narrow scope to see more items in detail.
|
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>
|
</div>
|
||||||
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
|
@endforeach
|
||||||
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
|
</div>
|
||||||
@if ($changedKeys !== [])
|
</div>
|
||||||
<div>
|
</x-filament::section>
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
</div>
|
||||||
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>
|
||||||
</x-dynamic-component>
|
</x-dynamic-component>
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
64
specs/332-restore-run-preview-productization/plan.md
Normal file
64
specs/332-restore-run-preview-productization/plan.md
Normal 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.
|
||||||
96
specs/332-restore-run-preview-productization/spec.md
Normal file
96
specs/332-restore-run-preview-productization/spec.md
Normal 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 Filament’s 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.
|
||||||
23
specs/332-restore-run-preview-productization/tasks.md
Normal file
23
specs/332-restore-run-preview-productization/tasks.md
Normal 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`.
|
||||||
Loading…
Reference in New Issue
Block a user