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\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,10 +70,118 @@
|
||||
|
||||
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">
|
||||
<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.'"
|
||||
@ -86,14 +215,37 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
|
||||
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
|
||||
<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 color="gray">
|
||||
{{ $policiesTotal }} {{ \Illuminate\Support\Str::plural('policy', $policiesTotal) }} reviewed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
|
||||
{{ $assignmentsChanged }} assignments changed
|
||||
@if ($assignmentsChanged <= 0)
|
||||
No assignment changes
|
||||
@elseif ($assignmentsChanged === 1)
|
||||
1 assignment will be updated
|
||||
@else
|
||||
{{ $assignmentsChanged }} assignments will be updated
|
||||
@endif
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
|
||||
{{ $scopeTagsChanged }} scope tags changed
|
||||
@if ($scopeTagsChanged <= 0)
|
||||
No scope tag changes
|
||||
@elseif ($scopeTagsChanged === 1)
|
||||
1 scope tag will be updated
|
||||
@else
|
||||
{{ $scopeTagsChanged }} scope tags will be updated
|
||||
@endif
|
||||
</x-filament::badge>
|
||||
@if ($diffsOmitted > 0)
|
||||
<x-filament::badge color="gray">
|
||||
@ -121,10 +273,13 @@
|
||||
@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';
|
||||
$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);
|
||||
@ -134,15 +289,16 @@
|
||||
$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">
|
||||
<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">
|
||||
{{ $action }}
|
||||
{{ \Illuminate\Support\Str::headline((string) $action) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success" size="sm">
|
||||
{{ $added }} added
|
||||
@ -170,6 +326,27 @@
|
||||
@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.
|
||||
@ -225,4 +402,68 @@
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
x-on:click="safetyGatesOpen = ! safetyGatesOpen"
|
||||
x-text="safetyGatesOpen ? 'Hide safety gates' : 'View safety gates'"
|
||||
>
|
||||
View safety gates
|
||||
</button>
|
||||
|
||||
<div class="space-y-2" x-show="safetyGatesOpen" x-cloak>
|
||||
@foreach ($safetyGates as $gate)
|
||||
@php
|
||||
$status = (string) ($gate['status'] ?? 'unavailable');
|
||||
$tone = $gateTone($status);
|
||||
$badge = $gateBadge($status);
|
||||
@endphp
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-3 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-full border border-gray-200 text-xs font-semibold text-gray-700 dark:border-white/10 dark:text-gray-200">
|
||||
{{ $gate['step'] ?? '•' }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ $gate['label'] ?? 'Gate' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $gate['summary'] ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$tone" size="sm">
|
||||
{{ $badge }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
|
||||
@ -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