diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource.php b/apps/platform/app/Filament/Resources/RestoreRunResource.php index 3dcd78d1..319e9474 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource.php @@ -102,6 +102,8 @@ class RestoreRunResource extends Resource protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static bool $isGloballySearchable = false; + public static function shouldRegisterNavigation(): bool { return NavigationScope::shouldRegisterEnvironmentNavigation() diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php index d8bdcb50..2f968d27 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunCreatePresenter.php @@ -17,6 +17,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; +use App\Support\RestoreReadinessResolution\RestoreReadinessResolver; use App\Support\RestoreSafety\ChecksIntegrityState; use App\Support\RestoreSafety\PreviewIntegrityState; use App\Support\RestoreSafety\RestoreSafetyCopy; @@ -230,6 +231,15 @@ public static function contract( draft: $draft, tenant: $tenant, ); + $readinessGuidance = app(RestoreReadinessResolver::class) + ->forWizardData( + data: $draft, + hasUsableSource: $hasUsableSource, + scopeDefined: $scopeDefined, + scopeDependencyResolved: $scopeDependencyResolved, + executionReadiness: is_array($executionReadiness) ? $executionReadiness : [], + ) + ->toArray(); $decisionCard = self::restoreWizardDecisionCard( backupSet: $backupSet, @@ -409,6 +419,7 @@ public static function contract( 'mapping_summary' => $mappingResolver, 'validation_summary' => $validationSummary, 'preview_summary' => $previewSummary, + 'readiness_guidance' => $readinessGuidance, 'wizard_gate' => $wizardGate, 'can_continue' => $canContinue, 'blocked_reason' => $blockedReason, @@ -427,6 +438,7 @@ public static function contract( ], 'validationSummary' => $validationSummary, 'previewSummary' => $previewSummary, + 'readinessGuidance' => $readinessGuidance, 'wizardGate' => $wizardGate, 'mappingResolver' => $mappingResolver, 'currentScope' => $scope, diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php index 8c4d348f..43f21e37 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php @@ -16,6 +16,7 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\RestoreReadinessResolution\RestoreReadinessResolver; use App\Support\RestoreRunStatus; use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\RestoreSafety\RestoreSafetyCopy; @@ -40,6 +41,13 @@ public function forRun(RestoreRun $restoreRun): array $operationRun = $this->scopedOperationRun($restoreRun); $operationProof = $this->operationProof($operationRun); $postRunEvidence = $this->postRunEvidence($restoreRun, $operationRun); + $readinessGuidance = app(RestoreReadinessResolver::class) + ->forRestoreRun( + restoreRun: $restoreRun, + operationUrl: is_string($operationProof['url'] ?? null) ? $operationProof['url'] : null, + evidenceUrl: is_string($postRunEvidence['url'] ?? null) ? $postRunEvidence['url'] : null, + ) + ->toArray(); $decision = $this->decision($restoreRun, $attention, $operationProof, $postRunEvidence); $resultSummary = $this->resultSummary($restoreRun); $itemOutcomes = $this->itemOutcomes($restoreRun); @@ -48,6 +56,7 @@ public function forRun(RestoreRun $restoreRun): array return [ 'decision' => $decision, + 'readinessGuidance' => $readinessGuidance, 'operationProof' => $operationProof, 'postRunEvidence' => $postRunEvidence, 'resultSummary' => $resultSummary, diff --git a/apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php b/apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php index eada5da7..6fe9ea47 100644 --- a/apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php +++ b/apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php @@ -828,7 +828,8 @@ private function supportingSignals( $overview = is_array($requiredPermissions['overview'] ?? null) ? $requiredPermissions['overview'] : []; - $providerPermissionsReady = $this->providerPermissionsTone($overview) === 'success'; + $providerPermissionsValue = $this->providerPermissionsValue($overview); + $providerPermissionsTone = $this->providerPermissionsTone($overview); $operationCount = (int) ($activeOperationSummary['count'] ?? 0); $operationsAction = $operationCount > 0 && is_string($activeOperationSummary['secondaryActionUrl'] ?? null) ? [ @@ -878,8 +879,8 @@ private function supportingSignals( $this->supportingSignal( key: 'provider_permissions', label: 'Provider permissions', - value: $providerPermissionsReady ? 'Ready' : 'Missing', - tone: $providerPermissionsReady ? 'success' : 'danger', + value: $providerPermissionsValue, + tone: $providerPermissionsTone, action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')), ), $this->supportingSignal( diff --git a/apps/platform/app/Support/RestoreReadinessResolution/RestoreGuidanceBasis.php b/apps/platform/app/Support/RestoreReadinessResolution/RestoreGuidanceBasis.php new file mode 100644 index 00000000..d8cc2da0 --- /dev/null +++ b/apps/platform/app/Support/RestoreReadinessResolution/RestoreGuidanceBasis.php @@ -0,0 +1,40 @@ + $scope + */ + public function __construct( + public string $fingerprint, + public array $scope, + ) {} + + /** + * @param array $data + */ + public static function fromData(RestoreSafetyResolver $resolver, array $data): self + { + $scope = $resolver->scopeFingerprintFromData($data)->toArray(); + $fingerprint = is_string($scope['fingerprint'] ?? null) ? $scope['fingerprint'] : ''; + + return new self( + fingerprint: $fingerprint, + scope: $scope, + ); + } + + /** + * @param array $data + */ + public function matchesData(RestoreSafetyResolver $resolver, array $data): bool + { + return hash_equals($this->fingerprint, self::fromData($resolver, $data)->fingerprint); + } +} diff --git a/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessAction.php b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessAction.php new file mode 100644 index 00000000..8be7116a --- /dev/null +++ b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessAction.php @@ -0,0 +1,64 @@ + 'Review backup selection', + self::DefineScope => 'Define restore scope', + self::ReviewGroupMappings => 'Review group mappings', + self::RunReadinessChecks => 'Run readiness checks', + self::ReviewValidationBlockers => 'Review validation blockers', + self::GeneratePreview => 'Generate preview', + self::RegeneratePreview => 'Regenerate preview', + self::ContinueToConfirmation => 'Continue to final confirmation', + self::OpenOperation => 'Open operation', + self::InspectRestoreResults => 'Inspect restore results', + self::InspectEvidence => 'Open evidence', + self::None => 'No safe action available', + }; + } + + public function mutatesPreparation(): bool + { + return in_array($this, [ + self::RunReadinessChecks, + self::GeneratePreview, + self::RegeneratePreview, + ], true); + } + + public function safetyCopy(): string + { + if ($this->mutatesPreparation()) { + return 'This will not execute the restore.'; + } + + return match ($this) { + self::ContinueToConfirmation => 'The restore still requires final confirmation before execution.', + self::OpenOperation => 'This guidance only opens existing execution evidence.', + self::InspectEvidence, + self::InspectRestoreResults => 'This guidance is inspection-only for an existing restore record.', + self::None => 'No preparation mutation is available from this state.', + default => 'This guidance does not execute the restore.', + }; + } +} diff --git a/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessReason.php b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessReason.php new file mode 100644 index 00000000..3112b100 --- /dev/null +++ b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessReason.php @@ -0,0 +1,47 @@ + 'Select a backup set before restore readiness can be judged.', + self::SourceUnusable => 'The selected backup does not contain usable restore payloads.', + self::ScopeRequired => 'Define the restore scope before readiness checks can prove the draft.', + self::GroupMappingRequired => 'Required group mappings must be resolved before validation can run.', + self::ChecksNotRun => 'Readiness checks have not run for the current restore scope.', + self::ChecksStale => 'The last readiness checks no longer match the current restore scope.', + self::ChecksBlocking => 'Readiness checks found blockers that must be resolved before execution.', + self::PreviewMissing => 'A restore preview has not been generated for the current scope.', + self::PreviewStale => 'The last restore preview no longer matches the current restore scope.', + self::ExecutionPrerequisiteBlocked => 'Execution prerequisites are blocked even though preparation evidence is available.', + self::ReadyForConfirmation => 'Current checks and preview evidence match the selected restore scope.', + self::ExecutionInProgress => 'Execution truth is owned by the linked OperationRun.', + self::ExecutionCompleted => 'This record is terminal; inspect result and evidence rather than preparation actions.', + self::ExecutionFailed => 'This restore did not complete successfully; inspect failure details and evidence.', + self::ExecutionCancelled => 'This restore did not execute to completion because it was cancelled or blocked.', + self::HistoricalNonActionable => 'This record is historical and must not expose preparation mutations.', + }; + } +} diff --git a/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessResolver.php b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessResolver.php new file mode 100644 index 00000000..a338b717 --- /dev/null +++ b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessResolver.php @@ -0,0 +1,247 @@ + $data + * @param array $executionReadiness + */ + public function forWizardData( + array $data, + bool $hasUsableSource, + bool $scopeDefined, + bool $scopeDependencyResolved, + array $executionReadiness = [], + ): RestoreReadinessSummary { + $basis = $this->basisForData($data); + $backupSetId = $data['backup_set_id'] ?? null; + + if (! is_numeric($backupSetId)) { + return $this->summary( + RestoreReadinessState::Blocked, + RestoreReadinessReason::SourceRequired, + RestoreReadinessAction::ReviewBackupSelection, + $basis, + ); + } + + if (! $hasUsableSource) { + return $this->summary( + RestoreReadinessState::Blocked, + RestoreReadinessReason::SourceUnusable, + RestoreReadinessAction::ReviewBackupSelection, + $basis, + ); + } + + if (! $scopeDefined) { + return $this->summary( + RestoreReadinessState::NeedsPreparation, + RestoreReadinessReason::ScopeRequired, + RestoreReadinessAction::DefineScope, + $basis, + ); + } + + if (! $scopeDependencyResolved) { + return $this->summary( + RestoreReadinessState::NeedsPreparation, + RestoreReadinessReason::GroupMappingRequired, + RestoreReadinessAction::ReviewGroupMappings, + $basis, + ); + } + + $checks = $this->restoreSafetyResolver->checksIntegrityFromData($data); + $preview = $this->restoreSafetyResolver->previewIntegrityFromData($data); + + if (! $checks->isCurrent()) { + return $this->summary( + RestoreReadinessState::Blocked, + $checks->state === ChecksIntegrityState::STATE_NOT_RUN + ? RestoreReadinessReason::ChecksNotRun + : RestoreReadinessReason::ChecksStale, + RestoreReadinessAction::RunReadinessChecks, + $basis, + ); + } + + if ($checks->blockingCount > 0) { + return $this->summary( + RestoreReadinessState::Blocked, + RestoreReadinessReason::ChecksBlocking, + RestoreReadinessAction::ReviewValidationBlockers, + $basis, + ); + } + + if (! $preview->isCurrent()) { + return $this->summary( + RestoreReadinessState::NeedsPreparation, + $preview->state === PreviewIntegrityState::STATE_NOT_GENERATED + ? RestoreReadinessReason::PreviewMissing + : RestoreReadinessReason::PreviewStale, + $preview->state === PreviewIntegrityState::STATE_NOT_GENERATED + ? RestoreReadinessAction::GeneratePreview + : RestoreReadinessAction::RegeneratePreview, + $basis, + ); + } + + if (array_key_exists('allowed', $executionReadiness) && ! (bool) $executionReadiness['allowed']) { + return $this->summary( + RestoreReadinessState::Blocked, + RestoreReadinessReason::ExecutionPrerequisiteBlocked, + RestoreReadinessAction::ReviewValidationBlockers, + $basis, + ); + } + + return $this->summary( + RestoreReadinessState::ReadyForConfirmation, + RestoreReadinessReason::ReadyForConfirmation, + RestoreReadinessAction::ContinueToConfirmation, + $basis, + ); + } + + public function forRestoreRun( + RestoreRun $restoreRun, + ?string $operationUrl = null, + ?string $evidenceUrl = null, + ): RestoreReadinessSummary { + $restoreRun->loadMissing(['operationRun']); + + $data = $this->dataFromRestoreRun($restoreRun); + $basis = $this->basisForData($data); + $status = RestoreRunStatus::fromString((string) $restoreRun->status); + + if ($status === RestoreRunStatus::Pending || $status === RestoreRunStatus::Queued || $status === RestoreRunStatus::Running) { + return $this->summary( + RestoreReadinessState::Executing, + RestoreReadinessReason::ExecutionInProgress, + RestoreReadinessAction::OpenOperation, + $basis, + $operationUrl, + 'OperationRun evidence', + ); + } + + if ($status === RestoreRunStatus::Failed) { + return $this->summary( + RestoreReadinessState::Failed, + RestoreReadinessReason::ExecutionFailed, + RestoreReadinessAction::InspectRestoreResults, + $basis, + $operationUrl, + 'Failure evidence', + ); + } + + if ($status === RestoreRunStatus::Cancelled || $status === RestoreRunStatus::Aborted) { + return $this->summary( + RestoreReadinessState::Cancelled, + RestoreReadinessReason::ExecutionCancelled, + RestoreReadinessAction::InspectRestoreResults, + $basis, + $operationUrl, + 'Cancellation evidence', + ); + } + + if (in_array($status, [ + RestoreRunStatus::Completed, + RestoreRunStatus::Partial, + RestoreRunStatus::CompletedWithErrors, + ], true)) { + return $this->summary( + RestoreReadinessState::Completed, + RestoreReadinessReason::ExecutionCompleted, + $evidenceUrl === null ? RestoreReadinessAction::InspectRestoreResults : RestoreReadinessAction::InspectEvidence, + $basis, + $evidenceUrl ?? $operationUrl, + $evidenceUrl === null ? 'Restore result evidence' : 'Post-run evidence', + ); + } + + return $this->forWizardData( + data: $data, + hasUsableSource: is_numeric($restoreRun->backup_set_id), + scopeDefined: true, + scopeDependencyResolved: true, + ); + } + + /** + * @param array $currentData + */ + public function actionBasisMatches(?string $expectedFingerprint, array $currentData): bool + { + if (! is_string($expectedFingerprint) || $expectedFingerprint === '') { + return false; + } + + return RestoreGuidanceBasis::fromData($this->restoreSafetyResolver, $currentData)->fingerprint === $expectedFingerprint; + } + + /** + * @param array $data + */ + public function basisForData(array $data): RestoreGuidanceBasis + { + return RestoreGuidanceBasis::fromData($this->restoreSafetyResolver, $data); + } + + private function summary( + RestoreReadinessState $state, + RestoreReadinessReason $reason, + RestoreReadinessAction $nextAction, + RestoreGuidanceBasis $basis, + ?string $evidenceUrl = null, + ?string $evidenceLabel = null, + ): RestoreReadinessSummary { + return new RestoreReadinessSummary( + state: $state, + reason: $reason, + nextAction: $nextAction, + basis: $basis, + evidenceUrl: $evidenceUrl, + evidenceLabel: $evidenceLabel, + ); + } + + /** + * @return array + */ + private function dataFromRestoreRun(RestoreRun $restoreRun): array + { + $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; + + return [ + 'backup_set_id' => $restoreRun->backup_set_id, + 'scope_mode' => (string) (($restoreRun->scopeBasis()['scope_mode'] ?? null) ?: ((is_array($restoreRun->requested_items) && $restoreRun->requested_items !== []) ? 'selected' : 'all')), + 'backup_item_ids' => is_array($restoreRun->requested_items) ? $restoreRun->requested_items : [], + 'group_mapping' => is_array($restoreRun->group_mapping) ? $restoreRun->group_mapping : [], + 'check_basis' => $restoreRun->checkBasis(), + 'preview_basis' => $restoreRun->previewBasis(), + 'check_summary' => is_array($metadata['check_summary'] ?? null) ? $metadata['check_summary'] : [], + 'checks_ran_at' => $restoreRun->checkBasis()['ran_at'] ?? ($metadata['checks_ran_at'] ?? null), + 'preview_summary' => is_array($metadata['preview_summary'] ?? null) ? $metadata['preview_summary'] : [], + 'preview_ran_at' => $restoreRun->previewBasis()['generated_at'] ?? ($metadata['preview_ran_at'] ?? null), + ]; + } +} diff --git a/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessState.php b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessState.php new file mode 100644 index 00000000..a37e4879 --- /dev/null +++ b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessState.php @@ -0,0 +1,45 @@ + "Restore can't continue yet.", + self::NeedsPreparation => 'Restore needs preparation.', + self::ReadyForConfirmation => 'Ready for final confirmation.', + self::Executing => 'Restore execution is in progress.', + self::Completed => 'Restore completed.', + self::Failed => 'Restore failed.', + self::Cancelled => 'Restore cancelled.', + self::Historical => 'Historical restore record.', + }; + } + + public function tone(): string + { + return match ($this) { + self::ReadyForConfirmation, + self::Completed => 'success', + self::NeedsPreparation, + self::Executing, + self::Historical => 'warning', + self::Blocked, + self::Failed, + self::Cancelled => 'danger', + }; + } +} diff --git a/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessSummary.php b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessSummary.php new file mode 100644 index 00000000..8f321532 --- /dev/null +++ b/apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessSummary.php @@ -0,0 +1,39 @@ + + */ + public function toArray(): array + { + return [ + 'state' => $this->state->value, + 'stateLabel' => $this->state->label(), + 'tone' => $this->state->tone(), + 'reason' => $this->reason->value, + 'reasonSummary' => $this->reason->summary(), + 'nextAction' => $this->nextAction->value, + 'nextActionLabel' => $this->nextAction->label(), + 'actionSafetyCopy' => $this->nextAction->safetyCopy(), + 'mutatesPreparation' => $this->nextAction->mutatesPreparation(), + 'basisFingerprint' => $this->basis->fingerprint, + 'basisScope' => $this->basis->scope, + 'evidenceUrl' => $this->evidenceUrl, + 'evidenceLabel' => $this->evidenceLabel, + ]; + } +} diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php index 3b77ecb6..e90275be 100644 --- a/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php +++ b/apps/platform/resources/views/filament/forms/components/restore-run-confirm-panel.blade.php @@ -3,6 +3,7 @@ $decisionCard = is_array($decisionCard ?? null) ? $decisionCard : []; $processFlow = is_array($processFlow ?? null) ? $processFlow : []; + $readinessGuidance = is_array($readinessGuidance ?? null) ? $readinessGuidance : []; $wizardGate = is_array($wizardGate ?? null) ? $wizardGate : []; $statusTone = (string) ($wizardGate['confirmation_state_tone'] ?? ($decisionCard['tone'] ?? 'gray')); @@ -80,6 +81,26 @@ +
+
+
+ Restore readiness +
+ + {{ $readinessGuidance['stateLabel'] ?? 'Restore readiness unavailable.' }} + +
+

+ {{ $readinessGuidance['reasonSummary'] ?? 'This guidance is based on the current restore scope and preview state.' }} +

+

+ Next safe action: {{ $readinessGuidance['nextActionLabel'] ?? ($decisionCard['nextAction'] ?? 'Review the current restore state.') }} +

+

+ {{ $readinessGuidance['actionSafetyCopy'] ?? 'This guidance does not execute the restore.' }} +

+
+
Operation proof is unavailable before execution. Post-run evidence is unavailable before execution. Recovery is not verified until post-run evidence exists.
diff --git a/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php b/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php index 6d2f9dd7..492f5650 100644 --- a/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php +++ b/apps/platform/resources/views/filament/forms/components/restore-run-safety-decision.blade.php @@ -1,6 +1,7 @@ @php $fieldWrapperView = $getFieldWrapperView(); $decisionCard = is_array($decisionCard ?? null) ? $decisionCard : []; + $readinessGuidance = is_array($readinessGuidance ?? null) ? $readinessGuidance : []; $statusBadgeClasses = static function (string $tone): string { return match ($tone) { @@ -134,10 +135,24 @@