TenantAtlas/apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php
ahmido f1a73490e4 feat: finalize dashboard recovery honesty (#215)
## Summary
- add a dedicated Recovery Readiness dashboard widget for backup posture and recovery evidence
- group Needs Attention items by domain and elevate the recovery call-to-action
- align restore-run and recovery posture tests with the extracted widget and continuity flows
- include the related spec artifacts for 184-dashboard-recovery-honesty

## Verification
- `cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact --filter="DashboardKpisWidget|DashboardRecoveryPosture|TenantDashboardDbOnly|TenantpilotSeedBackupHealthBrowserFixtureCommand|NeedsAttentionWidget"`
- browser smoke verified on the calm, unvalidated, and weakened dashboard states

## Notes
- Livewire v4.0+ compliant with Filament v5
- no panel provider changes; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- Recovery Readiness stays within the existing tenant dashboard asset strategy; no new Filament asset registration required

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #215
2026-04-08 23:21:36 +00:00

297 lines
11 KiB
PHP

<?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);
}
}