feat: productize restore run detail proof surface
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m13s
@ -9,6 +9,7 @@
|
||||
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter;
|
||||
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunDetailPresenter;
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunRestoreJob;
|
||||
@ -300,7 +301,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$key,
|
||||
parent::getEloquentQuery()->withTrashed()->with('backupSet'),
|
||||
parent::getEloquentQuery()->withTrashed()->with(['backupSet', 'operationRun', 'tenant']),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1671,38 +1672,43 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
|
||||
Infolists\Components\TextEntry::make('counts')
|
||||
->label('Counts')
|
||||
->state(function (RestoreRun $record): string {
|
||||
$meta = $record->metadata ?? [];
|
||||
$total = (int) ($meta['total'] ?? 0);
|
||||
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
||||
$failed = (int) ($meta['failed'] ?? 0);
|
||||
|
||||
return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
|
||||
}),
|
||||
Infolists\Components\TextEntry::make('is_dry_run')
|
||||
->label('Dry-run')
|
||||
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
|
||||
->badge(),
|
||||
Infolists\Components\TextEntry::make('requested_by'),
|
||||
Infolists\Components\TextEntry::make('started_at')->dateTime(),
|
||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
||||
Infolists\Components\ViewEntry::make('preview')
|
||||
->label('Preview')
|
||||
->view('filament.infolists.entries.restore-preview')
|
||||
->state(fn (RestoreRun $record): array => static::detailPreviewState($record)),
|
||||
Infolists\Components\ViewEntry::make('results')
|
||||
->label('Results')
|
||||
->label('Post-execution proof')
|
||||
->view('filament.infolists.entries.restore-results')
|
||||
->state(fn (RestoreRun $record): array => static::detailResultsState($record)),
|
||||
->state(fn (RestoreRun $record): array => static::detailResultsState($record))
|
||||
->columnSpanFull(),
|
||||
Section::make('Technical preview evidence')
|
||||
->description('Secondary pre-execution context. The post-execution proof decision above is the operator-facing source of truth.')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('preview')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.restore-preview')
|
||||
->state(fn (RestoreRun $record): array => static::detailPreviewState($record)),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columnSpanFull(),
|
||||
Section::make('Technical record metadata')
|
||||
->description('Raw lifecycle fields for audit and support. Use the proof decision above for restore outcome status.')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->label('Record lifecycle')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state): string => Str::headline((string) $state))
|
||||
->color('gray'),
|
||||
Infolists\Components\TextEntry::make('is_dry_run')
|
||||
->label('Preview-only flag')
|
||||
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
|
||||
->badge(),
|
||||
Infolists\Components\TextEntry::make('requested_by')->placeholder('Not recorded'),
|
||||
Infolists\Components\TextEntry::make('started_at')->dateTime()->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -2680,11 +2686,7 @@ private static function detailPreviewState(RestoreRun $record): array
|
||||
*/
|
||||
private static function detailResultsState(RestoreRun $record): array
|
||||
{
|
||||
return [
|
||||
'results' => is_array($record->results) ? $record->results : [],
|
||||
'resultAttention' => static::restoreSafetyResolver()->resultAttentionForRun($record)->toArray(),
|
||||
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
|
||||
];
|
||||
return app(RestoreRunDetailPresenter::class)->forRun($record);
|
||||
}
|
||||
|
||||
private static function restoreSafetyResolver(): RestoreSafetyResolver
|
||||
|
||||
@ -0,0 +1,628 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\RestoreRunResource\Presenters;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final readonly class RestoreRunDetailPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forRun(RestoreRun $restoreRun): array
|
||||
{
|
||||
$restoreRun->loadMissing(['backupSet', 'operationRun', 'tenant']);
|
||||
|
||||
$attention = $this->restoreSafetyResolver->resultAttentionForRun($restoreRun);
|
||||
$operationRun = $this->scopedOperationRun($restoreRun);
|
||||
$operationProof = $this->operationProof($operationRun);
|
||||
$postRunEvidence = $this->postRunEvidence($restoreRun, $operationRun);
|
||||
$decision = $this->decision($restoreRun, $attention, $operationProof, $postRunEvidence);
|
||||
$resultSummary = $this->resultSummary($restoreRun);
|
||||
$itemOutcomes = $this->itemOutcomes($restoreRun);
|
||||
$foundationOutcomes = $this->foundationOutcomes($restoreRun);
|
||||
$itemOutcomeEvidence = $this->itemOutcomeEvidence($itemOutcomes, $resultSummary);
|
||||
|
||||
return [
|
||||
'decision' => $decision,
|
||||
'operationProof' => $operationProof,
|
||||
'postRunEvidence' => $postRunEvidence,
|
||||
'resultSummary' => $resultSummary,
|
||||
'itemOutcomeEvidence' => $itemOutcomeEvidence,
|
||||
'itemOutcomes' => $itemOutcomes,
|
||||
'foundationOutcomes' => $foundationOutcomes,
|
||||
'diagnostics' => [
|
||||
'state' => 'collapsed',
|
||||
'summary' => 'Diagnostics are secondary. Raw restore payloads, provider request IDs, and low-level execution details stay out of the first decision path.',
|
||||
'execution_basis' => $this->executionBasis($restoreRun),
|
||||
'items_requiring_attention' => count(array_filter(
|
||||
$itemOutcomes,
|
||||
static fn (array $item): bool => (bool) ($item['needs_attention'] ?? false),
|
||||
)),
|
||||
],
|
||||
'runContext' => [
|
||||
'backup_set' => $restoreRun->backupSet?->name ?? 'Backup set unavailable',
|
||||
'target_environment' => $restoreRun->tenant?->name ?? 'Environment unavailable',
|
||||
'requested_by' => filled($restoreRun->requested_by) ? (string) $restoreRun->requested_by : 'Not recorded',
|
||||
'started_at' => $restoreRun->started_at?->toDayDateTimeString(),
|
||||
'completed_at' => $restoreRun->completed_at?->toDayDateTimeString(),
|
||||
'dry_run' => (bool) $restoreRun->is_dry_run,
|
||||
],
|
||||
'results' => is_array($restoreRun->results) ? $restoreRun->results : [],
|
||||
'resultAttention' => $attention->toArray(),
|
||||
'executionSafetySnapshot' => $restoreRun->executionSafetySnapshot(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $operationProof
|
||||
* @param array<string, mixed> $postRunEvidence
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decision(
|
||||
RestoreRun $restoreRun,
|
||||
RestoreResultAttention $attention,
|
||||
array $operationProof,
|
||||
array $postRunEvidence,
|
||||
): array {
|
||||
$state = $this->decisionState($restoreRun, $attention, $operationProof, $postRunEvidence);
|
||||
|
||||
$copy = match ($state) {
|
||||
'not_executed' => [
|
||||
'status_label' => 'Not executed',
|
||||
'reason' => 'This record proves preview truth, not environment recovery.',
|
||||
'impact' => 'No execution proof or post-run evidence exists yet.',
|
||||
'primary_next_action' => 'Review preview',
|
||||
'primary_next_url' => null,
|
||||
'tone' => 'gray',
|
||||
'icon' => 'heroicon-m-eye',
|
||||
],
|
||||
'in_progress' => [
|
||||
'status_label' => 'Execution in progress',
|
||||
'reason' => 'Restore execution is currently running.',
|
||||
'impact' => 'Results and post-run evidence are not final yet.',
|
||||
'primary_next_action' => 'View operation progress',
|
||||
'primary_next_url' => $operationProof['url'] ?? null,
|
||||
'tone' => 'info',
|
||||
'icon' => 'heroicon-m-arrow-path',
|
||||
],
|
||||
'completed_with_evidence' => [
|
||||
'status_label' => 'Completed with evidence available',
|
||||
'reason' => 'Execution proof and post-run evidence are available.',
|
||||
'impact' => 'Review evidence before treating this restore as recovery proof.',
|
||||
'primary_next_action' => 'Open evidence',
|
||||
'primary_next_url' => $postRunEvidence['url'] ?? null,
|
||||
'tone' => 'success',
|
||||
'icon' => 'heroicon-m-shield-check',
|
||||
],
|
||||
'needs_review' => [
|
||||
'status_label' => 'Completed with items needing review',
|
||||
'reason' => $attention->summary,
|
||||
'impact' => 'Review item outcomes before relying on the result.',
|
||||
'primary_next_action' => 'Review item outcomes',
|
||||
'primary_next_url' => null,
|
||||
'tone' => 'warning',
|
||||
'icon' => 'heroicon-m-exclamation-triangle',
|
||||
],
|
||||
'failed' => [
|
||||
'status_label' => 'Restore failed',
|
||||
'reason' => 'The restore did not complete successfully.',
|
||||
'impact' => 'Some requested changes may not have been applied.',
|
||||
'primary_next_action' => 'Review failure details',
|
||||
'primary_next_url' => null,
|
||||
'tone' => 'danger',
|
||||
'icon' => 'heroicon-m-x-circle',
|
||||
],
|
||||
'blocked_or_cancelled' => [
|
||||
'status_label' => 'Restore blocked / cancelled',
|
||||
'reason' => 'Restore did not execute due to cancellation or blocker.',
|
||||
'impact' => 'No recovery proof exists.',
|
||||
'primary_next_action' => 'Review blocker',
|
||||
'primary_next_url' => $operationProof['url'] ?? null,
|
||||
'tone' => 'warning',
|
||||
'icon' => 'heroicon-m-no-symbol',
|
||||
],
|
||||
default => [
|
||||
'status_label' => 'Completed, recovery proof incomplete',
|
||||
'reason' => 'Execution completed, but post-run evidence is not available yet.',
|
||||
'impact' => 'Do not treat this restore as verified recovery until evidence has been reviewed.',
|
||||
'primary_next_action' => filled($operationProof['url'] ?? null) ? 'Open operation proof' : 'Review proof gap',
|
||||
'primary_next_url' => $operationProof['url'] ?? null,
|
||||
'tone' => 'warning',
|
||||
'icon' => 'heroicon-m-shield-exclamation',
|
||||
],
|
||||
};
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
...$copy,
|
||||
'question' => 'Was this restore executed safely, and is recovery proof available?',
|
||||
'attention_summary' => $attention->summary,
|
||||
'primary_cause_family' => RestoreSafetyCopy::primaryCauseFamily($attention->primaryCauseFamily),
|
||||
'result_next_action' => RestoreSafetyCopy::primaryNextAction($attention->primaryNextAction),
|
||||
'recovery_claim_boundary' => RestoreSafetyCopy::recoveryBoundary($attention->recoveryClaimBoundary),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $operationProof
|
||||
* @param array<string, mixed> $postRunEvidence
|
||||
*/
|
||||
private function decisionState(
|
||||
RestoreRun $restoreRun,
|
||||
RestoreResultAttention $attention,
|
||||
array $operationProof,
|
||||
array $postRunEvidence,
|
||||
): string {
|
||||
$status = RestoreRunStatus::fromString((string) $restoreRun->status);
|
||||
|
||||
if ($restoreRun->is_dry_run || in_array($status, [
|
||||
RestoreRunStatus::Draft,
|
||||
RestoreRunStatus::Scoped,
|
||||
RestoreRunStatus::Checked,
|
||||
RestoreRunStatus::Previewed,
|
||||
], true)) {
|
||||
return 'not_executed';
|
||||
}
|
||||
|
||||
if (in_array($status, [RestoreRunStatus::Queued, RestoreRunStatus::Running], true)
|
||||
|| ($operationProof['state'] ?? null) === 'in_progress') {
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
if ($status === RestoreRunStatus::Failed || ($operationProof['outcome'] ?? null) === OperationRunOutcome::Failed->value) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (in_array($status, [RestoreRunStatus::Cancelled, RestoreRunStatus::Aborted], true)
|
||||
|| ($operationProof['outcome'] ?? null) === OperationRunOutcome::Blocked->value) {
|
||||
return 'blocked_or_cancelled';
|
||||
}
|
||||
|
||||
if (in_array($status, [RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors], true)) {
|
||||
return 'needs_review';
|
||||
}
|
||||
|
||||
if (($postRunEvidence['state'] ?? null) === 'available') {
|
||||
return 'completed_with_evidence';
|
||||
}
|
||||
|
||||
return 'completed_proof_incomplete';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function operationProof(?OperationRun $operationRun): array
|
||||
{
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => 'Operation proof unavailable',
|
||||
'url' => null,
|
||||
'status' => null,
|
||||
'outcome' => null,
|
||||
'status_badge' => $this->statusBadge('gray', 'Unavailable', 'heroicon-m-minus-circle'),
|
||||
'outcome_badge' => null,
|
||||
'identifier' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$status = (string) $operationRun->status;
|
||||
$outcome = (string) $operationRun->outcome;
|
||||
$state = in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||
? 'in_progress'
|
||||
: 'available';
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
'label' => $state === 'in_progress' ? 'Operation proof in progress' : 'Operation proof available',
|
||||
'url' => OperationRunLinks::tenantlessView($operationRun),
|
||||
'status' => $status,
|
||||
'outcome' => $outcome,
|
||||
'status_badge' => $this->badge(BadgeDomain::OperationRunStatus, $status),
|
||||
'outcome_badge' => $this->badge(BadgeDomain::OperationRunOutcome, $outcome),
|
||||
'identifier' => OperationRunLinks::identifier($operationRun),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function postRunEvidence(RestoreRun $restoreRun, ?OperationRun $operationRun): array
|
||||
{
|
||||
if (! $operationRun instanceof OperationRun || ! $this->canViewEvidence($restoreRun)) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => 'Post-run evidence unavailable',
|
||||
'url' => null,
|
||||
'status' => null,
|
||||
'completeness' => null,
|
||||
'status_badge' => $this->statusBadge('gray', 'Unavailable', 'heroicon-m-minus-circle'),
|
||||
'completeness_badge' => null,
|
||||
'identifier' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$snapshots = EvidenceSnapshot::query()
|
||||
->where('operation_run_id', (int) $operationRun->getKey())
|
||||
->where('workspace_id', (int) $restoreRun->workspace_id)
|
||||
->where('managed_environment_id', (int) $restoreRun->managed_environment_id)
|
||||
->whereIn('status', [
|
||||
EvidenceSnapshotStatus::Active->value,
|
||||
EvidenceSnapshotStatus::Generating->value,
|
||||
EvidenceSnapshotStatus::Queued->value,
|
||||
])
|
||||
->latest('id')
|
||||
->get();
|
||||
|
||||
$snapshot = $snapshots->firstWhere('status', EvidenceSnapshotStatus::Active->value)
|
||||
?? $snapshots->first();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot || ! EvidenceSnapshotResource::canView($snapshot)) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => 'Post-run evidence unavailable',
|
||||
'url' => null,
|
||||
'status' => null,
|
||||
'completeness' => null,
|
||||
'status_badge' => $this->statusBadge('gray', 'Unavailable', 'heroicon-m-minus-circle'),
|
||||
'completeness_badge' => null,
|
||||
'identifier' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$state = $snapshot->status === EvidenceSnapshotStatus::Active->value ? 'available' : 'in_progress';
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
'label' => $state === 'available' ? 'Post-run evidence available' : 'Post-run evidence in progress',
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $restoreRun->tenant),
|
||||
'status' => (string) $snapshot->status,
|
||||
'completeness' => (string) $snapshot->completeness_state,
|
||||
'status_badge' => $this->badge(BadgeDomain::EvidenceSnapshotStatus, (string) $snapshot->status),
|
||||
'completeness_badge' => $this->badge(BadgeDomain::EvidenceCompleteness, (string) $snapshot->completeness_state),
|
||||
'identifier' => 'Evidence snapshot #'.$snapshot->getKey(),
|
||||
];
|
||||
}
|
||||
|
||||
private function scopedOperationRun(RestoreRun $restoreRun): ?OperationRun
|
||||
{
|
||||
$operationRun = $restoreRun->operationRun;
|
||||
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $operationRun->workspace_id !== (int) $restoreRun->workspace_id
|
||||
|| (int) $operationRun->managed_environment_id !== (int) $restoreRun->managed_environment_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! Gate::forUser($user)->allows('view', $operationRun)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $operationRun;
|
||||
}
|
||||
|
||||
private function canViewEvidence(RestoreRun $restoreRun): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = $restoreRun->tenant;
|
||||
|
||||
return $user instanceof User
|
||||
&& $tenant !== null
|
||||
&& $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resultSummary(RestoreRun $restoreRun): array
|
||||
{
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
$keys = ['total', 'succeeded', 'failed', 'skipped', 'partial', 'non_applied'];
|
||||
$available = collect($keys)->contains(
|
||||
static fn (string $key): bool => array_key_exists($key, $metadata) && is_numeric($metadata[$key]),
|
||||
);
|
||||
|
||||
if (! $available) {
|
||||
return [
|
||||
'available' => false,
|
||||
'message' => 'Result summary unavailable',
|
||||
'counts' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$counts = [
|
||||
'requested' => $this->numericCount($metadata, 'total'),
|
||||
'applied' => $this->numericCount($metadata, 'succeeded'),
|
||||
'failed' => $this->numericCount($metadata, 'failed'),
|
||||
'skipped' => $this->numericCount($metadata, 'skipped'),
|
||||
'partial' => $this->numericCount($metadata, 'partial'),
|
||||
'non_applied' => $this->numericCount($metadata, 'non_applied'),
|
||||
];
|
||||
|
||||
$reviewValues = array_filter([
|
||||
$counts['failed'],
|
||||
$counts['skipped'],
|
||||
$counts['partial'],
|
||||
$counts['non_applied'],
|
||||
], static fn (?int $value): bool => $value !== null);
|
||||
|
||||
$counts['needs_review'] = $reviewValues === [] ? null : array_sum($reviewValues);
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'message' => 'Repo-backed result counts from restore metadata.',
|
||||
'counts' => $counts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $itemOutcomes
|
||||
* @param array<string, mixed> $resultSummary
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function itemOutcomeEvidence(array $itemOutcomes, array $resultSummary): array
|
||||
{
|
||||
if ($itemOutcomes !== []) {
|
||||
return [
|
||||
'state' => 'available',
|
||||
'label' => count($itemOutcomes).' item '.(count($itemOutcomes) === 1 ? 'record' : 'records'),
|
||||
'tone' => 'gray',
|
||||
'message' => 'Per-item outcome rows are available for review.',
|
||||
];
|
||||
}
|
||||
|
||||
if (($resultSummary['available'] ?? false) === true) {
|
||||
return [
|
||||
'state' => 'summary_only',
|
||||
'label' => 'Metadata counts only',
|
||||
'tone' => 'warning',
|
||||
'message' => 'Aggregate restore counts are available, but item-level outcome rows are not stored for this run. Use the OperationRun proof and aggregate counts for follow-up.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => 'No item records',
|
||||
'tone' => 'gray',
|
||||
'message' => 'No aggregate counts or item-level outcome rows are available for this run.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function numericCount(array $metadata, string $key): ?int
|
||||
{
|
||||
return array_key_exists($key, $metadata) && is_numeric($metadata[$key])
|
||||
? (int) $metadata[$key]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function itemOutcomes(RestoreRun $restoreRun): array
|
||||
{
|
||||
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
|
||||
|
||||
return collect($items)
|
||||
->filter(static fn (mixed $item): bool => is_array($item))
|
||||
->map(function (array $item, int $index): array {
|
||||
$status = $this->stringValue($item['status'] ?? null, 'unknown');
|
||||
$assignmentSummary = is_array($item['assignment_summary'] ?? null) ? $item['assignment_summary'] : [];
|
||||
$settingsApply = is_array($item['settings_apply'] ?? null) ? $item['settings_apply'] : [];
|
||||
|
||||
return [
|
||||
'name' => $this->itemName($item, $index),
|
||||
'type' => $this->stringValue($item['policy_type'] ?? null, 'Policy'),
|
||||
'platform' => $this->stringValue($item['platform'] ?? null, '—'),
|
||||
'status' => $status,
|
||||
'status_badge' => $this->badge(BadgeDomain::RestoreResultStatus, $status),
|
||||
'restore_mode' => $this->stringValue($item['restore_mode'] ?? null, null),
|
||||
'reason' => $this->safeReason($item),
|
||||
'assignments' => $this->summaryLine($assignmentSummary, [
|
||||
'success' => 'applied',
|
||||
'failed' => 'failed',
|
||||
'skipped' => 'not applied',
|
||||
]),
|
||||
'settings' => $this->summaryLine($settingsApply, [
|
||||
'applied' => 'applied',
|
||||
'failed' => 'failed',
|
||||
'manual_required' => 'manual',
|
||||
]),
|
||||
'needs_attention' => in_array($status, ['failed', 'partial', 'manual_required', 'skipped'], true),
|
||||
'diagnostics' => $this->itemDiagnostics($item),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function foundationOutcomes(RestoreRun $restoreRun): array
|
||||
{
|
||||
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||
$foundations = is_array($results['foundations'] ?? null) ? array_values($results['foundations']) : [];
|
||||
|
||||
return collect($foundations)
|
||||
->filter(static fn (mixed $item): bool => is_array($item))
|
||||
->map(function (array $item, int $index): array {
|
||||
$decision = $this->stringValue($item['decision'] ?? null, 'unknown');
|
||||
$reason = $this->safeReason($item);
|
||||
|
||||
return [
|
||||
'name' => $this->stringValue($item['sourceName'] ?? $item['sourceId'] ?? null, 'Foundation '.($index + 1)),
|
||||
'type' => $this->stringValue($item['type'] ?? null, 'foundation'),
|
||||
'target' => $this->stringValue($item['targetName'] ?? null, null),
|
||||
'decision' => $decision,
|
||||
'decision_badge' => $this->badge(
|
||||
BadgeDomain::RestorePreviewDecision,
|
||||
$decision === 'dry_run' ? 'dry_run' : $decision,
|
||||
),
|
||||
'reason' => $this->safeFoundationReason($item),
|
||||
'needs_attention' => in_array($decision, ['failed', 'skipped'], true),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function itemDiagnostics(array $item): array
|
||||
{
|
||||
$diagnostics = [];
|
||||
|
||||
foreach (['graph_error_message', 'graph_error_code', 'graph_request_id', 'graph_client_request_id', 'graph_method', 'graph_path'] as $key) {
|
||||
if (filled($item[$key] ?? null)) {
|
||||
$diagnostics[] = Str::headline($key).' recorded';
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($diagnostics));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function itemName(array $item, int $index): string
|
||||
{
|
||||
foreach (['policy_display_name', 'display_name', 'policy_identifier', 'policy_id'] as $key) {
|
||||
if (filled($item[$key] ?? null)) {
|
||||
return (string) $item[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return 'Policy '.($index + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function safeReason(array $item): ?string
|
||||
{
|
||||
$reason = $this->stringValue($item['reason'] ?? null, null);
|
||||
|
||||
if ($reason === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($reason) {
|
||||
'preview_only' => 'Preview only. This item was not applied during execution.',
|
||||
default => Str::headline($reason),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function safeFoundationReason(array $item): ?string
|
||||
{
|
||||
$reason = $this->stringValue($item['reason'] ?? null, null);
|
||||
|
||||
if ($reason === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($reason) {
|
||||
'preview_only' => 'Preview only. This foundation type is not applied during execution.',
|
||||
default => Str::headline($reason),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @param array<string, string> $labels
|
||||
*/
|
||||
private function summaryLine(array $summary, array $labels): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($labels as $key => $label) {
|
||||
if (array_key_exists($key, $summary) && is_numeric($summary[$key])) {
|
||||
$parts[] = ((int) $summary[$key]).' '.$label;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : implode(' • ', $parts);
|
||||
}
|
||||
|
||||
private function executionBasis(RestoreRun $restoreRun): string
|
||||
{
|
||||
$snapshot = $restoreRun->executionSafetySnapshot();
|
||||
$safetyState = $snapshot['safety_state'] ?? null;
|
||||
|
||||
if (! is_string($safetyState) || $safetyState === '') {
|
||||
return 'No execution safety snapshot was recorded.';
|
||||
}
|
||||
|
||||
return 'Execution basis: '.RestoreSafetyCopy::safetyStateLabel($safetyState).'.';
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value, ?string $fallback): ?string
|
||||
{
|
||||
return is_string($value) && trim($value) !== '' ? trim($value) : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string,color:string,icon:?string,iconColor:?string}
|
||||
*/
|
||||
private function badge(BadgeDomain $domain, ?string $state): array
|
||||
{
|
||||
$spec = BadgeRenderer::spec($domain, $state);
|
||||
|
||||
return [
|
||||
'label' => $spec->label,
|
||||
'color' => $spec->color,
|
||||
'icon' => $spec->icon,
|
||||
'iconColor' => $spec->iconColor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string,color:string,icon:?string,iconColor:?string}
|
||||
*/
|
||||
private function statusBadge(string $color, string $label, ?string $icon = null): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'color' => $color,
|
||||
'icon' => $icon,
|
||||
'iconColor' => $color,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,452 +1,384 @@
|
||||
@php
|
||||
$state = $getState() ?? [];
|
||||
$state = is_array($state) ? $state : [];
|
||||
$resultAttention = is_array($state['resultAttention'] ?? null) ? $state['resultAttention'] : [];
|
||||
$executionSafetySnapshot = is_array($state['executionSafetySnapshot'] ?? null) ? $state['executionSafetySnapshot'] : [];
|
||||
$state = is_array($state['results'] ?? null) ? $state['results'] : $state;
|
||||
$isFoundationEntry = function ($item) {
|
||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||
};
|
||||
$surface = $getState() ?? [];
|
||||
$surface = is_array($surface) ? $surface : [];
|
||||
|
||||
if (is_array($state) && array_key_exists('items', $state)) {
|
||||
$foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry);
|
||||
$policyItems = collect($state['items'] ?? [])->values();
|
||||
$results = $state;
|
||||
} else {
|
||||
$results = $state;
|
||||
$foundationItems = collect($results)->filter($isFoundationEntry);
|
||||
$policyItems = collect($results)->reject($isFoundationEntry);
|
||||
}
|
||||
$decision = is_array($surface['decision'] ?? null) ? $surface['decision'] : [];
|
||||
$operationProof = is_array($surface['operationProof'] ?? null) ? $surface['operationProof'] : [];
|
||||
$postRunEvidence = is_array($surface['postRunEvidence'] ?? null) ? $surface['postRunEvidence'] : [];
|
||||
$resultSummary = is_array($surface['resultSummary'] ?? null) ? $surface['resultSummary'] : [];
|
||||
$summaryCounts = is_array($resultSummary['counts'] ?? null) ? $resultSummary['counts'] : [];
|
||||
$itemOutcomeEvidence = is_array($surface['itemOutcomeEvidence'] ?? null) ? $surface['itemOutcomeEvidence'] : [];
|
||||
$itemOutcomes = collect(is_array($surface['itemOutcomes'] ?? null) ? $surface['itemOutcomes'] : []);
|
||||
$foundationOutcomes = collect(is_array($surface['foundationOutcomes'] ?? null) ? $surface['foundationOutcomes'] : []);
|
||||
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
|
||||
$runContext = is_array($surface['runContext'] ?? null) ? $surface['runContext'] : [];
|
||||
$resultAttention = is_array($surface['resultAttention'] ?? null) ? $surface['resultAttention'] : [];
|
||||
|
||||
$tenant = rescue(fn () => \App\Models\ManagedEnvironment::current(), null);
|
||||
$groupLabelResolver = $tenant ? app(\App\Services\Directory\EntraGroupLabelResolver::class) : null;
|
||||
$attentionSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestoreResultStatus,
|
||||
$resultAttention['state'] ?? ($decision['state'] ?? 'not_executed')
|
||||
);
|
||||
|
||||
$formatGroupId = function ($groupId, $fallbackName = null) use ($tenant, $groupLabelResolver) {
|
||||
if (! is_string($groupId) || $groupId === '') {
|
||||
return null;
|
||||
}
|
||||
$summaryCards = [
|
||||
'requested' => 'Requested',
|
||||
'applied' => 'Applied',
|
||||
'failed' => 'Failed',
|
||||
'skipped' => 'Skipped',
|
||||
'needs_review' => 'Needs review',
|
||||
];
|
||||
|
||||
$cachedName = null;
|
||||
|
||||
if ($tenant && $groupLabelResolver) {
|
||||
$cached = $groupLabelResolver->lookupMany($tenant, [$groupId]);
|
||||
$cachedName = $cached[strtolower($groupId)] ?? null;
|
||||
}
|
||||
|
||||
$name = is_string($fallbackName) && $fallbackName !== '' ? $fallbackName : null;
|
||||
|
||||
return \App\Services\Directory\EntraGroupLabelResolver::formatLabel($cachedName ?? $name, $groupId);
|
||||
};
|
||||
$formatCount = static fn (mixed $value): string => is_numeric($value) ? number_format((int) $value) : '—';
|
||||
@endphp
|
||||
|
||||
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
|
||||
<p class="text-sm text-gray-600">No restore results have been recorded yet.</p>
|
||||
@else
|
||||
@php
|
||||
$needsAttention = (bool) ($resultAttention['follow_up_required'] ?? false)
|
||||
|| $policyItems->contains(function ($item) {
|
||||
$status = $item['status'] ?? null;
|
||||
<div data-testid="restore-run-detail-surface" class="space-y-6">
|
||||
<section
|
||||
data-testid="restore-run-decision-card"
|
||||
class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$decision['tone'] ?? 'gray'"
|
||||
:icon="$decision['icon'] ?? null"
|
||||
:icon-color="$decision['tone'] ?? 'gray'"
|
||||
size="sm"
|
||||
>
|
||||
{{ $decision['status_label'] ?? 'Restore result unavailable' }}
|
||||
</x-filament::badge>
|
||||
|
||||
return in_array($status, ['partial', 'manual_required'], true);
|
||||
});
|
||||
$attentionSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestoreResultStatus,
|
||||
$resultAttention['state'] ?? ($needsAttention ? 'completed_with_follow_up' : 'completed')
|
||||
);
|
||||
$executionBasisLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::safetyStateLabel(
|
||||
is_string($executionSafetySnapshot['safety_state'] ?? null) ? $executionSafetySnapshot['safety_state'] : null
|
||||
);
|
||||
$primaryNextAction = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(
|
||||
is_string($resultAttention['primary_next_action'] ?? null) ? $resultAttention['primary_next_action'] : 'review_result'
|
||||
);
|
||||
$primaryCauseFamily = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryCauseFamily(
|
||||
is_string($resultAttention['primary_cause_family'] ?? null) ? $resultAttention['primary_cause_family'] : 'none'
|
||||
);
|
||||
$recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary(
|
||||
is_string($resultAttention['recovery_claim_boundary'] ?? null)
|
||||
? $resultAttention['recovery_claim_boundary']
|
||||
: 'run_completed_not_recovery_proven'
|
||||
);
|
||||
@endphp
|
||||
<x-filament::badge :color="$attentionSpec->color" :icon="$attentionSpec->icon" :icon-color="$attentionSpec->iconColor" size="sm">
|
||||
{{ $attentionSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$attentionSpec->color" :icon="$attentionSpec->icon" size="sm">
|
||||
{{ $attentionSpec->label }}
|
||||
</x-filament::badge>
|
||||
@if (($executionSafetySnapshot['safety_state'] ?? null) !== null)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Execution basis: {{ $executionBasisLabel }}
|
||||
</x-filament::badge>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primary operator question</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-950 dark:text-white">
|
||||
{{ $decision['question'] ?? 'Was this restore executed safely, and is recovery proof available?' }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Reason</p>
|
||||
<p class="mt-1 text-sm text-slate-800 dark:text-slate-100">{{ $decision['reason'] ?? 'Restore result reason unavailable.' }}</p>
|
||||
@if (filled($decision['attention_summary'] ?? null))
|
||||
<p class="mt-2 text-xs text-slate-600 dark:text-slate-300">{{ $decision['attention_summary'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Impact</p>
|
||||
<p class="mt-1 text-sm text-slate-800 dark:text-slate-100">{{ $decision['impact'] ?? 'Review restore details before relying on this record.' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primary next action</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900 dark:text-white">{{ $decision['primary_next_action'] ?? 'Review restore details' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Dominant action</p>
|
||||
@if (filled($decision['primary_next_url'] ?? null))
|
||||
<x-filament::button tag="a" :href="$decision['primary_next_url']" size="sm" class="mt-2 w-full justify-center">
|
||||
{{ $decision['primary_next_action'] ?? 'Open detail' }}
|
||||
</x-filament::button>
|
||||
@else
|
||||
<div class="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-900 dark:border-white/10 dark:bg-gray-950 dark:text-white">
|
||||
{{ $decision['primary_next_action'] ?? 'Review restore details' }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this run proves</div>
|
||||
<div class="mt-1">{{ $resultAttention['summary'] ?? 'Restore result truth is unavailable.' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primary next step</div>
|
||||
<div class="mt-1">{{ $primaryNextAction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Main follow-up driver</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">{{ $primaryCauseFamily }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this record does not prove</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">{{ $recoveryBoundary }}</div>
|
||||
<div class="mt-3 space-y-2 text-xs text-slate-600 dark:text-slate-300">
|
||||
<p><span class="font-semibold">Result next step:</span> {{ $decision['result_next_action'] ?? 'Review the completed restore details.' }}</p>
|
||||
<p><span class="font-semibold">Main follow-up driver:</span> {{ $decision['primary_cause_family'] ?? 'No dominant cause recorded' }}</p>
|
||||
<p><span class="font-semibold">Boundary:</span> {{ $decision['recovery_claim_boundary'] ?? 'Target environment recovery is not proven.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if ($foundationItems->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
|
||||
@foreach ($foundationItems as $item)
|
||||
<div
|
||||
data-testid="restore-run-proof-workbench"
|
||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]"
|
||||
>
|
||||
<main data-testid="restore-run-result-primary" class="space-y-6">
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">Restore result summary</h3>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">{{ $resultSummary['message'] ?? 'Result summary unavailable' }}</p>
|
||||
</div>
|
||||
<x-filament::badge color="gray" size="sm">Repo-backed</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (($resultSummary['available'] ?? false) === true)
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
@foreach ($summaryCards as $key => $label)
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">{{ $label }}</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-slate-950 dark:text-white">{{ $formatCount($summaryCounts[$key] ?? null) }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200">
|
||||
Result summary unavailable. No fake zero counts are shown.
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section data-testid="restore-run-item-outcomes" class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">Item outcomes</h3>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">Per-item results are table-first. Item diagnostics stay behind row disclosure.</p>
|
||||
</div>
|
||||
<x-filament::badge :color="$itemOutcomeEvidence['tone'] ?? 'gray'" size="sm">
|
||||
{{ $itemOutcomeEvidence['label'] ?? ($itemOutcomes->count().' items') }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($itemOutcomes->isEmpty())
|
||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ $itemOutcomeEvidence['message'] ?? 'No item outcomes have been recorded yet.' }}
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-slate-200 dark:border-white/10">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm dark:divide-white/10">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-white/5 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Item</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Assignments</th>
|
||||
<th class="px-3 py-2">Settings</th>
|
||||
<th class="px-3 py-2">Review reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 bg-white dark:divide-white/10 dark:bg-gray-900">
|
||||
@foreach ($itemOutcomes as $item)
|
||||
@php
|
||||
$statusBadge = is_array($item['status_badge'] ?? null) ? $item['status_badge'] : [];
|
||||
$diagnostics = collect(is_array($item['diagnostics'] ?? null) ? $item['diagnostics'] : []);
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-3 py-3 align-top">
|
||||
<div class="font-medium text-slate-950 dark:text-white">{{ $item['name'] ?? 'Policy' }}</div>
|
||||
<div class="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{{ $item['type'] ?? 'Policy' }} @if (($item['platform'] ?? '—') !== '—') • {{ $item['platform'] }} @endif</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-top">
|
||||
<x-filament::badge
|
||||
:color="$statusBadge['color'] ?? 'gray'"
|
||||
:icon="$statusBadge['icon'] ?? null"
|
||||
:icon-color="$statusBadge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $statusBadge['label'] ?? 'Unknown' }}
|
||||
</x-filament::badge>
|
||||
@if (filled($item['restore_mode'] ?? null))
|
||||
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ \Illuminate\Support\Str::headline($item['restore_mode']) }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-3 align-top text-slate-700 dark:text-slate-200">{{ $item['assignments'] ?? '—' }}</td>
|
||||
<td class="px-3 py-3 align-top text-slate-700 dark:text-slate-200">{{ $item['settings'] ?? '—' }}</td>
|
||||
<td class="px-3 py-3 align-top text-slate-700 dark:text-slate-200">
|
||||
<div>{{ $item['reason'] ?? (($item['needs_attention'] ?? false) ? 'Review required' : 'No follow-up recorded') }}</div>
|
||||
@if ($diagnostics->isNotEmpty())
|
||||
<details class="mt-2 rounded border border-slate-200 bg-slate-50 px-2 py-1 text-xs text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
|
||||
<summary class="cursor-pointer font-semibold">Diagnostics recorded</summary>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-4">
|
||||
@foreach ($diagnostics as $diagnostic)
|
||||
<li>{{ $diagnostic }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</details>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if ($foundationOutcomes->isNotEmpty())
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">Foundations</h3>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-slate-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm dark:divide-white/10">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-white/5 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Foundation</th>
|
||||
<th class="px-3 py-2">Decision</th>
|
||||
<th class="px-3 py-2">Target</th>
|
||||
<th class="px-3 py-2">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 bg-white dark:divide-white/10 dark:bg-gray-900">
|
||||
@foreach ($foundationOutcomes as $foundation)
|
||||
@php
|
||||
$decisionBadge = is_array($foundation['decision_badge'] ?? null) ? $foundation['decision_badge'] : [];
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-3 py-3 align-top">
|
||||
<div class="font-medium text-slate-950 dark:text-white">{{ $foundation['name'] ?? 'Foundation' }}</div>
|
||||
<div class="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{{ $foundation['type'] ?? 'foundation' }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-top">
|
||||
<x-filament::badge
|
||||
:color="$decisionBadge['color'] ?? 'gray'"
|
||||
:icon="$decisionBadge['icon'] ?? null"
|
||||
:icon-color="$decisionBadge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $decisionBadge['label'] ?? 'Unknown' }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-top text-slate-700 dark:text-slate-200">{{ $foundation['target'] ?? '—' }}</td>
|
||||
<td class="px-3 py-3 align-top text-slate-700 dark:text-slate-200">{{ $foundation['reason'] ?? '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</main>
|
||||
|
||||
<aside data-testid="restore-run-proof-panel" class="space-y-4">
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<h3 class="text-sm font-semibold text-slate-950 dark:text-white">Proof panel</h3>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
@php
|
||||
$decision = $item['decision'] ?? 'mapped_existing';
|
||||
$foundationIsPreviewOnly = ($item['reason'] ?? null) === 'preview_only'
|
||||
|| ($item['restore_mode'] ?? null) === 'preview-only'
|
||||
|| $decision === 'dry_run';
|
||||
$decisionSpec = $foundationIsPreviewOnly
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, 'preview_only')
|
||||
: \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
|
||||
$foundationReason = $item['reason'] ?? null;
|
||||
|
||||
if ($foundationReason === 'preview_only') {
|
||||
$foundationReason = 'Preview only. This foundation type is not applied during execution.';
|
||||
}
|
||||
$operationStatusBadge = is_array($operationProof['status_badge'] ?? null) ? $operationProof['status_badge'] : [];
|
||||
$operationOutcomeBadge = is_array($operationProof['outcome_badge'] ?? null) ? $operationProof['outcome_badge'] : null;
|
||||
$evidenceStatusBadge = is_array($postRunEvidence['status_badge'] ?? null) ? $postRunEvidence['status_badge'] : [];
|
||||
$evidenceCompletenessBadge = is_array($postRunEvidence['completeness_badge'] ?? null) ? $postRunEvidence['completeness_badge'] : null;
|
||||
@endphp
|
||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
|
||||
<x-filament::badge :color="$decisionSpec->color" :icon="$decisionSpec->icon" size="sm">
|
||||
{{ $decisionSpec->label }}
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">OperationRun proof</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-950 dark:text-white">{{ $operationProof['label'] ?? 'Operation proof unavailable' }}</p>
|
||||
@if (filled($operationProof['identifier'] ?? null))
|
||||
<p class="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{{ $operationProof['identifier'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-filament::badge
|
||||
:color="$operationStatusBadge['color'] ?? 'gray'"
|
||||
:icon="$operationStatusBadge['icon'] ?? null"
|
||||
:icon-color="$operationStatusBadge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $operationStatusBadge['label'] ?? 'Unavailable' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600">
|
||||
{{ $item['type'] ?? 'foundation' }}
|
||||
</div>
|
||||
@if (! empty($item['targetName']))
|
||||
<div class="mt-1 text-xs text-gray-600">
|
||||
Target: {{ $item['targetName'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($foundationReason))
|
||||
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
{{ $foundationReason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($needsAttention)
|
||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
{{ $resultAttention['summary'] ?? 'Some items still need follow-up. Review the per-item details below.' }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($policyItems->isNotEmpty())
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
|
||||
@foreach ($policyItems as $item)
|
||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="font-semibold text-gray-900">
|
||||
{{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }}
|
||||
<span class="ml-2 text-xs text-gray-500">{{ $item['policy_type'] ?? '' }}</span>
|
||||
</div>
|
||||
@php
|
||||
$status = $item['status'] ?? 'unknown';
|
||||
$restoreMode = $item['restore_mode'] ?? null;
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $status);
|
||||
@endphp
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($restoreMode === 'preview-only')
|
||||
@php
|
||||
$restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode);
|
||||
@endphp
|
||||
<x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm">
|
||||
{{ $restoreModeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
@if (is_array($operationOutcomeBadge))
|
||||
<x-filament::badge
|
||||
:color="$operationOutcomeBadge['color'] ?? 'gray'"
|
||||
:icon="$operationOutcomeBadge['icon'] ?? null"
|
||||
:icon-color="$operationOutcomeBadge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $operationOutcomeBadge['label'] ?? 'Unknown outcome' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$itemReason = $item['reason'] ?? null;
|
||||
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
||||
|
||||
if ($itemReason === 'preview_only') {
|
||||
$itemReason = 'Preview only. This policy type is not applied during execution.';
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
||||
<div class="mt-2 text-sm text-gray-800">
|
||||
{{ $itemReason }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($item['assignment_summary']) && is_array($item['assignment_summary']))
|
||||
@php
|
||||
$summary = $item['assignment_summary'];
|
||||
$assignmentOutcomes = $item['assignment_outcomes'] ?? [];
|
||||
$assignmentIssues = collect($assignmentOutcomes)
|
||||
->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true))
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} applied •
|
||||
{{ (int) ($summary['failed'] ?? 0) }} failed items •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} not applied
|
||||
</div>
|
||||
|
||||
@if ($assignmentIssues->isNotEmpty())
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Assignment details</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($assignmentIssues as $outcome)
|
||||
@php
|
||||
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $outcomeStatus);
|
||||
$assignmentGroupId = $outcome['group_id']
|
||||
?? ($outcome['assignment']['target']['groupId'] ?? null);
|
||||
$assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null);
|
||||
$mappedGroupId = $outcome['mapped_group_id'] ?? null;
|
||||
$mappedGroupLabel = $formatGroupId(is_string($mappedGroupId) ? $mappedGroupId : null);
|
||||
@endphp
|
||||
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
|
||||
</div>
|
||||
<x-filament::badge :color="$outcomeSpec->color" :icon="$outcomeSpec->icon" size="sm">
|
||||
{{ $outcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (! empty($outcome['mapped_group_id']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
Mapped to: {{ $mappedGroupLabel ?? $outcome['mapped_group_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$outcomeReason = $outcome['reason'] ?? null;
|
||||
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
{{ $outcomeReason }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code']))
|
||||
<div class="mt-1 text-[11px] text-amber-900">
|
||||
<div>{{ $outcome['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||
@if (! empty($outcome['graph_error_code']))
|
||||
<div class="mt-0.5 text-amber-800">Code: {{ $outcome['graph_error_code'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
|
||||
@php
|
||||
$summary = $item['compliance_action_summary'];
|
||||
$complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null)
|
||||
? $item['compliance_action_outcomes']
|
||||
: [];
|
||||
$complianceEntries = collect($complianceOutcomes)->values();
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} not applied
|
||||
</div>
|
||||
|
||||
@if ($complianceEntries->isNotEmpty())
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($complianceEntries as $outcome)
|
||||
@php
|
||||
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $outcomeStatus);
|
||||
@endphp
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Template {{ $outcome['template_id'] ?? 'unknown' }}
|
||||
</div>
|
||||
<x-filament::badge :color="$outcomeSpec->color" :icon="$outcomeSpec->icon" size="sm">
|
||||
{{ $outcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@if (! empty($outcome['rule_name']))
|
||||
<div class="mt-1 text-[11px] text-gray-700">
|
||||
Rule: {{ $outcome['rule_name'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($outcome['mapped_template_id']))
|
||||
<div class="mt-1 text-[11px] text-gray-700">
|
||||
Mapped to: {{ $outcome['mapped_template_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($outcome['reason']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
{{ $outcome['reason'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (! empty($item['created_policy_id']))
|
||||
@php
|
||||
$createdMode = $item['created_policy_mode'] ?? null;
|
||||
$createdMessage = match ($createdMode) {
|
||||
'metadata_only' => 'New policy created (metadata only). Apply settings manually.',
|
||||
'created' => 'New policy created.',
|
||||
default => 'New policy created (manual cleanup required).',
|
||||
};
|
||||
@endphp
|
||||
<div class="mt-2 text-xs text-amber-800">
|
||||
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code']))
|
||||
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<div class="font-semibold">Graph error</div>
|
||||
<div>{{ $item['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||
@if (! empty($item['graph_error_code']))
|
||||
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
|
||||
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
|
||||
@if (! empty($item['graph_method']))
|
||||
<div>method: {{ $item['graph_method'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_path']))
|
||||
<div>path: {{ $item['graph_path'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_request_id']))
|
||||
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
||||
@endif
|
||||
@if (! empty($item['graph_client_request_id']))
|
||||
<div>client-request-id: {{ $item['graph_client_request_id'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($item['settings_apply']) && is_array($item['settings_apply']))
|
||||
@php
|
||||
$apply = $item['settings_apply'];
|
||||
$total = (int) ($apply['total'] ?? 0);
|
||||
$applied = (int) ($apply['applied'] ?? 0);
|
||||
$failed = (int) ($apply['failed'] ?? 0);
|
||||
$manual = (int) ($apply['manual_required'] ?? 0);
|
||||
$issues = $apply['issues'] ?? [];
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Settings applied: {{ $applied }}/{{ $total }}
|
||||
@if ($failed > 0 || $manual > 0)
|
||||
• {{ $failed }} failed • {{ $manual }} manual
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! empty($issues))
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Settings requiring attention</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($issues as $issue)
|
||||
@php
|
||||
$issueStatus = $issue['status'] ?? 'unknown';
|
||||
$issueColor = match ($issueStatus) {
|
||||
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||
};
|
||||
@endphp
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Setting {{ $issue['setting_id'] ?? 'unknown' }}
|
||||
</div>
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $issueColor }}">
|
||||
{{ $issueStatus }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (! empty($issue['reason']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
{{ $issue['reason'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code']))
|
||||
<div class="mt-1 text-[11px] text-amber-900">
|
||||
<div>{{ $issue['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||
@if (! empty($issue['graph_error_code']))
|
||||
<div class="mt-0.5 text-amber-800">Code: {{ $issue['graph_error_code'] }}</div>
|
||||
@endif
|
||||
@if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id']))
|
||||
<div class="mt-0.5 space-y-0.5 text-amber-800">
|
||||
@if (! empty($issue['graph_request_id']))
|
||||
<div>request-id: {{ $issue['graph_request_id'] }}</div>
|
||||
@endif
|
||||
@if (! empty($issue['graph_client_request_id']))
|
||||
<div>client-request-id: {{ $issue['graph_client_request_id'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (! empty($item['platform']))
|
||||
<div class="mt-2 text-[11px] text-gray-500">
|
||||
Platform: {{ $item['platform'] }}
|
||||
</div>
|
||||
@if (filled($operationProof['url'] ?? null))
|
||||
<x-filament::button tag="a" :href="$operationProof['url']" size="xs" color="gray" class="mt-3">
|
||||
Open operation
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Post-run evidence</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-950 dark:text-white">{{ $postRunEvidence['label'] ?? 'Post-run evidence unavailable' }}</p>
|
||||
@if (filled($postRunEvidence['identifier'] ?? null))
|
||||
<p class="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{{ $postRunEvidence['identifier'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-filament::badge
|
||||
:color="$evidenceStatusBadge['color'] ?? 'gray'"
|
||||
:icon="$evidenceStatusBadge['icon'] ?? null"
|
||||
:icon-color="$evidenceStatusBadge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $evidenceStatusBadge['label'] ?? 'Unavailable' }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if (is_array($evidenceCompletenessBadge))
|
||||
<x-filament::badge
|
||||
:color="$evidenceCompletenessBadge['color'] ?? 'gray'"
|
||||
:icon="$evidenceCompletenessBadge['icon'] ?? null"
|
||||
:icon-color="$evidenceCompletenessBadge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $evidenceCompletenessBadge['label'] ?? 'Unknown completeness' }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($postRunEvidence['url'] ?? null))
|
||||
<x-filament::button tag="a" :href="$postRunEvidence['url']" size="xs" color="gray" class="mt-3">
|
||||
Open evidence
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<h3 class="text-sm font-semibold text-slate-950 dark:text-white">Run context</h3>
|
||||
<dl class="mt-3 space-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Source backup</dt>
|
||||
<dd class="mt-0.5 text-slate-900 dark:text-white">{{ $runContext['backup_set'] ?? 'Backup set unavailable' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Target environment</dt>
|
||||
<dd class="mt-0.5 text-slate-900 dark:text-white">{{ $runContext['target_environment'] ?? 'Environment unavailable' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Requested by</dt>
|
||||
<dd class="mt-0.5 text-slate-900 dark:text-white">{{ $runContext['requested_by'] ?? 'Not recorded' }}</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Started</dt>
|
||||
<dd class="mt-0.5 text-slate-900 dark:text-white">{{ $runContext['started_at'] ?? '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Completed</dt>
|
||||
<dd class="mt-0.5 text-slate-900 dark:text-white">{{ $runContext['completed_at'] ?? '—' }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<details
|
||||
data-testid="restore-run-diagnostics"
|
||||
class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-slate-950 dark:text-white">
|
||||
Diagnostics collapsed
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2 text-sm text-slate-700 dark:text-slate-200">
|
||||
<p>{{ $diagnostics['summary'] ?? 'Diagnostics are secondary.' }}</p>
|
||||
<p>{{ $diagnostics['execution_basis'] ?? 'No execution basis recorded.' }}</p>
|
||||
<p>Items requiring attention: {{ (int) ($diagnostics['items_requiring_attention'] ?? 0) }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\User;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec335BrowserScreenshotName(string $name): string
|
||||
{
|
||||
return 'spec335-restore-run-detail-'.$name;
|
||||
}
|
||||
|
||||
function spec335BrowserLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect): string
|
||||
{
|
||||
return route('admin.local.smoke-login', [
|
||||
'email' => $user->email,
|
||||
'tenant' => $tenant->external_id,
|
||||
'workspace' => $tenant->workspace->slug,
|
||||
'redirect' => $redirect,
|
||||
]);
|
||||
}
|
||||
|
||||
function spec335BrowserViewPath(ManagedEnvironment $tenant, RestoreRun $restoreRun): string
|
||||
{
|
||||
$url = RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'admin', tenant: $tenant);
|
||||
|
||||
return parse_url($url, PHP_URL_PATH) ?: '/admin';
|
||||
}
|
||||
|
||||
function spec335BrowserBackupSet(ManagedEnvironment $tenant): BackupSet
|
||||
{
|
||||
return BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec335 Browser Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 4,
|
||||
]);
|
||||
}
|
||||
|
||||
function spec335BrowserOperationRun(ManagedEnvironment $tenant, string $outcome = OperationRunOutcome::Succeeded->value): OperationRun
|
||||
{
|
||||
return OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => OperationRunType::RestoreExecute->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $outcome,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function spec335BrowserCompletedRestoreRun(ManagedEnvironment $tenant, BackupSet $backupSet, OperationRun $operationRun): RestoreRun
|
||||
{
|
||||
return RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([
|
||||
'status' => 'completed',
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'requested_by' => 'Spec335 Browser Operator',
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'Spec335 Browser Policy',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
'assignment_summary' => [
|
||||
'success' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
],
|
||||
'settings_apply' => [
|
||||
'applied' => 4,
|
||||
'failed' => 0,
|
||||
'manual_required' => 0,
|
||||
],
|
||||
'graph_error_message' => 'browser raw payload should stay hidden',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'total' => 4,
|
||||
'succeeded' => 3,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
'partial' => 0,
|
||||
'non_applied' => 1,
|
||||
'execution_safety_snapshot' => [
|
||||
'safety_state' => 'ready_with_caution',
|
||||
],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('Spec335 smokes restore run detail post-execution proof states and screenshots', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$backupSet = spec335BrowserBackupSet($tenant);
|
||||
|
||||
$draftRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([
|
||||
'status' => 'draft',
|
||||
'is_dry_run' => true,
|
||||
'results' => [],
|
||||
'metadata' => [],
|
||||
'started_at' => null,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
$completedOperation = spec335BrowserOperationRun($tenant);
|
||||
$completedRun = spec335BrowserCompletedRestoreRun($tenant, $backupSet, $completedOperation);
|
||||
|
||||
$failedOperation = spec335BrowserOperationRun($tenant, OperationRunOutcome::Failed->value);
|
||||
$failedRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([
|
||||
'status' => 'failed',
|
||||
'operation_run_id' => (int) $failedOperation->getKey(),
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'failed',
|
||||
'policy_identifier' => 'Spec335 Failed Browser Policy',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'total' => 1,
|
||||
'succeeded' => 0,
|
||||
'failed' => 1,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$page = visit(spec335BrowserLoginUrl($user, $tenant, spec335BrowserViewPath($tenant, $draftRun)))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Was this restore executed safely, and is recovery proof available?')
|
||||
->assertSee('Not executed')
|
||||
->assertSee('Operation proof unavailable')
|
||||
->assertSee('Post-run evidence unavailable')
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics\"]")?.open === false', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec335BrowserScreenshotName('01-restore-run-draft'));
|
||||
|
||||
$page = visit(spec335BrowserViewPath($tenant, $completedRun))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Completed, recovery proof incomplete')
|
||||
->assertSee('Operation proof available')
|
||||
->assertSee('Post-run evidence unavailable')
|
||||
->assertSee('Restore result summary')
|
||||
->assertSee('Item outcomes')
|
||||
->assertSee('Spec335 Browser Policy')
|
||||
->assertDontSee('browser raw payload should stay hidden')
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics\"]")?.open === false', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec335BrowserScreenshotName('02-restore-run-completed-proof-incomplete'));
|
||||
|
||||
$page->screenshot(true, spec335BrowserScreenshotName('03-restore-run-operation-proof'));
|
||||
|
||||
$page->screenshot(true, spec335BrowserScreenshotName('04-restore-run-evidence-unavailable'));
|
||||
|
||||
$page->screenshot(true, spec335BrowserScreenshotName('05-restore-run-item-outcomes'));
|
||||
|
||||
$page->screenshot(true, spec335BrowserScreenshotName('07-restore-run-diagnostics-collapsed'));
|
||||
|
||||
visit(spec335BrowserViewPath($tenant, $failedRun))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Restore failed')
|
||||
->assertSee('Review failure details')
|
||||
->assertSee('Spec335 Failed Browser Policy')
|
||||
->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics\"]")?.open === false', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec335BrowserScreenshotName('06-restore-run-failed-if-supported'));
|
||||
|
||||
visit(spec335BrowserLoginUrl($user, $tenant, spec335BrowserViewPath($tenant, $completedRun)))
|
||||
->inDarkMode()
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Completed, recovery proof incomplete')
|
||||
->assertSee('Operation proof available')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec335BrowserScreenshotName('08-restore-run-dark-mode'));
|
||||
});
|
||||
@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ViewRestoreRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec335BackupSet(ManagedEnvironment $tenant): BackupSet
|
||||
{
|
||||
return BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec335 Backup Set',
|
||||
'status' => 'completed',
|
||||
'item_count' => 10,
|
||||
]);
|
||||
}
|
||||
|
||||
function spec335RestoreOperationRun(ManagedEnvironment $tenant, string $outcome = OperationRunOutcome::Succeeded->value): OperationRun
|
||||
{
|
||||
return OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => OperationRunType::RestoreExecute->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $outcome,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'restore_run_id' => 999,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec335CompletedRestoreRun(ManagedEnvironment $tenant, BackupSet $backupSet, ?OperationRun $operationRun = null): RestoreRun
|
||||
{
|
||||
return RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([
|
||||
'status' => 'completed',
|
||||
'operation_run_id' => $operationRun?->getKey(),
|
||||
'requested_by' => 'Spec335 Operator',
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
10 => [
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'Spec335 Completed Policy',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
'assignment_summary' => [
|
||||
'success' => 2,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
],
|
||||
'settings_apply' => [
|
||||
'applied' => 8,
|
||||
'failed' => 0,
|
||||
'manual_required' => 0,
|
||||
],
|
||||
'graph_error_message' => 'raw payload should stay hidden',
|
||||
'graph_request_id' => 'raw-request-id-should-stay-hidden',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'total' => 10,
|
||||
'succeeded' => 8,
|
||||
'failed' => 1,
|
||||
'skipped' => 1,
|
||||
'partial' => 0,
|
||||
'non_applied' => 1,
|
||||
'execution_safety_snapshot' => [
|
||||
'safety_state' => 'ready_with_caution',
|
||||
'follow_up_boundary' => 'run_completed_not_recovery_proven',
|
||||
],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('renders the decision-first restore run detail with proof incomplete boundaries', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = spec335BackupSet($tenant);
|
||||
$operationRun = spec335RestoreOperationRun($tenant);
|
||||
$restoreRun = spec335CompletedRestoreRun($tenant, $backupSet, $operationRun);
|
||||
|
||||
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
|
||||
->assertSee('Was this restore executed safely, and is recovery proof available?')
|
||||
->assertSee('Completed, recovery proof incomplete')
|
||||
->assertSee('Do not treat this restore as verified recovery until evidence has been reviewed.')
|
||||
->assertSee('Operation proof available')
|
||||
->assertSee('Open operation proof')
|
||||
->assertSee(OperationRunLinks::identifier($operationRun))
|
||||
->assertSee('Post-run evidence unavailable')
|
||||
->assertSee('Restore result summary')
|
||||
->assertSee('Requested')
|
||||
->assertSee('10')
|
||||
->assertSee('Needs review')
|
||||
->assertSee('3')
|
||||
->assertSee('Item outcomes')
|
||||
->assertSee('Spec335 Completed Policy')
|
||||
->assertSee('Diagnostics collapsed')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('raw-request-id-should-stay-hidden')
|
||||
->assertDontSee('Recovery verified')
|
||||
->assertDontSee('Healthy')
|
||||
->assertDontSee('Customer-safe');
|
||||
});
|
||||
|
||||
it('explains metadata-only follow-up counts when item outcome rows are absent', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = spec335BackupSet($tenant);
|
||||
$operationRun = spec335RestoreOperationRun($tenant);
|
||||
$restoreRun = spec335CompletedRestoreRun($tenant, $backupSet, $operationRun);
|
||||
$restoreRun->forceFill([
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [],
|
||||
],
|
||||
])->save();
|
||||
|
||||
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
|
||||
->assertSee('Needs review')
|
||||
->assertSee('3')
|
||||
->assertSee('Metadata counts only')
|
||||
->assertSee('Aggregate restore counts are available, but item-level outcome rows are not stored for this run.')
|
||||
->assertDontSee('0 items');
|
||||
});
|
||||
|
||||
it('links repo-backed post-run evidence when an authorized snapshot exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = spec335BackupSet($tenant);
|
||||
$operationRun = spec335RestoreOperationRun($tenant);
|
||||
$restoreRun = spec335CompletedRestoreRun($tenant, $backupSet, $operationRun);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
|
||||
->assertSee('Completed with evidence available')
|
||||
->assertSee('Post-run evidence available')
|
||||
->assertSee('Evidence snapshot #'.$snapshot->getKey())
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant), false);
|
||||
});
|
||||
|
||||
it('shows unavailable operation proof when a completed restore run has no linked operation', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$restoreRun = RestoreRun::withoutEvents(
|
||||
fn (): RestoreRun => spec335CompletedRestoreRun($tenant, spec335BackupSet($tenant)),
|
||||
);
|
||||
|
||||
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
|
||||
->assertSee('Completed, recovery proof incomplete')
|
||||
->assertSee('Operation proof unavailable')
|
||||
->assertSee('Post-run evidence unavailable')
|
||||
->assertDontSee('Open operation');
|
||||
});
|
||||
|
||||
it('does not leak cross-workspace operation or evidence links', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$foreignTenant = ManagedEnvironment::factory()->create();
|
||||
$foreignOperationRun = spec335RestoreOperationRun($foreignTenant);
|
||||
$foreignSnapshot = EvidenceSnapshot::query()->create([
|
||||
'managed_environment_id' => (int) $foreignTenant->getKey(),
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'operation_run_id' => (int) $foreignOperationRun->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$restoreRun = spec335CompletedRestoreRun($tenant, spec335BackupSet($tenant), $foreignOperationRun);
|
||||
|
||||
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
|
||||
->assertSee('Operation proof unavailable')
|
||||
->assertSee('Post-run evidence unavailable')
|
||||
->assertDontSee(OperationRunLinks::tenantlessView($foreignOperationRun), false)
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $foreignSnapshot], tenant: $foreignTenant), false);
|
||||
});
|
||||
|
||||
it('preserves restore run view authorization semantics', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$restoreRun = spec335CompletedRestoreRun($tenant, spec335BackupSet($tenant));
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'admin', tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$otherTenant = ManagedEnvironment::factory()->create();
|
||||
[$otherUser] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$this->actingAs($otherUser)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $otherTenant->workspace_id])
|
||||
->get(RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'admin', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunDetailPresenter;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('recomputes post-run evidence availability from current repo-backed state', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$operationRun = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => OperationRunType::RestoreExecute->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([
|
||||
'status' => 'completed',
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'metadata' => [
|
||||
'total' => 1,
|
||||
'succeeded' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'partial' => 0,
|
||||
'non_applied' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$presenter = app(RestoreRunDetailPresenter::class);
|
||||
|
||||
$first = $presenter->forRun($restoreRun->fresh(['backupSet', 'operationRun', 'tenant']));
|
||||
|
||||
expect(data_get($first, 'decision.state'))->toBe('completed_proof_incomplete')
|
||||
->and(data_get($first, 'operationProof.state'))->toBe('available')
|
||||
->and(data_get($first, 'postRunEvidence.state'))->toBe('unavailable');
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$second = $presenter->forRun($restoreRun->fresh(['backupSet', 'operationRun', 'tenant']));
|
||||
|
||||
expect(data_get($second, 'decision.state'))->toBe('completed_with_evidence')
|
||||
->and(data_get($second, 'postRunEvidence.state'))->toBe('available')
|
||||
->and(data_get($second, 'postRunEvidence.identifier'))->toContain('Evidence snapshot #');
|
||||
});
|
||||
|
||||
it('does not expose operation proof from another workspace or environment', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$foreignTenant = \App\Models\ManagedEnvironment::factory()->create();
|
||||
$foreignOperationRun = OperationRun::factory()->forTenant($foreignTenant)->create([
|
||||
'type' => OperationRunType::RestoreExecute->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([
|
||||
'status' => 'completed',
|
||||
'operation_run_id' => (int) $foreignOperationRun->getKey(),
|
||||
]);
|
||||
|
||||
$surface = app(RestoreRunDetailPresenter::class)
|
||||
->forRun($restoreRun->fresh(['backupSet', 'operationRun', 'tenant']));
|
||||
|
||||
expect(data_get($surface, 'operationProof.state'))->toBe('unavailable')
|
||||
->and(data_get($surface, 'operationProof.url'))->toBeNull()
|
||||
->and(data_get($surface, 'postRunEvidence.state'))->toBe('unavailable');
|
||||
});
|
||||
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 255 KiB |
@ -80,6 +80,7 @@ ## Restore Results Shape (repo-verified)
|
||||
|
||||
- Result summary counts: `derived from existing model` (from `metadata`)
|
||||
- Item outcome table: `derived from existing model` (from `results.items` and related metadata)
|
||||
- Summary counts and item rows are independent. If metadata counts exist but `results.items` is empty, the UI must label the state as metadata-only rather than implying zero affected items.
|
||||
|
||||
## Restore Result Attention Contract (repo-verified)
|
||||
|
||||
@ -99,10 +100,10 @@ ## Restore Result Attention Contract (repo-verified)
|
||||
- `recovery_claim_boundary` (notably enforces "completed != recovery proven")
|
||||
- `tone`
|
||||
|
||||
Gap vs Spec 335 UX:
|
||||
Spec 335 UX use:
|
||||
|
||||
- OperationRun proof state is not part of this contract today (`not available` in this contract).
|
||||
- Post-run evidence state is not part of this contract today (`not available` in this contract).
|
||||
- `RestoreResultAttention` remains the source for result attention copy and recovery-claim boundaries.
|
||||
- OperationRun proof state and post-run evidence state are derived by the Restore Run detail presenter, not persisted into this contract.
|
||||
|
||||
## OperationRun Proof (repo-verified)
|
||||
|
||||
@ -120,6 +121,8 @@ ## OperationRun Proof (repo-verified)
|
||||
|
||||
- OperationRun "proof" is repo-real for restore runs that have `operation_run_id`.
|
||||
- UI link helpers exist (repo-verified): `apps/platform/app/Support/OperationRunLinks.php` and Filament `OperationRunResource`.
|
||||
- `apps/platform/app/Observers/RestoreRunObserver.php` and `apps/platform/app/Listeners/SyncRestoreRunToOperationRun.php` create/sync adapter `OperationRun` rows from `previewed` onward. Legacy/imported rows can still have no linked operation and must render proof as unavailable.
|
||||
- Restore Run detail proof links must be same-workspace/same-managed-environment and authorized through the existing `OperationRun` view policy before a link is shown.
|
||||
|
||||
## EvidenceSnapshot (post-run evidence) (foundation-real)
|
||||
|
||||
@ -137,7 +140,10 @@ ## EvidenceSnapshot (post-run evidence) (foundation-real)
|
||||
Evidence availability for restore runs:
|
||||
|
||||
- Model + viewer exist (`foundation-real`).
|
||||
- Whether restore execution produces an evidence snapshot is workflow-dependent and must be verified at runtime/fixtures (`deferred`).
|
||||
- Restore execution does not guarantee an evidence snapshot. Absence is rendered as "Post-run evidence unavailable" and never as verified recovery.
|
||||
- The Restore Run detail presenter resolves evidence by linked `operation_run_id`, same `workspace_id`, same `managed_environment_id`, and statuses `active`, `generating`, or `queued`.
|
||||
- If multiple snapshots exist for the operation run, the presenter links the latest `active` snapshot when present, otherwise the latest queued/generating snapshot as in-progress evidence.
|
||||
- Evidence links require the current user to have `Capabilities::EVIDENCE_VIEW` for the tenant and `EvidenceSnapshotResource::canView($snapshot)`.
|
||||
|
||||
## Current Restore Run Detail UI (repo-verified)
|
||||
|
||||
@ -151,9 +157,9 @@ ## Current Restore Run Detail UI (repo-verified)
|
||||
- Preview entry view: `apps/platform/resources/views/filament/infolists/entries/restore-preview.blade.php`
|
||||
- Results entry view: `apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php`
|
||||
|
||||
Known gap:
|
||||
Spec 335 implementation target:
|
||||
|
||||
- No dedicated proof/evidence aside on the detail page today (proof panel exists for Create wizard, not for View).
|
||||
- The detail page uses a derived presenter-backed decision card and proof/evidence aside. No new persisted proof state is introduced.
|
||||
|
||||
## Existing Tests (repo-verified)
|
||||
|
||||
@ -176,9 +182,6 @@ ## Permissions / Capabilities (repo-verified)
|
||||
|
||||
- `RestoreRunResource::resolveScopedRecordOrFail()` routes through tenant-owned record resolution, preserving tenant/workspace scoping.
|
||||
|
||||
## Open Truth Questions (deferred)
|
||||
|
||||
- Do restore execution operations in current fixtures produce an `EvidenceSnapshot` linked to the restore `OperationRun`?
|
||||
- If multiple evidence snapshots exist for one operation run, which one should be linked (latest active vs latest any)?
|
||||
- What are the RBAC rules for viewing EvidenceSnapshot and OperationRun from the Restore Run detail surface (capability names, deny-as-not-found vs forbidden)?
|
||||
## Open Truth Questions
|
||||
|
||||
- None for Spec 335 implementation. Evidence generation itself remains out of scope; the page only reflects repo-backed snapshots that already exist.
|
||||
|
||||
@ -15,6 +15,14 @@ ## Fields (required on first screen)
|
||||
- Result summary (repo-backed only)
|
||||
- Diagnostics default state: collapsed
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
- Operation proof is available only when the linked `operation_runs` row is same-workspace, same-managed-environment, and authorized for the current user.
|
||||
- Post-run evidence is repo-backed only. It is available for the latest authorized `active` evidence snapshot for the linked operation run, same workspace, and same managed environment.
|
||||
- Evidence snapshots in `queued` or `generating` state may be shown as in progress. Absent, unauthorized, or cross-workspace evidence is unavailable.
|
||||
- Result attention copy remains visible, but for completed restore runs it does not override the top-level proof/evidence decision unless the persisted restore status itself is partial/error.
|
||||
- Aggregate result counts may be present without item-level rows. The UI must label that as metadata-only evidence and must not show it as "0 items" when counts indicate affected or follow-up work.
|
||||
|
||||
## State Matrix
|
||||
|
||||
### 1) Not Executed (Draft / Preview Only)
|
||||
@ -55,13 +63,13 @@ ### 3) Completed (Recovery Proof Incomplete)
|
||||
|
||||
- **State**: `completed_proof_incomplete`
|
||||
- **Persisted triggers**:
|
||||
- terminal restore run (`status in { completed, partial, completed_with_errors }`) AND operation proof is present (`operation_run_id != null`)
|
||||
- post-run evidence snapshot not present or not current for the operation run
|
||||
- terminal completed restore run (`status = completed`)
|
||||
- post-run evidence snapshot not present, not active/current for the operation run, unauthorized, or cross-workspace
|
||||
- **Visible status**: Completed, recovery proof incomplete
|
||||
- **Reason**: Execution completed, but post-run evidence is not available yet.
|
||||
- **Impact**: Do not treat this restore as verified recovery until evidence has been reviewed.
|
||||
- **Primary next action**: Review restored details
|
||||
- **Operation proof state**: Available
|
||||
- **Primary next action**: Open operation proof when authorized proof exists; otherwise Review proof gap
|
||||
- **Operation proof state**: Available if a scoped authorized operation run exists, otherwise Unavailable for legacy/imported rows
|
||||
- **Post-run evidence state**: Unavailable
|
||||
- **Result summary**: repo-backed counts from `restore_runs.metadata` when present
|
||||
- **Allowed proof claims**: "Execution proof available" (never "recovery verified")
|
||||
@ -71,8 +79,8 @@ ### 4) Completed (Evidence Available)
|
||||
|
||||
- **State**: `completed_with_evidence`
|
||||
- **Persisted triggers**:
|
||||
- terminal restore run AND operation proof present
|
||||
- at least one evidence snapshot exists for the same `operation_run_id` and tenant, with status/currentness appropriate to repo truth
|
||||
- terminal completed restore run
|
||||
- at least one authorized `active` evidence snapshot exists for the same `operation_run_id`, workspace, and managed environment
|
||||
- **Visible status**: Completed with evidence available
|
||||
- **Reason**: Execution proof and post-run evidence are available.
|
||||
- **Impact**: Review evidence before treating the restore as recovery proof.
|
||||
@ -89,8 +97,8 @@ ### 5) Completed With Items Needing Review (Partial / Follow-Up)
|
||||
|
||||
- **State**: `needs_review`
|
||||
- **Persisted triggers** (repo-verified via `RestoreResultAttention`):
|
||||
- `RestoreSafetyResolver::resultAttentionForRun(...).state in { partial, completed_with_follow_up }`, OR
|
||||
- `restore_runs.status = partial`
|
||||
- `restore_runs.status in { partial, completed_with_errors }`
|
||||
- `RestoreSafetyResolver::resultAttentionForRun(...).state in { partial, completed_with_follow_up }` is still displayed as secondary result-attention copy/badging for `completed` runs, but does not supersede the top proof/evidence decision.
|
||||
- **Visible status**: Completed with items needing review
|
||||
- **Reason**: Some items were skipped, partially applied, or failed.
|
||||
- **Impact**: Review item outcomes before relying on the result.
|
||||
@ -132,4 +140,3 @@ ### 7) Cancelled / Blocked
|
||||
- **Result summary**: show "Unavailable" unless repo-backed
|
||||
- **Allowed proof claims**: none beyond the above
|
||||
- **Diagnostics default**: Collapsed
|
||||
|
||||
|
||||
@ -161,8 +161,8 @@ ## Required UX Contract
|
||||
'status_label' => 'Completed, recovery proof incomplete',
|
||||
'reason' => 'Execution completed, but post-run evidence is not available yet.',
|
||||
'impact' => 'Do not treat this restore as verified recovery until evidence has been reviewed.',
|
||||
'primary_next_action' => 'Review restored details',
|
||||
'primary_next_url' => null,
|
||||
'primary_next_action' => 'Open operation proof',
|
||||
'primary_next_url' => '...',
|
||||
'operation_proof' => [
|
||||
'state' => 'available',
|
||||
'label' => 'Operation proof available',
|
||||
@ -265,4 +265,3 @@ ### Technical
|
||||
- No backend rewrite.
|
||||
- No migrations unless explicitly justified and added to this spec before implementation.
|
||||
- No new packages.
|
||||
|
||||
|
||||
@ -11,60 +11,60 @@ # Tasks: Spec 335 - Restore Run Detail / Post-Execution Proof Productization
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [ ] New or changed tests stay in the smallest honest family, and browser additions are explicit.
|
||||
- [ ] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||
- [ ] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [ ] The dangerous-workflow proof/evidence surface profile is explicit.
|
||||
- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in the smallest honest family, and browser additions are explicit.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] The dangerous-workflow proof/evidence surface profile is explicit.
|
||||
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||
|
||||
## Phase 1: Repo Truth (blocks runtime changes)
|
||||
|
||||
**Purpose**: Freeze repo truth for RestoreRun results/proof/evidence before changing UI.
|
||||
|
||||
- [ ] T001 Re-read `spec.md`, `plan.md`, and this `tasks.md`.
|
||||
- [ ] T002 Verify current Restore Run view implementation and state sources:
|
||||
- [x] T001 Re-read `spec.md`, `plan.md`, and this `tasks.md`.
|
||||
- [x] T002 Verify current Restore Run view implementation and state sources:
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php` (infolist + `detailResultsState`)
|
||||
- `apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php`
|
||||
- `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php` (`resultAttentionForRun`)
|
||||
- [ ] T003 Update/confirm `repo-truth-map.md` is accurate for:
|
||||
- [x] T003 Update/confirm `repo-truth-map.md` is accurate for:
|
||||
- `RestoreRun` model fields + `RestoreRunStatus` values
|
||||
- results shape (`results.foundations`, `results.items`) and summary counts (`metadata.total/succeeded/failed/skipped/partial/non_applied`)
|
||||
- `operation_run_id` relationship + current OperationRun outcome/status behavior
|
||||
- Evidence snapshot availability (query path, status/completeness enums)
|
||||
- [ ] T004 Update/confirm `restore-result-state-contract.md` is aligned to repo truth (no invented evidence/proof states).
|
||||
- [x] T004 Update/confirm `restore-result-state-contract.md` is aligned to repo truth (no invented evidence/proof states).
|
||||
|
||||
## Phase 2: Restore Run Detail Presenter (derived view-model, optional)
|
||||
|
||||
**Purpose**: Ensure one decision-first UI contract drives the view surface.
|
||||
|
||||
- [ ] T005 Decide whether a presenter/view-model is needed. If the view becomes a multi-section surface (decision card + proof panel + evidence state + table), prefer a presenter to avoid page-local logic drift.
|
||||
- [ ] T006 If introduced, implement a thin derived presenter that outputs:
|
||||
- [x] T005 Decide whether a presenter/view-model is needed. If the view becomes a multi-section surface (decision card + proof panel + evidence state + table), prefer a presenter to avoid page-local logic drift.
|
||||
- [x] T006 If introduced, implement a thin derived presenter that outputs:
|
||||
- `status_label`, `reason`, `impact`, `primary_next_action`
|
||||
- `operation_proof` state + URL (tenant/workspace-safe, capability-gated)
|
||||
- `post_run_evidence` state + URL (repo-backed only)
|
||||
- `result_summary` counts (repo-backed only)
|
||||
- `diagnostics_state = collapsed`
|
||||
- [ ] T007 Prove presenter output determinism with Unit tests (no static memoization).
|
||||
- [x] T007 Prove presenter output determinism with Unit tests (no static memoization).
|
||||
|
||||
## Phase 3: Detail Page UI (decision-first main/aside)
|
||||
|
||||
**Purpose**: Productize the page layout and hierarchy.
|
||||
|
||||
- [ ] T008 Refactor Restore Run view page into a main/aside hierarchy:
|
||||
- [x] T008 Refactor Restore Run view page into a main/aside hierarchy:
|
||||
- Main: decision card + result summary + item outcomes (table) + secondary run details
|
||||
- Aside: proof panel (source backup, target env, requested by, operation proof, post-run evidence, audit trail) + diagnostics collapsed
|
||||
- [ ] T009 Ensure diagnostics and raw payloads remain collapsed/secondary by default (no stack traces, no raw JSON as primary UI).
|
||||
- [ ] T010 Ensure the page does not display "recovery verified", "healthy", "compliant", or "customer-safe" claims unless repo truth supports that semantics.
|
||||
- [x] T009 Ensure diagnostics and raw payloads remain collapsed/secondary by default (no stack traces, no raw JSON as primary UI).
|
||||
- [x] T010 Ensure the page does not display "recovery verified", "healthy", "compliant", or "customer-safe" claims unless repo truth supports that semantics.
|
||||
|
||||
## Phase 4: Proof/Evidence Links (repo-backed only)
|
||||
|
||||
**Purpose**: Make execution proof and post-run evidence explicit, separate, and truthful.
|
||||
|
||||
- [ ] T011 Operation proof:
|
||||
- [x] T011 Operation proof:
|
||||
- restore run with `operation_run_id` shows proof state + link to OperationRun detail
|
||||
- restore run without operation run shows "unavailable" state
|
||||
- [ ] T012 Post-run evidence:
|
||||
- [x] T012 Post-run evidence:
|
||||
- when evidence snapshots exist for the linked operation run (tenant-scoped), show state + link to Evidence Snapshot detail
|
||||
- when absent, show "unavailable" and do not imply recovery proof
|
||||
|
||||
@ -72,32 +72,32 @@ ## Phase 5: Item Outcomes (table-first, no payload dump)
|
||||
|
||||
**Purpose**: Make per-item outcomes reviewable without flooding the page.
|
||||
|
||||
- [ ] T013 Render item outcomes as a table (not large cards) when `results.items` exists.
|
||||
- [ ] T014 Show compact summary counts from `restore_runs.metadata` (only when repo-backed; no fake zeros).
|
||||
- [ ] T015 Keep raw per-item payload/diff/diagnostics behind disclosure.
|
||||
- [x] T013 Render item outcomes as a table (not large cards) when `results.items` exists.
|
||||
- [x] T014 Show compact summary counts from `restore_runs.metadata` (only when repo-backed; no fake zeros).
|
||||
- [x] T015 Keep raw per-item payload/diff/diagnostics behind disclosure.
|
||||
|
||||
## Phase 6: RBAC / Isolation
|
||||
|
||||
- [ ] T016 Add at least one positive and one negative authorization test for Restore Run view access.
|
||||
- [ ] T017 Prove cross-workspace/tenant proof and evidence links cannot leak (deny-as-not-found semantics preserved).
|
||||
- [x] T016 Add at least one positive and one negative authorization test for Restore Run view access.
|
||||
- [x] T017 Prove cross-workspace/tenant proof and evidence links cannot leak (deny-as-not-found semantics preserved).
|
||||
|
||||
## Phase 7: Tests
|
||||
|
||||
- [ ] T018 Add Feature test: `apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php` covering:
|
||||
- [x] T018 Add Feature test: `apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php` covering:
|
||||
- decision question visible
|
||||
- "Completed" does not imply recovery verified
|
||||
- operation proof state visible
|
||||
- post-run evidence state visible and truthful
|
||||
- diagnostics collapsed; raw payload hidden by default
|
||||
- [ ] T019 Extend or align with existing coverage:
|
||||
- [x] T019 Extend or align with existing coverage:
|
||||
- `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`
|
||||
- [ ] T020 Add Browser smoke/screenshot test: `apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php`.
|
||||
- [x] T020 Add Browser smoke/screenshot test: `apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php`.
|
||||
|
||||
## Phase 8: Screenshots
|
||||
|
||||
- [ ] T021 Capture required screenshots under:
|
||||
- [x] T021 Capture required screenshots under:
|
||||
- `specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/`
|
||||
- [ ] T022 Capture at least:
|
||||
- [x] T022 Capture at least:
|
||||
- `01-restore-run-draft.png`
|
||||
- `02-restore-run-completed-proof-incomplete.png`
|
||||
- `03-restore-run-operation-proof.png`
|
||||
@ -111,7 +111,7 @@ ## Phase 8: Screenshots
|
||||
|
||||
## Phase 9: Validation
|
||||
|
||||
- [ ] T023 Run:
|
||||
- [x] T023 Run:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
|
||||
@ -119,10 +119,9 @@ ## Phase 9: Validation
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- [ ] NT001 Do not change restore execution backend behavior.
|
||||
- [ ] NT002 Do not add new Graph calls or ProviderGateway behavior.
|
||||
- [ ] NT003 Do not change `OperationRun` lifecycle semantics (link-only).
|
||||
- [ ] NT004 Do not add migrations, packages, env vars, queues, scheduler, or storage changes.
|
||||
- [ ] NT005 Do not redesign Restore Create wizard (Spec 333 owns Create UX).
|
||||
- [ ] NT006 Do not introduce any false recovery-proof claims.
|
||||
|
||||
- [x] NT001 Do not change restore execution backend behavior.
|
||||
- [x] NT002 Do not add new Graph calls or ProviderGateway behavior.
|
||||
- [x] NT003 Do not change `OperationRun` lifecycle semantics (link-only).
|
||||
- [x] NT004 Do not add migrations, packages, env vars, queues, scheduler, or storage changes.
|
||||
- [x] NT005 Do not redesign Restore Create wizard (Spec 333 owns Create UX).
|
||||
- [x] NT006 Do not introduce any false recovery-proof claims.
|
||||
|
||||