TenantAtlas/apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php
ahmido 9fbd3e5ec7 Spec 186: implement tenant registry recovery triage (#217)
## Summary
- turn the tenant registry into a workspace-scoped recovery triage surface with backup posture and recovery evidence columns
- preserve workspace overview backup and recovery drilldown intent by routing multi-tenant cases into filtered tenant registry slices
- add the Spec 186 planning artifacts, focused regression coverage, and shared triage presentation helpers

## Testing
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`

## Notes
- no schema change
- no new persisted recovery truth
- branch includes the full Spec 186 spec, plan, research, data model, contract, quickstart, and tasks artifacts

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #217
2026-04-09 19:20:48 +00:00

239 lines
8.7 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 App\Support\Tenants\TenantRecoveryTriagePresentation;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\ModelNotFoundException;
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' => TenantRecoveryTriagePresentation::backupPostureLabel($backupHealth),
'description' => TenantRecoveryTriagePresentation::backupPostureDescription($backupHealth, $backupHealthAction['helperText']),
'color' => TenantRecoveryTriagePresentation::backupPostureTone($backupHealth),
'url' => $backupHealthAction['actionUrl'],
],
'recoveryEvidence' => [
'label' => 'Recovery evidence',
'value' => TenantRecoveryTriagePresentation::recoveryEvidenceLabel($recoveryEvidence),
'description' => TenantRecoveryTriagePresentation::recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
'color' => TenantRecoveryTriagePresentation::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 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);
}
}