## 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
297 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|