Add product process flow for restore create
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m49s

This commit is contained in:
Ahmed Darrazi 2026-05-26 02:03:14 +02:00
parent 36c9de3ddd
commit a7897fa064
55 changed files with 5476 additions and 1066 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\EntraGroupResource;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Tables\Columns\TextColumn;
@ -18,14 +19,39 @@ class EntraGroupCachePickerTable extends TableComponent
{
public string $sourceGroupId;
public function mount(string $sourceGroupId): void
public ?string $sourceGroupDisplayName = null;
public ?int $tenantId = null;
public bool $hasCachedGroups = false;
public ?string $groupSyncUrl = null;
public ?string $groupSyncOperationsUrl = null;
public function mount(string $sourceGroupId, ?string $sourceGroupDisplayName = null, ?int $tenantId = null): void
{
$this->sourceGroupId = $sourceGroupId;
$this->sourceGroupDisplayName = filled($sourceGroupDisplayName) ? $sourceGroupDisplayName : null;
$this->tenantId = is_int($tenantId) && $tenantId > 0 ? $tenantId : null;
$tenant = $this->resolveTenantContext();
if ($tenant instanceof ManagedEnvironment) {
$this->tenantId = (int) $tenant->getKey();
$this->hasCachedGroups = EntraGroup::query()
->where('managed_environment_id', $tenant->getKey())
->exists();
$this->groupSyncUrl = EntraGroupResource::getUrl('index', tenant: $tenant);
$this->groupSyncOperationsUrl = OperationRunLinks::index($tenant, operationType: 'directory.groups.sync');
}
}
public function table(Table $table): Table
{
$tenantId = ManagedEnvironment::current()?->getKey();
$tenantId = $this->tenantId ?? $this->resolveTenantContext()?->getKey();
$query = EntraGroup::query();
@ -143,18 +169,9 @@ public function table(Table $table): Table
$this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id);
}),
])
->emptyStateHeading('No cached groups found')
->emptyStateDescription('Run “Sync Groups” first, then come back here.')
->emptyStateActions([
Action::make('open_groups')
->label('Directory Groups')
->icon('heroicon-o-user-group')
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: ManagedEnvironment::current())),
Action::make('open_sync_runs')
->label('Operations')
->icon('heroicon-o-clock')
->url(fn (): string => OperationRunLinks::index(ManagedEnvironment::current())),
]);
->emptyStateHeading('No cached directory groups match your search')
->emptyStateDescription('Try a broader search, or sync directory groups if the cache is incomplete for this environment.')
->emptyStateActions([]);
}
public function render(): View
@ -200,4 +217,25 @@ private function groupTypeColor(string $type): string
default => 'gray',
};
}
private function resolveTenantContext(): ?ManagedEnvironment
{
if ($this->tenantId !== null) {
$tenant = ManagedEnvironment::query()->find($this->tenantId);
if ($tenant instanceof ManagedEnvironment) {
return $tenant;
}
}
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
if ($tenant instanceof ManagedEnvironment) {
return $tenant;
}
$tenant = ManagedEnvironment::current();
return $tenant instanceof ManagedEnvironment ? $tenant : null;
}
}

View File

@ -12,8 +12,10 @@ public function __construct(public bool $allowSkip = true) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$message = 'Enter a valid group object ID (GUID), or use Skip assignment.';
if (! is_string($value)) {
$fail('Please enter SKIP or a valid UUID.');
$fail($message);
return;
}
@ -21,7 +23,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$value = trim($value);
if ($value === '') {
$fail('Please enter SKIP or a valid UUID.');
$fail($message);
return;
}
@ -31,7 +33,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
if (! Str::isUuid($value)) {
$fail('Please enter SKIP or a valid UUID.');
$fail($message);
}
}
}

View File

@ -112,7 +112,7 @@ public function lookupMany(ManagedEnvironment $tenant, array $groupIds): array
public static function formatLabel(?string $displayName, string $id): string
{
$name = filled($displayName) ? $displayName : 'Unresolved';
$name = filled($displayName) ? $displayName : 'Unknown group';
return sprintf('%s (%s)', trim($name), self::shortToken($id));
}

View File

@ -4,9 +4,9 @@
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ManagedEnvironment;
use App\Services\Graph\GroupResolver;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use Carbon\CarbonImmutable;
@ -365,8 +365,8 @@ private function checkMetadataOnlySnapshots(Collection $policyItems): ?array
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
$message = $hasRestoreEnabled
? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.'
: 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.';
? 'Some selected items were captured as metadata-only. Restore execution is blocked until provider connectivity is restored.'
: 'Some selected items were captured as metadata-only. Execution remains preview-only, and payload completeness is limited.';
return [
'code' => 'metadata_only',

View File

@ -474,6 +474,6 @@ private function defaultSetCompactSummary(int $totalItems): string
private function positiveClaimBoundary(): string
{
return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.';
return 'Input quality signals do not prove that execution is safe or that recovery is verified.';
}
}

View File

@ -18,9 +18,9 @@ public function spec(mixed $value): BadgeSpec
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
'current' => new BadgeSpec('Current checks', 'success', 'heroicon-m-check-circle', 'success'),
'current' => new BadgeSpec('Latest check result', 'info', 'heroicon-m-clock', 'info'),
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
'stale' => new BadgeSpec('Stale evidence', 'gray', 'heroicon-m-clock', 'gray'),
'not_run' => new BadgeSpec('Not run', 'gray', 'heroicon-m-eye-slash', 'gray'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();

View File

@ -23,7 +23,7 @@ public function spec(mixed $value): BadgeSpec
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
'current' => new BadgeSpec('Current basis', 'success', 'heroicon-m-check-circle', 'success'),
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
'stale' => new BadgeSpec('Stale evidence', 'gray', 'heroicon-m-clock', 'gray'),
'not_generated' => new BadgeSpec('Not generated', 'gray', 'heroicon-m-eye-slash', 'gray'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();

View File

@ -22,7 +22,7 @@ public static function safetyStateLabel(?string $state): string
public static function primaryNextAction(?string $action): string
{
return match ($action) {
'resolve_blockers' => 'Resolve the technical blockers before real execution.',
'resolve_blockers' => 'Review prerequisites before execution.',
'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.',
@ -59,9 +59,9 @@ public static function recoveryBoundary(?string $boundary): string
{
return match ($boundary) {
'preview_only_no_execution_proven' => 'No execution was performed from this record.',
'execution_failed_no_recovery_claim' => 'ManagedEnvironment recovery is not proven.',
'run_completed_not_recovery_proven' => 'ManagedEnvironment-wide recovery is not proven.',
default => 'ManagedEnvironment-wide recovery is not proven.',
'execution_failed_no_recovery_claim' => 'Target environment recovery is not proven.',
'run_completed_not_recovery_proven' => 'Target environment recovery is not proven.',
default => 'Target environment recovery is not proven.',
};
}

View File

@ -6,13 +6,14 @@
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\RestoreRun;
use App\Models\ManagedEnvironment;
use App\Models\RestoreRun;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Providers\ProviderConnectionResolver;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Auth\Capabilities;
final readonly class RestoreSafetyResolver
{
@ -22,6 +23,7 @@ public function __construct(
private CapabilityResolver $capabilityResolver,
private WriteGateInterface $writeGate,
private TenantBackupHealthResolver $backupHealthResolver,
private ProviderConnectionResolver $providerConnections,
) {}
/**
@ -274,6 +276,14 @@ public function executionReadiness(ManagedEnvironment $tenant, User $user, array
$blockingReasons[] = 'missing_capability';
}
if (! $dryRun) {
$providerResolution = $this->providerConnections->resolveDefault($tenant, 'microsoft');
if (! $providerResolution->resolved) {
$blockingReasons[] = $providerResolution->effectiveReasonCode();
}
}
if (! $dryRun) {
try {
$this->writeGate->evaluate($tenant, 'restore.execute');
@ -294,8 +304,8 @@ public function executionReadiness(ManagedEnvironment $tenant, User $user, array
$allowed = $blockingReasons === [];
$displaySummary = $allowed
? 'The platform can start a restore for this tenant once the operator chooses to proceed.'
: 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.';
? 'Restore execution can start for this environment once the operator chooses to proceed.'
: 'Provider readiness or restore prerequisites currently prevent real execution.';
return new ExecutionReadinessState(
allowed: $allowed,
@ -324,7 +334,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $
positiveClaimSuppressed: true,
primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked',
primaryNextAction: 'resolve_blockers',
summary: 'Real execution is blocked until the technical prerequisites are healthy again.',
summary: 'Restore execution is blocked until required prerequisites are healthy again.',
);
}
@ -337,7 +347,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $
positiveClaimSuppressed: true,
primaryIssueCode: $previewIntegrity->state,
primaryNextAction: 'regenerate_preview',
summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.',
summary: 'Execution could start, but the preview basis is not current enough to support a calm go signal.',
);
}
@ -350,7 +360,7 @@ public function safetyAssessment(ManagedEnvironment $tenant, User $user, array $
positiveClaimSuppressed: true,
primaryIssueCode: $checksIntegrity->state,
primaryNextAction: 'rerun_checks',
summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.',
summary: 'Execution could start, but the checks basis is not current enough to support a calm go signal.',
);
}
@ -428,7 +438,7 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
state: RestoreResultAttention::STATE_NOT_EXECUTED,
followUpRequired: false,
primaryCauseFamily: 'none',
summary: 'This record proves preview truth, not tenant recovery.',
summary: 'This record proves preview truth, not environment recovery.',
primaryNextAction: 'review_preview',
recoveryClaimBoundary: 'preview_only_no_execution_proven',
tone: 'gray',
@ -475,7 +485,7 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
state: RestoreResultAttention::STATE_COMPLETED,
followUpRequired: false,
primaryCauseFamily: 'none',
summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.',
summary: 'The restore completed without visible follow-up, but this still does not prove environment-wide recovery.',
primaryNextAction: 'review_result',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'success',
@ -500,6 +510,7 @@ public function dashboardRecoveryEvidence(ManagedEnvironment $tenant): array
{
$backupHealth = $this->backupHealthResolver->assess($tenant);
$relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
return $this->dashboardRecoveryEvidencePayload(
backupHealth: $backupHealth,
relevantRun: $relevantRestoreHistory['run'],

View File

@ -0,0 +1,149 @@
@php
$processFlow = is_array($processFlow ?? null) ? $processFlow : [];
$steps = is_array($processFlow['steps'] ?? null) ? $processFlow['steps'] : [];
$compact = (bool) ($processFlow['compact'] ?? false);
$badgeTone = static function (string $status): string {
return match ($status) {
'complete' => 'success',
'required' => 'warning',
'blocked' => 'danger',
default => 'gray',
};
};
$badgeLabel = static function (string $status): string {
return match ($status) {
'complete' => 'Complete',
'required' => 'Required',
'blocked' => 'Blocked',
default => 'Unavailable',
};
};
@endphp
@if ($compact)
<div data-testid="restore-run-process-flow-compact" x-data="{ safetyGatesOpen: false }">
<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">
{{ $processFlow['gatesComplete'] ?? 0 }}/{{ $processFlow['gatesTotal'] ?? count($steps) }} 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">{{ $processFlow['nextGate'] ?? 'Unavailable' }}</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">{{ $processFlow['executionLabel'] ?? 'Unavailable' }}</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $processFlow['executionSummary'] ?? 'Execution remains unavailable until required safety gates are complete.' }}
</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>
<template x-if="safetyGatesOpen">
<div class="space-y-2" data-testid="restore-run-process-flow-compact-expanded">
@foreach ($steps as $step)
@php
$status = (string) ($step['status'] ?? 'unavailable');
@endphp
<div
data-testid="restore-run-process-flow-step"
data-step-label="{{ $step['label'] ?? 'Gate' }}"
data-step-status="{{ $status }}"
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">
{{ $step['step'] ?? '•' }}
</div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ $step['label'] ?? 'Gate' }}
</div>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $step['summary'] ?? '' }}
</div>
</div>
<x-filament::badge :color="$badgeTone($status)" size="sm">
{{ $badgeLabel($status) }}
</x-filament::badge>
</div>
</div>
@endforeach
</div>
</template>
</div>
</x-filament::section>
</div>
@else
<template
x-if="typeof isStepAccessible === 'function' ? String(step).includes('select-backup-set') : true"
>
<div data-testid="restore-run-process-flow-full">
<x-filament::section heading="{{ $processFlow['title'] ?? 'Restore safety gates' }}">
<div class="space-y-4">
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
Restore safety stays visible from the first step. This flow shows what is complete, what is still blocked, and what remains unavailable before execution.
</p>
<ol class="space-y-3" aria-label="Restore safety gates">
@foreach ($steps as $step)
@php
$status = (string) ($step['status'] ?? 'unavailable');
$cardClasses = match ($status) {
'complete' => 'border-success-200 bg-success-50/60 dark:border-success-700 dark:bg-success-950/30',
'required' => 'border-warning-200 bg-warning-50/60 dark:border-warning-700 dark:bg-warning-950/30',
'blocked' => 'border-danger-200 bg-danger-50/60 dark:border-danger-700 dark:bg-danger-950/30',
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/50',
};
@endphp
<li
data-testid="restore-run-process-flow-step"
data-step-label="{{ $step['label'] ?? 'Gate' }}"
data-step-status="{{ $status }}"
class="rounded-lg border px-4 py-4 {{ $cardClasses }}"
>
<div class="flex items-start gap-3">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-xs font-semibold text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
{{ $step['step'] ?? '•' }}
</span>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $step['label'] ?? 'Gate' }}
</div>
<x-filament::badge :color="$badgeTone($status)" size="sm">
{{ $badgeLabel($status) }}
</x-filament::badge>
</div>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $step['summary'] ?? '' }}
</p>
</div>
</div>
</li>
@endforeach
</ol>
</div>
</x-filament::section>
</div>
</template>
@endif

View File

@ -0,0 +1,56 @@
@php
$proofAside = is_array($proofAside ?? null) ? $proofAside : [];
$items = is_array($proofAside['items'] ?? null) ? $proofAside['items'] : [];
$diagnosticsDisclosure = is_array($diagnosticsDisclosure ?? null) ? $diagnosticsDisclosure : [];
$badgeClasses = static function (string $tone): string {
return match ($tone) {
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300',
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300',
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300',
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
};
};
@endphp
<aside data-testid="restore-run-proof-panel" class="min-w-0 space-y-3">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $proofAside['title'] ?? 'Restore Proof' }}
</h2>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
Proof stays explicit throughout the wizard. Execution proof and post-run evidence remain unavailable until a confirmed run starts.
</p>
</div>
@foreach ($items as $item)
<div
data-testid="restore-run-proof-item"
data-proof-label="{{ $item['label'] ?? 'Proof item' }}"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex flex-col gap-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $item['label'] ?? 'Proof item' }}
</div>
<div>
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $badgeClasses((string) ($item['tone'] ?? 'gray')) }}">
{{ $item['value'] ?? 'Unavailable' }}
</span>
</div>
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $item['description'] ?? '' }}
</p>
</div>
</div>
@endforeach
<details data-testid="restore-run-diagnostics-disclosure" class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<summary class="cursor-pointer text-sm font-semibold text-gray-950 dark:text-white">
{{ $diagnosticsDisclosure['label'] ?? 'Diagnostics - Collapsed' }}
</summary>
<p class="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $diagnosticsDisclosure['summary'] ?? 'Diagnostics remain closed by default.' }}
</p>
</details>
</aside>

View File

@ -0,0 +1,49 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$backupQualityCard = is_array($backupQualityCard ?? null) ? $backupQualityCard : [];
$counts = is_array($backupQualityCard['counts'] ?? null) ? $backupQualityCard['counts'] : [];
$statusBadgeClasses = static function (bool $available): string {
return $available
? 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300'
: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300';
};
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<x-filament::section>
<div data-testid="restore-run-backup-quality-summary" class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
Backup quality summary
</h2>
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $statusBadgeClasses((bool) ($backupQualityCard['available'] ?? false)) }}">
{{ $backupQualityCard['status'] ?? 'Unavailable' }}
</span>
</div>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $backupQualityCard['summary'] ?? 'Backup quality hints describe input strength only.' }}
</p>
@if ($counts !== [])
<dl class="grid gap-3 md:grid-cols-5">
@foreach ($counts as $count)
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $count['label'] ?? 'Count' }}</dt>
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $count['value'] ?? 0 }}</dd>
</div>
@endforeach
</dl>
@endif
<div class="rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-100">
{{ $backupQualityCard['positiveClaimBoundary'] ?? 'Input quality signals do not prove that execution is safe or that recovery is verified.' }}
</div>
<div class="text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $backupQualityCard['nextAction'] ?? 'Inspect item-level backup detail before continuing.' }}
</div>
</div>
</x-filament::section>
</x-dynamic-component>

View File

@ -1,26 +1,35 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$results = $getState() ?? [];
$results = is_array($results) ? $results : [];
$validationSummary = is_array($validationSummary ?? null) ? $validationSummary : [];
$validationSummary = $validationSummary !== []
? $validationSummary
: (is_array($validation_summary ?? null) ? $validation_summary : []);
$summary = $summary ?? [];
$summary = is_array($summary) ? $summary : [];
$integritySpec = $validationSummary['integritySpec'] ?? null;
$checksIntegrity = $checksIntegrity ?? [];
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
if (! $integritySpec instanceof \App\Support\Badges\BadgeSpec) {
$integritySpec = \App\Support\Badges\BadgeSpec::unknown();
}
$executionReadiness = $executionReadiness ?? [];
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
$blocking = (int) ($validationSummary['blockingCount'] ?? 0);
$warning = (int) ($validationSummary['warningCount'] ?? 0);
$safe = (int) ($validationSummary['safeCount'] ?? 0);
$safetyAssessment = $safetyAssessment ?? [];
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
$integritySummary = (string) ($validationSummary['integritySummary'] ?? 'Run checks for the current scope before real execution.');
$nextActionLabel = (string) ($validationSummary['nextActionLabel'] ?? 'Run the safety checks again for the current scope.');
$blocking = (int) ($summary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0));
$warning = (int) ($summary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0));
$safe = (int) ($summary['safe'] ?? 0);
$startabilitySummary = (string) ($validationSummary['executionReadinessSummary'] ?? 'Execution prerequisites are unavailable.');
$startabilityTone = (string) ($validationSummary['executionReadinessTone'] ?? ((bool) ($validationSummary['executionAllowed'] ?? false) ? 'success' : 'warning'));
$ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null);
$providerCredentialBlocked = (bool) ($validationSummary['providerCredentialBlocked'] ?? false);
$providerConnectionsUrl = $validationSummary['providerConnectionsUrl'] ?? null;
$providerConnectionsUrl = is_string($providerConnectionsUrl) && $providerConnectionsUrl !== '' ? $providerConnectionsUrl : null;
$invalidationReasons = is_array($validationSummary['invalidationReasons'] ?? null) ? $validationSummary['invalidationReasons'] : [];
$groupedResults = is_array($validationSummary['groupedResults'] ?? null) ? $validationSummary['groupedResults'] : [];
$ranAt = $validationSummary['ranAt'] ?? null;
$ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') {
@ -31,27 +40,44 @@
}
}
$severitySpec = static function (?string $severity): \App\Support\Badges\BadgeSpec {
return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity);
};
$integritySpec = $severitySpec($checksIntegrity['state'] ?? 'not_run');
$integritySummary = $checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.';
$nextAction = $safetyAssessment['primary_next_action'] ?? 'rerun_checks';
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'rerun_checks');
$startabilitySummary = $executionReadiness['display_summary'] ?? 'Execution readiness is unavailable.';
$startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning';
$limitedList = static function (array $items, int $limit = 5): array {
if (count($items) <= $limit) {
return $items;
}
return array_slice($items, 0, $limit);
};
$resultsPresent = collect($groupedResults)
->filter(fn ($bucket) => is_array($bucket) && $bucket !== [])
->isNotEmpty();
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4">
@if ($providerCredentialBlocked)
<x-filament::section>
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
Validation blocked
</div>
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
Provider credentials are not available for this environment.
</div>
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
Restore checks cannot run until the provider connection is repaired.
</div>
@if ($providerConnectionsUrl)
<div>
<x-filament::button
tag="a"
size="sm"
color="primary"
:href="$providerConnectionsUrl"
target="_blank"
rel="noopener noreferrer"
>
Review provider connection
</x-filament::button>
</div>
@endif
</div>
</x-filament::section>
@endif
<x-filament::section
heading="Safety checks"
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Checks tell you whether the current scope can be defended, not just whether it can start.'"
@ -62,24 +88,18 @@
{{ $integritySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$startabilityTone" size="sm">
{{ (bool) ($executionReadiness['allowed'] ?? false) ? 'Technically startable' : 'Technical blocker present' }}
{{ $startabilityTone === 'success' ? 'Prerequisites healthy' : 'Execution blocked' }}
</x-filament::badge>
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
<x-filament::badge color="warning" size="sm">
Ready with caution
</x-filament::badge>
@elseif (($safetyAssessment['state'] ?? null) === 'ready')
<x-filament::badge color="success" size="sm">
Ready
</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 current checks prove</div>
<div class="font-medium">What validation proves</div>
<div class="mt-1">{{ $integritySummary }}</div>
<div class="mt-2 text-xs text-slate-600 dark:text-slate-300">
Technical startability: {{ $startabilitySummary }}
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Execution prerequisites
</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
{{ $startabilitySummary }}
</div>
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Primary next step
@ -90,81 +110,82 @@
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
{{ $blocking }} blockers
</x-filament::badge>
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
{{ $warning }} warnings
</x-filament::badge>
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
{{ $safe }} safe
</x-filament::badge>
</div>
@if (($checksIntegrity['invalidation_reasons'] ?? []) !== [])
@if ($invalidationReasons !== [])
<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), $checksIntegrity['invalidation_reasons'])) }}
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $invalidationReasons)) }}
</div>
@endif
</div>
</x-filament::section>
@if ($results === [])
@if (! $resultsPresent)
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300">
No checks have been recorded for this scope yet.
</div>
</x-filament::section>
@else
<div class="space-y-3">
@foreach ($results as $result)
<div class="space-y-5">
@foreach ([
'blocking' => ['label' => 'Blockers', 'tone' => 'danger'],
'warning' => ['label' => 'Warnings', 'tone' => 'warning'],
'safe' => ['label' => 'Safe checks', 'tone' => 'success'],
] as $bucket => $bucketMeta)
@php
$severity = is_array($result) ? ($result['severity'] ?? 'safe') : 'safe';
$title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check';
$message = is_array($result) ? ($result['message'] ?? null) : null;
$meta = is_array($result) ? ($result['meta'] ?? []) : [];
$meta = is_array($meta) ? $meta : [];
$unmappedGroups = $meta['unmapped'] ?? [];
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
$spec = $severitySpec($severity);
$bucketResults = is_array($groupedResults[$bucket] ?? null) ? $groupedResults[$bucket] : [];
@endphp
<x-filament::section>
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
@if ($bucketResults !== [])
<x-filament::section>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $bucketMeta['label'] }}
</div>
@if (is_string($message) && $message !== '')
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
<x-filament::badge :color="$bucketMeta['tone']" size="sm">
{{ count($bucketResults) }}
</x-filament::badge>
</div>
<div class="mt-4 space-y-3">
@foreach ($bucketResults as $result)
@php
$title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check';
$message = is_array($result) ? ($result['message'] ?? null) : null;
@endphp
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ is_string($title) ? $title : 'Check' }}
</div>
@if (is_string($message) && $message !== '')
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<x-filament::badge :color="$bucketMeta['tone']" size="sm">
{{ $bucketMeta['label'] }}
</x-filament::badge>
</div>
</div>
@endif
@endforeach
</div>
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
{{ $spec->label }}
</x-filament::badge>
</div>
@if ($unmappedGroups !== [])
<div class="mt-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Unmapped groups
</div>
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@foreach ($unmappedGroups as $group)
@php
$label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null;
@endphp
@if (is_string($label) && $label !== '')
<li>{{ $label }}</li>
@endif
@endforeach
</ul>
</div>
@endif
</x-filament::section>
</x-filament::section>
@endif
@endforeach
</div>
@endif

View File

@ -0,0 +1,70 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$decisionCard = is_array($decisionCard ?? null) ? $decisionCard : [];
$processFlow = is_array($processFlow ?? null) ? $processFlow : [];
$statusTone = (string) ($decisionCard['tone'] ?? 'gray');
$statusBadgeClasses = static function (string $tone): string {
return match ($tone) {
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300',
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300',
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300',
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
};
};
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<x-filament::section heading="Confirmation summary" description="Execution only becomes available after high-friction confirmation. Proof remains unavailable until a run starts.">
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $decisionCard['title'] ?? 'Restore Safety' }}
</div>
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $statusBadgeClasses($statusTone) }}">
{{ $decisionCard['status'] ?? 'Unavailable' }}
</span>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
Primary next step
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $decisionCard['nextAction'] ?? 'Review the current restore state.' }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
Next gate
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $processFlow['nextGate'] ?? 'Unavailable' }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
Execution
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $processFlow['executionLabel'] ?? 'Unavailable' }}
</div>
@if (filled($processFlow['executionSummary'] ?? null))
<div class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $processFlow['executionSummary'] }}
</div>
@endif
</div>
</div>
<div class="rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-100">
Confirmation does not claim recovery. Operation proof and post-run evidence remain unavailable until execution starts.
</div>
</div>
</x-filament::section>
</x-dynamic-component>

View File

@ -0,0 +1,12 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$identityHtml = $identityHtml ?? null;
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
@if ($identityHtml)
{!! $identityHtml !!}
@endif
</div>
</x-dynamic-component>

View File

@ -0,0 +1,98 @@
@php
$fieldWrapperView = $getFieldWrapperView();
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@php
$mappingResolver = is_array($mappingResolver ?? null) ? $mappingResolver : [];
$resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0);
$totalCount = (int) ($mappingResolver['totalCount'] ?? 0);
$unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0);
$skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0);
$manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0);
$requirementLabel = (string) ($mappingResolver['requirementLabel'] ?? 'Required before validation can run');
$explanation = (string) ($mappingResolver['explanation'] ?? 'Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.');
$cacheNotice = (string) ($mappingResolver['cacheNotice'] ?? '');
$groupSyncUrl = $mappingResolver['groupSyncUrl'] ?? null;
$groupSyncOperationsUrl = $mappingResolver['groupSyncOperationsUrl'] ?? null;
@endphp
<div data-testid="restore-run-mapping-resolver-summary" class="space-y-4 rounded-lg border border-gray-200 bg-gray-50/70 p-4 dark:border-gray-800 dark:bg-gray-900/40">
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-semibold text-primary-700 dark:border-primary-700 dark:bg-primary-950/30 dark:text-primary-300">
{{ $resolvedCount }} of {{ $totalCount }} mappings resolved
</span>
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-xs font-semibold text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300">
{{ $unresolvedCount }} unresolved
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
{{ $skippedCount }} skipped
</span>
@if ($manualFallbackCount > 0)
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
{{ $manualFallbackCount }} manual fallback
</span>
@endif
</div>
<div class="space-y-2">
<p class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $requirementLabel }}
</p>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $explanation }}
</p>
</div>
@if ($cacheNotice !== '')
<div class="rounded-lg border border-warning-200 bg-warning-50/80 px-4 py-3 text-sm leading-6 text-warning-800 dark:border-warning-700 dark:bg-warning-950/20 dark:text-warning-200">
{{ $cacheNotice }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button
size="sm"
color="gray"
type="button"
data-testid="restore-run-hide-mapping-details"
x-on:click="
const section = $el.closest('.fi-section');
section?.querySelector('.fi-section-header')?.click();
"
>
Hide mapping details
</x-filament::button>
@if (filled($groupSyncUrl))
<x-filament::button
size="sm"
color="warning"
tag="a"
:href="$groupSyncUrl"
target="_blank"
rel="noopener noreferrer"
data-testid="restore-run-open-group-sync"
>
Open group sync
</x-filament::button>
@endif
@if (filled($groupSyncOperationsUrl))
<x-filament::button
size="sm"
color="gray"
tag="a"
:href="$groupSyncOperationsUrl"
target="_blank"
rel="noopener noreferrer"
data-testid="restore-run-view-group-sync-operations"
>
View group sync operations
</x-filament::button>
@endif
</div>
</div>
</x-dynamic-component>

View File

@ -1,42 +1,35 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$diffs = $getState() ?? [];
$diffs = is_array($diffs) ? $diffs : [];
$previewState = is_array($previewSummary ?? null) ? $previewSummary : [];
$previewState = $previewState !== []
? $previewState
: (is_array($preview_summary ?? null) ? $preview_summary : []);
$summary = $summary ?? [];
$summary = is_array($summary) ? $summary : [];
$integritySpec = $previewState['integritySpec'] ?? null;
$previewIntegrity = $previewIntegrity ?? [];
$previewIntegrity = is_array($previewIntegrity) ? $previewIntegrity : [];
if (! $integritySpec instanceof \App\Support\Badges\BadgeSpec) {
$integritySpec = \App\Support\Badges\BadgeSpec::unknown();
}
$checksIntegrity = $checksIntegrity ?? [];
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
$generatedAt = $previewState['generatedAt'] ?? null;
$generatedAtLabel = null;
$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;
if (is_string($ranAt) && $ranAt !== '') {
if (is_string($generatedAt) && $generatedAt !== '') {
try {
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
$generatedAtLabel = \Carbon\CarbonImmutable::parse($generatedAt)->format('Y-m-d H:i');
} catch (\Throwable) {
$ranAtLabel = $ranAt;
$generatedAtLabel = $generatedAt;
}
}
$integritySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::RestorePreviewDecision,
$previewIntegrity['state'] ?? 'not_generated'
);
$integritySummary = (string) ($previewState['integritySummary'] ?? 'Generate a preview before real execution.');
$scopeLabel = (string) ($previewState['scopeLabel'] ?? 'Restore scope');
$primaryNextActionLabel = (string) ($previewState['primaryNextActionLabel'] ?? 'Review the current scope and safety evidence.');
$summary = is_array($previewState['previewSummary'] ?? null) ? $previewState['previewSummary'] : [];
$needsAttentionDiffs = is_array($previewState['needsAttentionDiffs'] ?? null) ? $previewState['needsAttentionDiffs'] : [];
$unchangedDiffs = is_array($previewState['unchangedDiffs'] ?? null) ? $previewState['unchangedDiffs'] : [];
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
@ -44,163 +37,54 @@
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
$integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.';
$reviewedCount = count($needsAttentionDiffs) + count($unchangedDiffs);
$nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview';
$nextAction = is_string($nextAction) ? $nextAction : 'generate_preview';
$previewIsCurrent = ($previewIntegrity['state'] ?? null) === 'current';
$checksAreCurrent = ($checksIntegrity['state'] ?? null) === 'current';
$executionAllowed = (bool) ($executionReadiness['allowed'] ?? false);
$policyLabel = static function (array $entry): string {
$displayName = $entry['display_name'] ?? $entry['displayName'] ?? null;
$identifier = $entry['policy_identifier'] ?? $entry['policyIdentifier'] ?? null;
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);
if (count($keys) <= $limit) {
return $keys;
if (is_string($displayName) && trim($displayName) !== '') {
return (string) \Illuminate\Support\Str::of(trim($displayName))
->headline()
->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker');
}
return array_slice($keys, 0, $limit);
if (is_string($identifier) && trim($identifier) !== '') {
return (string) \Illuminate\Support\Str::of(trim($identifier))
->headline()
->replaceMatches('/\\bbitlocker\\b/i', 'BitLocker');
}
return 'Policy';
};
$sourceSelected = (int) ($currentScope['backup_set_id'] ?? 0) > 0;
$targetSelected = $executionReadiness !== [];
$validationComplete = $checksAreCurrent;
$previewComplete = $previewIsCurrent;
$technicalBlocked = ! $executionAllowed;
$policyActionLabel = static function (array $entry): string {
$action = $entry['action'] ?? null;
$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',
return match ((string) $action) {
'create' => 'Create',
'delete' => 'Delete',
'update' => 'Update',
default => 'Review',
};
};
$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="grid grid-cols-1 gap-4 lg:grid-cols-12" x-data="{ safetyGatesOpen: false }">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
<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.'"
:description="$generatedAtLabel ? ('Generated: ' . $generatedAtLabel) : '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-4">
<div class="flex flex-wrap items-center 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
</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
<x-filament::badge color="gray" size="sm">
{{ $scopeLabel }}
</x-filament::badge>
</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">
@ -210,260 +94,135 @@
Primary next step
</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
{{ $nextActionLabel }}
{{ $primaryNextActionLabel }}
</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 color="gray">
{{ $policiesTotal }} {{ \Illuminate\Support\Str::plural('policy', $policiesTotal) }} reviewed
</x-filament::badge>
<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 class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Policies reviewed</div>
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
{{ $reviewedCount }} {{ \Illuminate\Support\Str::plural('policy', $reviewedCount) }} reviewed
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Policies changed</div>
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
{{ $policiesChanged }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Assignments changed</div>
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
{{ $assignmentsChanged }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Scope tags changed</div>
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
{{ $scopeTagsChanged }}
</div>
</div>
</div>
</div>
</x-filament::section>
@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::section heading="Policy change preview">
<div class="space-y-4">
@if ($policiesTotal === 0)
<div class="text-sm text-gray-600 dark:text-gray-300">
No policies are included in this preview yet.
</div>
@elseif ($needsAttentionDiffs === [] && $unchangedDiffs !== [])
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-800 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-200">
No policy changes
</div>
@endif
@if ($diffsOmitted > 0)
<div class="rounded-lg border border-warning-200 bg-warning-50/80 px-4 py-3 text-sm leading-6 text-warning-800 dark:border-warning-700 dark:bg-warning-950/20 dark:text-warning-200">
{{ $diffsOmitted }} {{ \Illuminate\Support\Str::plural('policy diff', $diffsOmitted) }} omitted due to preview limits. Narrow scope to review more.
</div>
@endif
@if ($needsAttentionDiffs !== [])
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
Needs attention
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-950/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Policy</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($needsAttentionDiffs as $entry)
@php
$entry = is_array($entry) ? $entry : [];
@endphp
<tr>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{{ $policyLabel($entry) }}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{{ $policyActionLabel($entry) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@if ($unchangedDiffs !== [])
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
Unchanged
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-950/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Policy</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($unchangedDiffs as $entry)
@php
$entry = is_array($entry) ? $entry : [];
@endphp
<tr>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{{ $policyLabel($entry) }}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
No policy changes
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</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.
</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">
{{ $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>
@include('filament.forms.components.partials.restore-run-process-flow-panel', [
'processFlow' => $processFlow ?? [],
])
<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>
@include('filament.forms.components.partials.restore-run-proof-panel', [
'proofAside' => $proofAside ?? [],
'diagnosticsDisclosure' => $diagnosticsDisclosure ?? [],
])
</div>
</div>
</x-dynamic-component>

View File

@ -0,0 +1,10 @@
@php
$fieldWrapperView = $getFieldWrapperView();
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@include('filament.forms.components.partials.restore-run-proof-panel', [
'proofAside' => $proofAside ?? [],
'diagnosticsDisclosure' => $diagnosticsDisclosure ?? [],
])
</x-dynamic-component>

View File

@ -0,0 +1,62 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$decisionCard = is_array($decisionCard ?? null) ? $decisionCard : [];
$statusBadgeClasses = static function (string $tone): string {
return match ($tone) {
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700 dark:bg-success-950/30 dark:text-success-300',
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/30 dark:text-warning-300',
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700 dark:bg-danger-950/30 dark:text-danger-300',
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
};
};
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<x-filament::section>
<div data-testid="restore-run-decision-card" class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div class="min-w-0 space-y-4">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $decisionCard['title'] ?? 'Restore Safety' }}
</h2>
<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold {{ $statusBadgeClasses((string) ($decisionCard['tone'] ?? 'gray')) }}">
{{ $decisionCard['status'] ?? 'Unavailable' }}
</span>
</div>
<dl class="grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['statusLabel'] ?? 'Status' }}</dt>
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $decisionCard['status'] ?? 'Unavailable' }}</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['reasonLabel'] ?? 'Reason' }}</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['reason'] ?? 'Restore safety reason is unavailable.' }}</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['impactLabel'] ?? 'Impact' }}</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['impact'] ?? 'Restore impact is unavailable.' }}</dd>
</div>
</dl>
</div>
<aside class="min-w-0">
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['nextActionLabel'] ?? 'Primary next action' }}</div>
<div data-testid="restore-run-decision-next-action" class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $decisionCard['nextAction'] ?? 'Review the current restore state.' }}
</div>
@if (filled($decisionCard['helperText'] ?? null))
<p class="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $decisionCard['helperText'] }}
</p>
@endif
</div>
</aside>
</div>
</x-filament::section>
</x-dynamic-component>

View File

@ -0,0 +1,9 @@
@php
$fieldWrapperView = $getFieldWrapperView();
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@include('filament.forms.components.partials.restore-run-process-flow-panel', [
'processFlow' => $processFlow ?? [],
])
</x-dynamic-component>

View File

@ -0,0 +1,98 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$currentScope = is_array($currentScope ?? null) ? $currentScope : [];
$mappingResolver = is_array($mappingResolver ?? null) ? $mappingResolver : [];
$scopeMode = ($currentScope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all';
$selectedItems = is_array($currentScope['selected_item_ids'] ?? null) ? $currentScope['selected_item_ids'] : [];
$selectedCount = count($selectedItems);
$resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0);
$totalCount = (int) ($mappingResolver['totalCount'] ?? 0);
$unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0);
$skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0);
$manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0);
$blockedReason = $blocked_reason ?? null;
$blockedReason = is_string($blockedReason) && $blockedReason !== '' ? $blockedReason : null;
$canContinue = $can_continue ?? null;
$canContinue = is_bool($canContinue) ? $canContinue : null;
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<x-filament::section heading="Scope summary" description="Define the scope, then resolve any required dependency mappings before validation.">
<div data-testid="restore-run-scope-summary" class="space-y-4">
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
Selected scope
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $scopeMode === 'selected' ? "{$selectedCount} selected item" . ($selectedCount === 1 ? '' : 's') : 'All items (default)' }}
</div>
<div class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
Scope changes invalidate checks and preview evidence.
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
Dependency mappings
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
@if ($totalCount === 0)
No mappings required
@else
{{ $resolvedCount }} of {{ $totalCount }} mappings resolved
@endif
</div>
@if ($totalCount > 0)
<div class="mt-2 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span>{{ $unresolvedCount }} unresolved</span>
<span>·</span>
<span>{{ $skippedCount }} skipped</span>
@if ($manualFallbackCount > 0)
<span>·</span>
<span>{{ $manualFallbackCount }} manual fallback</span>
@endif
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
Next
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $canContinue === true ? 'Ready to continue' : 'Blocked' }}
</div>
@if ($blockedReason)
<div class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $blockedReason }}
</div>
@endif
</div>
</div>
@if ($totalCount > 0)
<div class="flex flex-wrap gap-2">
<button
type="button"
data-testid="restore-run-open-mapping-resolver"
class="fi-color fi-color-primary fi-bg-color-400 hover:fi-bg-color-300 dark:fi-bg-color-600 dark:hover:fi-bg-color-500 fi-text-color-900 hover:fi-text-color-800 dark:fi-text-color-950 dark:hover:fi-text-color-950 fi-btn fi-size-sm"
wire:loading.attr="disabled"
x-on:click="
const section = document.querySelector('[data-testid=restore-run-mapping-resolver-section]');
section?.scrollIntoView();
section?.querySelector('.fi-section-header')?.click();
"
>
Resolve mappings
</button>
</div>
@endif
</div>
</x-filament::section>
</x-dynamic-component>

View File

@ -1,3 +1,3 @@
<div class="space-y-4">
<livewire:entra-group-cache-picker-table :sourceGroupId="$sourceGroupId" />
<livewire:entra-group-cache-picker-table :sourceGroupId="$sourceGroupId" :sourceGroupDisplayName="$sourceGroupDisplayName ?? null" :tenantId="$tenantId ?? null" />
</div>

View File

@ -1,3 +1,68 @@
<div class="space-y-2">
{{ $this->table }}
<div class="space-y-4" data-testid="restore-group-picker-modal-content">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80" data-testid="restore-group-picker-context">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
Source group
</p>
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100" data-testid="restore-group-picker-source-name">
{{ $sourceGroupDisplayName ?? 'Unknown source group' }}
</p>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200" data-testid="restore-group-picker-source-id">
Source ID: {{ $sourceGroupId }}
</p>
</div>
@if ($hasCachedGroups)
<div class="space-y-2" data-testid="restore-group-picker-table">
{{ $this->table }}
</div>
@else
<div class="rounded-xl border border-dashed border-warning-300 bg-warning-50/70 p-6 dark:border-warning-700 dark:bg-warning-950/25" data-testid="restore-group-picker-empty-state">
<div class="space-y-3">
<div>
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
No directory group cache available
</h3>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
TenantPilot needs cached directory groups before target mappings can be selected.
</p>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
Sync directory groups, then return to this mapping.
</p>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
If you already know the target object ID, close this picker and enter it manually in the mapping field.
</p>
</div>
<div class="flex flex-wrap gap-3">
@if (filled($groupSyncUrl))
<x-filament::button
data-testid="restore-group-picker-open-sync"
:href="$groupSyncUrl"
tag="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
size="sm"
>
Open group sync
</x-filament::button>
@endif
@if (filled($groupSyncOperationsUrl))
<x-filament::button
data-testid="restore-group-picker-view-operations"
:href="$groupSyncOperationsUrl"
tag="a"
target="_blank"
rel="noopener noreferrer"
color="gray"
size="sm"
>
View group sync operations
</x-filament::button>
@endif
</div>
</div>
</div>
@endif
</div>

View File

@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->browser()->timeout(30_000);
uses(RefreshDatabase::class);
function spec332RestoreWizardSmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string
{
return route('admin.local.smoke-login', array_filter([
'email' => $user->email,
'tenant' => $tenant->external_id,
'workspace' => $tenant->workspace->slug,
'redirect' => $redirect,
], static fn (?string $value): bool => filled($value)));
}
it('keeps safety gates collapsed by default on the restore preview step', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'policy-preview-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
PolicyVersion::create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'snapshot' => [
'foo' => 'current',
],
'metadata' => [],
'assignments' => [],
'scope_tags' => [],
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Preview Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'payload' => [
'foo' => 'backup',
],
'assignments' => [],
'metadata' => [],
]);
bindFailHardGraphClient();
$redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
$redirectPath = parse_url($redirectBase, PHP_URL_PATH) ?: '/admin';
$redirect = $redirectPath
.'?backup_set_id='.(int) $backupSet->getKey()
.'&scope_mode=selected'
.'&backup_item_ids='.(int) $backupItem->getKey();
visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, $redirect))
->resize(1920, 1200)
->waitForText('Select Backup Set')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Next')
->waitForText('Define Restore Scope')
->click('Next')
->waitForText('Run checks')
->click('Run checks')
->waitForText('No group-based assignments detected.')
->click('Next')
->waitForText('Generate preview')
->click('Generate preview')
->waitForText('Policy change preview')
->assertSee('Review the preview and complete confirmation before execution can be queued.')
->assertSee('View safety gates')
->assertDontSee('Hide safety gates')
->assertSee('Execution: Unavailable');
});

View File

@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\User;
pest()->browser()->timeout(60_000);
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec332RestoreWizardScreenshot(string $name): string
{
return 'spec332-restore-create-'.$name;
}
function spec332CopyBrowserScreenshot(string $name, ?string $targetFilename = null): void
{
$filename = spec332RestoreWizardScreenshot($name).'.png';
$source = base_path('tests/Browser/Screenshots/'.$filename);
$targetDirectory = repo_path('specs/332-product-process-flow-system-v1/artifacts/screenshots');
$targetFilename ??= $filename;
if (is_dir($targetDirectory) && ! is_writable($targetDirectory)) {
return;
}
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
return;
}
if (! is_file($source)) {
$source = \Pest\Browser\Support\Screenshot::path($filename);
}
if (is_file($source)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$targetFilename);
}
}
function spec332RestoreWizardScreenshotsLoginUrl(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)));
}
function spec332ScreenshotsTenant(): array
{
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
bindFailHardGraphClient();
return [$user, $tenant];
}
function spec332ScreenshotsRedirect(ManagedEnvironment $tenant): string
{
$redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin';
}
function spec332ScreenshotsSelectBackupSet($page, BackupSet $backupSet): void
{
$selected = $page->script(<<<JS
(() => {
const select = document.getElementById('form.backup_set_id');
if (! select) {
return false;
}
select.value = '{$backupSet->getKey()}';
select.dispatchEvent(new Event('input', { bubbles: true }));
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
JS);
expect($selected)->toBeTrue();
}
function spec332ScreenshotsWizardNext($page): void
{
$clicked = $page->script(<<<'JS'
(() => {
const footer = document.querySelector('.fi-sc-wizard-footer');
if (! footer) {
return false;
}
const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]');
if (! nextTrigger) {
return false;
}
if (nextTrigger.classList.contains('fi-hidden')) {
return false;
}
nextTrigger.scrollIntoView({ block: 'center' });
nextTrigger.click();
return true;
})()
JS);
expect($clicked)->toBeTrue();
}
function spec332ScreenshotsUsableBackupFixture(ManagedEnvironment $tenant): BackupSet
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-screenshots-policy-usable',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Screenshots 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 Screenshots Usable Backup',
'status' => 'completed',
'item_count' => 1,
]);
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',
'displayName' => 'Spec332 Screenshots Policy',
],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Screenshots Policy',
],
]);
return $backupSet;
}
function spec332ScreenshotsUnresolvedGroupBackupFixture(ManagedEnvironment $tenant): BackupSet
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-screenshots-policy-group',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Screenshots Group Mapping Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Screenshots Group Backup',
'status' => 'completed',
'item_count' => 1,
]);
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',
'displayName' => 'Spec332 Screenshots Group Mapping Policy',
],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => '11111111-1111-1111-1111-111111111111',
'group_display_name' => 'Spec332 Missing Group',
],
]],
'metadata' => [
'displayName' => 'Spec332 Screenshots Group Mapping Policy',
],
]);
return $backupSet;
}
function spec332ScreenshotsMetadataOnlyFixture(ManagedEnvironment $tenant): BackupSet
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-screenshots-policy-metadata-only',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Screenshots Metadata Only Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Screenshots Metadata-only Backup',
'status' => 'completed',
'item_count' => 1,
]);
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' => [],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Screenshots Metadata Only Policy',
'snapshot_source' => 'metadata_only',
'warnings' => ['metadata only fallback'],
],
]);
return $backupSet;
}
it('captures step 1 screenshot with a degraded backup selected', function (): void {
[$user, $tenant] = spec332ScreenshotsTenant();
$backupSet = spec332ScreenshotsMetadataOnlyFixture($tenant);
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332ScreenshotsSelectBackupSet($page, $backupSet);
$page->waitForText('The selected backup does not contain a usable captured item yet.');
$page->script('window.scrollTo(0, 0);');
$page->screenshot(true, spec332RestoreWizardScreenshot('step-1-backup-selected'));
spec332CopyBrowserScreenshot('step-1-backup-selected', 'step-1-backup-selected.png');
});
it('captures step 2 screenshots for mapping summary + resolver expansion', function (): void {
[$user, $tenant] = spec332ScreenshotsTenant();
$backupSet = spec332ScreenshotsUnresolvedGroupBackupFixture($tenant);
EntraGroup::factory()->for($tenant)->create([
'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'display_name' => 'Spec332 Cached Target Group',
'last_seen_at' => now(),
]);
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332ScreenshotsSelectBackupSet($page, $backupSet);
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Resolve target mappings');
$page->script('window.scrollTo(0, 0);');
$page->screenshot(true, spec332RestoreWizardScreenshot('step-2-scope-default'));
spec332CopyBrowserScreenshot('step-2-scope-default', 'step-2-scope-default.png');
$page->click('[data-testid="restore-run-open-mapping-resolver"]')
->waitForText('Resolve mappings');
$page->script('window.scrollTo(0, 0);');
$page->screenshot(true, spec332RestoreWizardScreenshot('step-2-resolver-expanded'));
spec332CopyBrowserScreenshot('step-2-resolver-expanded', 'step-2-resolver-expanded.png');
});
it('captures step 3 screenshot when validation is blocked', function (): void {
[$user, $tenant] = spec332ScreenshotsTenant();
$backupSet = spec332ScreenshotsMetadataOnlyFixture($tenant);
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332ScreenshotsSelectBackupSet($page, $backupSet);
$page->waitForText('The selected backup does not contain a usable captured item yet.');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Define Restore Scope');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Safety & Conflict Checks');
$page->waitForText('Run checks')
->click('Run checks')
->waitForText('Snapshot completeness');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Validation blocked');
$page->script('window.scrollTo(0, 0);');
$page->screenshot(true, spec332RestoreWizardScreenshot('step-3-validation-blocked'));
spec332CopyBrowserScreenshot('step-3-validation-blocked', 'step-3-validation-blocked.png');
});
it('captures step 4 and 5 screenshots after preview generation', function (): void {
[$user, $tenant] = spec332ScreenshotsTenant();
$backupSet = spec332ScreenshotsUsableBackupFixture($tenant);
$page = visit(spec332RestoreWizardScreenshotsLoginUrl($user, $tenant, spec332ScreenshotsRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332ScreenshotsSelectBackupSet($page, $backupSet);
$page->waitForText('A usable source backup is selected for this restore draft.');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Define Restore Scope');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Safety & Conflict Checks');
$page->waitForText('Run checks')
->click('Run checks')
->waitForText('No group-based assignments detected.');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Generate preview')
->click('Generate preview')
->waitForText('Policy change preview');
$page->script('window.scrollTo(0, 0);');
$page->screenshot(true, spec332RestoreWizardScreenshot('step-4-preview-generated'));
spec332CopyBrowserScreenshot('step-4-preview-generated', 'step-4-preview-generated.png');
spec332ScreenshotsWizardNext($page);
$page->waitForText('Confirm & Execute');
$page->script('window.scrollTo(0, 0);');
$page->screenshot(true, spec332RestoreWizardScreenshot('step-5-confirm-ready'));
spec332CopyBrowserScreenshot('step-5-confirm-ready', 'step-5-confirm-ready.png');
});

View File

@ -0,0 +1,605 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
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)));
}
function spec332BrowserTenant(): array
{
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
bindFailHardGraphClient();
return [$user, $tenant];
}
function spec332BrowserRedirect(ManagedEnvironment $tenant): string
{
$redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin';
}
function spec332BrowserSelectBackupSet($page, BackupSet $backupSet): void
{
$selected = $page->script(<<<JS
(() => {
const select = document.getElementById('form.backup_set_id');
if (! select) {
return false;
}
select.value = '{$backupSet->getKey()}';
select.dispatchEvent(new Event('input', { bubbles: true }));
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
JS);
expect($selected)->toBeTrue();
}
function spec332BrowserElementIsVisibleScript(string $testId): string
{
return <<<JS
(() => {
const element = document.querySelector('[data-testid="{$testId}"]');
if (! element) {
return false;
}
const style = window.getComputedStyle(element);
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& ! element.hidden
&& Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
})()
JS;
}
function spec332BrowserWizardNext($page): void
{
$clicked = $page->script(<<<'JS'
(() => {
const footer = document.querySelector('.fi-sc-wizard-footer');
if (! footer) {
return false;
}
const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]');
if (! nextTrigger) {
return false;
}
if (nextTrigger.classList.contains('fi-hidden')) {
return false;
}
nextTrigger.scrollIntoView({ block: 'center' });
nextTrigger.click();
return true;
})()
JS);
expect($clicked)->toBeTrue();
}
function spec332BrowserUsableBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-browser-policy-usable',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Browser 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 Browser Usable 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',
'displayName' => 'Spec332 Browser Policy',
],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Browser Policy',
],
]);
return [$backupSet, $backupItem];
}
function spec332BrowserUnresolvedGroupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-browser-policy-group',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Group Mapping Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Browser Group 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' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => '11111111-1111-1111-1111-111111111111',
'group_display_name' => 'Spec332 Missing Group',
],
]],
'metadata' => [
'displayName' => 'Spec332 Group Mapping Policy',
],
]);
return [$backupSet, $backupItem];
}
function spec332BrowserMetadataOnlyFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-browser-policy-metadata-only',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Metadata Only Browser Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Browser Metadata-only 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' => [],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Metadata Only Browser Policy',
'snapshot_source' => 'metadata_only',
'warnings' => ['metadata only fallback'],
],
]);
return [$backupSet, $backupItem];
}
it('shows the full product process flow on step 1', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('A usable source backup is selected for this restore draft.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Restore Safety')
->assertSee('Backup quality summary')
->assertSee('Restore safety gates')
->assertSee('Restore Proof')
->assertSee('Diagnostics - Collapsed')
->assertSee('Continue to scope and resolve required mappings.')
->assertSee('Validate impact before execution.')
->assertDontSee('Technical startability')
->assertDontSee('write-gate')
->assertDontSee('hard-blocker')
->assertDontSee('Is this dangerous?')
->assertDontSee('tenant-wide recoverability')
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), true)
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), false)
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true)
->assertSee('A usable source backup is selected for this restore draft.');
});
it('shows compact restore safety status by default on step 2', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('A usable source backup is selected for this restore draft.');
spec332BrowserWizardNext($page);
$page->waitForText('2/7 gates complete')
->assertSee('Restore safety status')
->assertSee('2/7 gates complete')
->assertSee('View safety gates')
->assertDontSee('Hide safety gates')
->assertSee('Restore Proof')
->assertSee('Diagnostics - Collapsed')
->assertDontSee('Technical startability')
->assertDontSee('write-gate')
->assertDontSee('hard-blocker')
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), true)
->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), false)
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true)
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true);
});
it('keeps group mapping details collapsed until explicitly opened on step 2', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
spec332BrowserWizardNext($page);
$page->waitForText('Resolve target mappings')
->assertSee('Scope summary')
->assertSee('Resolve mappings')
->assertSee('0 of 1 mappings resolved')
->assertSee('Resolve target mappings')
->assertSee('Restore safety status')
->assertSee('Restore Proof')
->assertSee('Diagnostics - Collapsed')
->assertSee('Resolve required mappings before validation can run.')
->assertDontSee('Paste the target Entra ID group Object ID (GUID).')
->assertScript(<<<'JS'
(() => {
const section = Array.from(document.querySelectorAll('.fi-section')).find((element) =>
element.textContent?.includes('Resolve target mappings')
);
return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'false';
})()
JS, true)
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true);
$page->click('[data-testid="restore-run-open-mapping-resolver"]');
$page->waitForText('0 of 1 mappings resolved')
->assertSee('0 of 1 mappings resolved')
->assertSee('1 unresolved')
->assertSee('0 skipped')
->assertSee('Resolve required mappings before validation can run.')
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.')
->assertSee('Hide mapping details')
->assertDontSee('Return to scope summary')
->assertDontSee('Paste the target Entra ID group Object ID (GUID).')
->assertScript(<<<'JS'
(() => {
const section = Array.from(document.querySelectorAll('.fi-section')).find((element) =>
element.textContent?.includes('Resolve target mappings')
);
return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'true';
})()
JS, true);
});
it('shows a task-specific empty picker when the directory group cache is unavailable on step 2', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
spec332BrowserWizardNext($page);
$page->waitForText('Resolve target mappings');
$pickerOpened = $page->script(<<<'JS'
(() => {
const header = Array.from(document.querySelectorAll('.fi-section-header')).find((element) =>
element.textContent?.includes('Resolve target mappings')
);
header?.click();
const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]');
pickerButton?.click();
return Boolean(pickerButton);
})()
JS);
expect($pickerOpened)->toBeTrue();
$page->waitForText('Resolve target group mapping')
->assertSee('Source group')
->assertSee('Spec332 Missing Group')
->assertSee('Source ID: 11111111-1111-1111-1111-111111111111')
->assertSee('No directory group cache available')
->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.')
->assertSee('Sync directory groups, then return to this mapping.')
->assertSee('Open group sync')
->assertSee('View group sync operations')
->assertScript(<<<'JS'
(() => {
const modalContent = document.querySelector('[data-testid="restore-group-picker-modal-content"]');
if (! modalContent) {
return false;
}
const actionLabels = Array.from(modalContent.querySelectorAll('a, button'))
.map((element) => element.textContent?.trim() ?? '')
.filter(Boolean);
return ! actionLabels.includes('Directory Groups')
&& ! actionLabels.includes('Operations')
&& ! modalContent.textContent?.includes('No cached groups found')
&& ! modalContent.textContent?.includes('No groups found in tenant')
&& ! modalContent.textContent?.includes('Search groups…');
})()
JS, true);
});
it('shows cached directory group results in the picker when cache exists on step 2', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
EntraGroup::factory()->for($tenant)->create([
'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'display_name' => 'Spec332 Cached Target Group',
'last_seen_at' => now(),
]);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
spec332BrowserWizardNext($page);
$page->waitForText('Resolve target mappings')
->click('[data-testid="restore-run-open-mapping-resolver"]');
$pickerOpened = $page->script(<<<'JS'
(() => {
const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]');
pickerButton?.click();
return Boolean(pickerButton);
})()
JS);
expect($pickerOpened)->toBeTrue();
$page->waitForText('Resolve target group mapping')
->assertSee('Spec332 Cached Target Group')
->assertDontSee('No directory group cache available');
});
it('blocks next on step 2 when required mappings are unresolved', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('Resolve 1 remaining group mapping before validation can prove the current draft.');
spec332BrowserWizardNext($page);
$page->waitForText('Resolve target mappings');
spec332BrowserWizardNext($page);
$page->waitForText('Mappings required')
->assertDontSee('field is required.')
->assertSee('Resolve required mappings before validation can run.')
->assertSee('Define Restore Scope');
});
it('blocks next on step 3 when validation has blockers', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserMetadataOnlyFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('The selected backup does not contain a usable captured item yet.');
spec332BrowserWizardNext($page);
$page->waitForText('Define Restore Scope');
spec332BrowserWizardNext($page);
$page->waitForText('Safety & Conflict Checks');
$page->waitForText('Run checks')
->click('Run checks')
->waitForText('Snapshot completeness');
spec332BrowserWizardNext($page);
$page
->waitForText('Validation blocked')
->assertSee('Resolve the blocking validation issues before moving to preview.')
->assertSee('Safety & Conflict Checks');
});
it('keeps preview decision-first on step 4 while safety gates stay compact', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('A usable source backup is selected for this restore draft.');
spec332BrowserWizardNext($page);
$page->waitForText('Define Restore Scope');
spec332BrowserWizardNext($page);
$page->waitForText('Safety & Conflict Checks');
$page->waitForText('Run checks')
->click('Run checks')
->waitForText('No group-based assignments detected.');
spec332BrowserWizardNext($page);
$page->waitForText('Generate preview')
->click('Generate preview')
->waitForText('Policy change preview')
->assertSee('Review the preview and complete confirmation before execution can be queued.')
->assertSee('Restore safety status')
->assertSee('View safety gates')
->assertDontSee('Hide safety gates')
->assertSee('Restore Proof')
->assertSee('Operation proof')
->assertSee('Diagnostics - Collapsed')
->assertDontSee('tenant-wide recovery is proven')
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact\"]") !== null', true)
->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-full\"]") === null', true)
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true);
});
it('shows confirm step readiness after preview and checks are current', function (): void {
[$user, $tenant] = spec332BrowserTenant();
[$backupSet] = spec332BrowserUsableBackupFixture($tenant);
$page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant)));
$page->resize(1920, 1200)
->waitForText('Select Backup Set');
spec332BrowserSelectBackupSet($page, $backupSet);
$page->waitForText('A usable source backup is selected for this restore draft.');
spec332BrowserWizardNext($page);
$page->waitForText('Define Restore Scope');
spec332BrowserWizardNext($page);
$page->waitForText('Safety & Conflict Checks');
$page->waitForText('Run checks')
->click('Run checks')
->waitForText('No group-based assignments detected.');
spec332BrowserWizardNext($page);
$page->waitForText('Generate preview')
->click('Generate preview')
->waitForText('Policy change preview');
spec332BrowserWizardNext($page);
$page
->waitForText('Confirm & Execute')
->assertSee('Confirmation summary')
->assertSee('Available after confirmation')
->assertSee('Confirmation does not claim recovery.')
->assertSee('Restore Proof')
->assertSee('Operation proof')
->assertSee('Post-run evidence')
->assertDontSee('tenant-wide recovery is proven');
});

View File

@ -20,10 +20,9 @@
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
@ -125,7 +124,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
]),
'Weakened',
'The restore did not complete successfully. Follow-up is still required.',
'ManagedEnvironment recovery is not proven.',
'Target environment recovery is not proven.',
RestoreResultAttention::STATE_FAILED,
],
'partial history' => [
@ -138,7 +137,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
]),
'Weakened',
'The restore reached a terminal state, but some items or assignments still need follow-up.',
'ManagedEnvironment-wide recovery is not proven.',
'Target environment recovery is not proven.',
RestoreResultAttention::STATE_PARTIAL,
],
'follow-up history' => [
@ -151,7 +150,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
]),
'Weakened',
'The restore completed, but follow-up remains for skipped or non-applied work.',
'ManagedEnvironment-wide recovery is not proven.',
'Target environment recovery is not proven.',
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
],
'calm completed history' => [
@ -164,7 +163,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
]),
'No recent issues visible',
'Recent executed restore history exists without a current follow-up signal.',
'ManagedEnvironment-wide recovery is not proven.',
'Target environment recovery is not proven.',
'no_recent_issues_visible',
],
]);
@ -249,12 +248,12 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
]);
expect($stats['High severity findings'])->toMatchArray([
'value' => '2',
'url' => FindingResource::getUrl('index', [
'tab' => 'needs_action',
'high_severity' => 1,
], panel: 'admin', tenant: $tenant),
])
'value' => '2',
'url' => FindingResource::getUrl('index', [
'tab' => 'needs_action',
'high_severity' => 1,
], panel: 'admin', tenant: $tenant),
])
->and($stats['Overdue findings']['value'])->toBe('0')
->and($stats['Overdue findings']['url'])->toBe(FindingResource::getUrl('index', [
'tab' => 'overdue',
@ -435,7 +434,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
expect($recoveryStat['description'])
->toContain('No executed restore history is visible in the latest tenant restore records.')
->toContain('ManagedEnvironment-wide recovery is not proven.');
->toContain('Target environment recovery is not proven.');
});
it('surfaces weak and calm restore history on the recovery evidence KPI', function (

View File

@ -23,10 +23,9 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
@ -707,7 +706,7 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
->assertSee('Backups are recent and healthy')
->assertSee('No recent restore issues visible')
->assertSee('Recent executed restore history exists without a current follow-up signal.')
->assertSee('ManagedEnvironment-wide recovery is not proven.')
->assertSee('Target environment recovery is not proven.')
->assertDontSee('Recovery evidence is unvalidated')
->assertDontSee('Recent restore failed');
});

View File

@ -106,7 +106,7 @@
->assertSee('Follow-up required')
->assertSee('Review skipped or non-applied items before closing the run.')
->assertSee('No dominant cause recorded')
->assertSee('ManagedEnvironment-wide recovery is not proven.')
->assertSee('Target environment recovery is not proven.')
->assertDontSee('review_skipped_items')
->assertDontSee('run_completed_not_recovery_proven');
});

View File

@ -65,7 +65,7 @@
->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.')
->assertDontSeeText('Review prerequisites before execution.')
->assertSeeText('Policy change preview')
->assertSeeText('BitLocker Require')
->assertSeeText('No policy changes')

View File

@ -39,7 +39,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
->assertSee('Select Backup Set');
});
test('restore wizard group mapping renders DB-only with manual GUID UX', function () {
test('restore wizard group mapping renders DB-only with resolver-mode UX', function () {
$tenant = ManagedEnvironment::factory()->create();
[$user] = createUserWithTenant($tenant);
@ -49,7 +49,6 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
]);
$groupId = '11111111-2222-3333-4444-555555555555';
$expectedMasked = '…'.substr($groupId, -8);
BackupItem::factory()->create([
'managed_environment_id' => $tenant->getKey(),
@ -72,7 +71,10 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
])
->get($url)
->assertOk()
->assertSee($expectedMasked)
->assertSee('Paste the target Entra ID group Object ID')
->assertSee('Use SKIP to omit the assignment.');
->assertSee('Example Group')
->assertSee('Source ID: '.$groupId)
->assertSee('Resolve target mappings')
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback.')
->assertDontSee('Paste the target Entra ID group Object ID')
->assertDontSee('Use SKIP to omit the assignment.');
});

View File

@ -0,0 +1,556 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec332ProductProcessFlowTenant(): array
{
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
return [$user, $tenant];
}
function spec332UsableBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-policy-usable',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Device Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Usable 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,
'payload' => [
'displayName' => 'Spec332 Device Policy',
'settings' => ['foo' => 'bar'],
],
'metadata' => [
'displayName' => 'Spec332 Device Policy',
],
'assignments' => [],
]);
return [$backupSet, $backupItem];
}
function spec332EmptyBackupFixture(ManagedEnvironment $tenant): BackupSet
{
return BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Empty Backup',
'status' => 'completed',
'item_count' => 0,
]);
}
function spec332MetadataOnlyBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-policy-metadata-only',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Metadata Only Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Metadata-only 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,
'payload' => [],
'metadata' => [
'displayName' => 'Spec332 Metadata Only Policy',
'snapshot_source' => 'metadata_only',
'warnings' => ['metadata only fallback'],
],
'assignments' => [],
]);
return [$backupSet, $backupItem];
}
function spec332UnresolvedGroupBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-policy-group',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Group Mapping Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Group 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,
'payload' => [
'displayName' => 'Spec332 Group Mapping Policy',
],
'metadata' => [
'displayName' => 'Spec332 Group Mapping Policy',
],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => '11111111-1111-1111-1111-111111111111',
'group_display_name' => 'Spec332 Missing Group',
],
]],
]);
return [$backupSet, $backupItem];
}
function spec332WizardComponent($user, ManagedEnvironment $tenant): \Livewire\Features\SupportTesting\Testable
{
setAdminPanelContext($tenant);
return Livewire::actingAs($user)->test(CreateRestoreRun::class);
}
function spec332CurrentPreviewData(BackupSet $backupSet, BackupItem $backupItem): array
{
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$data = [
'backup_set_id' => (int) $backupSet->getKey(),
'scope_mode' => 'selected',
'backup_item_ids' => [(int) $backupItem->getKey()],
'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,
'raw_payload_marker' => 'spec332 raw payload should stay hidden',
],
'preview_diffs' => [[
'policy_identifier' => 'spec332-policy-usable',
'display_name' => 'Spec332 Device Policy',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'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);
return RestoreRunResource::synchronizeRestoreSafetyDraft($data);
}
it('keeps only the reconciled spec 332 directory under the active product process flow path', function (): void {
$spec332Directories = collect(glob(repo_path('specs/332-*')) ?: [])
->map(static fn (string $path): string => basename($path))
->sort()
->values()
->all();
expect($spec332Directories)->toBe([
'332-product-process-flow-system-v1',
])
->and(is_dir(repo_path('specs/332-product-process-flow-system-v1')))->toBeTrue()
->and(is_dir(repo_path('specs/332-restore-run-preview-productization')))->toBeFalse()
->and((string) file_get_contents(repo_path('specs/332-product-process-flow-system-v1/spec.md')))
->toContain('Product Process Flow System v1')
->toContain('Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path');
});
it('renders the full product process flow on step 1 for a usable backup source', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UsableBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->assertSee('Restore Safety')
->assertSee('Backup quality summary')
->assertSee('Restore safety gates')
->assertSee('Restore Proof')
->assertSee('Diagnostics - Collapsed')
->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.')
->assertSee('A usable source backup is selected for this restore draft.')
->assertSee('Continue to scope and resolve required mappings.')
->assertSee('Validate impact before execution.')
->assertSee('This create flow does not prove recoverability before execution and post-run evidence exist.')
->assertDontSee('Technical startability')
->assertDontSee('write-gate')
->assertDontSee('hard-blocker')
->assertDontSee('Is this dangerous?')
->assertDontSee('tenant-wide recoverability')
->assertSeeHtml('data-step-label="Usable source selected"')
->assertSeeHtml('data-proof-label="Operation proof"');
expect($component->html())->toContain('data-testid="restore-run-process-flow-full"');
});
it('does not mark usable source as complete when the backup has no captured items', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
$backupSet = spec332EmptyBackupFixture($tenant);
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.')
->assertSeeHtml('data-step-label="Usable source selected"')
->assertSeeHtml('data-step-status="required"');
});
it('renders compact restore safety status on step 2 while keeping restore proof visible', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UsableBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertSee('Restore safety status')
->assertSee('2/7 gates complete')
->assertSee('View safety gates')
->assertSee('Restore Proof')
->assertSee('Requested by')
->assertSee('Diagnostics - Collapsed')
->assertSeeHtml('data-testid="restore-run-process-flow-compact"');
expect($component->html())->toContain('data-testid="restore-run-process-flow-compact"');
});
it('keeps group mapping details collapsed by default on step 2 until the resolver is opened explicitly', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertSee('Scope summary')
->assertSee('Resolve mappings')
->assertSee('Resolve target mappings')
->assertSee('Restore safety status')
->assertSee('Restore Proof')
->assertSee('0 of 1 mappings resolved')
->assertSee('1 unresolved')
->assertSee('0 skipped')
->assertSee('Resolve required mappings before validation can run.')
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.')
->assertDontSee('Paste the target Entra ID group Object ID (GUID).');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
expect(preg_match('/isCollapsed:\s*true[\s\S]{0,2200}Resolve target mappings/', $html))->toBe(1);
});
it('shows cached target group identity in mapping helper text when a cached target is selected', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
$targetGroupId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
EntraGroup::factory()->for($tenant)->create([
'entra_id' => $targetGroupId,
'display_name' => 'Spec332 Cached Target Group',
]);
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->fillForm([
'group_mapping' => [
$sourceGroupId => $targetGroupId,
],
])
->assertSee('Target group: Spec332 Cached Target Group')
->assertSee('Target ID: '.$targetGroupId);
});
it('labels manual GUID mapping as a manual fallback and counts it in the resolver summary', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
$targetGroupId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->fillForm([
'group_mapping' => [
$sourceGroupId => $targetGroupId,
],
])
->assertSee('1 of 1 mappings resolved')
->assertSee('0 unresolved')
->assertSee('0 skipped')
->assertSee('1 manual fallback')
->assertSee('Manual target object ID')
->assertSee('Badge: Manual fallback');
});
it('does not treat invalid GUID values as resolved in the resolver summary', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->fillForm([
'group_mapping' => [
$sourceGroupId => 'not-a-guid',
],
])
->assertSee('0 of 1 mappings resolved')
->assertSee('1 unresolved')
->assertSee('Invalid group object ID (GUID).');
});
it('supports skipping and undoing a mapping assignment on step 2', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
$sourceGroupToken = '11111111_1111_1111_1111_111111111111';
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertFormComponentActionVisible("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}")
->callFormComponentAction("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}")
->assertSet("data.group_mapping.{$sourceGroupId}", 'SKIP')
->assertFormFieldHidden("group_mapping.{$sourceGroupId}")
->assertFormFieldVisible("group_mapping_skipped_{$sourceGroupToken}")
->assertSee('1 skipped')
->assertSee('This assignment will not be restored.')
->assertFormComponentActionVisible("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}")
->callFormComponentAction("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}")
->assertSet("data.group_mapping.{$sourceGroupId}", null)
->assertFormFieldVisible("group_mapping.{$sourceGroupId}");
expect($component)->not->toBeNull();
});
it('blocks progression out of step 2 while required mappings are unresolved', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2);
$component
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertNotified('Mappings required')
->assertSee('Resolve required mappings before validation can run.');
});
it('productizes the empty target group picker when step 2 needs dependency mapping', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UnresolvedGroupBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2);
$component
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [(int) $backupItem->getKey()],
]);
/** @var \Filament\Schemas\Schema $schema */
$schema = $component->instance()->form;
$field = $schema->getComponentByStatePath('group_mapping.11111111-1111-1111-1111-111111111111');
$action = collect($field?->getSuffixActions() ?? [])
->first(fn ($candidate) => $candidate->getName() === 'select_from_directory_cache_11111111_1111_1111_1111_111111111111');
expect($field)->not->toBeNull()
->and($action)->not->toBeNull()
->and($action->getModalHeading())->toBe('Resolve target group mapping');
});
it('shows a product-safe blocked state for checks when provider credentials are missing', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false);
[$backupSet] = spec332UsableBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->goToNextWizardStep()
->assertWizardCurrentStep(3)
->assertSee('Validation blocked')
->assertSee('Provider credentials are not available for this environment.')
->assertSee('Restore checks cannot run until the provider connection is repaired.')
->assertSee('Review provider connection')
->assertDontSee('Provider credentials are missing');
$component
->goToNextWizardStep()
->assertWizardCurrentStep(3)
->assertNotified('Validation blocked');
expect($component->html())->not->toContain('Exception');
});
it('keeps preview decision-first while showing compact safety status and restore proof', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
$component = spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(4)
->assertWizardCurrentStep(4)
->assertSee('Review the preview and complete confirmation before execution can be queued.')
->assertDontSee('Review prerequisites before execution.')
->assertSee('Restore safety status')
->assertSee('Next gate:')
->assertSee('Confirmation')
->assertSee('Restore Proof')
->assertSee('Operation proof')
->assertSee('Post-run evidence')
->assertSee('Diagnostics - Collapsed')
->assertDontSee('spec332 raw payload should stay hidden')
->assertDontSee('tenant-wide recovery is proven');
});
it('keeps confirm step locked when execution prerequisites are unavailable', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false);
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
$component = spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(5)
->assertWizardCurrentStep(5)
->assertSee('Confirmation summary')
->assertSee('Execution')
->assertSee('Unavailable')
->assertSee('Review prerequisites before execution.')
->assertSee('Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.')
->assertSee('Confirmation does not claim recovery.')
->assertFormFieldDisabled('is_dry_run');
expect($component->html())->not->toContain('Operation proof is complete');
});
it('shows confirm step readiness when execution prerequisites are healthy', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(5)
->assertWizardCurrentStep(5)
->assertSee('Confirmation summary')
->assertSee('Execution')
->assertSee('Available after confirmation')
->assertSee('Confirmation does not claim recovery.')
->assertFormFieldEnabled('is_dry_run');
});

View File

@ -5,8 +5,8 @@
use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Resources\ManagedEnvironmentResource;
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
use App\Models\Policy;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\User;
use App\Support\BackupHealth\BackupFreshnessEvaluation;
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
@ -19,8 +19,6 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use function Pest\Laravel\mock;
uses(RefreshDatabase::class);
afterEach(function (): void {
@ -122,7 +120,7 @@ function tenantRegistryRecoveryEvidence(
return [
'overview_state' => $overviewState,
'summary' => $summary,
'claim_boundary' => 'ManagedEnvironment-wide recovery is not proven.',
'claim_boundary' => 'Target environment recovery is not proven.',
'reason' => $reason,
'latest_relevant_restore_run' => null,
'latest_relevant_attention' => null,

View File

@ -67,6 +67,6 @@
->test(TenantlessOperationRunViewer::class, ['run' => $operationRun])
->assertSee('Restore continuation')
->assertSee('Follow-up required')
->assertSee('ManagedEnvironment-wide recovery is not proven.')
->assertSee('Target environment recovery is not proven.')
->assertSee('Open restore run');
});

View File

@ -1,11 +1,13 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Livewire\EntraGroupCachePickerTable;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Graph\GroupResolver;
use App\Support\RestoreSafety\RestoreScopeFingerprint;
@ -99,6 +101,105 @@
$component->assertFormFieldVisible('group_mapping.source-group-1');
});
test('restore group mapping picker productizes the empty directory cache state', function () {
$tenant = ManagedEnvironment::factory()->create([
'managed_environment_id' => 'tenant-1',
'name' => 'ManagedEnvironment One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$this->actingAs($user);
setAdminPanelContext($tenant);
$component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [
'sourceGroupId' => '00000000-0000-0000-0000-d908d2dd',
'sourceGroupDisplayName' => 'ADSyncOperators',
'tenantId' => (int) $tenant->getKey(),
]);
$component
->assertSee('Source group')
->assertSee('ADSyncOperators')
->assertSee('Source ID: 00000000-0000-0000-0000-d908d2dd')
->assertSee('No directory group cache available')
->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.')
->assertSee('Sync directory groups, then return to this mapping.')
->assertSee('Open group sync')
->assertSee('View group sync operations')
->assertDontSee('No cached groups found')
->assertDontSee('Directory Groups')
->assertDontSee('No groups found in tenant')
->assertDontSee('Search groups…')
->assertDontSee('Stale');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$expectedGroupSyncUrl = \App\Filament\Resources\EntraGroupResource::getUrl('index', tenant: $tenant);
$expectedOperationsUrl = \App\Support\OperationRunLinks::index($tenant, operationType: 'directory.groups.sync');
expect($html)
->toContain('data-testid="restore-group-picker-empty-state"')
->toContain('href="'.$expectedGroupSyncUrl.'"')
->toContain('href="'.$expectedOperationsUrl.'"')
->toContain('target="_blank"')
->not->toMatch('/>\s*Operations\s*</');
});
test('restore group mapping picker lists cached directory groups for the current environment only', function () {
$tenant = ManagedEnvironment::factory()->create([
'managed_environment_id' => 'tenant-1',
'name' => 'ManagedEnvironment One',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$otherTenant = ManagedEnvironment::factory()->create([
'managed_environment_id' => 'tenant-2',
'name' => 'ManagedEnvironment Two',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$cached = EntraGroup::factory()->for($tenant)->create([
'display_name' => 'Spec332 Cached Group',
'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
]);
EntraGroup::factory()->for($otherTenant)->create([
'display_name' => 'Foreign Cached Group',
'entra_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
]);
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$this->actingAs($user);
setAdminPanelContext($tenant);
$component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [
'sourceGroupId' => '00000000-0000-0000-0000-d908d2dd',
'sourceGroupDisplayName' => 'ADSyncOperators',
'tenantId' => (int) $tenant->getKey(),
]);
$component
->assertSee('Source group')
->assertSee('ADSyncOperators')
->assertSee('Source ID: 00000000-0000-0000-0000-d908d2dd')
->assertSee('Spec332 Cached Group')
->assertDontSee('Foreign Cached Group')
->assertDontSee('No directory group cache available');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
expect($html)
->toContain('data-testid="restore-group-picker-table"');
});
test('restore wizard persists group mapping selections', function () {
$tenant = ManagedEnvironment::factory()->create([
'managed_environment_id' => 'tenant-1',

View File

@ -3,9 +3,9 @@
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Graph\GroupResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -93,7 +93,8 @@
])
->goToNextWizardStep()
->assertFormComponentActionVisible('check_results', 'run_restore_checks')
->callFormComponentAction('check_results', 'run_restore_checks');
->callFormComponentAction('check_results', 'run_restore_checks')
->assertNotified('Safety checks finished with blockers');
$summary = $component->get('data.check_summary');
$results = $component->get('data.check_results');

View File

@ -66,6 +66,7 @@
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$this->actingAs($user);
setAdminPanelContext($tenant);
ensureDefaultProviderConnection($tenant, 'microsoft');
$data = [
'backup_set_id' => $backupSet->id,

View File

@ -63,8 +63,8 @@
expect($safe->color)->toBe('success');
$current = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'current');
expect($current->label)->toBe('Current checks');
expect($current->color)->toBe('success');
expect($current->label)->toBe('Latest check result');
expect($current->color)->toBe('info');
$invalidated = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'invalidated');
expect($invalidated->label)->toBe('Invalidated');

View File

@ -7,11 +7,11 @@
uses(RefreshDatabase::class);
it('formats unresolved labels using an ellipsis + last 8 chars', function () {
it('formats unknown labels using an ellipsis + last 8 chars', function () {
$id = '11111111-2222-3333-4444-555555555555';
expect(EntraGroupLabelResolver::formatLabel(null, $id))
->toBe('Unresolved (…55555555)');
->toBe('Unknown group (…55555555)');
});
it('resolves labels from the tenant cache (tenant-scoped)', function () {
@ -46,5 +46,5 @@
$resolver = app(EntraGroupLabelResolver::class);
expect($resolver->resolveOne($tenant, 'group-123'))
->toBe('Unresolved (group123)');
->toBe('Unknown group (group123)');
});

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('recomputes the restore create presenter contract from current database state', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-presenter-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Presenter Policy',
'platform' => 'windows',
'metadata' => [],
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Presenter 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,
'payload' => [],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Metadata Only Policy',
'snapshot_source' => 'metadata_only',
'warnings' => ['metadata only fallback'],
],
]);
$wizardData = [
'backup_set_id' => (int) $backupSet->getKey(),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => [],
];
$first = RestoreRunCreatePresenter::contract(
data: $wizardData,
currentStep: 1,
compactFlow: false,
tenant: $tenant,
user: $user,
);
$firstSummary = data_get($first, 'processFlow.steps.0.summary');
expect($firstSummary)
->toBeString()
->toContain('does not contain a usable captured item yet');
$backupItem->update([
'payload' => [
'id' => 'spec332-presenter-policy',
'displayName' => 'Spec332 Presenter Policy',
'settings' => ['foo' => 'bar'],
],
'metadata' => [
'displayName' => 'Spec332 Presenter Policy',
],
]);
$second = RestoreRunCreatePresenter::contract(
data: $wizardData,
currentStep: 1,
compactFlow: false,
tenant: $tenant,
user: $user,
);
$secondSummary = data_get($second, 'processFlow.steps.0.summary');
expect($secondSummary)
->toBeString()
->toContain('A usable source backup is selected for this restore draft.')
->not->toContain('does not contain a usable captured item yet');
});
it('does not leak presenter state between independent restore draft contracts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-presenter-policy-b',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Presenter Policy B',
'platform' => 'windows',
'metadata' => [],
]);
$metadataOnlyBackup = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Presenter Metadata Backup',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $metadataOnlyBackup->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Metadata Only Policy',
'snapshot_source' => 'metadata_only',
],
]);
$first = RestoreRunCreatePresenter::contract(
data: [
'backup_set_id' => (int) $metadataOnlyBackup->getKey(),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => [],
],
currentStep: 1,
compactFlow: false,
tenant: $tenant,
user: $user,
);
expect(data_get($first, 'processFlow.steps.0.summary'))
->toBeString()
->toContain('does not contain a usable captured item yet');
$usableBackup = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Presenter Usable Backup',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $usableBackup->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'id' => 'spec332-presenter-policy-b',
'displayName' => 'Spec332 Presenter Policy B',
'settings' => ['foo' => 'bar'],
],
'assignments' => [],
'metadata' => [
'displayName' => 'Spec332 Presenter Policy B',
],
]);
$second = RestoreRunCreatePresenter::contract(
data: [
'backup_set_id' => (int) $usableBackup->getKey(),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => [],
],
currentStep: 1,
compactFlow: false,
tenant: $tenant,
user: $user,
);
expect(data_get($second, 'processFlow.steps.0.summary'))
->toBeString()
->toContain('A usable source backup is selected for this restore draft.')
->not->toContain('does not contain a usable captured item yet');
});

View File

@ -149,7 +149,7 @@
expect($overview['overview_state'])->toBe('weakened')
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestFailed->getKey())
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_FAILED)
->and($overview['claim_boundary'])->toBe('ManagedEnvironment recovery is not proven.')
->and($overview['claim_boundary'])->toBe('Target environment recovery is not proven.')
->and($overview['reason'])->toBe(RestoreResultAttention::STATE_FAILED);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View File

@ -0,0 +1,90 @@
# Implementation Plan: Spec 332 - Product Process Flow System v1
- Branch: `332-product-process-flow-system-v1`
- Date: 2026-05-25
- Spec: `specs/332-product-process-flow-system-v1/spec.md`
## Reconciliation Note
Spec 332 was reconciled from the narrower `restore-run-preview-productization` path into the intended `product-process-flow-system-v1` scope. The previous path underrepresented the actual product/process-flow goal and caused restore safety state to drift too late into Preview.
## Summary
Implement a reusable Product Process Flow pattern and apply it first to Restore Run Create:
- Step 1 shows the full restore safety/product process flow, decision card, backup quality summary, restore proof aside, and collapsed diagnostics.
- Step 2 and later keep step-specific content primary while exposing compact restore safety status.
- Step 2 keeps dependency mapping in an explicit resolver mode with progress copy, shared guidance, and blocked progression until required mappings are resolved.
- The Step 2 dependency-mapping picker uses task-specific cache-empty guidance instead of a generic directory-cache table empty state.
- Preview stays decision-first.
- Confirm stays high-friction before execution.
## Affected Surfaces / Files
- Wizard logic:
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
- Restore safety copy:
- `apps/platform/app/Support/RestoreSafety/RestoreSafetyCopy.php`
- Product Process Flow views:
- `apps/platform/resources/views/filament/forms/components/restore-run-checks.blade.php`
- `apps/platform/resources/views/filament/forms/components/restore-run-preview.blade.php`
- shared/new restore-run process-flow partials as needed
- Group picker modal + component:
- `apps/platform/resources/views/filament/modals/entra-group-cache-picker.blade.php`
- `apps/platform/resources/views/livewire/entra-group-cache-picker-table.blade.php`
- `apps/platform/app/Livewire/EntraGroupCachePickerTable.php`
- Tests:
- `apps/platform/tests/Feature/Filament/RestoreRunPreviewProductizationTest.php`
- restore-run create wizard feature tests covering step 1 through confirm
- `apps/platform/tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php`
## Technical Approach
1. **Spec reconciliation**
- Keep only `specs/332-product-process-flow-system-v1`.
- Update spec/plan/tasks so Product Process Flow is the primary scope and Restore Preview productization is one slice of that scope.
2. **Shared Product Process Flow state**
- Reuse existing `wizardSafetyState`, restore safety assessment, preview integrity, execution readiness, and backup quality summary helpers.
- Add or extract presentation helpers/partials so Step 1 full flow and Step 2+ compact status read from the same underlying state.
3. **Step 1 contract**
- Restore Safety decision card above the selector.
- Backup set selector and backup quality summary.
- Full vertical `Restore safety gates`.
- Restore Proof aside with diagnostics collapsed.
4. **Later-step contract**
- Keep step-specific content primary.
- Render compact Restore Safety Status plus proof context where useful.
- Productize Step 2 resolver mode around collapsed-by-default mapping details, progress copy, blocked progression, and cache-empty follow-up.
- Preserve gate enforcement with `afterValidation()` and `Halt`.
5. **Preview and confirm**
- Preview remains decision-first and truthfully reflects gate state.
- Confirm preserves high-friction confirmation without implying completed recovery proof.
6. **Tests**
- Feature tests for full Step 1, compact Step 2+, blocked validation, truthful preview copy, proof persistence, collapsed diagnostics, and no false recovery-proof claims.
- Browser smoke through Steps 1-4.
## Validation Commands
Narrow first:
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament --filter=Spec332 --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/RestoreRun* --compact`
Browser smoke:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec332RestoreRunWizardProductProcessFlowSmokeTest.php --compact`
Formatting:
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
- `git diff --check`
## Dependencies
- Existing restore safety / preview / backup quality resolvers remain the source of truth.
- Spec 334 (nested Filament/Livewire context hardening) must remain present so browser smoke can run without tenantless Livewire context failures.

View File

@ -0,0 +1,193 @@
# Feature Specification: Spec 332 - Product Process Flow System v1
- Feature Branch: `332-product-process-flow-system-v1`
- Created: 2026-05-24
- Updated: 2026-05-25
- Status: Draft
- Input: restore-run create wizard drift review + repo implementation + tests
## Reconciliation Note
Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path into the intended `specs/332-product-process-flow-system-v1` scope. The previous path underrepresented the actual product/process-flow goal and caused restore safety state to drift too late into Preview.
Restore Preview productization remains part of Spec 332, but it is only one consumer slice. The primary deliverable is a reusable Product Process Flow pattern with Restore Run Create as the first consumer.
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Restore safety, proof, and gate progression became concentrated too late in the wizard. Step 1 drifted toward a plain backup selector, while meaningful restore readiness moved mostly into Preview.
- **Today's failure**: Operators cannot judge restore viability from the start of the flow. This weakens safe decision-making, obscures the next blocked gate, and hides proof/evidence structure until late in a high-risk workflow.
- **User-visible improvement**: Restore Run shows a reusable Product Process Flow from the first step: a decision card, backup quality summary, full vertical restore safety gates, restore proof aside, and collapsed diagnostics. Later steps show compact safety status, while Preview remains decision-first.
- **Smallest enterprise-capable version**: Introduce a reusable Product Process Flow pattern and wire it into the existing Restore Run create wizard. Reuse existing restore safety, preview, and backup quality resolvers. No new persisted entities.
- **Explicit non-goals**: No new restore execution engine, no new queue orchestration, no new Graph contract layer, no new risk taxonomy, and no new route family.
- **Permanent complexity imported**: Shared wizard presentation pattern, bounded Blade/UI state, step gating, and focused feature/browser regression coverage.
- **Why now**: Restore is high-risk and operator-critical. Safety/proof state must be truthful from step 1, not deferred until preview.
- **Why not local**: The flow pattern is reusable across risky multi-step workflows. Leaving it embedded only in Preview encourages repeated drift and inconsistent operator guidance.
- **Approval class**: Core Enterprise
- **Red flags triggered**: UI surface behavior change (wizard), high-risk operator workflow, evidence/proof messaging. Defense: bounded reuse, explicit copy rules, feature tests, browser smoke.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/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.
- Safety, proof, preview, diagnostics, and backup quality state remain derived from existing restore/backup resolvers.
- **RBAC**:
- Tenant membership required.
- Existing restore capabilities remain the authority; this spec does not change authorization 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, with Product Process Flow pattern applied across Step 1 through Confirm.
- **Design depth**: Manual Review Required (operator-critical, risky workflow).
- **Repo-truth level**: repo-verified (feature + browser tests).
- **New pattern required**: yes; reusable Product Process Flow pattern using existing restore safety / proof / backup quality state.
- **Screenshot required**: no (covered by dedicated browser smoke assertions).
- **Dangerous-action review required**: yes; execute restore remains high-friction and must not imply recovery proof before execution/post-run evidence exists.
- **Coverage files updated or explicitly not needed**: `N/A - scope is covered via focused feature tests + browser smoke`.
## Goals
1. Introduce a reusable Product Process Flow pattern for risky multi-step product workflows.
2. Apply that pattern to Restore Run Create without changing the underlying restore domain model.
3. Show restore safety from the start of the wizard, not only in Preview.
4. Keep Preview decision-first, but no longer as the only place where safety/proof state appears.
5. Keep Confirm high-friction before execution.
6. Prevent false recovery-proof claims before execution/post-run evidence exists.
## Product Contract
### Step 1: Select Backup Set
Render, in this order:
1. Restore Safety decision card
- Status
- Reason
- Impact
- Primary next action
2. Backup set selector
3. Backup quality summary
- item count
- degraded items
- metadata-only items
- assignment issues
- orphaned assignments
- clear copy that input quality does not prove that execution is safe or that recovery is verified
4. Full vertical Product Process Flow
- Title: `Restore safety gates`
- Steps:
- Usable source selected
- Target selected
- Scope/dependency mapping
- Validation
- Preview
- Confirmation
- Execution and evidence when represented separately
5. Restore Proof aside
- Source backup
- Target environment
- Requested by
- Restore scope
- Operation proof
- Post-run evidence
- Diagnostics
6. Diagnostics collapsed by default
### Step 2 and later
- Show step-specific content first.
- Show compact Restore Safety Status instead of the full seven-gate flow by default.
- Keep `Restore Proof` available where useful.
- Keep dependency mapping details hidden by default until the resolver is opened explicitly.
- When dependency mapping is expanded, present it as `Resolve target mappings` with resolver progress, one shared explanation, and no repeated per-row helper copy.
- Block Next while required mappings remain unresolved.
- When dependency mapping opens the target group picker without cached directory groups, show a task-specific empty state:
- modal heading: `Resolve target group mapping`
- source group context remains visible
- empty state copy explains cache availability, not tenant group absence
- primary CTA uses `Open group sync` unless direct in-modal sync is explicitly supported
- search/filter controls do not dominate the modal when the cache is empty
- Show:
- completed gates count (for example `2/7 gates complete`)
- next gate
- execution available/unavailable state
- `View safety gates`
### Step 3: Validate
- Validation blockers remain explicit.
- Next is blocked when validation is blocked.
### Step 4: Preview
- Preview remains decision-first.
- Compact safety status is shown by default.
- Preview copy must reflect actual gate state and avoid false blocker language once Preview is complete.
### Step 5: Confirm
- Confirmation remains high-friction before execution.
- No false recovery-proof claim is shown before execution/post-run evidence exists.
## Non-Goals
- No changes to restore execution behavior, queue orchestration, or Graph contract paths.
- No new persisted state families, tables, or enum taxonomies.
- No separate “trust framework” outside the Product Process Flow pattern and Restore Run consumer.
## Implementation Notes
- Reuse existing restore safety, preview integrity, execution readiness, and backup quality resolvers.
- Prefer shared Product Process Flow presentation primitives over one-off Preview-only copy.
- Keep diagnostics collapsed by default and avoid raw payload presentation in the default wizard path.
- Gating continues to use Filament wizard step lifecycle hooks and `Halt` where appropriate.
## Testing / Lane / Runtime Impact
- **Test purpose / classification**: Feature + Browser smoke
- **Validation lanes**: confidence + browser
- **Updated tests**:
- Step 1 renders full Product Process Flow
- Step 1 renders Restore Safety decision card
- Step 1 renders Restore Proof aside
- Step 1 usable source gate reflects actual backup contents
- Step 2+ render compact safety status
- Step 2 resolver mode stays explicit and blocks progression while required mappings remain unresolved
- Step 2 empty group picker renders task-specific cache-empty guidance
- Preview remains decision-first
- Diagnostics remain collapsed
- No false recovery-proof claims are visible
## Acceptance Criteria
- Only one Spec 332 directory exists in the repo: `specs/332-product-process-flow-system-v1`.
- Step 1 is not a bare backup selector page.
- Step 1 renders the full Product Process Flow, Restore Safety decision card, backup quality summary, Restore Proof aside, and collapsed diagnostics.
- A backup with usable captured items marks the usable source gate complete.
- A backup with zero/no captured items does not mark the usable source gate complete.
- Step 2 and later render compact Restore Safety Status by default, not the full gate flow.
- Step 2 hides mapping details by default and only shows resolver details after explicit expansion.
- Step 2 blocks Next until required mappings are resolved.
- The Step 2 group mapping picker shows task-specific cache-empty guidance and does not imply the tenant has no groups.
- Preview remains decision-first and is not the only place where safety/proof state appears.
- Confirm remains high-friction before execution.
- No false recovery-proof claim appears before execution/post-run evidence exists.
- Feature tests and browser smoke pass.

View File

@ -0,0 +1,49 @@
# Tasks: Spec 332 - Product Process Flow System v1
**Input**: `specs/332-product-process-flow-system-v1/spec.md`, `specs/332-product-process-flow-system-v1/plan.md`
## Reconciliation
- [x] Keep only one Spec 332 directory in the repo: `specs/332-product-process-flow-system-v1`.
- [x] Document that Spec 332 was reconciled from the narrower `restore-run-preview-productization` path into the intended Product Process Flow scope.
## Phase 1: Product Process Flow pattern
- [x] Reuse existing restore safety, preview integrity, execution readiness, and backup quality state as the Product Process Flow source of truth.
- [x] Add the shared Step 1 full-flow presentation for Restore Run Create.
- [x] Add the shared compact later-step safety status presentation.
## Phase 2: Restore Run wizard contract
- [x] Step 1 renders Restore Safety decision card.
- [x] Step 1 renders backup selector plus backup quality summary.
- [x] Step 1 renders the full vertical `Restore safety gates` flow.
- [x] Step 1 renders Restore Proof aside with diagnostics collapsed.
- [x] Step 2+ render compact Restore Safety Status instead of the full gate flow by default.
- [x] Step 2 hides mapping details by default and only reveals an explicit `Resolve target mappings` resolver when opened.
- [x] Step 2 resolver shows progress copy and blocks Next until required mappings are resolved.
- [x] Step 2 group mapping picker uses task-specific cache-empty guidance and source-group context instead of generic directory-cache empty copy.
- [x] Step 3 blocks Next when validation blockers exist.
- [x] Step 4 remains decision-first and reflects actual gate state.
- [x] Step 5 remains high-friction before execution.
- [x] No false recovery-proof claims appear before execution/post-run evidence exists.
## Phase 3: Tests + formatting
- [x] Add/update feature tests for:
- only one Spec 332 directory exists
- Step 1 full Product Process Flow
- Step 1 usable source gate truthfulness
- Step 2 compact safety status
- Step 2 explicit resolver mode and blocked progression
- Step 2 empty group-picker productized empty state
- Step 3 blocked progression
- Step 4 truthful preview copy
- Restore Proof persistence
- diagnostics collapsed
- no false recovery-proof claims
- [x] Add/update browser smoke for Step 1, Step 2 default, Step 2 resolver, Step 2 empty picker, Step 3 blocked, and Step 4 preview.
- [x] Run targeted Spec 332 and Restore Run create wizard tests.
- [x] Run browser smoke.
- [x] Run `pint --dirty`.
- [x] Run `git diff --check`.

View File

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

View File

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

View File

@ -1,23 +0,0 @@
# 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`.