feat: productize restore wizard preview safety gates
This commit is contained in:
parent
b4cf61d8c7
commit
36c9de3ddd
@ -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')
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user