## 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
239 lines
8.7 KiB
PHP
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);
|
|
}
|
|
}
|