feat: finalize dashboard recovery honesty
This commit is contained in:
parent
03b1beb616
commit
35feea555d
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -153,6 +153,8 @@ ## Active Technologies
|
||||
- PostgreSQL, Redis, filesystem storage under the Laravel app `storage/` tree, plus existing Vite build artifacts in `public/build`; no new database persistence planned (182-platform-relocation)
|
||||
- PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose (183-website-workspace-foundation)
|
||||
- Existing PostgreSQL, Redis, and filesystem storage for `apps/platform`; static build artifacts for `apps/website`; repository-managed workspace manifests and docs; no new database persistence (183-website-workspace-foundation)
|
||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers (184-dashboard-recovery-honesty)
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned (184-dashboard-recovery-honesty)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -187,8 +189,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 184-dashboard-recovery-honesty: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
|
||||
- 183-website-workspace-foundation: Added PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose
|
||||
- 182-platform-relocation: Added PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose
|
||||
- 180-tenant-backup-health: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
@ -30,6 +31,7 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
RecoveryReadiness::class,
|
||||
DashboardKpis::class,
|
||||
NeedsAttention::class,
|
||||
BaselineCompareNow::class,
|
||||
|
||||
@ -96,6 +96,22 @@ public static function shouldRegisterNavigation(): bool
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -249,7 +265,7 @@ public static function makeCreateAction(): Actions\CreateAction
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
|
||||
->with('backupSet');
|
||||
->with(['backupSet', 'operationRun']);
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
@ -930,6 +946,10 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('result_attention_summary')
|
||||
->label('Result attention')
|
||||
->state(fn (RestoreRun $record): string => static::restoreSafetyResolver()->resultAttentionForRun($record)->summary)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('summary_total')
|
||||
->label('Total')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||
|
||||
@ -51,4 +51,16 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return match (request()->string('recovery_posture_reason')->toString()) {
|
||||
'no_history' => 'No executed restore history is visible in the latest tenant restore records.',
|
||||
'failed' => 'The dashboard opened restore history because the latest executed restore failed and a specific detail is not available.',
|
||||
'partial' => 'The dashboard opened restore history because the latest executed restore completed partially and a specific detail is not available.',
|
||||
'completed_with_follow_up' => 'The dashboard opened restore history because skipped or non-applied work still needs follow-up.',
|
||||
'no_recent_issues_visible' => 'The dashboard opened restore history because no recent restore issues are visible, but tenant-wide recovery is still not proven.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,4 +14,15 @@ protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return RestoreRunResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return match (request()->string('recovery_posture_reason')->toString()) {
|
||||
'failed' => 'The dashboard opened this restore run because the latest executed restore failed.',
|
||||
'partial' => 'The dashboard opened this restore run because the latest executed restore completed partially.',
|
||||
'completed_with_follow_up' => 'The dashboard opened this restore run because skipped or non-applied work still needs follow-up.',
|
||||
'no_recent_issues_visible' => 'The dashboard opened this restore run because no recent restore issues are visible, but tenant-wide recovery is still not proven.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,25 +4,18 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\BackupHealthActionTarget;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DashboardKpis extends StatsOverviewWidget
|
||||
{
|
||||
@ -45,8 +38,6 @@ protected function getStats(): array
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
$backupHealthAction = $this->resolveBackupHealthAction($tenant, $backupHealth->primaryActionTarget);
|
||||
|
||||
$openDriftFindings = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
@ -88,10 +79,6 @@ protected function getStats(): array
|
||||
$findingsHelperText = $this->findingsHelperText($tenant);
|
||||
|
||||
return [
|
||||
Stat::make('Backup posture', Str::headline($backupHealth->posture))
|
||||
->description($this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']))
|
||||
->color($backupHealth->tone())
|
||||
->url($backupHealthAction['actionUrl']),
|
||||
Stat::make('Open drift findings', $openDriftFindings)
|
||||
->description($openDriftUrl === null && $openDriftFindings > 0
|
||||
? $findingsHelperText
|
||||
@ -137,7 +124,6 @@ protected function getStats(): array
|
||||
private function emptyStats(): array
|
||||
{
|
||||
return [
|
||||
Stat::make('Backup posture', '—'),
|
||||
Stat::make('Open drift findings', 0),
|
||||
Stat::make('High severity active findings', 0),
|
||||
Stat::make('Active operations', 0),
|
||||
@ -173,106 +159,4 @@ private function canOpenFindings(Tenant $tenant): bool
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupHealthAction(Tenant $tenant, ?BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! $target instanceof BackupHealthActionTarget) {
|
||||
return [
|
||||
'actionUrl' => null,
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->canOpenBackupSurfaces($tenant)) {
|
||||
return [
|
||||
'actionUrl' => null,
|
||||
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||
];
|
||||
}
|
||||
|
||||
return match ($target->surface) {
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
|
||||
'actionUrl' => BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
|
||||
default => [
|
||||
'actionUrl' => null,
|
||||
'helperText' => null,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupSetAction(Tenant $tenant, BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! is_numeric($target->recordId)) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
|
||||
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('view', [
|
||||
'record' => $target->recordId,
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
|
||||
{
|
||||
$description = $assessment->supportingMessage ?? $assessment->headline;
|
||||
|
||||
if ($helperText === null) {
|
||||
return $description;
|
||||
}
|
||||
|
||||
return trim($description.' '.$helperText);
|
||||
}
|
||||
|
||||
private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,10 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -22,6 +24,9 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@ -49,9 +54,14 @@ protected function getViewData(): array
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
$compareAssessment = $aggregate->summaryAssessment;
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
$recoveryEvidence = $this->recoveryEvidence($tenant);
|
||||
|
||||
$items = [];
|
||||
|
||||
if (($recoveryItem = $this->recoveryEvidenceAttentionItem($tenant, $backupHealth, $recoveryEvidence)) !== null) {
|
||||
$items[] = $recoveryItem;
|
||||
}
|
||||
|
||||
if (($backupHealthItem = $this->backupHealthAttentionItem($tenant, $backupHealth)) instanceof BackupHealthDashboardSignal) {
|
||||
$items[] = array_merge(
|
||||
$backupHealthItem->toArray(),
|
||||
@ -195,6 +205,7 @@ protected function getViewData(): array
|
||||
if ($items === []) {
|
||||
$healthyChecks = [
|
||||
...array_filter([$this->backupHealthHealthyCheck($backupHealth)]),
|
||||
...array_filter([$this->recoveryEvidenceHealthyCheck($recoveryEvidence)]),
|
||||
[
|
||||
'title' => 'Baseline compare looks trustworthy',
|
||||
'body' => $aggregate->headline,
|
||||
@ -276,6 +287,17 @@ private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAsses
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function recoveryEvidence(Tenant $tenant): array
|
||||
{
|
||||
/** @var RestoreSafetyResolver $resolver */
|
||||
$resolver = app(RestoreSafetyResolver::class);
|
||||
|
||||
return $resolver->dashboardRecoveryEvidence($tenant);
|
||||
}
|
||||
|
||||
private function backupHealthAttentionItem(Tenant $tenant, TenantBackupHealthAssessment $assessment): ?BackupHealthDashboardSignal
|
||||
{
|
||||
if (! $assessment->hasActiveReason()) {
|
||||
@ -304,7 +326,84 @@ private function backupHealthHealthyCheck(TenantBackupHealthAssessment $assessme
|
||||
|
||||
return [
|
||||
'title' => 'Backups are recent and healthy',
|
||||
'body' => $assessment->supportingMessage ?? 'The latest completed backup is recent and shows no material degradation.',
|
||||
'body' => trim(implode(' ', array_filter([
|
||||
$assessment->supportingMessage ?? 'The latest completed backup is recent and shows no material degradation.',
|
||||
$assessment->positiveClaimBoundary,
|
||||
], static fn (?string $part): bool => filled($part)))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function recoveryEvidenceAttentionItem(Tenant $tenant, TenantBackupHealthAssessment $backupHealth, array $recoveryEvidence): ?array
|
||||
{
|
||||
$overviewState = is_string($recoveryEvidence['overview_state'] ?? null)
|
||||
? $recoveryEvidence['overview_state']
|
||||
: null;
|
||||
|
||||
if ($overviewState === 'unvalidated') {
|
||||
return [
|
||||
'key' => 'recovery_evidence_unvalidated',
|
||||
'title' => 'Recovery evidence is unvalidated',
|
||||
'body' => (string) ($recoveryEvidence['summary'] ?? 'No executed restore history is visible in the latest tenant restore records.'),
|
||||
'supportingMessage' => $backupHealth->positiveClaimBoundary,
|
||||
'badge' => 'Recovery',
|
||||
'badgeColor' => 'warning',
|
||||
'actionElevated' => true,
|
||||
...$this->recoveryActionPayload($tenant, $recoveryEvidence, 'Open restore history'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($overviewState !== 'weakened') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attention = $recoveryEvidence['latest_relevant_attention'] ?? null;
|
||||
$attentionState = $attention instanceof RestoreResultAttention
|
||||
? $attention->state
|
||||
: (is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: null);
|
||||
$primaryNextAction = $attention instanceof RestoreResultAttention
|
||||
? RestoreSafetyCopy::primaryNextAction($attention->primaryNextAction)
|
||||
: null;
|
||||
$claimBoundary = is_string($recoveryEvidence['claim_boundary'] ?? null)
|
||||
? $recoveryEvidence['claim_boundary']
|
||||
: null;
|
||||
|
||||
return [
|
||||
'key' => 'recovery_evidence_'.$attentionState,
|
||||
'title' => $this->recoveryAttentionTitle($attentionState),
|
||||
'body' => (string) ($recoveryEvidence['summary'] ?? 'Recent restore history weakens confidence.'),
|
||||
'supportingMessage' => trim(implode(' ', array_filter([
|
||||
$primaryNextAction,
|
||||
$claimBoundary,
|
||||
], static fn (?string $part): bool => filled($part)))),
|
||||
'badge' => 'Recovery',
|
||||
'badgeColor' => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
|
||||
'actionElevated' => true,
|
||||
...$this->recoveryActionPayload($tenant, $recoveryEvidence, 'Open restore run'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array{title: string, body: string}|null
|
||||
*/
|
||||
private function recoveryEvidenceHealthyCheck(array $recoveryEvidence): ?array
|
||||
{
|
||||
if (($recoveryEvidence['overview_state'] ?? null) !== 'no_recent_issues_visible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'No recent restore issues visible',
|
||||
'body' => trim(implode(' ', array_filter([
|
||||
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
|
||||
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
|
||||
], static fn (?string $part): bool => filled($part)))),
|
||||
];
|
||||
}
|
||||
|
||||
@ -417,4 +516,81 @@ private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function recoveryAttentionTitle(?string $attentionState): string
|
||||
{
|
||||
return match ($attentionState) {
|
||||
RestoreResultAttention::STATE_FAILED => 'Recent restore failed',
|
||||
RestoreResultAttention::STATE_PARTIAL => 'Recent restore is partial',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP => 'Recent restore needs follow-up',
|
||||
default => 'Recent restore history weakens confidence',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array{actionLabel: string, actionUrl: string|null, actionDisabled: bool, helperText: string|null}
|
||||
*/
|
||||
private function recoveryActionPayload(Tenant $tenant, array $recoveryEvidence, string $label): array
|
||||
{
|
||||
if (! $this->canOpenRestoreHistory($tenant)) {
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => null,
|
||||
'actionDisabled' => true,
|
||||
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||
];
|
||||
}
|
||||
|
||||
$reason = is_string($recoveryEvidence['reason'] ?? null) && $recoveryEvidence['reason'] !== ''
|
||||
? $recoveryEvidence['reason']
|
||||
: 'no_history';
|
||||
$latestRun = $recoveryEvidence['latest_relevant_restore_run'] ?? null;
|
||||
|
||||
if (! $latestRun instanceof RestoreRun) {
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
RestoreRunResource::resolveScopedRecordOrFail($latestRun->getKey());
|
||||
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $latestRun->getKey(),
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionLabel' => 'Open restore history',
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => 'The latest restore detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function canOpenRestoreHistory(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function restoreRunListUrl(Tenant $tenant, string $reason): string
|
||||
{
|
||||
return RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\BackupHealthActionTarget;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RecoveryReadiness extends Widget
|
||||
{
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.recovery-readiness';
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'backupPosture' => $this->emptyStatPayload('Backup posture'),
|
||||
'recoveryEvidence' => $this->emptyStatPayload('Recovery evidence'),
|
||||
];
|
||||
}
|
||||
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
$backupHealthAction = $this->resolveBackupHealthAction($tenant, $backupHealth->primaryActionTarget);
|
||||
$recoveryEvidence = $this->recoveryEvidence($tenant);
|
||||
$recoveryAction = $this->resolveRecoveryAction($tenant, $recoveryEvidence);
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
|
||||
'backupPosture' => [
|
||||
'label' => 'Backup posture',
|
||||
'value' => Str::headline($backupHealth->posture),
|
||||
'description' => $this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']),
|
||||
'color' => $backupHealth->tone(),
|
||||
'url' => $backupHealthAction['actionUrl'],
|
||||
],
|
||||
'recoveryEvidence' => [
|
||||
'label' => 'Recovery evidence',
|
||||
'value' => $this->recoveryEvidenceValue($recoveryEvidence['overview_state']),
|
||||
'description' => $this->recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
|
||||
'color' => $this->recoveryEvidenceTone($recoveryEvidence),
|
||||
'url' => $recoveryAction['actionUrl'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, value: string, description: string|null, color: string, url: string|null}
|
||||
*/
|
||||
private function emptyStatPayload(string $label): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'value' => '—',
|
||||
'description' => null,
|
||||
'color' => 'gray',
|
||||
'url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function recoveryEvidence(Tenant $tenant): array
|
||||
{
|
||||
/** @var RestoreSafetyResolver $resolver */
|
||||
$resolver = app(RestoreSafetyResolver::class);
|
||||
|
||||
return $resolver->dashboardRecoveryEvidence($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupHealthAction(Tenant $tenant, ?BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! $target instanceof BackupHealthActionTarget) {
|
||||
return ['actionUrl' => null, 'helperText' => null];
|
||||
}
|
||||
|
||||
if (! $this->canOpenBackupSurfaces($tenant)) {
|
||||
return ['actionUrl' => null, 'helperText' => UiTooltips::INSUFFICIENT_PERMISSION];
|
||||
}
|
||||
|
||||
return match ($target->surface) {
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
|
||||
'actionUrl' => BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
|
||||
default => ['actionUrl' => null, 'helperText' => null],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupSetAction(Tenant $tenant, BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! is_numeric($target->recordId)) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
|
||||
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('view', [
|
||||
'record' => $target->recordId,
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveRecoveryAction(Tenant $tenant, array $recoveryEvidence): array
|
||||
{
|
||||
if (! $this->canOpenRestoreHistory($tenant)) {
|
||||
return ['actionUrl' => null, 'helperText' => UiTooltips::INSUFFICIENT_PERMISSION];
|
||||
}
|
||||
|
||||
$reason = is_string($recoveryEvidence['reason'] ?? null) && $recoveryEvidence['reason'] !== ''
|
||||
? $recoveryEvidence['reason']
|
||||
: 'no_history';
|
||||
$latestRun = $recoveryEvidence['latest_relevant_restore_run'] ?? null;
|
||||
|
||||
if (! $latestRun instanceof RestoreRun) {
|
||||
return [
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
RestoreRunResource::resolveScopedRecordOrFail($latestRun->getKey());
|
||||
|
||||
return [
|
||||
'actionUrl' => RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $latestRun->getKey(),
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'helperText' => 'The latest restore detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function recoveryEvidenceValue(string $overviewState): string
|
||||
{
|
||||
return match ($overviewState) {
|
||||
'unvalidated' => 'Unvalidated',
|
||||
'weakened' => 'Weakened',
|
||||
'no_recent_issues_visible' => 'No recent issues visible',
|
||||
default => Str::headline($overviewState),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
*/
|
||||
private function recoveryEvidenceDescription(array $recoveryEvidence, ?string $helperText): string
|
||||
{
|
||||
$parts = [
|
||||
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
|
||||
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
|
||||
$helperText,
|
||||
];
|
||||
|
||||
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
*/
|
||||
private function recoveryEvidenceTone(array $recoveryEvidence): string
|
||||
{
|
||||
$attentionState = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: null;
|
||||
|
||||
return match ($recoveryEvidence['overview_state'] ?? null) {
|
||||
'unvalidated' => 'warning',
|
||||
'weakened' => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
|
||||
'no_recent_issues_visible' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
|
||||
{
|
||||
$parts = [
|
||||
$assessment->supportingMessage ?? $assessment->headline,
|
||||
];
|
||||
|
||||
if ($assessment->posture === TenantBackupHealthAssessment::POSTURE_HEALTHY) {
|
||||
$parts[] = $assessment->positiveClaimBoundary;
|
||||
}
|
||||
|
||||
if ($helperText !== null) {
|
||||
$parts[] = $helperText;
|
||||
}
|
||||
|
||||
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
}
|
||||
|
||||
private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function canOpenRestoreHistory(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function restoreRunListUrl(Tenant $tenant, string $reason): string
|
||||
{
|
||||
return RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,13 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final readonly class RestoreSafetyResolver
|
||||
{
|
||||
private const int DASHBOARD_RECOVERY_CANDIDATE_LIMIT = 10;
|
||||
|
||||
public function __construct(
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private WriteGateInterface $writeGate,
|
||||
@ -477,6 +480,75 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* backup_posture: string,
|
||||
* overview_state: string,
|
||||
* headline: string,
|
||||
* summary: string,
|
||||
* claim_boundary: string,
|
||||
* latest_relevant_restore_run_id: ?int,
|
||||
* latest_relevant_attention_state: ?string,
|
||||
* latest_relevant_restore_run: ?RestoreRun,
|
||||
* latest_relevant_attention: ?RestoreResultAttention,
|
||||
* reason: string
|
||||
* }
|
||||
*/
|
||||
public function dashboardRecoveryEvidence(Tenant $tenant): array
|
||||
{
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
|
||||
$relevantRun = $relevantRestoreHistory['run'];
|
||||
$relevantAttention = $relevantRestoreHistory['attention'];
|
||||
|
||||
if (! $relevantRun instanceof RestoreRun || ! $relevantAttention instanceof RestoreResultAttention) {
|
||||
return [
|
||||
'backup_posture' => $backupHealth->posture,
|
||||
'overview_state' => 'unvalidated',
|
||||
'headline' => 'Recovery evidence is unvalidated',
|
||||
'summary' => 'No executed restore history is visible in the latest tenant restore records.',
|
||||
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary('run_completed_not_recovery_proven'),
|
||||
'latest_relevant_restore_run_id' => null,
|
||||
'latest_relevant_attention_state' => null,
|
||||
'latest_relevant_restore_run' => null,
|
||||
'latest_relevant_attention' => null,
|
||||
'reason' => 'no_history',
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($relevantAttention->state, [
|
||||
RestoreResultAttention::STATE_FAILED,
|
||||
RestoreResultAttention::STATE_PARTIAL,
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
], true)) {
|
||||
return [
|
||||
'backup_posture' => $backupHealth->posture,
|
||||
'overview_state' => 'weakened',
|
||||
'headline' => 'Recent restore history weakens confidence',
|
||||
'summary' => $relevantAttention->summary,
|
||||
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
|
||||
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
|
||||
'latest_relevant_attention_state' => $relevantAttention->state,
|
||||
'latest_relevant_restore_run' => $relevantRun,
|
||||
'latest_relevant_attention' => $relevantAttention,
|
||||
'reason' => $relevantAttention->state,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'backup_posture' => $backupHealth->posture,
|
||||
'overview_state' => 'no_recent_issues_visible',
|
||||
'headline' => 'No recent restore issues visible',
|
||||
'summary' => 'Recent executed restore history exists without a current follow-up signal.',
|
||||
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
|
||||
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
|
||||
'latest_relevant_attention_state' => $relevantAttention->state,
|
||||
'latest_relevant_restore_run' => $relevantRun,
|
||||
'latest_relevant_attention' => $relevantAttention,
|
||||
'reason' => 'no_recent_issues_visible',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $basis
|
||||
* @return list<string>
|
||||
@ -534,6 +606,44 @@ public function invalidationReasonsForBasis(
|
||||
return $derivedReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{run: ?RestoreRun, attention: ?RestoreResultAttention}
|
||||
*/
|
||||
private function latestRelevantRestoreHistory(Tenant $tenant): array
|
||||
{
|
||||
foreach ($this->dashboardRecoveryCandidates($tenant) as $candidate) {
|
||||
$attention = $this->resultAttentionForRun($candidate);
|
||||
|
||||
if ($attention->state === RestoreResultAttention::STATE_NOT_EXECUTED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'run' => $candidate,
|
||||
'attention' => $attention,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'run' => null,
|
||||
'attention' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
|
||||
*/
|
||||
private function dashboardRecoveryCandidates(Tenant $tenant)
|
||||
{
|
||||
return RestoreRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->with('operationRun:id,outcome')
|
||||
->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
|
||||
->orderByDesc('id')
|
||||
->limit(self::DASHBOARD_RECOVERY_CANDIDATE_LIMIT)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
|
||||
{
|
||||
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
|
||||
|
||||
@ -27,4 +27,20 @@ public function definition(): array
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function recentCompleted(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function staleCompleted(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,4 +32,109 @@ public function definition(): array
|
||||
'completed_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function previewOnly(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'previewed',
|
||||
'is_dry_run' => true,
|
||||
'preview' => [
|
||||
[
|
||||
'policy_identifier' => 'preview-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows',
|
||||
'action' => 'update',
|
||||
],
|
||||
],
|
||||
'results' => [],
|
||||
'metadata' => [],
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function failedOutcome(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'failed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'failed',
|
||||
'policy_identifier' => 'failed-policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partialOutcome(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'partial',
|
||||
'policy_identifier' => 'partial-policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedWithFollowUp(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'follow-up-policy',
|
||||
'assignment_outcomes' => [
|
||||
['status' => 'skipped', 'assignment' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'non_applied' => 1,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedOutcome(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'completed-policy',
|
||||
'assignment_outcomes' => [
|
||||
['status' => 'success', 'assignment' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'non_applied' => 0,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,8 +27,19 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$domainOrder = ['Recovery', 'Backups', 'Governance', 'Findings', 'Baseline', 'Operations'];
|
||||
$grouped = collect($items)->groupBy('badge')->sortBy(fn ($group, $badge) => array_search($badge, $domainOrder, true) !== false ? array_search($badge, $domainOrder, true) : 999);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
@foreach ($grouped as $domain => $domainItems)
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach ($items as $item)
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ $domain }}
|
||||
</div>
|
||||
|
||||
@foreach ($domainItems as $item)
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
@ -43,7 +54,17 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
||||
|
||||
@if (filled($item['actionLabel'] ?? null))
|
||||
<div class="mt-3">
|
||||
@if (filled($item['actionUrl'] ?? null))
|
||||
@if (($item['actionElevated'] ?? false) && filled($item['actionUrl'] ?? null))
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="$item['actionUrl']"
|
||||
size="sm"
|
||||
:color="$item['badgeColor'] ?? 'primary'"
|
||||
outlined
|
||||
>
|
||||
{{ $item['actionLabel'] }}
|
||||
</x-filament::button>
|
||||
@elseif (filled($item['actionUrl'] ?? null))
|
||||
<x-filament::link :href="$item['actionUrl']" size="sm" class="font-medium">
|
||||
{{ $item['actionLabel'] }}
|
||||
</x-filament::link>
|
||||
@ -69,6 +90,8 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<div
|
||||
@if ($pollingInterval)
|
||||
wire:poll.{{ $pollingInterval }}
|
||||
@endif
|
||||
>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-shield-check"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
Recovery Readiness
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach ([$backupPosture, $recoveryEvidence] as $stat)
|
||||
@php
|
||||
$colorClasses = match ($stat['color']) {
|
||||
'danger' => 'border-danger-300 dark:border-danger-700',
|
||||
'warning' => 'border-warning-300 dark:border-warning-700',
|
||||
'success' => 'border-success-300 dark:border-success-700',
|
||||
'info' => 'border-info-300 dark:border-info-700',
|
||||
default => 'border-gray-200 dark:border-white/10',
|
||||
};
|
||||
$valueColorClasses = match ($stat['color']) {
|
||||
'danger' => 'text-danger-600 dark:text-danger-400',
|
||||
'warning' => 'text-warning-600 dark:text-warning-400',
|
||||
'success' => 'text-success-600 dark:text-success-400',
|
||||
'info' => 'text-info-600 dark:text-info-400',
|
||||
default => 'text-gray-950 dark:text-white',
|
||||
};
|
||||
$descriptionColorClasses = match ($stat['color']) {
|
||||
'danger' => 'text-danger-600 dark:text-danger-400',
|
||||
'warning' => 'text-warning-600 dark:text-warning-400',
|
||||
'success' => 'text-success-600 dark:text-success-400',
|
||||
default => 'text-gray-600 dark:text-gray-300',
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if ($stat['url'])
|
||||
<a
|
||||
href="{{ $stat['url'] }}"
|
||||
class="{{ $colorClasses }} block rounded-xl border-l-4 bg-white p-5 shadow-sm transition hover:shadow-md dark:bg-gray-900"
|
||||
>
|
||||
@else
|
||||
<div class="{{ $colorClasses }} rounded-xl border-l-4 bg-white p-5 shadow-sm dark:bg-gray-900">
|
||||
@endif
|
||||
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $stat['label'] }}
|
||||
</div>
|
||||
|
||||
<div class="{{ $valueColorClasses }} mt-1 text-2xl font-bold tracking-tight">
|
||||
{{ $stat['value'] }}
|
||||
</div>
|
||||
|
||||
@if (filled($stat['description']))
|
||||
<div class="{{ $descriptionColorClasses }} mt-2 text-sm leading-relaxed">
|
||||
{{ $stat['description'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($stat['url'])
|
||||
</a>
|
||||
@else
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@ -4,8 +4,8 @@
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
@ -60,7 +60,7 @@
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Stale');
|
||||
|
||||
|
||||
@ -5,18 +5,22 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
@ -44,14 +48,34 @@ function dashboardKpiStatPayloads($component): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function backupPostureStatPayload(\App\Models\Tenant $tenant): array
|
||||
function recoveryReadinessViewData(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
return dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class))['Backup posture'];
|
||||
$component = Livewire::test(RecoveryReadiness::class);
|
||||
$method = new ReflectionMethod(RecoveryReadiness::class, 'getViewData');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($component->instance());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
*/
|
||||
function backupPostureStatPayload(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
return recoveryReadinessViewData($tenant)['backupPosture'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
*/
|
||||
function recoveryEvidenceStatPayload(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
return recoveryReadinessViewData($tenant)['recoveryEvidence'];
|
||||
}
|
||||
|
||||
function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
|
||||
@ -75,6 +99,77 @@ function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attri
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
function makeHealthyBackupForRecoveryKpi(\App\Models\Tenant $tenant, array $attributes = []): BackupSet
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create(array_merge([
|
||||
'name' => 'Healthy recovery KPI backup',
|
||||
'item_count' => 1,
|
||||
], $attributes));
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'healthy-recovery-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
dataset('dashboard-recovery-evidence-cases', [
|
||||
'failed history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
'Tenant recovery is not proven.',
|
||||
RestoreResultAttention::STATE_FAILED,
|
||||
],
|
||||
'partial history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->partialOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
'Tenant-wide recovery is not proven.',
|
||||
RestoreResultAttention::STATE_PARTIAL,
|
||||
],
|
||||
'follow-up history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
'Tenant-wide recovery is not proven.',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
],
|
||||
'calm completed history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'No recent issues visible',
|
||||
'Recent executed restore history exists without a current follow-up signal.',
|
||||
'Tenant-wide recovery is not proven.',
|
||||
'no_recent_issues_visible',
|
||||
],
|
||||
]);
|
||||
|
||||
it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -330,6 +425,75 @@ function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attri
|
||||
expect($stat['description'])->toContain('20 minutes');
|
||||
});
|
||||
|
||||
it('keeps healthy backups honest when no executed restore history exists yet', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = makeHealthyBackupForRecoveryKpi($tenant);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$backupStat = backupPostureStatPayload($tenant);
|
||||
$recoveryStat = recoveryEvidenceStatPayload($tenant);
|
||||
|
||||
expect($backupStat['value'])->toBe('Healthy')
|
||||
->and($backupStat['description'])->toContain('Backup health reflects backup inputs only and does not prove restore success.');
|
||||
|
||||
expect($recoveryStat)->toMatchArray([
|
||||
'value' => 'Unvalidated',
|
||||
'url' => RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'no_history',
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($recoveryStat['description'])
|
||||
->toContain('No executed restore history is visible in the latest tenant restore records.')
|
||||
->toContain('Tenant-wide recovery is not proven.');
|
||||
});
|
||||
|
||||
it('surfaces weak and calm restore history on the recovery evidence KPI', function (
|
||||
Closure $makeRestoreRun,
|
||||
string $expectedValue,
|
||||
string $expectedSummary,
|
||||
string $expectedBoundary,
|
||||
string $expectedReason,
|
||||
): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryKpi($tenant);
|
||||
|
||||
$restoreBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Restore evidence backup',
|
||||
]);
|
||||
|
||||
$restoreRun = $makeRestoreRun($tenant, $restoreBackupSet);
|
||||
|
||||
$recoveryStat = recoveryEvidenceStatPayload($tenant);
|
||||
|
||||
expect($recoveryStat)->toMatchArray([
|
||||
'value' => $expectedValue,
|
||||
'url' => RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
'recovery_posture_reason' => $expectedReason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($recoveryStat['description'])
|
||||
->toContain($expectedSummary)
|
||||
->toContain($expectedBoundary);
|
||||
})->with('dashboard-recovery-evidence-cases');
|
||||
|
||||
it('keeps the posture healthy but routes the KPI to schedules when backup automation needs follow-up', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeHealthyBackupForRecoveryPerformance(\App\Models\Tenant $tenant): BackupSet
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create([
|
||||
'name' => 'Performance healthy backup',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'performance-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
it('caps dashboard recovery evidence derivation to the latest 10 restore-run candidates', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryPerformance($tenant);
|
||||
|
||||
$historyBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Candidate cap backup',
|
||||
]);
|
||||
|
||||
foreach (range(1, 10) as $minutesAgo) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'created_at' => now()->subMinutes($minutesAgo),
|
||||
'started_at' => now()->subMinutes($minutesAgo),
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'created_at' => now()->subMinutes(11),
|
||||
'started_at' => now()->subMinutes(11),
|
||||
'completed_at' => now()->subMinutes(11),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Unvalidated')
|
||||
->assertDontSee('Weakened');
|
||||
});
|
||||
|
||||
it('renders dashboard recovery posture and restore-history list with bounded query volume', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryPerformance($tenant);
|
||||
|
||||
$historyBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Query-shape backup',
|
||||
]);
|
||||
|
||||
foreach (range(1, 5) as $offset) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes($offset),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (range(6, 10) as $offset) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes($offset),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (range(11, 15) as $offset) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes($offset),
|
||||
]);
|
||||
}
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
assertNoOutboundHttp(function (): void {
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Weakened');
|
||||
});
|
||||
|
||||
$dashboardQueries = count(DB::getQueryLog());
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant): void {
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Result attention')
|
||||
->assertSee('The restore did not complete successfully. Follow-up is still required.');
|
||||
});
|
||||
|
||||
$listQueries = count(DB::getQueryLog());
|
||||
|
||||
expect($dashboardQueries)->toBeLessThanOrEqual(20)
|
||||
->and($listQueries)->toBeLessThanOrEqual(40);
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
@ -15,12 +16,14 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
@ -68,6 +71,61 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\Tenant $tenant, array $attributes = []): BackupSet
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create(array_merge([
|
||||
'name' => 'Healthy recovery needs-attention backup',
|
||||
'item_count' => 1,
|
||||
], $attributes));
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'healthy-recovery-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
dataset('needs-attention-recovery-cases', [
|
||||
'failed history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Recent restore failed',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
'failed',
|
||||
],
|
||||
'partial history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->partialOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Recent restore is partial',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
'partial',
|
||||
],
|
||||
'follow-up history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Recent restore needs follow-up',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
],
|
||||
]);
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
@ -133,6 +191,14 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($healthyBackup)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -161,6 +227,7 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Current governance and findings signals look trustworthy.')
|
||||
->assertSee('Baseline compare looks trustworthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('No confirmed drift in the latest baseline compare.')
|
||||
->assertDontSee('Baseline compare posture');
|
||||
|
||||
@ -499,6 +566,14 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($healthyBackup)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -526,11 +601,133 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('Baseline compare looks trustworthy')
|
||||
->assertDontSee('Backup schedules need follow-up')
|
||||
->assertDontSee('No usable backup basis');
|
||||
});
|
||||
|
||||
it('surfaces missing restore history and suppresses the healthy fallback when recovery evidence is unvalidated', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = makeHealthyBackupForRecoveryNeedsAttention($tenant);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Recovery evidence is unvalidated')
|
||||
->assertSee('No executed restore history is visible in the latest tenant restore records.')
|
||||
->assertSee('Backup health reflects backup inputs only and does not prove restore success.')
|
||||
->assertSee('Open restore history')
|
||||
->assertDontSee('Current governance and findings signals look trustworthy.')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
|
||||
expect($component->html())->toContain(RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'no_history',
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
it('surfaces recent weak restore history in needs-attention with the matching restore drillthrough', function (
|
||||
Closure $makeRestoreRun,
|
||||
string $expectedTitle,
|
||||
string $expectedBody,
|
||||
string $expectedReason,
|
||||
): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryNeedsAttention($tenant);
|
||||
|
||||
$restoreBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Recovery attention restore backup',
|
||||
]);
|
||||
|
||||
$restoreRun = $makeRestoreRun($tenant, $restoreBackupSet);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee($expectedTitle)
|
||||
->assertSee($expectedBody)
|
||||
->assertSee('Open restore run')
|
||||
->assertDontSee('Current governance and findings signals look trustworthy.');
|
||||
|
||||
expect($component->html())->toContain(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
'recovery_posture_reason' => $expectedReason,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
})->with('needs-attention-recovery-cases');
|
||||
|
||||
it('adds a calm recovery healthy-check without claiming tenant-wide recovery proof', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryNeedsAttention($tenant);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subHour(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$restoreBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Calm recovery restore backup',
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($restoreBackupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Current governance and findings signals look trustworthy.')
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('Recent executed restore history exists without a current follow-up signal.')
|
||||
->assertSee('Tenant-wide recovery is not proven.')
|
||||
->assertDontSee('Recovery evidence is unvalidated')
|
||||
->assertDontSee('Recent restore failed');
|
||||
});
|
||||
|
||||
it('keeps backup-health attention visible but non-clickable when the member lacks backup view access', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ViewRestoreRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
@ -12,6 +13,45 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
dataset('dashboard-linked-restore-result-reasons', [
|
||||
'failed' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'failed',
|
||||
'The dashboard opened this restore run because the latest executed restore failed.',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
],
|
||||
'partial' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->partialOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'partial',
|
||||
'The dashboard opened this restore run because the latest executed restore completed partially.',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
],
|
||||
'completed with follow-up' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'completed_with_follow_up',
|
||||
'The dashboard opened this restore run because skipped or non-applied work still needs follow-up.',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
],
|
||||
]);
|
||||
|
||||
it('elevates restore result attention above raw item diagnostics', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -215,3 +255,26 @@
|
||||
->assertSee('Preview only. This foundation type is not applied during execution.')
|
||||
->assertDontSee('Unknown');
|
||||
});
|
||||
|
||||
it('keeps dashboard-linked restore-result confirmation aligned for problematic restore runs', function (
|
||||
Closure $makeRestoreRun,
|
||||
string $reason,
|
||||
string $expectedSubheading,
|
||||
string $expectedSummary,
|
||||
): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
$restoreRun = $makeRestoreRun($tenant, $backupSet);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee($expectedSubheading)
|
||||
->assertSee($expectedSummary);
|
||||
})->with('dashboard-linked-restore-result-reasons');
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('confirms missing relevant restore history on the restore-run list surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'no_history',
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('No executed restore history is visible in the latest tenant restore records.')
|
||||
->assertSee('No restore runs');
|
||||
});
|
||||
|
||||
it('keeps recovery-posture list fallback copy readable when a weak restore signal lands on history', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Weak history backup',
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'completed_with_follow_up',
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('The dashboard opened restore history because skipped or non-applied work still needs follow-up.')
|
||||
->assertSee('The restore completed, but follow-up remains for skipped or non-applied work.');
|
||||
});
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -13,6 +15,8 @@
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
@ -127,3 +131,67 @@ function getRestoreRunEmptyStateAction(Testable $component, string $name): ?Acti
|
||||
expect($action->isDisabled())->toBeTrue();
|
||||
expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
test('readonly members can inspect restore-run history while mutations remain disabled', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->for($tenant)->for($backupSet)->completedOutcome()->create();
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListRestoreRuns::class)
|
||||
->assertCanSeeTableRecords([$restoreRun])
|
||||
->assertTableActionDisabled('archive', $restoreRun);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('in-scope members without restore-history view capability receive 403 on restore-run list and detail drillthroughs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->for($tenant)->for($backupSet)->failedOutcome()->create();
|
||||
|
||||
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||
$mock->shouldReceive('primeMemberships')->andReturnNull();
|
||||
$mock->shouldReceive('isMember')
|
||||
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
||||
|
||||
$mock->shouldReceive('can')
|
||||
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||
|
||||
return match ($capability) {
|
||||
Capabilities::TENANT_VIEW => false,
|
||||
default => true,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
@ -59,9 +60,11 @@
|
||||
// lazy-loaded widgets and will not appear in the initial
|
||||
// server-rendered HTML.
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Healthy')
|
||||
->assertSee('Healthy');
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
->assertSee('Active operations')
|
||||
->assertSee('healthy queued or running tenant work');
|
||||
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
function seedRecoveryVisibilityScenario(Tenant $tenant): RestoreRun
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create([
|
||||
'name' => 'Recovery visibility backup',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'recovery-visibility-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
}
|
||||
|
||||
it('keeps recovery drillthrough inspectable for readonly tenant members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||
$restoreRun = seedRecoveryVisibilityScenario($tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Recent restore failed')
|
||||
->assertSee('Open restore run');
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Weakened');
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('keeps recovery summaries cautious while restore-history drillthrough is forbidden for in-scope members without view capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$restoreRun = seedRecoveryVisibilityScenario($tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
|
||||
|
||||
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||
$mock->shouldReceive('primeMemberships')->andReturnNull();
|
||||
$mock->shouldReceive('isMember')
|
||||
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
||||
|
||||
$mock->shouldReceive('can')
|
||||
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||
|
||||
return match ($capability) {
|
||||
Capabilities::TENANT_VIEW => false,
|
||||
default => true,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Recent restore failed')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->assertSee('Open restore run');
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Weakened')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps dashboard and restore-history recovery surfaces deny-as-not-found for non-members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
$restoreRun = seedRecoveryVisibilityScenario($tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertNotFound();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -68,3 +69,111 @@
|
||||
->and($attention->followUpRequired)->toBeTrue()
|
||||
->and($attention->primaryNextAction)->toBe('review_skipped_items');
|
||||
});
|
||||
|
||||
it('ignores preview-only runs when deriving tenant recovery evidence', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['overview_state'])->toBe('unvalidated')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBeNull()
|
||||
->and($overview['latest_relevant_attention_state'])->toBeNull()
|
||||
->and($overview['reason'])->toBe('no_history');
|
||||
});
|
||||
|
||||
it('uses the latest relevant restore run when calmer history is newer than older failures', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$latestCompleted = RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['overview_state'])->toBe('no_recent_issues_visible')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestCompleted->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_COMPLETED)
|
||||
->and($overview['reason'])->toBe('no_recent_issues_visible');
|
||||
});
|
||||
|
||||
it('keeps recent weak restore history ahead of older calmer history', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$latestFailed = RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['overview_state'])->toBe('weakened')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestFailed->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_FAILED)
|
||||
->and($overview['claim_boundary'])->toBe('Tenant recovery is not proven.')
|
||||
->and($overview['reason'])->toBe(RestoreResultAttention::STATE_FAILED);
|
||||
});
|
||||
|
||||
it('falls back to calm completed history when no recent weak evidence is present', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
BackupSet::factory()->for($tenant)->recentCompleted()->create();
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
$latestCompleted = RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['backup_posture'])->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY)
|
||||
->and($overview['overview_state'])->toBe('no_recent_issues_visible')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestCompleted->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_COMPLETED)
|
||||
->and($overview['reason'])->toBe('no_recent_issues_visible');
|
||||
});
|
||||
|
||||
175
specs/001-dashboard-recovery-honesty/spec.md
Normal file
175
specs/001-dashboard-recovery-honesty/spec.md
Normal file
@ -0,0 +1,175 @@
|
||||
# Feature Specification: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Feature Branch**: `[001-dashboard-recovery-honesty]`
|
||||
**Created**: 2026-04-08
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 184 — Dashboard Recovery Posture Honesty"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: `/admin/t/{tenant}`, `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}`, `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/backup-sets/{record}`
|
||||
- **Data Ownership**: Tenant-owned `BackupSet`, `RestoreRun`, and linked `OperationRun` outcome context are read within the active workspace and tenant scope to derive a more honest overview statement. No new persisted recovery-confidence state is introduced.
|
||||
- **RBAC**: Workspace plus tenant membership remains required on every affected surface. Members who can open the tenant dashboard must see honest summary boundaries even when they cannot start or manage restore runs. Existing restore-run creation and mutation actions remain under current restore permissions. Non-members continue to receive deny-as-not-found semantics.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Dashboard / stats overview | Explicit stat click per signal | forbidden | Supporting text inside the stat description | none | `/admin/t/{tenant}` | Signal-specific drill-through to `/admin/t/{tenant}/restore-runs` or `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Dashboard KPIs / Backup posture | Backup health is separate from restore evidence | existing widget pattern |
|
||||
| Needs Attention / Healthy Checks panel | Dashboard / attention summary | Explicit card CTA per attention item; healthy state is read-only | forbidden | Card CTA and helper copy only | none | `/admin/t/{tenant}` | `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Needs attention / Healthy checks | Unknown and weakened recovery confidence are visible before drilldown | existing widget pattern |
|
||||
| Restore runs page | CRUD / list-first resource | Full-row click to restore-run detail | required | Existing header action plus More menu | Existing More and bulk More groups | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{record}` | Tenant context plus restore-run identity | Restore runs / Restore run | Recent restore outcome and follow-up reason confirm the overview claim | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Tenant operator | Dashboard summary | Do healthy backups also have supporting restore evidence, or is that still unknown? | Backup posture, recovery-confidence qualifier, visible claim boundary, next step | Per-run causes, raw backup metadata, deeper restore evidence | backup health, recovery evidence availability, recent restore attention | None; read-only summary | Open restore history, open supporting backup context when backup health itself needs follow-up | none |
|
||||
| Needs Attention / Healthy Checks panel | Tenant operator | Dashboard attention and healthy-boundary surface | What recovery-confidence issue needs action now, and why? | No restore history, weakened recent restore history, boundary copy, concrete next action | Full restore results, preview or check details, low-level run metadata | backup health, recovery evidence availability, restore result attention, recency | None; read-only summary | Open restore history, open latest problematic restore run | none |
|
||||
| Restore runs page | Tenant operator | List and detail | Which restore runs explain the dashboard signal? | Recent restore status, result-attention reason, completed timing, related backup context | Assignment-level failures, preview detail, low-level result payloads | execution lifecycle, result attention, follow-up state | Existing restore-run maintenance actions only | Inspect restore run, create restore run | Existing rerun, archive, restore archived, and force-delete actions |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: A tenant dashboard can currently look calm or healthy even when restore history is absent or recent restore results weaken confidence, so operators can overread backup health as recovery posture.
|
||||
- **Existing structure is insufficient because**: Backup health, restore history, and restore result attention already exist as separate truths, but the summary surfaces do not yet combine them with an honest claim boundary. Operators must manually cross-check multiple pages to avoid an overclaim.
|
||||
- **Narrowest correct implementation**: Derive a small set of overview honesty signals from existing backup health assessment, restore history presence, and per-run restore result attention, then show them on the existing dashboard widgets and existing restore-run drilldowns.
|
||||
- **Ownership cost**: Additional widget copy, narrow derived-summary logic, and focused feature plus RBAC regression tests that keep overview language and drilldown continuity aligned.
|
||||
- **Alternative intentionally rejected**: A new recovery-confidence score, enum, page, or persisted posture state was rejected because it would introduce new truth and new ownership cost before the current overview surfaces tell the existing truth accurately.
|
||||
- **Release truth**: current-release truth hardening
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See Unvalidated Recovery Confidence Early (Priority: P1)
|
||||
|
||||
A tenant operator opens the tenant dashboard and needs to know within seconds whether healthy-looking backups are backed by any relevant restore evidence or whether recovery confidence is still unvalidated.
|
||||
|
||||
**Why this priority**: This is the highest-risk trust gap. If the first overview screen quietly converts healthy backups into a healthy recovery impression, later detail truth arrives too late.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering the tenant dashboard with healthy backup fixtures and no relevant restore history, then verifying that the overview shows an explicit unvalidated or unknown recovery-confidence signal instead of an all-clear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backup posture and no relevant restore history, **When** the operator opens the tenant dashboard, **Then** the summary shows healthy backups plus an explicit unvalidated or unknown recovery-confidence message and a next action.
|
||||
2. **Given** the same tenant has no other attention items, **When** the healthy-check state renders, **Then** the widget does not show an unqualified all-good message and instead keeps the recovery-confidence boundary visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Escalate Weak Restore History on Overview (Priority: P2)
|
||||
|
||||
A tenant operator reviewing the dashboard needs recent failed, partial, or follow-up restore results to affect the overview immediately instead of hiding inside restore history details.
|
||||
|
||||
**Why this priority**: Weak restore history is evidence that directly changes how much trust the operator should place in recovery posture. It cannot remain a drilldown-only fact.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering overview surfaces with recent failed, partial, and follow-up restore fixtures and verifying that each case creates a visible confidence-related attention signal with matching drilldown behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backups but a recent failed or partial restore run, **When** the operator opens the dashboard, **Then** Needs Attention shows a recovery-confidence issue that links to restore history explaining the same failure state.
|
||||
2. **Given** a tenant has a recent restore run that completed with follow-up required, **When** the operator opens the dashboard, **Then** the overview shows weakened confidence rather than a neutral or healthy-only message.
|
||||
3. **Given** recent restore history exists without a current confidence-weakening attention state, **When** the operator opens the dashboard, **Then** the overview may say that no recent restore issues are visible but does not claim that recovery is proven.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Honest Drilldowns and RBAC Boundaries (Priority: P3)
|
||||
|
||||
A tenant operator or read-only member needs the dashboard signal and the destination surface to tell the same story, while RBAC limits must never make the summary look stronger than the accessible evidence.
|
||||
|
||||
**Why this priority**: Overview honesty fails if the next click contradicts the dashboard or if authorization gaps hide weakness by omission.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening overview signals as different tenant members, verifying that the linked restore-history surface confirms the same reason, and ensuring restricted users still see cautious summary language.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the dashboard says recovery confidence is unvalidated because no relevant restore history exists, **When** the operator follows the dashboard action, **Then** the destination surface confirms that the tenant lacks relevant restore history.
|
||||
2. **Given** the dashboard says recovery confidence is weakened by a recent problematic restore, **When** the operator follows the dashboard action, **Then** the destination surface confirms the same failed, partial, or follow-up reason.
|
||||
3. **Given** a tenant member can see the dashboard but cannot open deeper restore evidence, **When** the dashboard renders, **Then** the summary remains cautious and truthful and does not replace missing evidence with a stronger claim.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has only draft, preview-only, or dry-run restore history; the overview treats recovery confidence as unvalidated rather than positive.
|
||||
- A tenant has both an older successful restore and a more recent failed or follow-up restore; the weakened signal takes precedence on the summary surface.
|
||||
- A summary signal points to a restore run that is no longer directly openable; the drilldown falls back to tenant-scoped restore history rather than a dead end.
|
||||
- A user can see the dashboard but lacks permission to inspect restore runs; the summary still states unknown or weakened confidence without suggesting that everything is healthy.
|
||||
- Healthy backup posture and backup-automation follow-up can coexist with unvalidated recovery confidence; the overview must not let one healthy-sounding statement erase the other caution.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
This feature introduces no new Microsoft Graph calls, no new background work, no new `OperationRun`, and no new persistence. It is a read-first truth-hardening slice that makes existing backup and restore evidence visible more honestly on tenant overview surfaces.
|
||||
|
||||
Authorization remains in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members must continue to receive 404 responses. Established members missing deeper restore capabilities must continue to receive 403 on execution paths, but summary visibility must not depend on restore-mutation rights.
|
||||
|
||||
This slice reuses existing Filament dashboard widgets, stat descriptions, attention cards, and existing restore-run resource surfaces. No new local badge framework, page-local status language, or extra action surface is introduced. UI-FIL-001 is satisfied by continuing to use existing Filament widget primitives and shared status language. UX-001 create, edit, and detail-form rules are not materially changed; the dashboard keeps its existing layout, and the restore-run resource keeps its existing list-and-view contract.
|
||||
|
||||
The affected Filament surfaces keep exactly one primary inspect or open model, add no redundant View actions, and introduce no new destructive actions. Existing destructive restore-run actions continue to follow the current placement and confirmation rules. Action Surface Contract expectations therefore remain satisfied.
|
||||
|
||||
Existing per-run restore result attention remains the authoritative signal for restore outcome quality. This feature may summarize or elevate that truth, but it must not duplicate it with a second scoring or status system.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-184-001**: The system MUST present tenant backup health and tenant recovery-confidence evidence as separate truths on tenant dashboard summary surfaces.
|
||||
- **FR-184-002**: When backup health is healthy but no relevant restore history exists, the system MUST display an explicit unknown or unvalidated recovery-confidence state and MUST NOT present an all-clear summary.
|
||||
- **FR-184-003**: When the system cannot determine recovery confidence from the available restore history, the system MUST say that limitation directly instead of inferring a positive recovery claim from backup health alone.
|
||||
- **FR-184-004**: Needs Attention or the healthy-boundary surface MUST surface absence of restore history as an overview-relevant condition with a clear next action.
|
||||
- **FR-184-005**: Recent restore history with `failed`, `partial`, `completed_with_follow_up`, or an equivalent confidence-weakening attention state MUST appear on overview surfaces as a recovery-confidence issue.
|
||||
- **FR-184-006**: Overview surfaces MUST distinguish unknown or unvalidated confidence from weakened confidence and MUST NOT collapse both states into one ambiguous bucket.
|
||||
- **FR-184-007**: Any positive backup-health summary on the dashboard MUST show a visible claim boundary that healthy backups reflect backup inputs only and do not prove restore success.
|
||||
- **FR-184-008**: Healthy checks MUST NOT render an unqualified healthy or all-clear state when recovery confidence is unknown, weakened, or not evaluated.
|
||||
- **FR-184-009**: When recovery confidence is unknown or weakened, overview copy MUST explain what is missing or concerning, why that affects confidence, and what the operator should do next.
|
||||
- **FR-184-010**: Overview signals about missing restore history MUST drill into a tenant-scoped restore-history surface that confirms the absence or insufficiency of relevant restore evidence.
|
||||
- **FR-184-011**: Overview signals about weakened restore history MUST drill into a tenant-scoped restore-history surface or restore-run detail that confirms the same failed, partial, or follow-up reason shown on the summary surface.
|
||||
- **FR-184-012**: The feature MUST reuse existing per-run restore result attention as the authoritative quality signal for restore outcomes and MUST NOT introduce a parallel positive-scoring or reason system.
|
||||
- **FR-184-013**: The feature MUST NOT introduce a new state or message that claims recovery is proven, guaranteed, or strongly confirmed beyond the evidence the current system already has.
|
||||
- **FR-184-014**: RBAC limits on restore history visibility MUST NOT cause summary surfaces to make stronger recovery claims than the visible evidence supports; when detailed restore evidence cannot be opened, the summary must remain cautious and truthful.
|
||||
- **FR-184-015**: Tenant-linked summaries shown outside the tenant dashboard, if they reuse this posture signal, MUST preserve the same meaning for unknown, weakened, and backup-only-positive states.
|
||||
- **FR-184-016**: The feature MUST derive its summary state from existing tenant backup health, restore history, and restore result attention records and MUST NOT add a new persisted recovery-confidence field, table, or scoring artifact.
|
||||
- **FR-184-017**: When recent restore history exists without a current confidence-weakening attention state, overview surfaces MAY state that no recent restore issues are visible, but MUST stop short of claiming recovery proof.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Relevant restore history means tenant-scoped restore runs that have reached an executed result state or another existing result-attention state that the current system can classify. Draft-only, preview-only, or dry-run-only history does not count as proven recovery evidence.
|
||||
- Existing restore history surfaces already show enough result detail to confirm failed, partial, and follow-up reasons once the operator drills down from the overview.
|
||||
- Workspace-level surfaces that later reuse this posture language should consume the same tenant-level semantics rather than creating a separate recovery-confidence vocabulary.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing tenant dashboard surfaces remain the operator entry point for this slice.
|
||||
- Existing `TenantBackupHealthAssessment` and `TenantBackupHealthResolver` remain the source of backup-input truth.
|
||||
- Existing `RestoreRun` history surfaces and `RestoreSafetyResolver::resultAttentionForRun(...)` remain the source of restore-outcome truth.
|
||||
- Existing RBAC helper-text and disabled-link patterns remain the fallback behavior when the operator cannot open deeper restore evidence.
|
||||
|
||||
## Out of Scope and Follow-up
|
||||
|
||||
- No new recovery-confidence engine, score, enum, or dedicated posture page.
|
||||
- No automatic restore validation, scheduled restore probes, or restore execution changes.
|
||||
- No new backup-health rules, restore-result-attention taxonomy changes, or restore-safety model redesign.
|
||||
- No new claim that a tenant is recovery-proven.
|
||||
- Reasonable follow-up work includes broader workspace-level recovery rollups after tenant-level overview honesty is stable.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard summary widgets | `app/Filament/Pages/TenantDashboard.php`, `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none added | Explicit stat and card CTA only; no row click | none | n/a | none | n/a | n/a | no new audit event | Action Surface Contract stays satisfied because the dashboard remains read-only. UI-FIL-001 stays satisfied through existing Filament widget primitives. UX-001 create and edit form rules are not applicable to this dashboard slice. |
|
||||
| RestoreRunResource list and detail | `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Existing `New restore run` action remains | `recordUrl()` clickable row to restore-run detail | Existing More-menu maintenance actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing `New restore run` empty-state CTA remains | none added | Existing restore-run create flow remains unchanged | existing restore-run mutation audit behavior only | This spec reuses restore-run list and detail as canonical drilldowns and adds no new destructive action or placement exception. |
|
||||
|
||||
## Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Backup health assessment**: Tenant-level summary of backup freshness and input health that is useful but not sufficient to prove recovery success.
|
||||
- **Restore history**: Tenant-scoped record of restore runs whose presence, absence, and recent outcomes affect how strongly the product can speak about recovery confidence.
|
||||
- **Restore result attention**: Per-run classification that distinguishes completed, failed, partial, follow-up, and not-executed outcome states that matter for operator trust.
|
||||
- **Recovery posture summary**: Non-persisted dashboard statement that combines backup health, restore history presence, and restore-result attention without becoming a new score or stored state.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance testing, operators can identify within 10 seconds whether a tenant has healthy backups plus unvalidated or weakened recovery evidence from `/admin/t/{tenant}` without opening raw details.
|
||||
- **SC-002**: In 100% of tested tenants with no relevant restore history, the dashboard or healthy-boundary surface shows an explicit unvalidated or unknown recovery-confidence signal and never shows a healthy-only all-clear.
|
||||
- **SC-003**: In 100% of tested tenants with recent failed, partial, or follow-up restore runs, the overview shows a confidence-related attention item with a drilldown that confirms the same reason.
|
||||
- **SC-004**: In 100% of tested positive backup-health scenarios, summary-level copy includes the claim boundary that healthy backups do not prove restore success.
|
||||
- **SC-005**: In 100% of tested RBAC-restricted scenarios, summary surfaces remain cautious and truthful even when the user cannot open deeper restore evidence pages.
|
||||
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-08
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-04-08. The spec keeps behavior focused on dashboard honesty, restore-history evidence, and operator trust boundaries. Repository-specific route and surface references are retained only where this template and constitution require concrete scope identification.
|
||||
@ -0,0 +1,366 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Dashboard Recovery Posture Honesty Surface Contracts
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Internal reference contract for tenant dashboard recovery-posture honesty.
|
||||
The application continues to return rendered HTML through Filament and
|
||||
Livewire. The vendor media types below document the structured dashboard and
|
||||
restore-history models that must be derivable before rendering. This is not
|
||||
a public API commitment.
|
||||
paths:
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
summary: Tenant dashboard recovery-posture summary surface
|
||||
description: >-
|
||||
Returns the rendered tenant dashboard. The vendor media type documents
|
||||
the backup posture, recovery-evidence summary, and recovery-related
|
||||
attention or healthy-check state that must be available before rendering.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered tenant dashboard page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.dashboard-recovery-posture+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantDashboardRecoverySurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks the capability required for the dashboard surface
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/restore-runs:
|
||||
get:
|
||||
summary: Restore history collection surface with recovery continuity context
|
||||
description: >-
|
||||
Returns the rendered restore-run list. The vendor media type documents
|
||||
the continuity subheading and result-attention facts needed to confirm a
|
||||
dashboard recovery signal.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: recovery_posture_reason
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/RecoveryPostureReason'
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered restore-run list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.restore-history-collection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RestoreHistoryCollectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks restore-history viewing capability
|
||||
'404':
|
||||
description: Restore history is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/restore-runs/{restoreRun}:
|
||||
get:
|
||||
summary: Restore history detail surface
|
||||
description: >-
|
||||
Returns the rendered restore-run detail page. The vendor media type
|
||||
documents the result-attention and claim-boundary facts that confirm a
|
||||
specific dashboard recovery signal.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: restoreRun
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered restore-run detail page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.restore-history-detail+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RestoreHistoryDetailSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks capability for the restore-run detail surface
|
||||
'404':
|
||||
description: Restore run is not visible because workspace or tenant membership is missing
|
||||
components:
|
||||
schemas:
|
||||
TenantDashboardRecoverySurface:
|
||||
type: object
|
||||
required:
|
||||
- backupPosture
|
||||
- recoveryEvidence
|
||||
properties:
|
||||
backupPosture:
|
||||
$ref: '#/components/schemas/BackupPostureStat'
|
||||
recoveryEvidence:
|
||||
$ref: '#/components/schemas/RecoveryEvidenceSummary'
|
||||
needsAttentionItems:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RecoveryAttentionItem'
|
||||
healthyChecks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HealthyCheck'
|
||||
BackupPostureStat:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- posture
|
||||
- summary
|
||||
- claimBoundary
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
posture:
|
||||
type: string
|
||||
enum:
|
||||
- absent
|
||||
- stale
|
||||
- degraded
|
||||
- healthy
|
||||
summary:
|
||||
type: string
|
||||
claimBoundary:
|
||||
type: string
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
RecoveryEvidenceSummary:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- headline
|
||||
- summary
|
||||
- claimBoundary
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
description: Canonical recovery-evidence state key for the dashboard surface.
|
||||
enum:
|
||||
- unvalidated
|
||||
- weakened
|
||||
- no_recent_issues_visible
|
||||
headline:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
claimBoundary:
|
||||
type: string
|
||||
latestRelevantRestoreRunId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
latestRelevantAttentionState:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum:
|
||||
- failed
|
||||
- partial
|
||||
- completed_with_follow_up
|
||||
- completed
|
||||
- null
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
RecoveryAttentionItem:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
- badge
|
||||
- badgeColor
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
supportingMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
badge:
|
||||
type: string
|
||||
badgeColor:
|
||||
type: string
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
HealthyCheck:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
RestoreHistoryCollectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
subheading:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RestoreHistoryRow'
|
||||
emptyState:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/EmptyState'
|
||||
- type: 'null'
|
||||
RestoreHistoryRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
- resultAttention
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
backupSetName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
status:
|
||||
type: string
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
requestedBy:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
resultAttention:
|
||||
$ref: '#/components/schemas/ResultAttentionFact'
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
RestoreHistoryDetailSurface:
|
||||
type: object
|
||||
required:
|
||||
- header
|
||||
- resultAttention
|
||||
properties:
|
||||
header:
|
||||
$ref: '#/components/schemas/RestoreHistoryHeader'
|
||||
resultAttention:
|
||||
$ref: '#/components/schemas/ResultAttentionFact'
|
||||
relatedLinks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActionLink'
|
||||
RestoreHistoryHeader:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
backupSetName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
ResultAttentionFact:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- summary
|
||||
- followUpRequired
|
||||
- claimBoundary
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- not_executed
|
||||
- failed
|
||||
- partial
|
||||
- completed_with_follow_up
|
||||
- completed
|
||||
summary:
|
||||
type: string
|
||||
followUpRequired:
|
||||
type: boolean
|
||||
claimBoundary:
|
||||
type: string
|
||||
primaryNextAction:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
EmptyState:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
ActionLink:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- disabled
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
disabled:
|
||||
type: boolean
|
||||
helperText:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
RecoveryPostureReason:
|
||||
type: string
|
||||
enum:
|
||||
- no_history
|
||||
- failed
|
||||
- partial
|
||||
- completed_with_follow_up
|
||||
- no_recent_issues_visible
|
||||
121
specs/184-dashboard-recovery-honesty/data-model.md
Normal file
121
specs/184-dashboard-recovery-honesty/data-model.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Data Model: Dashboard Recovery Posture Honesty
|
||||
|
||||
## Existing Evidence Models
|
||||
|
||||
### TenantBackupHealthAssessment
|
||||
|
||||
Existing derived tenant-level backup-input assessment from `TenantBackupHealthResolver`.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `tenantId` | integer | Tenant scope for the assessment |
|
||||
| `posture` | string | `absent`, `stale`, `degraded`, or `healthy` backup-input posture |
|
||||
| `primaryReason` | string nullable | Why the current backup-input posture is not calmly healthy |
|
||||
| `headline` | string | Operator-facing headline for the backup-input truth |
|
||||
| `supportingMessage` | string nullable | Supporting backup-health explanation |
|
||||
| `healthyClaimAllowed` | boolean | Whether the surface may speak positively about backup-input health |
|
||||
| `primaryActionTarget` | action target nullable | Canonical backup drillthrough |
|
||||
| `positiveClaimBoundary` | string | Canonical statement that backup health reflects backup inputs only and does not prove restore success |
|
||||
|
||||
### RestoreRun
|
||||
|
||||
Existing tenant-owned operational record of restore activity.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `id` | integer | Restore-run identity |
|
||||
| `tenant_id` | integer | Tenant scope |
|
||||
| `backup_set_id` | integer nullable | Backup set used for the run |
|
||||
| `status` | string | Restore lifecycle status |
|
||||
| `is_dry_run` | boolean | Whether the record is preview-only |
|
||||
| `results` | array/json | Item-, foundation-, and assignment-level execution outcomes |
|
||||
| `metadata` | array/json | Additional execution and preview metadata, including `non_applied` and scope basis |
|
||||
| `completed_at` | datetime nullable | Terminal completion timestamp |
|
||||
| `operationRun` | relation nullable | Linked `OperationRun` for umbrella execution outcome |
|
||||
|
||||
### RestoreResultAttention
|
||||
|
||||
Existing per-run derived truth from `RestoreSafetyResolver::resultAttentionForRun(...)`.
|
||||
|
||||
| State | Follow-up required | Meaning |
|
||||
|------|--------------------|---------|
|
||||
| `not_executed` | no | Preview-only or not-yet-executed record; proves preview truth only |
|
||||
| `failed` | yes | Execution failed; no recovery claim can be made |
|
||||
| `partial` | yes | Execution reached terminal state but some items or assignments failed or only partially applied |
|
||||
| `completed_with_follow_up` | yes | Execution completed, but skipped or non-applied work still weakens confidence |
|
||||
| `completed` | no | No visible follow-up remains, but tenant-wide recovery is still not proven |
|
||||
|
||||
Relevant fields carried by the value object:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `state` | string | One of the five result-attention states above |
|
||||
| `summary` | string | Operator-facing explanation of the outcome |
|
||||
| `followUpRequired` | boolean | Whether the result weakens confidence or needs action |
|
||||
| `primaryNextAction` | string | Recommended next action for the run |
|
||||
| `recoveryClaimBoundary` | string | Canonical claim-boundary identifier for the run outcome |
|
||||
| `tone` | string | Summary tone for UI presentation |
|
||||
|
||||
## Derived Surface Projection
|
||||
|
||||
### Recovery Evidence Summary
|
||||
|
||||
This spec does **not** add a new persisted entity. It adds a derived tenant-level dashboard projection that combines existing backup health and restore evidence at render time.
|
||||
|
||||
| Field | Type | Persisted | Meaning |
|
||||
|------|------|-----------|---------|
|
||||
| `tenantId` | integer | no | Tenant scope |
|
||||
| `backupPosture` | string | no | Current `TenantBackupHealthAssessment.posture` |
|
||||
| `relevantRestoreHistoryPresent` | boolean | no | Whether the tenant has any executed, non-preview restore history |
|
||||
| `latestRelevantRestoreRunId` | integer nullable | no | The most recent executed restore run relevant to overview continuity |
|
||||
| `latestRelevantAttentionState` | string nullable | no | Result-attention state for the latest relevant run |
|
||||
| `overviewState` | string | no | Canonical state key: `unvalidated`, `weakened`, or `no_recent_issues_visible` |
|
||||
| `headline` | string | no | Operator-facing dashboard headline for the recovery-evidence condition |
|
||||
| `summary` | string | no | Supporting summary text for the state |
|
||||
| `claimBoundary` | string | no | Text that prevents the summary from becoming a proof claim |
|
||||
| `action` | object nullable | no | Nested action payload with `label`, `url`, `disabled`, and `helperText`, matching the contract `ActionLink` shape |
|
||||
|
||||
### Overview State Rules
|
||||
|
||||
| Overview state | Entry rule | Required surface effect |
|
||||
|------|------------|-------------------------|
|
||||
| `unvalidated` | No executed, non-preview restore history exists for the tenant | Dashboard must show that recovery confidence is unvalidated and provide a next action into restore history |
|
||||
| `weakened` | Latest relevant restore history resolves to `failed`, `partial`, or `completed_with_follow_up` | Needs Attention must surface the weakened condition and link to the exact run or canonical restore-run list fallback |
|
||||
| `no_recent_issues_visible` | Relevant restore history exists and the latest relevant attention is `completed` | The dashboard may say no recent restore issues are visible, but must preserve the non-proof boundary |
|
||||
|
||||
Operator-facing copy may say that recovery evidence is not yet known, but the canonical derived state remains `unvalidated`.
|
||||
|
||||
### Non-qualifying restore records
|
||||
|
||||
The following records do **not** count as relevant restore history for overview confidence:
|
||||
|
||||
- `is_dry_run = true`
|
||||
- `status in [draft, scoped, checked, previewed]`
|
||||
- Any record whose `RestoreResultAttention.state` is `not_executed`
|
||||
|
||||
## Relationships
|
||||
|
||||
| Source | Relationship | Target | Use in this spec |
|
||||
|------|--------------|--------|------------------|
|
||||
| Tenant | has many | BackupSet | Existing backup-input truth via `TenantBackupHealthResolver` |
|
||||
| Tenant | has many | RestoreRun | Existing restore-history truth used for overview evidence |
|
||||
| RestoreRun | belongs to | BackupSet | Supports drillthrough context |
|
||||
| RestoreRun | optionally belongs to | OperationRun | Contributes operation outcome to `RestoreResultAttention` |
|
||||
|
||||
## Derived Continuity Context
|
||||
|
||||
The canonical restore-run list receives a **non-persisted page context** from the dashboard for fallback continuity.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `recovery_posture_reason` | query string | Why the user arrived on the restore-run list from the dashboard |
|
||||
|
||||
Suggested reason values:
|
||||
|
||||
- `no_history`
|
||||
- `failed`
|
||||
- `partial`
|
||||
- `completed_with_follow_up`
|
||||
- `no_recent_issues_visible`
|
||||
|
||||
These are list-continuity reasons only. They are not new persisted domain states.
|
||||
272
specs/184-dashboard-recovery-honesty/plan.md
Normal file
272
specs/184-dashboard-recovery-honesty/plan.md
Normal file
@ -0,0 +1,272 @@
|
||||
# Implementation Plan: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Branch**: `184-dashboard-recovery-honesty` | **Date**: 2026-04-08 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Harden the tenant dashboard so healthy backup inputs never read as validated recovery posture by default. The implementation will keep backup health and restore evidence as separate truths, reuse the existing backup positive-claim boundary and per-run `RestoreResultAttention` semantics, add an honest recovery-evidence summary to the tenant dashboard KPI and attention surfaces, and route no-history or weak-history signals into canonical restore-run list or detail drilldowns using the repo’s existing continuity-banner pattern. The slice stays read-only, introduces no new persistence, enum, provider, asset, or recovery-confidence engine, and limits code changes to existing dashboard widgets, existing restore-run list or detail surfaces, and focused Pest coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
|
||||
**Storage**: PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned
|
||||
**Testing**: Pest feature tests, Livewire widget or page tests, and narrow unit coverage for restore-history derivation, all run through Sail
|
||||
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
|
||||
**Project Type**: Laravel monolith web application rooted at `apps/platform`
|
||||
**Performance Goals**: Keep tenant dashboard rendering DB-only, avoid external calls at render time, cap recovery-evidence derivation to the most recent 10 tenant-scoped restore-run candidates with only the required relations eager-loaded, and preserve the existing restore-run list scanability without N+1 row lookups
|
||||
**Constraints**: No new persisted recovery-confidence field or table, no new recovery-proven signal, no new Graph calls, no new panel or provider registration, no new global-search behavior, no RBAC drift, no summary claim stronger than the evidence visible to the current user, and no new Filament asset registration
|
||||
**Scale/Scope**: One tenant dashboard page, two existing dashboard widgets, one restore-run list continuity seam, one restore-run detail reuse seam, one internal dashboard surface contract, and focused unit plus feature regression coverage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Inventory-first | Pass | Existing backup and restore artifacts remain the only evidence sources; no ownership model changes |
|
||||
| Read/write separation | Pass | This slice is read-only summary hardening; no new mutation or remote work is introduced |
|
||||
| Graph contract path | Pass | No Graph calls, contract-registry changes, or provider changes are required |
|
||||
| Deterministic capabilities | Pass | Existing capability registry, policies, and `UiEnforcement` remain authoritative |
|
||||
| RBAC-UX planes and 404 vs 403 | Pass | Tenant dashboard and restore-run surfaces remain in `/admin/t/{tenant}/...`; non-members stay 404; in-scope members retain existing capability semantics |
|
||||
| Workspace isolation | Pass | No workspace-scope broadening is planned; workspace overview remains unchanged in this slice |
|
||||
| Tenant isolation | Pass | All evidence and drilldowns stay tenant-owned and tenant-scoped |
|
||||
| Destructive confirmation standard | Pass | No new destructive dashboard actions are added; existing restore-run destructive actions remain confirmed and server-authorized |
|
||||
| Global search safety | Pass | No new globally searchable resource or search behavior is introduced; existing resources retain their current view-page-backed search safety |
|
||||
| Run observability / Ops-UX | Pass | No new `OperationRun`, background work, notification surface, or lifecycle transition is added |
|
||||
| Data minimization | Pass | The slice reuses existing backup and restore metadata and keeps diagnostics on existing restore surfaces |
|
||||
| Proportionality (PROP-001) | Pass | The design extends existing widgets, list pages, and restore-safety seams instead of adding a new recovery-confidence subsystem |
|
||||
| No premature abstraction (ABSTR-001) | Pass | No new registry, interface, or orchestration layer is planned; at most a narrow extension of existing restore-safety seams or local widget derivation is needed |
|
||||
| Persisted truth (PERSIST-001) | Pass | Recovery posture remains derived at render time; no new stored truth is introduced |
|
||||
| Behavioral state (STATE-001) | Pass | `unvalidated`, `weakened`, and `no_recent_issues_visible` stay derived UI semantics, not new persisted domain state |
|
||||
| UI semantics (UI-SEM-001) | Pass | Recovery honesty is rendered directly on existing widgets and restore surfaces; no new presenter framework is planned |
|
||||
| Badge semantics (BADGE-001) | Pass | Existing badge catalogs remain authoritative; if restore attention appears on the list, it will reuse existing restore status and attention semantics rather than page-local mappings |
|
||||
| Filament-native UI (UI-FIL-001) | Pass | Existing `StatsOverviewWidget`, dashboard section view, Filament links, badges, and restore-run resource tables remain the primary seams |
|
||||
| UI Action Surface Contract | Pass | No new inspect model or destructive placement is introduced; restore-run list retains clickable-row inspect and grouped mutations |
|
||||
| UX-001 / HDR-001 | Pass | The dashboard keeps its existing widget layout; no new record headers or form layout changes are introduced |
|
||||
| Filament v5 / Livewire v4 compliance | Pass | The plan stays inside current Filament v5 and Livewire v4 surface patterns |
|
||||
| Provider registration location | Pass | No provider change is needed; existing Laravel 11+ registration remains in `bootstrap/providers.php` |
|
||||
| Global-search rule for changed resources | Pass | No change to searchable resource registration; `RestoreRunResource` already has detail pages and this slice touches no search-specific behavior |
|
||||
| Asset strategy | Pass | No new assets are planned; deployment continues to include `cd apps/platform && php artisan filament:assets` unchanged |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Keep the implementation scoped to tenant dashboard and restore-history confirmation surfaces; do not expand `WorkspaceOverviewBuilder` in this slice because the workspace overview does not currently restate backup or recovery posture.
|
||||
- Reuse `TenantBackupHealthAssessment::positiveClaimBoundary` as the canonical backup-health boundary on summary surfaces instead of inventing new copy.
|
||||
- Treat only non-dry-run, non-preview, executed restore runs as relevant restore history for overview confidence language.
|
||||
- Reuse `RestoreSafetyResolver::resultAttentionForRun(...)` as the sole authority for failed, partial, completed-with-follow-up, and completed-without-follow-up semantics.
|
||||
- Prefer canonical restore-run list drilldowns with a continuity reason banner when no specific run exists or when a direct detail link would be fragile; use restore-run detail only when a recent problematic run exists and is accessible.
|
||||
- Keep summary language cautious under RBAC restrictions: if deeper restore evidence cannot be opened, the summary may disable or fall back on the action but must not upgrade the claim.
|
||||
- Reuse the existing backup-health list continuity pattern (`backup_health_reason`) for restore-history continuity rather than adding a new UI shell or route family.
|
||||
- Extend existing dashboard, restore-run, and restore-attention tests instead of creating a new browser-level harness.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/`:
|
||||
|
||||
- `research.md`: implementation decisions, constraints, and alternatives for recovery-posture honesty
|
||||
- `data-model.md`: existing evidence models and the derived overview recovery-posture projection
|
||||
- `contracts/dashboard-recovery-posture.openapi.yaml`: internal reference contract for tenant dashboard and restore-history drilldown surfaces
|
||||
- `quickstart.md`: focused validation workflow for honest dashboard recovery posture
|
||||
|
||||
Design decisions:
|
||||
|
||||
- Keep backup health and recovery evidence separate on the dashboard: backup posture remains the backup-input truth; a dedicated recovery-evidence summary or equivalent dashboard qualifier carries the restore-history truth.
|
||||
- Suppress all-clear semantics when relevant restore history is absent by making no-history its own derived overview condition rather than folding it into healthy backup posture.
|
||||
- Use existing per-run `RestoreResultAttention` output to decide whether recent restore history weakens confidence; do not duplicate that logic with raw status checks in widgets.
|
||||
- Add restore-history continuity to `ListRestoreRuns` via a query-string reason and subheading pattern analogous to `ListBackupSets` and `ListBackupSchedules`.
|
||||
- Make restore-run list rows more self-confirming by surfacing result-attention summary in a default-visible column or equivalent default-visible fact.
|
||||
- Reuse existing `ViewRestoreRun` result-attention presentation for detailed drilldown confirmation instead of building a new recovery detail page.
|
||||
- Leave workspace overview unchanged in this spec to avoid introducing a second summary surface with partially duplicated semantics; verify instead that no new contradiction is introduced.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/184-dashboard-recovery-honesty/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── dashboard-recovery-posture.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── TenantDashboard.php
|
||||
│ │ ├── Widgets/
|
||||
│ │ │ └── Dashboard/
|
||||
│ │ │ ├── DashboardKpis.php
|
||||
│ │ │ └── NeedsAttention.php
|
||||
│ │ └── Resources/
|
||||
│ │ ├── RestoreRunResource.php
|
||||
│ │ └── RestoreRunResource/
|
||||
│ │ └── Pages/
|
||||
│ │ ├── ListRestoreRuns.php
|
||||
│ │ └── ViewRestoreRun.php
|
||||
│ ├── Models/
|
||||
│ │ ├── BackupSet.php
|
||||
│ │ ├── RestoreRun.php
|
||||
│ │ └── OperationRun.php
|
||||
│ └── Support/
|
||||
│ ├── BackupHealth/
|
||||
│ │ ├── TenantBackupHealthAssessment.php
|
||||
│ │ └── TenantBackupHealthResolver.php
|
||||
│ ├── RestoreSafety/
|
||||
│ │ ├── RestoreResultAttention.php
|
||||
│ │ └── RestoreSafetyResolver.php
|
||||
│ └── OperationRunLinks.php
|
||||
├── resources/views/
|
||||
│ └── filament/widgets/dashboard/
|
||||
│ └── needs-attention.blade.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ │ ├── DashboardKpisWidgetTest.php
|
||||
│ │ ├── NeedsAttentionWidgetTest.php
|
||||
│ │ ├── RestoreResultAttentionSurfaceTest.php
|
||||
│ │ ├── RestoreRunUiEnforcementTest.php
|
||||
│ │ ├── DashboardRecoveryPosturePerformanceTest.php
|
||||
│ │ └── RestoreRunListContinuityTest.php
|
||||
│ └── Rbac/
|
||||
│ └── DashboardRecoveryPostureVisibilityTest.php
|
||||
└── Unit/
|
||||
└── Support/
|
||||
└── RestoreSafety/
|
||||
└── RestoreResultAttentionTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith inside `apps/platform`. The implementation stays inside existing dashboard widgets, existing restore-run resources or pages, existing restore-safety support classes, and existing test directories. No new root folders, no new panel providers, and no new cross-cutting framework are planned.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Derive Relevant Restore History Without A New Engine
|
||||
|
||||
**Goal**: Decide whether restore evidence is absent, weakened, or merely non-proving by reusing existing restore-safety truth and a minimal tenant-scoped history query.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php` or co-located dashboard widget methods | Add the minimal tenant-scoped query seam needed to determine relevant restore history, capped to the 10 most recent restore-run candidates and eager-loading only the relations required for the latest relevant visible executed run and its applicable `RestoreResultAttention`, without introducing a new persistent recovery-confidence model |
|
||||
| A.2 | Existing dashboard widgets only | Keep derived state local and lightweight: no new persisted field, no new enum, and no cross-domain recovery framework; if shared logic is unavoidable, keep it inside the existing restore-safety support namespace or a narrow local helper |
|
||||
| A.3 | `apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php` and a focused new unit test if needed | Cover preview-only exclusion, no-history detection, latest-run precedence, and weak-history precedence over older calmer history |
|
||||
|
||||
### Phase B — Surface Honest Recovery Language On The KPI Strip
|
||||
|
||||
**Goal**: Make the tenant dashboard scan distinguish backup-input health from recovery evidence in the first glance.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` | Keep the existing `Backup posture` stat as backup-input truth and append the visible backup claim boundary where the summary could otherwise sound stronger than the evidence |
|
||||
| B.2 | `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` | Add a recovery-evidence stat or equivalent adjacent summary signal that expresses `unvalidated`, `weakened`, or `no_recent_issues_visible` without ever claiming recovery proof |
|
||||
| B.3 | `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` | Route KPI drilldowns to canonical restore-run list or detail surfaces, with disabled or fallback behavior when a more specific drilldown is unavailable or inappropriate |
|
||||
|
||||
### Phase C — Integrate Recovery Honesty Into Needs Attention And Healthy Checks
|
||||
|
||||
**Goal**: Ensure healthy backup posture does not collapse into a quiet all-clear when restore evidence is missing or concerning.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add a recovery-family attention item for `no relevant restore history` that explains why confidence is unvalidated and points to restore history |
|
||||
| C.2 | `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add a recovery-family attention item for recent `failed`, `partial`, or `completed_with_follow_up` restore evidence using existing `RestoreResultAttention` semantics |
|
||||
| C.3 | `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add a healthy-check entry for `no recent restore issues visible` that still preserves the non-proof boundary, and prevent healthy backup messaging from becoming an unqualified all-clear |
|
||||
| C.4 | `apps/platform/resources/views/filament/widgets/dashboard/needs-attention.blade.php` only if needed | Reuse the existing attention and healthy-check rendering shape; only adjust the lead copy if the current calm sentence would still overclaim once recovery checks are present |
|
||||
|
||||
### Phase D — Restore History Drillthrough Continuity
|
||||
|
||||
**Goal**: Make every summary link land on a restore-history surface that confirms the same truth the dashboard just stated.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php` | Add `backup_health_reason`-style continuity support with a `recovery_posture_reason` subheading so `no history` and list-fallback links remain self-explanatory |
|
||||
| D.2 | `apps/platform/app/Filament/Resources/RestoreRunResource.php` | Add a default-visible result-attention summary column or equivalent row fact derived through `RestoreSafetyResolver::resultAttentionForRun(...)` so the restore-run list immediately confirms failed, partial, and follow-up states |
|
||||
| D.3 | `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Reuse the existing result-attention section as the canonical detail confirmation surface; add only lightweight context copy if a dashboard-linked reason is not already obvious |
|
||||
| D.4 | `apps/platform/app/Support/OperationRunLinks.php` and widget link builders only if needed | Keep canonical list and detail routing intact; prefer list fallback when a direct run link is brittle, unavailable, or semantically weaker than the restore-history list context |
|
||||
|
||||
### Phase E — Regression Protection And Focused Verification
|
||||
|
||||
**Goal**: Lock the honesty boundary into automated tests without widening the feature into a larger recovery-confidence program.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php` | Cover healthy backups with no relevant restore history, claim-boundary visibility on the KPI strip, and recovery-evidence summary behavior |
|
||||
| E.2 | `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Cover no-history attention, failed or partial or follow-up escalation, healthy-check fallback, and non-overclaim behavior |
|
||||
| E.3 | `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php` | Add continuity coverage for `recovery_posture_reason` list drilldowns, especially `no_history` fallback and weak-history fallback |
|
||||
| E.4 | `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php` | Confirm dashboard drilldowns land on restore surfaces that show the same result-attention reason and claim boundary |
|
||||
| E.5 | `apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php` and `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php` | Prove readonly members still see cautious summary truth, in-scope members lacking restore-history view receive 403 on drillthrough while the dashboard stays truthful, and non-members still receive 404 behavior |
|
||||
| E.6 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — No Relevant Restore History Is Derived From Executed Runs Only
|
||||
|
||||
Preview-only, dry-run, draft, scoped, checked, and previewed restore records already carry a boundary that they do not prove execution. The overview should therefore treat them as insufficient evidence rather than as calming history.
|
||||
|
||||
### D-002 — Backup Health And Recovery Evidence Stay Orthogonal
|
||||
|
||||
`TenantBackupHealthAssessment` remains the source of backup-input truth. Recovery honesty is a second derived summary that references restore history without changing what backup health means.
|
||||
|
||||
### D-003 — Existing Claim Boundaries Stay Canonical
|
||||
|
||||
The backup positive-claim boundary already exists in `TenantBackupHealthAssessment`, and per-run restore claim boundaries already exist in `RestoreResultAttention`. The plan reuses those boundaries instead of inventing a second copy system.
|
||||
|
||||
### D-004 — Continuity Uses Existing List-Subheading Patterns
|
||||
|
||||
The repo already uses query-string continuity copy on list pages (`backup_health_reason`) to explain why a dashboard drillthrough landed on a list instead of a detail page. Restore history will use the same pattern rather than a new shell, modal, or route family.
|
||||
|
||||
### D-005 — Workspace Overview Stays Unchanged In This Slice
|
||||
|
||||
Current workspace overview logic surfaces governance, compare, findings, alerts, and operations, but not backup or recovery posture. Not changing it is the smallest way to avoid introducing contradictory portfolio-level semantics in this spec.
|
||||
|
||||
### D-006 — Readonly Users Can Still Validate The Claim Boundary
|
||||
|
||||
Existing tests show readonly tenant members can open restore-run history while mutations stay disabled. The plan uses that existing view-rights shape to preserve honesty for lower-capability operators without widening restore permissions.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Relevant restore history is misclassified because preview-only or dry-run records are counted as real evidence | High | Medium | Add explicit derivation tests for preview-only exclusion and executed-run selection |
|
||||
| Dashboard widgets drift semantically because backup health and restore evidence are derived in two different ways | High | Medium | Centralize the minimal history-selection logic in one existing seam or prove the local duplication is identical through tests |
|
||||
| Restore-run list drilldowns do not visibly confirm `completed_with_follow_up` or other weak-history reasons | High | Medium | Add a default-visible result-attention fact on list rows and a continuity subheading on fallback list views |
|
||||
| RBAC-restricted users see calmer summary language because a direct restore detail link is unavailable | High | Low | Keep summary claims independent from link availability and add readonly visibility regression coverage |
|
||||
| Dashboard render cost grows due to extra restore-history queries | Medium | Medium | Cap candidate selection to the latest 10 restore runs, eager-load only the required relations in that query, and keep workspace overview out of scope in this slice |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend the existing dashboard widget tests first instead of creating a new browser-driven harness.
|
||||
- Add unit coverage for the relevant restore-history selection and weak-history precedence if a shared restore-safety query seam is introduced.
|
||||
- Add feature coverage proving that healthy backup posture plus no relevant restore history never yields an all-clear on the dashboard.
|
||||
- Add feature coverage proving that failed, partial, and follow-up restore outcomes become overview-visible attention states.
|
||||
- Add continuity tests for dashboard-to-restore-history drilldowns, including list fallback and detail confirmation.
|
||||
- Add RBAC regression tests proving readonly members still see cautious truth while non-members remain 404 and restore mutations stay disabled.
|
||||
- Add query-shape regression coverage proving recovery-evidence derivation stays capped to the latest 10 candidate restore runs and does not introduce N+1 queries on the dashboard or restore-run list surfaces.
|
||||
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a broader suite run.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are currently justified. The plan deliberately avoids new persistence, new enums, new registries, and new page shells. Any shared derivation added during implementation must stay narrower than a new recovery-confidence subsystem and remain inside existing restore-safety or dashboard seams.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators can currently read healthy backup summaries as stronger recovery assurance than the product can actually prove when restore history is absent or weak.
|
||||
- **Existing structure is insufficient because**: The backup-health boundary and the per-run restore-result truth already exist, but the tenant dashboard does not currently connect them or preserve the same explanation on drilldown.
|
||||
- **Narrowest correct implementation**: Extend existing dashboard widgets, existing restore-run list/detail seams, and existing restore-safety truth rather than adding a new recovery-confidence model or page.
|
||||
- **Ownership cost created**: Additional widget copy, a narrow restore-history derivation path, one list continuity seam, and focused unit plus feature tests.
|
||||
- **Alternative intentionally rejected**: A full recovery-confidence engine, persisted posture state, new enum family, or standalone recovery page were rejected because they add broader truth and maintenance cost than this honesty slice needs.
|
||||
- **Release truth**: Current-release truth hardening.
|
||||
93
specs/184-dashboard-recovery-honesty/quickstart.md
Normal file
93
specs/184-dashboard-recovery-honesty/quickstart.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Quickstart: Dashboard Recovery Posture Honesty
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the application services if they are not already running:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Use a tenant member account with at least readonly access for verification.
|
||||
|
||||
## Focused Automated Verification
|
||||
|
||||
Run the smallest relevant test set first:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunUiEnforcementTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php
|
||||
```
|
||||
|
||||
When implementation adds the new continuity and performance guard files, run them as well. The performance regression must exercise both the tenant dashboard and the restore-run list render paths:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunListContinuityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php
|
||||
```
|
||||
|
||||
Format after code changes:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Validation Scenarios
|
||||
|
||||
### Scenario 1: Healthy backups, no restore history
|
||||
|
||||
1. Open the tenant dashboard.
|
||||
2. Verify `Backup posture` can still be healthy.
|
||||
3. Verify the dashboard also shows that recovery evidence is unvalidated.
|
||||
4. Verify the backup-health claim boundary is visible on the summary surface.
|
||||
5. Follow the recovery drillthrough and confirm the restore-run list explains that no relevant restore history exists.
|
||||
|
||||
### Scenario 2: Recent failed, partial, or follow-up restore
|
||||
|
||||
1. Open the tenant dashboard for a tenant with recent problematic restore history.
|
||||
2. Verify Needs Attention surfaces the recovery issue before any all-clear messaging.
|
||||
3. Follow the drillthrough.
|
||||
4. Confirm the destination surface shows the same reason: failed, partial, or follow-up required.
|
||||
|
||||
### Scenario 3: No recent restore issues visible
|
||||
|
||||
1. Open the tenant dashboard for a tenant with recent executed restore history and no current attention state.
|
||||
2. Verify the surface can say no recent restore issues are visible.
|
||||
3. Verify it still stops short of proving or guaranteeing recovery.
|
||||
|
||||
### Scenario 4: Readonly tenant member
|
||||
|
||||
1. Sign in as a readonly tenant member.
|
||||
2. Open the same tenant dashboard.
|
||||
3. Verify the summary remains cautious and truthful.
|
||||
4. Verify restore-run history remains inspectable for the readonly member.
|
||||
5. Verify restore mutations remain disabled.
|
||||
|
||||
### Scenario 5: In-scope member without restore-history view
|
||||
|
||||
1. Sign in as a tenant member who can open the dashboard but lacks restore-history viewing capability.
|
||||
2. Open the tenant dashboard.
|
||||
3. Verify the recovery summary stays unvalidated or weakened as appropriate and does not become calmer because drillthrough is unavailable.
|
||||
4. Follow the recovery action if one is offered.
|
||||
5. Confirm the restore-history surface denies access with 403 semantics while the dashboard summary remains truthful.
|
||||
|
||||
## Non-goals Check
|
||||
|
||||
Before considering the slice complete, verify that no surface introduces any of the following language:
|
||||
|
||||
- `recovery proven`
|
||||
- `recovery guaranteed`
|
||||
- `strong recovery confidence`
|
||||
- Any equivalent positive claim stronger than the current evidence supports
|
||||
|
||||
## Deployment Note
|
||||
|
||||
No new Filament assets are planned for this slice. Deployment keeps the existing asset step unchanged:
|
||||
|
||||
```bash
|
||||
cd apps/platform && php artisan filament:assets
|
||||
```
|
||||
91
specs/184-dashboard-recovery-honesty/research.md
Normal file
91
specs/184-dashboard-recovery-honesty/research.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Research: Dashboard Recovery Posture Honesty
|
||||
|
||||
## Decision 1: Keep the slice on tenant dashboard and restore-history confirmation surfaces
|
||||
|
||||
**Decision**: Implement Spec 184 on the tenant dashboard (`DashboardKpis`, `NeedsAttention`) and the canonical restore-run list or detail drilldowns. Do not expand `WorkspaceOverviewBuilder` in this slice.
|
||||
|
||||
**Rationale**: Current workspace overview logic does not restate backup or recovery posture; it surfaces governance, compare, findings, alerts, and operations, then links into the tenant dashboard when appropriate. Not changing it keeps the blast radius small and avoids introducing a second partially overlapping recovery summary.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Extend workspace overview now with recovery posture. Rejected because it broadens the slice, increases query and wording risk, and is not needed to stop the tenant-dashboard overclaim.
|
||||
- Create a new recovery overview page. Rejected because the spec explicitly avoids a new recovery-confidence surface.
|
||||
|
||||
## Decision 2: Reuse the existing backup positive-claim boundary
|
||||
|
||||
**Decision**: Reuse `TenantBackupHealthAssessment::positiveClaimBoundary` on summary-level dashboard surfaces.
|
||||
|
||||
**Rationale**: The product already has canonical copy that says backup health reflects backup inputs only and does not prove restore success. Reusing it avoids page-local rewrites and keeps the claim boundary consistent across backup-detail and dashboard contexts.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce new dashboard-only claim-boundary copy. Rejected because it creates semantic drift for the same truth.
|
||||
- Leave the boundary only on detail pages. Rejected because the spec specifically hardens summary surfaces.
|
||||
|
||||
## Decision 3: Define relevant restore history as executed, non-preview restore runs only
|
||||
|
||||
**Decision**: Treat only non-dry-run, non-preview, executed restore runs as relevant restore history for overview recovery language.
|
||||
|
||||
**Rationale**: `RestoreSafetyResolver::resultAttentionForRun(...)` already treats dry-runs and preview states as `not_executed` and explicitly says they do not prove execution. Counting those records as recovery evidence would overstate confidence.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Count every `RestoreRun` record, including preview-only runs. Rejected because preview truth is not execution truth.
|
||||
- Count only fully completed runs. Rejected because failed, partial, and follow-up runs are exactly the weak-history evidence the overview must surface.
|
||||
|
||||
## Decision 4: Use `RestoreResultAttention` as the sole authority for weak-history states
|
||||
|
||||
**Decision**: Reuse `RestoreSafetyResolver::resultAttentionForRun(...)` for failed, partial, completed-with-follow-up, and completed semantics instead of remapping raw statuses in widgets.
|
||||
|
||||
**Rationale**: The resolver already inspects status, operation outcome, item results, assignment failures, skips, and metadata such as `non_applied`. It is the narrowest existing source of truth for result quality and already carries recovery-claim boundaries.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build dashboard-specific mapping from raw `RestoreRun.status`. Rejected because it would ignore existing deeper result logic and duplicate truth.
|
||||
- Introduce a new tenant-level recovery enum. Rejected because the spec is explicitly not a recovery-confidence engine.
|
||||
|
||||
## Decision 5: Use list-subheading continuity for no-history and fallback drilldowns
|
||||
|
||||
**Decision**: Reuse the `backup_health_reason` continuity pattern on `ListRestoreRuns` with a restore-history-specific reason query parameter for list fallbacks.
|
||||
|
||||
**Rationale**: The repo already uses list subheadings on backup-set and backup-schedule pages to explain why a dashboard drillthrough landed on a list. The same pattern fits `no history` and weak-history list fallbacks without new UI shells or modal layers.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Always deep-link to a restore-run detail. Rejected because no-history cases have no record, and weak-history detail links can become brittle when a record is deleted or inaccessible.
|
||||
- Use only existing table filters with no continuity copy. Rejected because current restore-run filters cannot explain `no history` and do not guarantee self-explanatory arrival states.
|
||||
|
||||
## Decision 6: Prefer canonical restore-run list fallback over stronger but fragile links
|
||||
|
||||
**Decision**: Link to restore-run detail only when a recent problematic run exists and the detail is the clearest confirmation. Otherwise fall back to the tenant restore-run list with continuity context.
|
||||
|
||||
**Rationale**: The list is the stable canonical collection route and is already accessible to readonly members. It is also the only truthful destination for `no history`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Always link to the most recent executed run. Rejected because that can create dead ends or misleading confirmations when the run is gone or no longer the right representative.
|
||||
- Link to admin operations pages instead of restore runs. Rejected because Spec 184 is about restore history and result attention, not generic operation monitoring.
|
||||
|
||||
## Decision 7: Keep summary language cautious under RBAC restrictions
|
||||
|
||||
**Decision**: Summary surfaces must stay cautious even if the current user cannot open the most specific restore evidence. Action links may disable or fall back, but the claim must never grow stronger.
|
||||
|
||||
**Rationale**: Existing tests show readonly tenant members can open restore-run history while mutations remain disabled. Even if a more specific deep link is unavailable, the summary must still express `unvalidated` or `weakened`, not `healthy`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hide recovery-honesty signals when drilldown is limited. Rejected because that would falsify the surface by omission.
|
||||
- Treat inaccessible detail as proof that no issues exist. Rejected because it directly violates the spec’s honesty boundary.
|
||||
|
||||
## Decision 8: Extend existing widget and restore-run tests instead of introducing a new harness
|
||||
|
||||
**Decision**: Build coverage on top of `DashboardKpisWidgetTest`, `NeedsAttentionWidgetTest`, `RestoreResultAttentionSurfaceTest`, `RestoreRunUiEnforcementTest`, and focused unit tests under `Support/RestoreSafety`.
|
||||
|
||||
**Rationale**: The repo already has targeted Livewire and Pest coverage for the exact seams being changed. Extending them is cheaper and keeps the tests aligned with business truth instead of test-only indirection.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a new browser-only or end-to-end suite. Rejected because the feature is a UI-truth-hardening slice on existing server-rendered surfaces.
|
||||
- Skip unit coverage and rely only on widget tests. Rejected because executed-history selection and attention precedence are easier and cheaper to pin down at the narrow derivation seam.
|
||||
|
||||
## Decision 9: No new assets, panel registration, or global-search behavior
|
||||
|
||||
**Decision**: Keep the implementation entirely within existing Filament widgets, resource pages, and Blade views. Do not add assets, providers, or search changes.
|
||||
|
||||
**Rationale**: This feature changes honesty and drillthrough continuity, not panel infrastructure. The current deployment step for `filament:assets` remains unchanged because there are no new assets.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add custom JavaScript or CSS for richer attention states. Rejected because existing Filament primitives are sufficient.
|
||||
- Change panel or navigation registration. Rejected because it is unrelated to the feature goal.
|
||||
177
specs/184-dashboard-recovery-honesty/spec.md
Normal file
177
specs/184-dashboard-recovery-honesty/spec.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Feature Specification: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Feature Branch**: `[184-dashboard-recovery-honesty]`
|
||||
**Created**: 2026-04-08
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 184 — Dashboard Recovery Posture Honesty"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: `/admin/t/{tenant}`, `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}`, `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/backup-sets/{record}`
|
||||
- **Data Ownership**: Tenant-owned `BackupSet`, `RestoreRun`, and linked `OperationRun` outcome context are read within the active workspace and tenant scope to derive a more honest overview statement. No new persisted recovery-confidence state is introduced.
|
||||
- **RBAC**: Workspace plus tenant membership remains required on every affected surface. Members who can open the tenant dashboard must see honest summary boundaries even when they cannot start or manage restore runs. Existing restore-run creation and mutation actions remain under current restore permissions. Non-members continue to receive deny-as-not-found semantics.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Dashboard / stats overview | Explicit stat click per signal | forbidden | Supporting text inside the stat description | none | `/admin/t/{tenant}` | Signal-specific drill-through to `/admin/t/{tenant}/restore-runs` or `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Dashboard KPIs / Backup posture | Backup health is separate from restore evidence | existing widget pattern |
|
||||
| Needs Attention / Healthy Checks panel | Dashboard / attention summary | Explicit card CTA per attention item; healthy state is read-only | forbidden | Card CTA and helper copy only | none | `/admin/t/{tenant}` | `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Needs attention / Healthy checks | Unvalidated and weakened recovery confidence are visible before drilldown | existing widget pattern |
|
||||
| Restore runs page | CRUD / list-first resource | Full-row click to restore-run detail | required | Existing header action plus More menu | Existing More and bulk More groups | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{record}` | Tenant context plus restore-run identity | Restore runs / Restore run | Recent restore outcome and follow-up reason confirm the overview claim | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Tenant operator | Dashboard summary | Do healthy backups also have supporting restore evidence, or is recovery posture still unvalidated? | Backup posture, recovery-confidence qualifier, visible claim boundary, next step | Per-run causes, raw backup metadata, deeper restore evidence | backup health, recovery evidence availability, recent restore attention | None; read-only summary | Open restore history, open supporting backup context when backup health itself needs follow-up | none |
|
||||
| Needs Attention / Healthy Checks panel | Tenant operator | Dashboard attention and healthy-boundary surface | What recovery-confidence issue needs action now, and why? | No restore history, weakened recent restore history, boundary copy, concrete next action | Full restore results, preview or check details, low-level run metadata | backup health, recovery evidence availability, restore result attention, recency | None; read-only summary | Open restore history, open latest problematic restore run | none |
|
||||
| Restore runs page | Tenant operator | List and detail | Which restore runs explain the dashboard signal? | Recent restore status, result-attention reason, completed timing, related backup context | Assignment-level failures, preview detail, low-level result payloads | execution lifecycle, result attention, follow-up state | Existing restore-run maintenance actions only | Inspect restore run, create restore run | Existing rerun, archive, restore archived, and force-delete actions |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: A tenant dashboard can currently look calm or healthy even when restore history is absent or recent restore results weaken confidence, so operators can overread backup health as recovery posture.
|
||||
- **Existing structure is insufficient because**: Backup health, restore history, and restore result attention already exist as separate truths, but the summary surfaces do not yet combine them with an honest claim boundary. Operators must manually cross-check multiple pages to avoid an overclaim.
|
||||
- **Narrowest correct implementation**: Derive a small set of overview honesty signals from existing backup health assessment, restore history presence, and per-run restore result attention, then show them on the existing dashboard widgets and existing restore-run drilldowns.
|
||||
- **Ownership cost**: Additional widget copy, narrow derived-summary logic, and focused feature plus RBAC regression tests that keep overview language and drilldown continuity aligned.
|
||||
- **Alternative intentionally rejected**: A new recovery-confidence score, enum, page, or persisted posture state was rejected because it would introduce new truth and new ownership cost before the current overview surfaces tell the existing truth accurately.
|
||||
- **Release truth**: current-release truth hardening
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See Unvalidated Recovery Confidence Early (Priority: P1)
|
||||
|
||||
A tenant operator opens the tenant dashboard and needs to know within seconds whether healthy-looking backups are backed by any relevant restore evidence or whether recovery confidence is still unvalidated.
|
||||
|
||||
**Why this priority**: This is the highest-risk trust gap. If the first overview screen quietly converts healthy backups into a healthy recovery impression, later detail truth arrives too late.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering the tenant dashboard with healthy backup fixtures and no relevant restore history, then verifying that the overview shows an explicit unvalidated recovery-confidence signal instead of an all-clear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backup posture and no relevant restore history, **When** the operator opens the tenant dashboard, **Then** the summary shows healthy backups plus an explicit unvalidated recovery-confidence message and a next action.
|
||||
2. **Given** the same tenant has no other attention items, **When** the healthy-check state renders, **Then** the widget does not show an unqualified all-good message and instead keeps the recovery-confidence boundary visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Escalate Weak Restore History on Overview (Priority: P2)
|
||||
|
||||
A tenant operator reviewing the dashboard needs recent failed, partial, or follow-up restore results to affect the overview immediately instead of hiding inside restore history details.
|
||||
|
||||
**Why this priority**: Weak restore history is evidence that directly changes how much trust the operator should place in recovery posture. It cannot remain a drilldown-only fact.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering overview surfaces with recent failed, partial, and follow-up restore fixtures and verifying that each case creates a visible confidence-related attention signal with matching drilldown behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backups but a recent failed or partial restore run, **When** the operator opens the dashboard, **Then** Needs Attention shows a recovery-confidence issue that links to restore history explaining the same failure state.
|
||||
2. **Given** a tenant has a recent restore run that completed with follow-up required, **When** the operator opens the dashboard, **Then** the overview shows weakened confidence rather than a neutral or healthy-only message.
|
||||
3. **Given** recent restore history exists without a current confidence-weakening attention state, **When** the operator opens the dashboard, **Then** the overview may say that no recent restore issues are visible but does not claim that recovery is proven.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Honest Drilldowns and RBAC Boundaries (Priority: P3)
|
||||
|
||||
A tenant operator or read-only member needs the dashboard signal and the destination surface to tell the same story, while RBAC limits must never make the summary look stronger than the accessible evidence.
|
||||
|
||||
**Why this priority**: Overview honesty fails if the next click contradicts the dashboard or if authorization gaps hide weakness by omission.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening overview signals as different tenant members, verifying that the linked restore-history surface confirms the same reason, and ensuring restricted users still see cautious summary language.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the dashboard says recovery confidence is unvalidated because no relevant restore history exists, **When** the operator follows the dashboard action, **Then** the destination surface confirms that the tenant lacks relevant restore history.
|
||||
2. **Given** the dashboard says recovery confidence is weakened by a recent problematic restore, **When** the operator follows the dashboard action, **Then** the destination surface confirms the same failed, partial, or follow-up reason.
|
||||
3. **Given** a tenant member can see the dashboard but cannot open deeper restore evidence, **When** the dashboard renders, **Then** the summary remains cautious and truthful and does not replace missing evidence with a stronger claim.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has only draft, preview-only, or dry-run restore history; the overview treats recovery confidence as unvalidated rather than positive.
|
||||
- A tenant has both an older successful restore and a more recent failed or follow-up restore; the weakened signal takes precedence on the summary surface.
|
||||
- A summary signal points to a restore run that is no longer directly openable; the drilldown falls back to tenant-scoped restore history rather than a dead end.
|
||||
- A user can see the dashboard but lacks permission to inspect restore runs; the summary still states unvalidated or weakened confidence without suggesting that everything is healthy.
|
||||
- Healthy backup posture and backup-automation follow-up can coexist with unvalidated recovery confidence; the overview must not let one healthy-sounding statement erase the other caution.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
This feature introduces no new Microsoft Graph calls, no new background work, no new `OperationRun`, and no new persistence. It is a read-first truth-hardening slice that makes existing backup and restore evidence visible more honestly on tenant overview surfaces.
|
||||
|
||||
Authorization remains in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members must continue to receive 404 responses. Established members missing deeper restore capabilities must continue to receive 403 on execution paths, but summary visibility must not depend on restore-mutation rights.
|
||||
|
||||
This slice reuses existing Filament dashboard widgets, stat descriptions, attention cards, and existing restore-run resource surfaces. No new local badge framework, page-local status language, or extra action surface is introduced. UI-FIL-001 is satisfied by continuing to use existing Filament widget primitives and shared status language. UX-001 create, edit, and detail-form rules are not materially changed; the dashboard keeps its existing layout, and the restore-run resource keeps its existing list-and-view contract.
|
||||
|
||||
The affected Filament surfaces keep exactly one primary inspect or open model, add no redundant View actions, and introduce no new destructive actions. Existing destructive restore-run actions continue to follow the current placement and confirmation rules. Action Surface Contract expectations therefore remain satisfied.
|
||||
|
||||
Existing per-run restore result attention remains the authoritative signal for restore outcome quality. This feature may summarize or elevate that truth, but it must not duplicate it with a second scoring or status system.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-184-001**: The system MUST present tenant backup health and tenant recovery-confidence evidence as separate truths on tenant dashboard summary surfaces.
|
||||
- **FR-184-002**: When backup health is healthy but no relevant restore history exists, the system MUST display an explicit unvalidated recovery-confidence state and MUST NOT present an all-clear summary.
|
||||
- **FR-184-003**: When the system cannot determine recovery confidence from the available restore history, the system MUST map that limitation to the canonical `unvalidated` overview state and say that limitation directly instead of inferring a positive recovery claim from backup health alone.
|
||||
- **FR-184-004**: Needs Attention or the healthy-boundary surface MUST surface absence of restore history as an overview-relevant condition with a clear next action.
|
||||
- **FR-184-005**: Recent restore history with `failed`, `partial`, `completed_with_follow_up`, or an equivalent confidence-weakening attention state MUST appear on overview surfaces as a recovery-confidence issue.
|
||||
- **FR-184-006**: Overview surfaces MUST distinguish unvalidated confidence from weakened confidence and MUST NOT collapse both states into one ambiguous bucket.
|
||||
- **FR-184-007**: Any positive backup-health summary on the dashboard MUST show a visible claim boundary that healthy backups reflect backup inputs only and do not prove restore success.
|
||||
- **FR-184-008**: Healthy checks MUST NOT render an unqualified healthy or all-clear state when recovery confidence is `unvalidated` or `weakened`, and any `no_recent_issues_visible` healthy check MUST preserve the non-proof boundary.
|
||||
- **FR-184-009**: When recovery confidence is unvalidated or weakened, overview copy MUST explain what is missing or concerning, why that affects confidence, and what the operator should do next.
|
||||
- **FR-184-010**: Overview signals about missing restore history MUST drill into a tenant-scoped restore-history surface that confirms the absence or insufficiency of relevant restore evidence.
|
||||
- **FR-184-011**: Overview signals about weakened restore history MUST drill into a tenant-scoped restore-history surface or restore-run detail that confirms the same failed, partial, or follow-up reason shown on the summary surface.
|
||||
- **FR-184-012**: The feature MUST reuse existing per-run restore result attention as the authoritative quality signal for restore outcomes and MUST NOT introduce a parallel positive-scoring or reason system.
|
||||
- **FR-184-013**: The feature MUST NOT introduce a new state or message that claims recovery is proven, guaranteed, or strongly confirmed beyond the evidence the current system already has.
|
||||
- **FR-184-014**: RBAC limits on restore history visibility MUST NOT cause summary surfaces to make stronger recovery claims than the visible evidence supports; when detailed restore evidence cannot be opened, the summary must remain cautious and truthful.
|
||||
- **FR-184-015**: This slice MUST NOT introduce or alter tenant-linked recovery posture summaries outside the tenant dashboard. Any future reuse of this posture signal on another surface MUST preserve the same distinction between backup posture and the canonical recovery-evidence states `unvalidated`, `weakened`, and `no_recent_issues_visible`.
|
||||
- **FR-184-016**: The feature MUST derive its summary state from existing tenant backup health, restore history, and restore result attention records and MUST NOT add a new persisted recovery-confidence field, table, or scoring artifact.
|
||||
- **FR-184-017**: When recent restore history exists without a current confidence-weakening attention state, overview surfaces MAY state that no recent restore issues are visible, but MUST stop short of claiming recovery proof.
|
||||
- **FR-184-018**: The feature MUST cap recovery-evidence derivation to the 10 most recent tenant-scoped restore-run candidates, eager-load only the relations required for summary and drillthrough selection, and MUST NOT introduce N+1 row lookups on dashboard or restore-run list surfaces.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Relevant restore history means tenant-scoped restore runs that have reached an executed result state or another existing result-attention state that the current system can classify. Draft-only, preview-only, or dry-run-only history does not count as proven recovery evidence.
|
||||
- Existing restore history surfaces already show enough result detail to confirm failed, partial, and follow-up reasons once the operator drills down from the overview.
|
||||
- Workspace-level surfaces that later reuse this posture language should consume the same tenant-level semantics rather than creating a separate recovery-confidence vocabulary.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing tenant dashboard surfaces remain the operator entry point for this slice.
|
||||
- Existing `TenantBackupHealthAssessment` and `TenantBackupHealthResolver` remain the source of backup-input truth.
|
||||
- Existing `RestoreRun` history surfaces and `RestoreSafetyResolver::resultAttentionForRun(...)` remain the source of restore-outcome truth.
|
||||
- Existing RBAC helper-text and disabled-link patterns remain the fallback behavior when the operator cannot open deeper restore evidence.
|
||||
|
||||
## Out of Scope and Follow-up
|
||||
|
||||
- No new recovery-confidence engine, score, enum, or dedicated posture page.
|
||||
- No automatic restore validation, scheduled restore probes, or restore execution changes.
|
||||
- No new backup-health rules, restore-result-attention taxonomy changes, or restore-safety model redesign.
|
||||
- No new claim that a tenant is recovery-proven.
|
||||
- Reasonable follow-up work includes broader workspace-level recovery rollups after tenant-level overview honesty is stable.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard summary widgets | `app/Filament/Pages/TenantDashboard.php`, `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none added | Explicit stat and card CTA only; no row click | none | n/a | none | n/a | n/a | no new audit event | Action Surface Contract stays satisfied because the dashboard remains read-only. UI-FIL-001 stays satisfied through existing Filament widget primitives. UX-001 create and edit form rules are not applicable to this dashboard slice. |
|
||||
| RestoreRunResource list and detail | `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Existing `New restore run` action remains | `recordUrl()` clickable row to restore-run detail | Existing More-menu maintenance actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing `New restore run` empty-state CTA remains | none added | Existing restore-run create flow remains unchanged | existing restore-run mutation audit behavior only | This spec reuses restore-run list and detail as canonical drilldowns and adds no new destructive action or placement exception. |
|
||||
|
||||
## Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Backup health assessment**: Tenant-level summary of backup freshness and input health that is useful but not sufficient to prove recovery success.
|
||||
- **Restore history**: Tenant-scoped record of restore runs whose presence, absence, and recent outcomes affect how strongly the product can speak about recovery confidence.
|
||||
- **Restore result attention**: Per-run classification that distinguishes completed, failed, partial, follow-up, and not-executed outcome states that matter for operator trust.
|
||||
- **Recovery posture summary**: Non-persisted dashboard statement that combines backup health, restore history presence, and restore-result attention without becoming a new score or stored state.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance testing, operators can identify within 10 seconds whether a tenant has healthy backups plus unvalidated or weakened recovery evidence from `/admin/t/{tenant}` without opening raw details.
|
||||
- **SC-002**: In 100% of tested tenants with no relevant restore history, the dashboard or healthy-boundary surface shows an explicit unvalidated recovery-confidence signal and never shows a healthy-only all-clear.
|
||||
- **SC-003**: In 100% of tested tenants with recent failed, partial, or follow-up restore runs, the overview shows a confidence-related attention item with a drilldown that confirms the same reason.
|
||||
- **SC-004**: In 100% of tested positive backup-health scenarios, summary-level copy includes the claim boundary that healthy backups do not prove restore success.
|
||||
- **SC-005**: In 100% of tested RBAC-restricted scenarios, summary surfaces remain cautious and truthful even when the user cannot open deeper restore evidence pages.
|
||||
- **SC-006**: In targeted regression coverage, recovery-evidence derivation evaluates no more than the 10 most recent tenant-scoped restore-run candidates and introduces no N+1 row queries on the dashboard or restore-run list surfaces.
|
||||
204
specs/184-dashboard-recovery-honesty/tasks.md
Normal file
204
specs/184-dashboard-recovery-honesty/tasks.md
Normal file
@ -0,0 +1,204 @@
|
||||
# Tasks: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Input**: Design documents from `/specs/184-dashboard-recovery-honesty/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/dashboard-recovery-posture.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior on existing Filament and Livewire surfaces, so Pest coverage must be added or extended before implementation is considered complete.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently testable, with the recommended delivery order `US1 -> US2 -> US3` because all three stories touch the same dashboard and restore-history seams.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare reusable test data states shared by all dashboard recovery-posture scenarios.
|
||||
|
||||
- [X] T001 [P] Add preview-only, failed, partial, completed-with-follow-up, and completed recovery-posture fixture states in `apps/platform/database/factories/RestoreRunFactory.php`
|
||||
- [X] T002 [P] Add recent and stale completed backup fixture states in `apps/platform/database/factories/BackupSetFactory.php`
|
||||
|
||||
**Checkpoint**: Shared test fixtures are ready for all stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the single restore-history derivation seam that all dashboard recovery-posture surfaces depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
|
||||
|
||||
- [X] T003 Add unit regressions for preview exclusion, latest-run precedence, weak-history precedence, and completed-history fallback in `apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php`
|
||||
- [X] T004 Implement tenant-scoped relevant restore-history selection and dashboard overview-state derivation in `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php`
|
||||
|
||||
**Checkpoint**: Relevant restore history is derived from one authoritative seam and covered by unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See Unvalidated Recovery Confidence Early (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make the tenant dashboard show that healthy backup inputs do not equal validated recovery posture when relevant restore history is absent.
|
||||
|
||||
**Independent Test**: Render the tenant dashboard with healthy backup fixtures and no executed, non-preview restore history, then verify the KPI strip and Needs Attention surfaces show an explicit unvalidated recovery signal instead of an all-clear.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T005 [P] [US1] Add healthy-backups-with-no-history KPI regression coverage in `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`
|
||||
- [X] T006 [P] [US1] Add no-history healthy-boundary regression coverage in `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Update `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` to keep `Backup posture` limited to backup-input truth, append the positive claim boundary, and add an `unvalidated` recovery-evidence KPI with a restore-history drillthrough
|
||||
- [X] T008 [P] [US1] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to surface missing relevant restore history and suppress an unqualified healthy fallback when recovery evidence is absent
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional and shows honest no-history recovery posture on the tenant dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Escalate Weak Restore History on Overview (Priority: P2)
|
||||
|
||||
**Goal**: Make recent failed, partial, or follow-up restore outcomes affect overview trust immediately instead of hiding only in restore-history details.
|
||||
|
||||
**Independent Test**: Render the tenant dashboard with recent failed, partial, and completed-with-follow-up restore fixtures and verify the overview surfaces show weakened confidence, while a calm `no recent issues visible` state appears only for recent completed restore history.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T009 [P] [US2] Add weak-history KPI regression coverage for failed, partial, follow-up, and calm completed restore outcomes in `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`
|
||||
- [X] T010 [P] [US2] Add weak-history attention and `no recent issues visible` coverage in `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
- [X] T011 [P] [US2] Add dashboard-linked restore-result confirmation coverage for problematic restore runs in `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T012 [P] [US2] Update `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` to derive `weakened` and `no_recent_issues_visible` recovery-evidence states from `RestoreSafetyResolver` output
|
||||
- [X] T013 [P] [US2] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to surface failed, partial, and `completed_with_follow_up` restore attention using canonical summaries and next actions
|
||||
- [X] T014 [P] [US2] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to add a `no_recent_issues_visible` healthy-check that preserves the non-proof boundary for calm recent restore history
|
||||
- [X] T015 [P] [US2] Update `apps/platform/app/Filament/Resources/RestoreRunResource.php` to expose a default-visible result-attention summary fact on restore-run list rows
|
||||
- [X] T016 [P] [US2] Update `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` to keep restore-result attention copy aligned with dashboard-linked failure, partial, and follow-up reasons
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional and weak restore evidence is visible on overview and restore-run confirmation surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Honest Drilldowns and RBAC Boundaries (Priority: P3)
|
||||
|
||||
**Goal**: Keep dashboard recovery signals, restore-history drilldowns, and RBAC outcomes aligned so missing access never makes the overview read more confidently than the evidence allows.
|
||||
|
||||
**Independent Test**: Open dashboard recovery signals as an owner, a readonly tenant member, and a non-member; verify list and detail drilldowns preserve the same reason, readonly members keep inspect access with disabled mutations, and non-members still receive deny-as-not-found behavior.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T017 [P] [US3] Create no-history and list-fallback continuity coverage in `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`
|
||||
- [X] T018 [P] [US3] Create readonly, capability-denied, and non-member recovery-posture visibility coverage in `apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php`
|
||||
- [X] T019 [P] [US3] Extend restore-run enforcement coverage for readonly inspect access, capability-denied drillthrough 403 responses, disabled mutations, and deny-as-not-found semantics in `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T020 [P] [US3] Update `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php` to accept `recovery_posture_reason` and render continuity subheadings for dashboard fallbacks
|
||||
- [X] T021 [P] [US3] Update `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` to fall back from missing or brittle direct restore-run links to canonical restore-run list URLs with continuity context
|
||||
- [X] T022 [P] [US3] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to mirror the same fallback and disabled-link behavior for recovery-confidence actions
|
||||
- [X] T023 [P] [US3] Update `apps/platform/app/Filament/Resources/RestoreRunResource.php` and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` to preserve truthful restore-history drillthroughs and 403 capability denials for in-scope members without changing existing mutation permissions
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional and dashboard-to-restore-history continuity stays honest across RBAC outcomes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final quality checks that span multiple stories.
|
||||
|
||||
- [X] T024 Verify `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` remains unchanged by this slice so no tenant-linked recovery posture summary is introduced outside the tenant dashboard
|
||||
- [X] T025 Review operator-facing recovery copy in `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`, `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php`, `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` to remove any `recovery proven`, `recovery guaranteed`, or equivalent overclaim language
|
||||
- [X] T026 [P] Add query-shape regression coverage for the 10-candidate cap and no N+1 rendering across the tenant dashboard and restore-run list surfaces in `apps/platform/tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`
|
||||
- [X] T027 [P] Run formatting and the focused verification workflow from `specs/184-dashboard-recovery-honesty/quickstart.md` against `apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`, `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php`, `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`, `apps/platform/tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`, and `apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP cut.
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same dashboard recovery-evidence seams in `DashboardKpis.php` and `NeedsAttention.php`.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 2 because it aligns continuity and RBAC behavior for both no-history and weak-history overview signals.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: Builds on the recovery-evidence KPI and attention seams introduced in US1.
|
||||
- **US3**: Builds on the drillthrough targets and recovery-evidence states introduced in US1 and US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation.
|
||||
- Update dashboard derivation or resource behavior before adjusting cross-surface copy.
|
||||
- Keep each story shippable on its own before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` and `T002` can run in parallel.
|
||||
- Within US1, `T005` and `T006` can run in parallel, then `T007` and `T008` can run in parallel.
|
||||
- Within US2, `T009`, `T010`, and `T011` can run in parallel, then `T012`, `T013`, `T014`, `T015`, and `T016` can be split across contributors.
|
||||
- Within US3, `T017`, `T018`, and `T019` can run in parallel, then `T020`, `T021`, `T022`, and `T023` can be split across contributors.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US1
|
||||
T005 Add healthy-backups-with-no-history KPI regression coverage in apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
T006 Add no-history healthy-boundary regression coverage in apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
|
||||
# Parallel implementation pass for US1
|
||||
T007 Update apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
T008 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US2
|
||||
T009 Add weak-history KPI regression coverage in apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
T010 Add weak-history attention coverage in apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
T011 Add restore-result confirmation coverage in apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php
|
||||
|
||||
# Parallel implementation pass for US2
|
||||
T012 Update apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
T013 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
T014 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
T015 Update apps/platform/app/Filament/Resources/RestoreRunResource.php
|
||||
T016 Update apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US3
|
||||
T017 Create apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php
|
||||
T018 Create apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php
|
||||
T019 Extend apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php
|
||||
|
||||
# Parallel implementation pass for US3
|
||||
T020 Update apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php
|
||||
T021 Update apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
T022 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
T023 Update apps/platform/app/Filament/Resources/RestoreRunResource.php and apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational derivation.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate the no-history dashboard truth using the focused tests in `quickstart.md`.
|
||||
5. Stop and review the wording before widening the slice.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to stop the highest-risk overclaim on the tenant dashboard.
|
||||
2. Add US2 to elevate weak restore history into the same overview surfaces.
|
||||
3. Add US3 to keep list or detail drilldowns and RBAC outcomes aligned with the dashboard summary.
|
||||
4. Finish with the focused formatting and verification pass from Phase 6.
|
||||
Loading…
Reference in New Issue
Block a user