Compare commits
2 Commits
176-backup
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f8eb28ca2 | |||
| e840007127 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -139,6 +139,8 @@ ## Active Technologies
|
||||
- PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned (181-restore-safety-integrity)
|
||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers (176-backup-quality-truth)
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned (176-backup-quality-truth)
|
||||
- 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 (180-tenant-backup-health)
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned (180-tenant-backup-health)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -158,8 +160,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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
|
||||
- 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
|
||||
- 181-restore-safety-integrity: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers
|
||||
- 178-ops-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
192
app/Console/Commands/SeedBackupHealthBrowserFixture.php
Normal file
192
app/Console/Commands/SeedBackupHealthBrowserFixture.php
Normal file
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class SeedBackupHealthBrowserFixture extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:backup-health:seed-browser-fixture {--force-refresh : Rebuild the fixture backup basis even if it already exists}';
|
||||
|
||||
protected $description = 'Seed a local/testing browser fixture for the Spec 180 blocked backup drill-through scenario.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
$this->error('This fixture command is limited to local and testing environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
||||
|
||||
if (! is_array($fixture)) {
|
||||
$this->error('The backup-health browser smoke fixture is not configured.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
||||
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
||||
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
|
||||
$tenantRouteKey = (string) ($scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
|
||||
|
||||
$workspace = Workspace::query()->updateOrCreate(
|
||||
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
|
||||
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 180 Backup Health Smoke')],
|
||||
);
|
||||
|
||||
$password = (string) ($userConfig['password'] ?? 'password');
|
||||
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+180@tenantpilot.local')],
|
||||
[
|
||||
'name' => (string) ($userConfig['name'] ?? 'Spec 180 Requester'),
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$tenant = Tenant::query()->updateOrCreate(
|
||||
['external_id' => $tenantRouteKey],
|
||||
[
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||
'tenant_id' => $tenantRouteKey,
|
||||
'app_client_id' => (string) ($scenarioConfig['app_client_id'] ?? '18000000-0000-4000-8000-000000000182'),
|
||||
'app_client_secret' => null,
|
||||
'app_certificate_thumbprint' => null,
|
||||
'app_status' => 'ok',
|
||||
'app_notes' => null,
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
'environment' => 'dev',
|
||||
'is_current' => false,
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
WorkspaceMembership::query()->updateOrCreate(
|
||||
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner'],
|
||||
);
|
||||
|
||||
TenantMembership::query()->updateOrCreate(
|
||||
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
|
||||
);
|
||||
|
||||
if (Schema::hasColumn('users', 'last_workspace_id')) {
|
||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_tenant_preferences')) {
|
||||
UserTenantPreference::query()->updateOrCreate(
|
||||
['user_id' => (int) $user->getKey(), 'tenant_id' => (int) $tenant->getKey()],
|
||||
['last_used_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
$policy = Policy::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||
],
|
||||
[
|
||||
'display_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
],
|
||||
);
|
||||
|
||||
$backupSet = BackupSet::withTrashed()->firstOrNew([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
|
||||
]);
|
||||
|
||||
$backupSet->forceFill([
|
||||
'created_by' => (string) $user->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
'deleted_at' => null,
|
||||
])->save();
|
||||
|
||||
if (method_exists($backupSet, 'trashed') && $backupSet->trashed()) {
|
||||
$backupSet->restore();
|
||||
}
|
||||
|
||||
$backupItem = BackupItem::withTrashed()->firstOrNew([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_identifier' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||
]);
|
||||
|
||||
$backupItem->forceFill([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'platform' => 'windows',
|
||||
'captured_at' => $backupSet->completed_at,
|
||||
'payload' => [
|
||||
'id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||
],
|
||||
'metadata' => [
|
||||
'policy_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||
'fixture' => 'spec-180-browser-smoke',
|
||||
],
|
||||
'assignments' => [],
|
||||
'deleted_at' => null,
|
||||
])->save();
|
||||
|
||||
if (method_exists($backupItem, 'trashed') && $backupItem->trashed()) {
|
||||
$backupItem->restore();
|
||||
}
|
||||
|
||||
if ((bool) $this->option('force-refresh')) {
|
||||
$backupSet->forceFill([
|
||||
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
|
||||
])->save();
|
||||
|
||||
$backupItem->forceFill([
|
||||
'captured_at' => $backupSet->completed_at,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Fixture', 'Value'],
|
||||
[
|
||||
['Workspace', (string) $workspace->name],
|
||||
['User email', (string) $user->email],
|
||||
['User password', $password],
|
||||
['Tenant', (string) $tenant->name],
|
||||
['Tenant external id', (string) $tenant->external_id],
|
||||
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
|
||||
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
|
||||
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
|
||||
['Locally denied capability', 'tenant.view'],
|
||||
],
|
||||
);
|
||||
|
||||
$this->info('The dashboard remains visible for this fixture user, while backup drill-through routes stay forbidden via a local/testing-only capability deny seam.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -395,6 +395,7 @@ public static function table(Table $table): Table
|
||||
return $nextRun->format('M j, Y H:i:s');
|
||||
}
|
||||
})
|
||||
->description(fn (BackupSchedule $record): ?string => static::scheduleFollowUpDescription($record))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
@ -1149,4 +1150,31 @@ protected static function dayOfWeekOptions(): array
|
||||
7 => 'Sunday',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function scheduleFollowUpDescription(BackupSchedule $record): ?string
|
||||
{
|
||||
if (! $record->is_enabled || $record->trashed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graceCutoff = now('UTC')->subMinutes(max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30)));
|
||||
$lastRunStatus = strtolower(trim((string) $record->last_run_status));
|
||||
$isOverdue = $record->next_run_at?->lessThan($graceCutoff) ?? false;
|
||||
$neverSuccessful = $record->last_run_at === null
|
||||
&& ($isOverdue || ($record->created_at?->lessThan($graceCutoff) ?? false));
|
||||
|
||||
if ($neverSuccessful) {
|
||||
return 'No successful run has been recorded yet.';
|
||||
}
|
||||
|
||||
if ($isOverdue) {
|
||||
return 'This schedule looks overdue.';
|
||||
}
|
||||
|
||||
if (in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true)) {
|
||||
return 'The last run needs follow-up.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
@ -64,4 +68,23 @@ private function syncCanonicalAdminTenantFilterState(): void
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
if (request()->string('backup_health_reason')->toString() !== TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 'One or more enabled schedules need follow-up.';
|
||||
}
|
||||
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
$summary = $resolver->assess($tenant)->scheduleFollowUp->summaryMessage;
|
||||
|
||||
return $summary ?? 'One or more enabled schedules need follow-up.';
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -675,11 +677,22 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
$relatedContext = static::relatedContextEntries($record);
|
||||
$isArchived = $record->trashed();
|
||||
$qualitySummary = static::backupQualitySummary($record);
|
||||
$backupHealthAssessment = static::backupHealthContinuityAssessment($record);
|
||||
$qualityBadge = match (true) {
|
||||
$qualitySummary->totalItems === 0 => $factory->statusBadge('No items', 'gray'),
|
||||
$qualitySummary->hasDegradations() => $factory->statusBadge('Degraded input', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
default => $factory->statusBadge('No degradations', 'success', 'heroicon-m-check-circle'),
|
||||
};
|
||||
$backupHealthBadge = $backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->statusBadge(
|
||||
static::backupHealthContinuityLabel($backupHealthAssessment),
|
||||
$backupHealthAssessment->tone(),
|
||||
'heroicon-m-exclamation-triangle',
|
||||
)
|
||||
: null;
|
||||
$descriptionHint = $backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? trim($backupHealthAssessment->headline.' '.($backupHealthAssessment->supportingMessage ?? ''))
|
||||
: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.';
|
||||
|
||||
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
|
||||
->header(new SummaryHeaderData(
|
||||
@ -688,19 +701,28 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||
...array_filter([$backupHealthBadge]),
|
||||
$qualityBadge,
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
...array_filter([
|
||||
$backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
|
||||
: null,
|
||||
]),
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
descriptionHint: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.',
|
||||
descriptionHint: $descriptionHint,
|
||||
))
|
||||
->decisionZone($factory->decisionZone(
|
||||
facts: array_values(array_filter([
|
||||
$backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
|
||||
: null,
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
|
||||
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
|
||||
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
|
||||
@ -717,7 +739,7 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
),
|
||||
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
|
||||
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
|
||||
attentionNote: $qualitySummary->positiveClaimBoundary,
|
||||
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
|
||||
title: 'Backup quality',
|
||||
))
|
||||
->addSection(
|
||||
@ -810,4 +832,39 @@ private static function backupQualitySummary(BackupSet $record): \App\Support\Ba
|
||||
|
||||
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
|
||||
}
|
||||
|
||||
private static function backupHealthContinuityAssessment(BackupSet $record): ?TenantBackupHealthAssessment
|
||||
{
|
||||
$requestedReason = request()->string('backup_health_reason')->toString();
|
||||
|
||||
if (! in_array($requestedReason, [
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
$assessment = $resolver->assess((int) $record->tenant_id);
|
||||
|
||||
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($assessment->primaryReason !== $requestedReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $assessment;
|
||||
}
|
||||
|
||||
private static function backupHealthContinuityLabel(TenantBackupHealthAssessment $assessment): string
|
||||
{
|
||||
return match ($assessment->primaryReason) {
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
|
||||
default => ucfirst($assessment->posture),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -40,4 +41,14 @@ protected function getTableEmptyStateActions(): array
|
||||
BackupSetResource::makeCreateAction(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return match (request()->string('backup_health_reason')->toString()) {
|
||||
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => 'No usable completed backup basis is currently available for this tenant.',
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'The latest backup detail is no longer available, so this view stays on the backup-set list.',
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'The latest backup detail is no longer available, so this view stays on the backup-set list.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,18 +4,25 @@
|
||||
|
||||
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
|
||||
{
|
||||
@ -38,6 +45,8 @@ 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)
|
||||
@ -79,6 +88,10 @@ 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
|
||||
@ -124,6 +137,7 @@ 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),
|
||||
@ -159,4 +173,106 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,12 +5,18 @@
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
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\BackupHealthDashboardSignal;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -18,6 +24,7 @@
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class NeedsAttention extends Widget
|
||||
{
|
||||
@ -41,9 +48,17 @@ protected function getViewData(): array
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
$compareAssessment = $aggregate->summaryAssessment;
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
|
||||
$items = [];
|
||||
|
||||
if (($backupHealthItem = $this->backupHealthAttentionItem($tenant, $backupHealth)) instanceof BackupHealthDashboardSignal) {
|
||||
$items[] = array_merge(
|
||||
$backupHealthItem->toArray(),
|
||||
$this->backupHealthActionPayload($tenant, $backupHealthItem->actionTarget, $backupHealthItem->actionLabel)
|
||||
);
|
||||
}
|
||||
|
||||
$overdueOpenCount = $aggregate->overdueOpenFindingsCount;
|
||||
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||
@ -179,6 +194,7 @@ protected function getViewData(): array
|
||||
|
||||
if ($items === []) {
|
||||
$healthyChecks = [
|
||||
...array_filter([$this->backupHealthHealthyCheck($backupHealth)]),
|
||||
[
|
||||
'title' => 'Baseline compare looks trustworthy',
|
||||
'body' => $aggregate->headline,
|
||||
@ -251,4 +267,154 @@ private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
|
||||
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
private function backupHealthAttentionItem(Tenant $tenant, TenantBackupHealthAssessment $assessment): ?BackupHealthDashboardSignal
|
||||
{
|
||||
if (! $assessment->hasActiveReason()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BackupHealthDashboardSignal(
|
||||
title: $this->backupHealthAttentionTitle($assessment),
|
||||
body: $assessment->supportingMessage ?? $assessment->headline,
|
||||
tone: $assessment->tone(),
|
||||
badge: 'Backups',
|
||||
badgeColor: $assessment->tone(),
|
||||
actionTarget: $assessment->primaryActionTarget,
|
||||
actionLabel: $assessment->primaryActionTarget?->label,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, body: string}|null
|
||||
*/
|
||||
private function backupHealthHealthyCheck(TenantBackupHealthAssessment $assessment): ?array
|
||||
{
|
||||
if (! $assessment->healthyClaimAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'Backups are recent and healthy',
|
||||
'body' => $assessment->supportingMessage ?? 'The latest completed backup is recent and shows no material degradation.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionLabel: string|null, actionUrl: string|null, actionDisabled: bool, helperText: string|null}
|
||||
*/
|
||||
private function backupHealthActionPayload(Tenant $tenant, ?BackupHealthActionTarget $target, ?string $label): array
|
||||
{
|
||||
if (! $target instanceof BackupHealthActionTarget) {
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => null,
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->canOpenBackupSurfaces($tenant)) {
|
||||
return [
|
||||
'actionLabel' => $label ?? $target->label,
|
||||
'actionUrl' => null,
|
||||
'actionDisabled' => true,
|
||||
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||
];
|
||||
}
|
||||
|
||||
return match ($target->surface) {
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
|
||||
'actionLabel' => $label ?? $target->label,
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
|
||||
'actionLabel' => $label ?? $target->label,
|
||||
'actionUrl' => BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->backupHealthBackupSetActionPayload($tenant, $target, $label),
|
||||
default => [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => null,
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionLabel: string|null, actionUrl: string|null, actionDisabled: bool, helperText: string|null}
|
||||
*/
|
||||
private function backupHealthBackupSetActionPayload(Tenant $tenant, BackupHealthActionTarget $target, ?string $label): array
|
||||
{
|
||||
if (! is_numeric($target->recordId)) {
|
||||
return [
|
||||
'actionLabel' => 'Open backup sets',
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
|
||||
|
||||
return [
|
||||
'actionLabel' => $label ?? $target->label,
|
||||
'actionUrl' => BackupSetResource::getUrl('view', [
|
||||
'record' => $target->recordId,
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionLabel' => 'Open backup sets',
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function backupHealthAttentionTitle(TenantBackupHealthAssessment $assessment): string
|
||||
{
|
||||
return match ($assessment->primaryReason) {
|
||||
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => 'No usable backup basis',
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
|
||||
TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP => 'Backup schedules need follow-up',
|
||||
default => $assessment->headline,
|
||||
};
|
||||
}
|
||||
|
||||
private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,12 @@ public function can(User $user, Tenant $tenant, string $capability): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isLocallyDeniedByBackupHealthBrowserFixture($user, $tenant, $capability)) {
|
||||
$this->logDenial($user, $tenant, $capability);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = RoleCapabilityMap::hasCapability($role, $capability);
|
||||
|
||||
if (! $allowed) {
|
||||
@ -61,6 +67,39 @@ public function can(User $user, Tenant $tenant, string $capability): bool
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
private function isLocallyDeniedByBackupHealthBrowserFixture(User $user, Tenant $tenant, string $capability): bool
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough');
|
||||
|
||||
if (! is_array($fixture)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fixtureUserEmail = config('tenantpilot.backup_health.browser_smoke_fixture.user.email');
|
||||
|
||||
if (! is_string($fixtureUserEmail) || $fixtureUserEmail === '' || $user->email !== $fixtureUserEmail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fixtureTenantExternalId = $fixture['tenant_external_id'] ?? null;
|
||||
|
||||
if (! is_string($fixtureTenantExternalId) || $fixtureTenantExternalId === '' || $tenant->external_id !== $fixtureTenantExternalId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deniedCapabilities = $fixture['capability_denials'] ?? [];
|
||||
|
||||
if (! is_array($deniedCapabilities)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($capability, $deniedCapabilities, true);
|
||||
}
|
||||
|
||||
private function logDenial(User $user, Tenant $tenant, string $capability): void
|
||||
{
|
||||
$key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]);
|
||||
|
||||
17
app/Support/BackupHealth/BackupFreshnessEvaluation.php
Normal file
17
app/Support/BackupHealth/BackupFreshnessEvaluation.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupHealth;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
final readonly class BackupFreshnessEvaluation
|
||||
{
|
||||
public function __construct(
|
||||
public ?CarbonInterface $latestCompletedAt,
|
||||
public CarbonInterface $cutoffAt,
|
||||
public bool $isFresh,
|
||||
public string $policySource = 'configured_window',
|
||||
) {}
|
||||
}
|
||||
51
app/Support/BackupHealth/BackupHealthActionTarget.php
Normal file
51
app/Support/BackupHealth/BackupHealthActionTarget.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupHealth;
|
||||
|
||||
final readonly class BackupHealthActionTarget
|
||||
{
|
||||
public const SURFACE_BACKUP_SETS_INDEX = 'backup_sets_index';
|
||||
|
||||
public const SURFACE_BACKUP_SET_VIEW = 'backup_set_view';
|
||||
|
||||
public const SURFACE_BACKUP_SCHEDULES_INDEX = 'backup_schedules_index';
|
||||
|
||||
public function __construct(
|
||||
public string $surface,
|
||||
public ?int $recordId,
|
||||
public string $label,
|
||||
public string $reason,
|
||||
) {}
|
||||
|
||||
public static function backupSetsIndex(string $reason, string $label = 'Open backup sets'): self
|
||||
{
|
||||
return new self(
|
||||
surface: self::SURFACE_BACKUP_SETS_INDEX,
|
||||
recordId: null,
|
||||
label: $label,
|
||||
reason: $reason,
|
||||
);
|
||||
}
|
||||
|
||||
public static function backupSetView(int $recordId, string $reason, string $label = 'Open latest backup'): self
|
||||
{
|
||||
return new self(
|
||||
surface: self::SURFACE_BACKUP_SET_VIEW,
|
||||
recordId: $recordId,
|
||||
label: $label,
|
||||
reason: $reason,
|
||||
);
|
||||
}
|
||||
|
||||
public static function backupSchedulesIndex(string $reason, string $label = 'Open backup schedules'): self
|
||||
{
|
||||
return new self(
|
||||
surface: self::SURFACE_BACKUP_SCHEDULES_INDEX,
|
||||
recordId: null,
|
||||
label: $label,
|
||||
reason: $reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
47
app/Support/BackupHealth/BackupHealthDashboardSignal.php
Normal file
47
app/Support/BackupHealth/BackupHealthDashboardSignal.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupHealth;
|
||||
|
||||
final readonly class BackupHealthDashboardSignal
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $body,
|
||||
public string $tone,
|
||||
public ?string $badge = null,
|
||||
public ?string $badgeColor = null,
|
||||
public ?BackupHealthActionTarget $actionTarget = null,
|
||||
public bool $actionDisabled = false,
|
||||
public ?string $helperText = null,
|
||||
public ?string $actionLabel = null,
|
||||
public ?string $supportingMessage = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* badge: string|null,
|
||||
* badgeColor: string|null,
|
||||
* actionLabel: string|null,
|
||||
* actionDisabled: bool,
|
||||
* helperText: string|null,
|
||||
* supportingMessage: string|null
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'badge' => $this->badge,
|
||||
'badgeColor' => $this->badgeColor,
|
||||
'actionLabel' => $this->actionLabel,
|
||||
'actionDisabled' => $this->actionDisabled,
|
||||
'helperText' => $this->helperText,
|
||||
'supportingMessage' => $this->supportingMessage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupHealth;
|
||||
|
||||
final readonly class BackupScheduleFollowUpEvaluation
|
||||
{
|
||||
public function __construct(
|
||||
public bool $hasEnabledSchedules,
|
||||
public int $enabledScheduleCount,
|
||||
public int $overdueScheduleCount,
|
||||
public int $failedRecentRunCount,
|
||||
public int $neverSuccessfulCount,
|
||||
public bool $needsFollowUp,
|
||||
public ?int $primaryScheduleId,
|
||||
public ?string $summaryMessage,
|
||||
) {}
|
||||
}
|
||||
60
app/Support/BackupHealth/TenantBackupHealthAssessment.php
Normal file
60
app/Support/BackupHealth/TenantBackupHealthAssessment.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupHealth;
|
||||
|
||||
use App\Support\BackupQuality\BackupQualitySummary;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
final readonly class TenantBackupHealthAssessment
|
||||
{
|
||||
public const POSTURE_ABSENT = 'absent';
|
||||
|
||||
public const POSTURE_STALE = 'stale';
|
||||
|
||||
public const POSTURE_DEGRADED = 'degraded';
|
||||
|
||||
public const POSTURE_HEALTHY = 'healthy';
|
||||
|
||||
public const REASON_NO_BACKUP_BASIS = 'no_backup_basis';
|
||||
|
||||
public const REASON_LATEST_BACKUP_STALE = 'latest_backup_stale';
|
||||
|
||||
public const REASON_LATEST_BACKUP_DEGRADED = 'latest_backup_degraded';
|
||||
|
||||
public const REASON_SCHEDULE_FOLLOW_UP = 'schedule_follow_up';
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public string $posture,
|
||||
public ?string $primaryReason,
|
||||
public string $headline,
|
||||
public ?string $supportingMessage,
|
||||
public ?int $latestRelevantBackupSetId,
|
||||
public ?CarbonInterface $latestRelevantCompletedAt,
|
||||
public ?BackupQualitySummary $qualitySummary,
|
||||
public BackupFreshnessEvaluation $freshnessEvaluation,
|
||||
public BackupScheduleFollowUpEvaluation $scheduleFollowUp,
|
||||
public bool $healthyClaimAllowed,
|
||||
public ?BackupHealthActionTarget $primaryActionTarget,
|
||||
public string $positiveClaimBoundary,
|
||||
) {}
|
||||
|
||||
public function hasActiveReason(): bool
|
||||
{
|
||||
return $this->primaryReason !== null;
|
||||
}
|
||||
|
||||
public function tone(): string
|
||||
{
|
||||
return match ($this->primaryReason ?? $this->posture) {
|
||||
self::REASON_NO_BACKUP_BASIS => 'danger',
|
||||
self::REASON_LATEST_BACKUP_STALE => 'warning',
|
||||
self::REASON_LATEST_BACKUP_DEGRADED => 'warning',
|
||||
self::REASON_SCHEDULE_FOLLOW_UP => 'warning',
|
||||
self::POSTURE_HEALTHY => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
321
app/Support/BackupHealth/TenantBackupHealthResolver.php
Normal file
321
app/Support/BackupHealth/TenantBackupHealthResolver.php
Normal file
@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupHealth;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class TenantBackupHealthResolver
|
||||
{
|
||||
private const POSITIVE_CLAIM_BOUNDARY = 'Backup health reflects backup inputs only and does not prove restore success.';
|
||||
|
||||
public function __construct(
|
||||
private readonly BackupQualityResolver $backupQualityResolver,
|
||||
) {}
|
||||
|
||||
public function assess(Tenant|int $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant
|
||||
? (int) $tenant->getKey()
|
||||
: (int) $tenant;
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$latestBackupSet = $this->latestRelevantBackupSet($tenantId);
|
||||
$qualitySummary = $latestBackupSet instanceof BackupSet
|
||||
? $this->backupQualityResolver->summarizeBackupSet($latestBackupSet)
|
||||
: null;
|
||||
$freshnessEvaluation = $this->freshnessEvaluation($latestBackupSet?->completed_at, $now);
|
||||
$scheduleFollowUp = $this->scheduleFollowUpEvaluation($tenantId, $now);
|
||||
|
||||
if (! $latestBackupSet instanceof BackupSet) {
|
||||
return new TenantBackupHealthAssessment(
|
||||
tenantId: $tenantId,
|
||||
posture: TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
primaryReason: TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||
headline: 'No usable backup basis is available.',
|
||||
supportingMessage: $this->combineMessages([
|
||||
'Create or finish a backup set before relying on restore input.',
|
||||
$scheduleFollowUp->summaryMessage,
|
||||
]),
|
||||
latestRelevantBackupSetId: null,
|
||||
latestRelevantCompletedAt: null,
|
||||
qualitySummary: null,
|
||||
freshnessEvaluation: $freshnessEvaluation,
|
||||
scheduleFollowUp: $scheduleFollowUp,
|
||||
healthyClaimAllowed: false,
|
||||
primaryActionTarget: BackupHealthActionTarget::backupSetsIndex(TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS),
|
||||
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $freshnessEvaluation->isFresh) {
|
||||
return new TenantBackupHealthAssessment(
|
||||
tenantId: $tenantId,
|
||||
posture: TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
primaryReason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
headline: 'Latest backup is stale.',
|
||||
supportingMessage: $this->combineMessages([
|
||||
$this->latestBackupAgeMessage($latestBackupSet->completed_at, $now),
|
||||
$qualitySummary?->hasDegradations() === true ? 'The latest completed backup is also degraded.' : null,
|
||||
$scheduleFollowUp->summaryMessage,
|
||||
]),
|
||||
latestRelevantBackupSetId: (int) $latestBackupSet->getKey(),
|
||||
latestRelevantCompletedAt: $latestBackupSet->completed_at,
|
||||
qualitySummary: $qualitySummary,
|
||||
freshnessEvaluation: $freshnessEvaluation,
|
||||
scheduleFollowUp: $scheduleFollowUp,
|
||||
healthyClaimAllowed: false,
|
||||
primaryActionTarget: BackupHealthActionTarget::backupSetView(
|
||||
recordId: (int) $latestBackupSet->getKey(),
|
||||
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
),
|
||||
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
|
||||
);
|
||||
}
|
||||
|
||||
if ($qualitySummary?->hasDegradations() === true) {
|
||||
return new TenantBackupHealthAssessment(
|
||||
tenantId: $tenantId,
|
||||
posture: TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
primaryReason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
headline: 'Latest backup is degraded.',
|
||||
supportingMessage: $this->combineMessages([
|
||||
$qualitySummary->summaryMessage,
|
||||
$this->latestBackupAgeMessage($latestBackupSet->completed_at, $now),
|
||||
$scheduleFollowUp->summaryMessage,
|
||||
]),
|
||||
latestRelevantBackupSetId: (int) $latestBackupSet->getKey(),
|
||||
latestRelevantCompletedAt: $latestBackupSet->completed_at,
|
||||
qualitySummary: $qualitySummary,
|
||||
freshnessEvaluation: $freshnessEvaluation,
|
||||
scheduleFollowUp: $scheduleFollowUp,
|
||||
healthyClaimAllowed: false,
|
||||
primaryActionTarget: BackupHealthActionTarget::backupSetView(
|
||||
recordId: (int) $latestBackupSet->getKey(),
|
||||
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
),
|
||||
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
|
||||
);
|
||||
}
|
||||
|
||||
$scheduleNeedsFollowUp = $scheduleFollowUp->needsFollowUp;
|
||||
|
||||
return new TenantBackupHealthAssessment(
|
||||
tenantId: $tenantId,
|
||||
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
|
||||
primaryReason: $scheduleNeedsFollowUp ? TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP : null,
|
||||
headline: $scheduleNeedsFollowUp
|
||||
? 'Backup schedules need follow-up.'
|
||||
: 'Backups are recent and healthy.',
|
||||
supportingMessage: $scheduleNeedsFollowUp
|
||||
? $this->combineMessages([
|
||||
'The latest completed backup is recent and shows no material degradation.',
|
||||
$scheduleFollowUp->summaryMessage,
|
||||
])
|
||||
: $this->latestBackupAgeMessage($latestBackupSet->completed_at, $now),
|
||||
latestRelevantBackupSetId: (int) $latestBackupSet->getKey(),
|
||||
latestRelevantCompletedAt: $latestBackupSet->completed_at,
|
||||
qualitySummary: $qualitySummary,
|
||||
freshnessEvaluation: $freshnessEvaluation,
|
||||
scheduleFollowUp: $scheduleFollowUp,
|
||||
healthyClaimAllowed: ! $scheduleNeedsFollowUp,
|
||||
primaryActionTarget: $scheduleNeedsFollowUp
|
||||
? BackupHealthActionTarget::backupSchedulesIndex(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP)
|
||||
: null,
|
||||
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
|
||||
);
|
||||
}
|
||||
|
||||
private function latestRelevantBackupSet(int $tenantId): ?BackupSet
|
||||
{
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('completed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->with([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
'tenant_id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
])
|
||||
->first([
|
||||
'id',
|
||||
'tenant_id',
|
||||
'workspace_id',
|
||||
'name',
|
||||
'status',
|
||||
'item_count',
|
||||
'created_by',
|
||||
'completed_at',
|
||||
'created_at',
|
||||
'metadata',
|
||||
'deleted_at',
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
private function freshnessEvaluation(?CarbonInterface $latestCompletedAt, CarbonImmutable $now): BackupFreshnessEvaluation
|
||||
{
|
||||
$cutoffAt = $now->subHours($this->freshnessHours());
|
||||
|
||||
return new BackupFreshnessEvaluation(
|
||||
latestCompletedAt: $latestCompletedAt,
|
||||
cutoffAt: $cutoffAt,
|
||||
isFresh: $latestCompletedAt?->greaterThanOrEqualTo($cutoffAt) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
private function scheduleFollowUpEvaluation(int $tenantId, CarbonImmutable $now): BackupScheduleFollowUpEvaluation
|
||||
{
|
||||
$graceCutoff = $now->subMinutes($this->scheduleOverdueGraceMinutes());
|
||||
$schedules = BackupSchedule::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_enabled', true)
|
||||
->orderBy('next_run_at')
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id',
|
||||
'tenant_id',
|
||||
'last_run_status',
|
||||
'last_run_at',
|
||||
'next_run_at',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
$enabledScheduleCount = $schedules->count();
|
||||
$overdueScheduleCount = 0;
|
||||
$failedRecentRunCount = 0;
|
||||
$neverSuccessfulCount = 0;
|
||||
$primaryScheduleId = null;
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
$isOverdue = $schedule->next_run_at?->lessThan($graceCutoff) ?? false;
|
||||
$lastRunStatus = strtolower(trim((string) $schedule->last_run_status));
|
||||
$needsFollowUpAfterRun = in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true);
|
||||
$neverSuccessful = $schedule->last_run_at === null
|
||||
&& ($isOverdue || ($schedule->created_at?->lessThan($graceCutoff) ?? false));
|
||||
|
||||
if ($isOverdue) {
|
||||
$overdueScheduleCount++;
|
||||
}
|
||||
|
||||
if ($needsFollowUpAfterRun) {
|
||||
$failedRecentRunCount++;
|
||||
}
|
||||
|
||||
if ($neverSuccessful) {
|
||||
$neverSuccessfulCount++;
|
||||
}
|
||||
|
||||
if ($primaryScheduleId === null && ($neverSuccessful || $isOverdue || $needsFollowUpAfterRun)) {
|
||||
$primaryScheduleId = (int) $schedule->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return new BackupScheduleFollowUpEvaluation(
|
||||
hasEnabledSchedules: $enabledScheduleCount > 0,
|
||||
enabledScheduleCount: $enabledScheduleCount,
|
||||
overdueScheduleCount: $overdueScheduleCount,
|
||||
failedRecentRunCount: $failedRecentRunCount,
|
||||
neverSuccessfulCount: $neverSuccessfulCount,
|
||||
needsFollowUp: $overdueScheduleCount > 0 || $failedRecentRunCount > 0 || $neverSuccessfulCount > 0,
|
||||
primaryScheduleId: $primaryScheduleId,
|
||||
summaryMessage: $this->scheduleSummaryMessage(
|
||||
enabledScheduleCount: $enabledScheduleCount,
|
||||
overdueScheduleCount: $overdueScheduleCount,
|
||||
failedRecentRunCount: $failedRecentRunCount,
|
||||
neverSuccessfulCount: $neverSuccessfulCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function latestBackupAgeMessage(?CarbonInterface $completedAt, CarbonImmutable $now): ?string
|
||||
{
|
||||
if (! $completedAt instanceof CarbonInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'The latest completed backup was %s.',
|
||||
$completedAt->diffForHumans($now, [
|
||||
'parts' => 2,
|
||||
'join' => true,
|
||||
'short' => false,
|
||||
'syntax' => CarbonInterface::DIFF_RELATIVE_TO_NOW,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private function freshnessHours(): int
|
||||
{
|
||||
return max(1, (int) config('tenantpilot.backup_health.freshness_hours', 24));
|
||||
}
|
||||
|
||||
private function scheduleOverdueGraceMinutes(): int
|
||||
{
|
||||
return max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30));
|
||||
}
|
||||
|
||||
private function scheduleSummaryMessage(
|
||||
int $enabledScheduleCount,
|
||||
int $overdueScheduleCount,
|
||||
int $failedRecentRunCount,
|
||||
int $neverSuccessfulCount,
|
||||
): ?string {
|
||||
if ($enabledScheduleCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($neverSuccessfulCount > 0) {
|
||||
return $neverSuccessfulCount === 1
|
||||
? 'One enabled schedule has not produced a successful run yet.'
|
||||
: sprintf('%d enabled schedules have not produced a successful run yet.', $neverSuccessfulCount);
|
||||
}
|
||||
|
||||
if ($overdueScheduleCount > 0) {
|
||||
return $overdueScheduleCount === 1
|
||||
? 'One enabled schedule looks overdue.'
|
||||
: sprintf('%d enabled schedules look overdue.', $overdueScheduleCount);
|
||||
}
|
||||
|
||||
if ($failedRecentRunCount > 0) {
|
||||
return $failedRecentRunCount === 1
|
||||
? 'One enabled schedule needs follow-up after the last run.'
|
||||
: sprintf('%d enabled schedules need follow-up after the last run.', $failedRecentRunCount);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string|null> $messages
|
||||
*/
|
||||
private function combineMessages(array $messages): ?string
|
||||
{
|
||||
$parts = array_values(array_filter(
|
||||
Arr::flatten($messages),
|
||||
static fn (mixed $message): bool => is_string($message) && $message !== ''
|
||||
));
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
@ -120,6 +120,36 @@
|
||||
],
|
||||
],
|
||||
|
||||
'backup_health' => [
|
||||
'freshness_hours' => (int) env('TENANTPILOT_BACKUP_HEALTH_FRESHNESS_HOURS', 24),
|
||||
'schedule_overdue_grace_minutes' => (int) env('TENANTPILOT_BACKUP_HEALTH_SCHEDULE_OVERDUE_GRACE_MINUTES', 30),
|
||||
'browser_smoke_fixture' => [
|
||||
'workspace' => [
|
||||
'name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_WORKSPACE_NAME', 'Spec 180 Backup Health Smoke'),
|
||||
'slug' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_WORKSPACE_SLUG', 'spec-180-backup-health-smoke'),
|
||||
],
|
||||
'user' => [
|
||||
'name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_USER_NAME', 'Spec 180 Requester'),
|
||||
'email' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_USER_EMAIL', 'smoke-requester+180@tenantpilot.local'),
|
||||
'password' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_USER_PASSWORD', 'password'),
|
||||
],
|
||||
'blocked_drillthrough' => [
|
||||
'tenant_name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_TENANT_NAME', 'Spec 180 Blocked Backup Tenant'),
|
||||
'tenant_external_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_TENANT_EXTERNAL_ID', '18000000-0000-4000-8000-000000000180'),
|
||||
'tenant_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_TENANT_ID', '18000000-0000-4000-8000-000000000180'),
|
||||
'app_client_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_APP_CLIENT_ID', '18000000-0000-4000-8000-000000000182'),
|
||||
'policy_external_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_POLICY_EXTERNAL_ID', 'spec-180-rbac-stale-policy'),
|
||||
'policy_type' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_POLICY_TYPE', 'settingsCatalogPolicy'),
|
||||
'policy_name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_POLICY_NAME', 'Spec 180 RBAC Smoke Policy'),
|
||||
'backup_set_name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_BACKUP_SET_NAME', 'Spec 180 Blocked Stale Backup'),
|
||||
'stale_age_hours' => (int) env('TENANTPILOT_BACKUP_HEALTH_SMOKE_STALE_AGE_HOURS', 48),
|
||||
'capability_denials' => [
|
||||
\App\Support\Auth\Capabilities::TENANT_VIEW,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||
|
||||
'supported_policy_types' => [
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@ -64,6 +65,32 @@
|
||||
->middleware('throttle:entra-callback')
|
||||
->name('auth.entra.callback');
|
||||
|
||||
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) {
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
||||
$userEmail = is_array($fixture) ? data_get($fixture, 'user.email') : null;
|
||||
$tenantRouteKey = is_array($fixture)
|
||||
? (data_get($fixture, 'blocked_drillthrough.tenant_id') ?? data_get($fixture, 'blocked_drillthrough.tenant_external_id'))
|
||||
: null;
|
||||
|
||||
abort_unless(is_string($userEmail) && $userEmail !== '', 404);
|
||||
abort_unless(is_string($tenantRouteKey) && $tenantRouteKey !== '', 404);
|
||||
|
||||
$user = User::query()->where('email', $userEmail)->firstOrFail();
|
||||
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->firstOrFail();
|
||||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
||||
$workspaceContext->rememberTenantContext($tenant, $request);
|
||||
|
||||
return redirect()->to('/admin/t/'.$tenant->external_id);
|
||||
})->name('admin.local.backup-health-browser-fixture-login');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
||||
->name('admin.switch-workspace');
|
||||
|
||||
36
specs/180-tenant-backup-health/checklists/requirements.md
Normal file
36
specs/180-tenant-backup-health/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Tenant Backup Health Signals
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-07
|
||||
**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-07 against the completed Spec 180 draft.
|
||||
- No clarification markers remain.
|
||||
- The spec stays bounded to tenant backup-health truth on dashboard and follow-up surfaces and does not expand into recovery-confidence or new persistence.
|
||||
@ -0,0 +1,420 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tenant Backup Health Surface Contracts
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Internal reference contract for tenant backup-health surfaces. The application
|
||||
continues to render HTML through Filament and Livewire. The vendor media types
|
||||
below document the structured dashboard, backup-set, and backup-schedule models
|
||||
that must be derivable before rendering. This is not a public API commitment.
|
||||
paths:
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
summary: Tenant dashboard backup-health surface
|
||||
description: >-
|
||||
Returns the rendered tenant dashboard. The vendor media type documents the
|
||||
backup-health summary, backup-health attention item, and healthy-check model
|
||||
that the dashboard must expose.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered tenant dashboard
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.tenant-backup-health-dashboard+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantBackupHealthDashboardSurface'
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/backup-sets:
|
||||
get:
|
||||
summary: Backup-set list confirmation surface
|
||||
description: >-
|
||||
Returns the rendered backup-set list page. The vendor media type documents
|
||||
how the latest relevant backup basis and no-backup posture are confirmed.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered backup-set list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.backup-health-backup-set-collection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSetCollectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks backup viewing capability
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/backup-sets/{backupSet}:
|
||||
get:
|
||||
summary: Backup-set detail confirmation surface
|
||||
description: >-
|
||||
Returns the rendered backup-set detail page. The vendor media type documents
|
||||
the recency and quality facts that must confirm stale or degraded latest-backup posture.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: backupSet
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered backup-set detail page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.backup-health-backup-set-detail+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSetDetailSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks backup viewing capability for the linked backup-set detail surface
|
||||
'404':
|
||||
description: Backup set is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/backup-schedules:
|
||||
get:
|
||||
summary: Backup-schedule follow-up confirmation surface
|
||||
description: >-
|
||||
Returns the rendered backup-schedule list page. The vendor media type documents
|
||||
the schedule-follow-up facts that must confirm overdue or missed schedule execution.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered backup-schedule list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.backup-health-schedule-collection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupScheduleCollectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks schedule viewing capability
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
components:
|
||||
schemas:
|
||||
TenantBackupHealthDashboardSurface:
|
||||
type: object
|
||||
required:
|
||||
- summary
|
||||
properties:
|
||||
summary:
|
||||
$ref: '#/components/schemas/BackupHealthSummary'
|
||||
attentionItem:
|
||||
description: Present when a backup-health caution remains. `healthyCheck` must be null in that case.
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/BackupHealthAttentionItem'
|
||||
- type: 'null'
|
||||
healthyCheck:
|
||||
description: Present only when no backup-health attention item remains unresolved, including `schedule_follow_up`.
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/HealthyCheck'
|
||||
- type: 'null'
|
||||
BackupHealthSummary:
|
||||
type: object
|
||||
required:
|
||||
- posture
|
||||
- primaryReason
|
||||
- headline
|
||||
- tone
|
||||
- healthyClaimAllowed
|
||||
properties:
|
||||
posture:
|
||||
type: string
|
||||
enum: [absent, stale, degraded, healthy]
|
||||
description: Describes the state of the latest relevant backup basis itself.
|
||||
primaryReason:
|
||||
description: The active follow-up reason. This may be `schedule_follow_up` even when `posture` remains `healthy`.
|
||||
oneOf:
|
||||
- type: string
|
||||
enum: [no_backup_basis, latest_backup_stale, latest_backup_degraded, schedule_follow_up]
|
||||
- type: 'null'
|
||||
headline:
|
||||
type: string
|
||||
supportingMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
tone:
|
||||
type: string
|
||||
enum: [danger, warning, success, gray]
|
||||
latestRelevantBackupSetId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
latestRelevantCompletedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
freshness:
|
||||
$ref: '#/components/schemas/FreshnessEvaluation'
|
||||
scheduleFollowUp:
|
||||
$ref: '#/components/schemas/ScheduleFollowUpEvaluation'
|
||||
healthyClaimAllowed:
|
||||
type: boolean
|
||||
description: True only when the backup basis is healthy and no unresolved `schedule_follow_up` or stronger caution remains.
|
||||
actionTarget:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionTarget'
|
||||
- type: 'null'
|
||||
actionDisabled:
|
||||
type: boolean
|
||||
helperText:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
positiveClaimBoundary:
|
||||
type: string
|
||||
BackupHealthAttentionItem:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
- badge
|
||||
- badgeColor
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
badge:
|
||||
type: string
|
||||
badgeColor:
|
||||
type: string
|
||||
actionTarget:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionTarget'
|
||||
- type: 'null'
|
||||
actionDisabled:
|
||||
type: boolean
|
||||
helperText:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
HealthyCheck:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
actionTarget:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionTarget'
|
||||
- type: 'null'
|
||||
FreshnessEvaluation:
|
||||
type: object
|
||||
required:
|
||||
- isFresh
|
||||
- policySource
|
||||
properties:
|
||||
latestCompletedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
cutoffAt:
|
||||
type: string
|
||||
format: date-time
|
||||
isFresh:
|
||||
type: boolean
|
||||
policySource:
|
||||
type: string
|
||||
enum: [configured_window]
|
||||
ScheduleFollowUpEvaluation:
|
||||
type: object
|
||||
required:
|
||||
- hasEnabledSchedules
|
||||
- needsFollowUp
|
||||
properties:
|
||||
hasEnabledSchedules:
|
||||
type: boolean
|
||||
enabledScheduleCount:
|
||||
type: integer
|
||||
overdueScheduleCount:
|
||||
type: integer
|
||||
failedRecentRunCount:
|
||||
type: integer
|
||||
neverSuccessfulCount:
|
||||
type: integer
|
||||
needsFollowUp:
|
||||
type: boolean
|
||||
summaryMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
ActionTarget:
|
||||
type: object
|
||||
required:
|
||||
- surface
|
||||
- label
|
||||
- reason
|
||||
properties:
|
||||
surface:
|
||||
type: string
|
||||
enum: [backup_sets_index, backup_set_view, backup_schedules_index]
|
||||
recordId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
label:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
BackupSetCollectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- postureConfirmation
|
||||
- rows
|
||||
properties:
|
||||
latestRelevantBackupSetId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
postureConfirmation:
|
||||
type: string
|
||||
description: Explicit confirmation of the current collection meaning, including `no usable completed backup basis exists` when the dashboard drillthrough is resolving a `no_backup_basis` state.
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupSetRow'
|
||||
BackupSetRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- lifecycleStatus
|
||||
- completedAt
|
||||
- qualitySummary
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
lifecycleStatus:
|
||||
type: string
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
isLatestRelevant:
|
||||
type: boolean
|
||||
qualitySummary:
|
||||
type: string
|
||||
BackupSetDetailSurface:
|
||||
type: object
|
||||
required:
|
||||
- header
|
||||
- freshness
|
||||
- qualitySummary
|
||||
- postureConfirmation
|
||||
properties:
|
||||
header:
|
||||
$ref: '#/components/schemas/BackupSetHeader'
|
||||
freshness:
|
||||
$ref: '#/components/schemas/FreshnessEvaluation'
|
||||
qualitySummary:
|
||||
type: string
|
||||
postureConfirmation:
|
||||
type: string
|
||||
positiveClaimBoundary:
|
||||
type: string
|
||||
BackupSetHeader:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- lifecycleStatus
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
lifecycleStatus:
|
||||
type: string
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
BackupScheduleCollectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
- followUpPresent
|
||||
properties:
|
||||
followUpPresent:
|
||||
type: boolean
|
||||
summaryMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupScheduleRow'
|
||||
BackupScheduleRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- isEnabled
|
||||
- followUpState
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
isEnabled:
|
||||
type: boolean
|
||||
lastRunStatus:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
lastRunAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
nextRunAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
followUpState:
|
||||
type: string
|
||||
enum: [none, overdue, failed_recently, never_successful, disabled]
|
||||
followUpMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
202
specs/180-tenant-backup-health/data-model.md
Normal file
202
specs/180-tenant-backup-health/data-model.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Data Model: Tenant Backup Health Signals
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add or change a top-level persisted domain entity. It introduces a narrow derived tenant backup-health model over existing tenant-owned backup and schedule records and integrates that derived truth into dashboard and drillthrough surfaces.
|
||||
|
||||
The central design task is to make the tenant dashboard answer four operator-visible states without changing:
|
||||
|
||||
- `BackupSet`, `BackupItem`, or `BackupSchedule` ownership
|
||||
- existing backup-quality source-of-truth rules
|
||||
- existing backup or schedule route identity
|
||||
- existing mutation, audit, or `OperationRun` responsibilities
|
||||
- the no-new-table and no-recovery-score boundary of the feature
|
||||
|
||||
## Existing Persistent Entities
|
||||
|
||||
### 1. BackupSet
|
||||
|
||||
- Purpose: Tenant-owned backup collection that records capture lifecycle state and groups captured backup items.
|
||||
- Existing persistent fields used by this feature:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `name`
|
||||
- `status`
|
||||
- `item_count`
|
||||
- `metadata`
|
||||
- `completed_at`
|
||||
- `created_at`
|
||||
- Existing relationships used by this feature:
|
||||
- `tenant`
|
||||
- `items`
|
||||
- `restoreRuns`
|
||||
|
||||
#### Notes
|
||||
|
||||
- `completed_at` is the primary timestamp used to decide whether a completed backup basis exists and whether it is fresh enough.
|
||||
- No new `backup_health` or `freshness_state` column is introduced on `backup_sets`.
|
||||
|
||||
### 2. BackupItem
|
||||
|
||||
- Purpose: Tenant-owned captured recovery input for one backed-up policy or foundation record.
|
||||
- Existing persistent fields used by this feature:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `backup_set_id`
|
||||
- `payload`
|
||||
- `assignments`
|
||||
- `metadata`
|
||||
- `captured_at`
|
||||
- Existing relationships used by this feature:
|
||||
- `tenant`
|
||||
- `backupSet`
|
||||
- `policyVersion`
|
||||
|
||||
#### Existing metadata signals used by this feature
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `source` | string or null | Direct source marker; may indicate metadata-only capture |
|
||||
| `snapshot_source` | string or null | Copied source marker from a linked policy version |
|
||||
| `warnings` | array<string> | Warning messages that may imply metadata-only fallback or other quality concerns |
|
||||
| `assignments_fetch_failed` | boolean | Assignment capture failed for the item |
|
||||
| `assignment_capture_reason` | string or null | Explanatory capture reason; not all values are degradations |
|
||||
| `has_orphaned_assignments` | boolean | One or more captured assignment targets were orphaned |
|
||||
| `integrity_warning` | string or null | Existing integrity or redaction warning carried into the backup item |
|
||||
|
||||
### 3. BackupSchedule
|
||||
|
||||
- Purpose: Tenant-owned schedule configuration for automated backup execution.
|
||||
- Existing persistent fields used by this feature:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `name`
|
||||
- `is_enabled`
|
||||
- `frequency`
|
||||
- `time_of_day`
|
||||
- `days_of_week`
|
||||
- `policy_types`
|
||||
- `last_run_status`
|
||||
- `last_run_at`
|
||||
- `next_run_at`
|
||||
- `timezone`
|
||||
- `created_at`
|
||||
- Existing relationships used by this feature:
|
||||
- `tenant`
|
||||
- `operationRuns`
|
||||
|
||||
#### Notes
|
||||
|
||||
- `last_run_at`, `last_run_status`, and `next_run_at` are sufficient to derive `schedule_follow_up`.
|
||||
- No new `schedule_health_state` or `backup_health_state` column is introduced.
|
||||
|
||||
## Derived Inputs
|
||||
|
||||
### 1. BackupHealthConfig
|
||||
|
||||
This is configuration, not persistence.
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `tenantpilot.backup_health.freshness_hours` | integer | Defines the canonical age window for the latest relevant completed backup basis |
|
||||
| `tenantpilot.backup_health.schedule_overdue_grace_minutes` | integer | Defines how far past `next_run_at` an enabled schedule may drift before the feature raises `schedule_follow_up` |
|
||||
|
||||
## Derived Models
|
||||
|
||||
All derived models in this section are lightweight concrete value objects in `app/Support/BackupHealth/`. The concrete file set is `TenantBackupHealthAssessment.php`, `BackupFreshnessEvaluation.php`, `BackupScheduleFollowUpEvaluation.php`, `BackupHealthActionTarget.php`, and `BackupHealthDashboardSignal.php`, with `TenantBackupHealthResolver.php` orchestrating them.
|
||||
|
||||
### 1. TenantBackupHealthAssessment
|
||||
|
||||
Tenant-level backup-health truth used by dashboard and drillthrough logic.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `tenantId` | integer | tenant context | Identity |
|
||||
| `posture` | string | derived | One of `absent`, `stale`, `degraded`, `healthy` |
|
||||
| `primaryReason` | string or null | derived | One of `no_backup_basis`, `latest_backup_stale`, `latest_backup_degraded`, or `schedule_follow_up`; `null` only when posture is healthy and no remaining follow-up reason exists |
|
||||
| `headline` | string | derived | Primary operator-facing summary for dashboard surfaces |
|
||||
| `supportingMessage` | string or null | derived | Secondary operator-facing explanation |
|
||||
| `latestRelevantBackupSetId` | integer or null | derived | The backup set that currently governs posture |
|
||||
| `latestRelevantCompletedAt` | datetime or null | derived from `BackupSet.completed_at` | Null when no usable completed basis exists |
|
||||
| `qualitySummary` | `BackupQualitySummary` or null | reused `BackupQualityResolver` output | Reused quality truth for the latest relevant backup basis |
|
||||
| `freshnessEvaluation` | `BackupFreshnessEvaluation` | derived | Separates recency truth from degradation truth |
|
||||
| `scheduleFollowUp` | `BackupScheduleFollowUpEvaluation` | derived | Secondary caution about automation timing |
|
||||
| `healthyClaimAllowed` | boolean | derived | True only when the evidence supports a positive healthy statement and no unresolved `schedule_follow_up` remains |
|
||||
| `primaryActionTarget` | `BackupHealthActionTarget` | derived | Reason-driven drillthrough destination |
|
||||
| `positiveClaimBoundary` | string | derived | Explains that backup health does not imply recoverability or restore success |
|
||||
|
||||
### 2. BackupFreshnessEvaluation
|
||||
|
||||
Derived recency truth for the latest relevant completed backup basis.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `latestCompletedAt` | datetime or null | `BackupSet.completed_at` | Null when no usable completed basis exists |
|
||||
| `cutoffAt` | datetime | derived from config and `now()` | The point after which a backup basis becomes stale |
|
||||
| `isFresh` | boolean | derived | False when no basis exists or when `latestCompletedAt` is older than `cutoffAt` |
|
||||
| `policySource` | string | derived | Initially `configured_window` to make the canonical freshness rule explicit and testable |
|
||||
|
||||
#### Rules
|
||||
|
||||
- If there is no relevant completed backup basis, the feature does not produce `fresh`; it produces `absent`.
|
||||
- If a latest relevant completed backup basis exists but predates `cutoffAt`, the primary posture becomes `stale`.
|
||||
- `stale` outranks `degraded` as a primary posture when both conditions are true.
|
||||
|
||||
### 3. BackupScheduleFollowUpEvaluation
|
||||
|
||||
Derived automation follow-up truth for enabled schedules.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `hasEnabledSchedules` | boolean | derived from `BackupSchedule.is_enabled` | False when no enabled schedules exist |
|
||||
| `enabledScheduleCount` | integer | derived | Informational count |
|
||||
| `overdueScheduleCount` | integer | derived from `next_run_at` and grace window | Counts enabled schedules that appear overdue |
|
||||
| `failedRecentRunCount` | integer | derived from `last_run_status` | Counts enabled schedules whose recent status indicates failure or warning |
|
||||
| `neverSuccessfulCount` | integer | derived from `last_run_at` and schedule timing | Counts enabled schedules with no success evidence even though they should already have produced it |
|
||||
| `needsFollowUp` | boolean | derived | True when schedule timing or status indicates operator review |
|
||||
| `primaryScheduleId` | integer or null | derived | Optional single record for direct continuity if the result is unambiguous |
|
||||
| `summaryMessage` | string or null | derived | Operator-facing explanation of the schedule caution |
|
||||
|
||||
#### Rules
|
||||
|
||||
- Schedule follow-up is secondary. It adds attention but does not upgrade a tenant into `healthy`.
|
||||
- Enabled schedules with `next_run_at` past the grace window or with missing success evidence after the schedule should already have produced its first successful run count toward `schedule_follow_up`.
|
||||
|
||||
### 4. BackupHealthActionTarget
|
||||
|
||||
Reason-driven drillthrough target chosen by the resolver.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `surface` | string | `backup_sets_index`, `backup_set_view`, or `backup_schedules_index` |
|
||||
| `recordId` | integer or null | Used only when the target is a specific backup set |
|
||||
| `label` | string | Operator-facing action label such as `Open backup sets` or `Open latest backup` |
|
||||
| `reason` | string | The problem class the destination is expected to confirm |
|
||||
|
||||
### 5. BackupHealthDashboardSignal
|
||||
|
||||
Shared dashboard-facing model for KPI, attention, and healthy-check rendering.
|
||||
|
||||
| Field | Type | Source |
|
||||
|---|---|---|
|
||||
| `title` | string | derived |
|
||||
| `body` | string | derived |
|
||||
| `tone` | string | derived |
|
||||
| `badge` | string or null | derived via shared badge primitives when the signal is rendered as an attention item |
|
||||
| `badgeColor` | string or null | derived via shared badge primitives when the signal is rendered as an attention item |
|
||||
| `actionTarget` | `BackupHealthActionTarget` or null | derived |
|
||||
| `actionDisabled` | boolean | derived from authorization and target availability |
|
||||
| `helperText` | string or null | derived |
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- `absent` applies when no usable completed backup basis exists for the tenant.
|
||||
- `stale` applies when the latest relevant completed backup basis exists but fails the freshness rule, regardless of whether it is also degraded.
|
||||
- `degraded` applies when the latest relevant completed backup basis is fresh enough but existing `BackupQualitySummary` shows material degradation.
|
||||
- `healthy` applies when a latest relevant completed backup basis exists, passes freshness, and shows no material degradation.
|
||||
- `schedule_follow_up` is additive and must never be used as proof of health.
|
||||
- If `schedule_follow_up` is the only remaining caution, posture may remain `healthy`, but `primaryReason` must be `schedule_follow_up` and `healthyClaimAllowed` must be `false` until the follow-up resolves.
|
||||
- Existing `BackupQualitySummary` produced by `BackupQualityResolver` remains the authority for degradation families and degraded item counts.
|
||||
- Dashboard summary truth must remain visible to entitled tenant-dashboard viewers even when a downstream action target is suppressed or disabled.
|
||||
273
specs/180-tenant-backup-health/plan.md
Normal file
273
specs/180-tenant-backup-health/plan.md
Normal file
@ -0,0 +1,273 @@
|
||||
# Implementation Plan: Tenant Backup Health Signals
|
||||
|
||||
**Branch**: `180-tenant-backup-health` | **Date**: 2026-04-07 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Harden the tenant dashboard so operators can tell within seconds whether a tenant has no usable backup basis, a stale latest backup basis, a degraded latest backup basis, or a healthy recent backup basis without opening deep backup surfaces first. The implementation keeps `BackupSet`, `BackupItem`, `BackupSchedule`, and the existing backup-quality layer as the only sources of truth, introduces one narrow derived tenant backup-health resolver over those records, adds a config-backed freshness policy with schedule follow-up semantics, integrates the result into `DashboardKpis` and `NeedsAttention`, and preserves reason-driven drillthrough into existing backup-set and backup-schedule surfaces without adding a new persistence model or recovery-confidence framework.
|
||||
|
||||
Key approach: work inside the existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, and `BackupQualityResolver` seams; derive tenant posture from the latest relevant completed backup set plus existing backup-quality truth and enabled-schedule timing; keep the feature Filament v5 and Livewire v4 compliant; avoid new tables, Graph calls, jobs, or asset registration; validate the result with focused Pest, Livewire, truth-alignment, and RBAC coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers
|
||||
**Storage**: PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned
|
||||
**Testing**: Pest feature tests, Livewire widget and resource tests, and unit tests for the narrow backup-health derivation layer, 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
|
||||
**Performance Goals**: Keep tenant-dashboard rendering DB-only and query-bounded, avoid new N+1 query hotspots while deriving the latest relevant backup basis, and preserve 5 to 10 second operator scanability on tenant dashboard and drillthrough destinations
|
||||
**Constraints**: No new backup-health table, no recovery-confidence score, no new Graph contract path, no new queue or `OperationRun`, no RBAC drift, no calmness leakage beyond evidence, no ad-hoc badge mappings, and no new global Filament assets
|
||||
**Scale/Scope**: One tenant-scoped dashboard composition, two existing dashboard widgets, one narrow derived backup-health layer, optional config additions in `config/tenantpilot.php`, and focused regression coverage across resolver, widget, drillthrough, and RBAC behavior
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Inventory-first | Pass | Backups remain immutable snapshot truth; the feature only summarizes existing backup and schedule state on read |
|
||||
| Read/write separation | Pass | This is a read-first dashboard hardening slice; existing backup and schedule mutations remain unchanged and separately confirmed or audited |
|
||||
| Graph contract path | Pass | No new Microsoft Graph calls or contract-registry changes are introduced |
|
||||
| Deterministic capabilities | Pass | Existing capability registry and tenant-scoped authorization remain authoritative; no raw capability strings are introduced |
|
||||
| RBAC-UX planes and 404 vs 403 | Pass | The feature stays in the tenant/admin plane under `/admin/t/{tenant}/...`; non-members remain `404`, and existing in-scope authorization stays server-side |
|
||||
| Workspace isolation | Pass | No workspace-scope broadening or cross-workspace aggregation is added |
|
||||
| Tenant isolation | Pass | Backup sets, backup items, schedules, and dashboard summaries stay tenant-owned and tenant-scoped |
|
||||
| Dangerous and destructive confirmations | Pass | No new destructive action is introduced. Existing backup and schedule destructive actions remain `->requiresConfirmation()` and capability-gated |
|
||||
| Global search safety | Pass | No new globally searchable resource is introduced or changed. `BackupSetResource` already has a view page, `BackupScheduleResource` already has an edit page, and global search configuration remains unchanged |
|
||||
| Run observability | Pass | No new long-running work or `OperationRun` usage is introduced |
|
||||
| Ops-UX 3-surface feedback | Pass | No new queued action or run feedback surface is added |
|
||||
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` are untouched |
|
||||
| Ops-UX summary counts | Pass | No new `summary_counts` keys are required |
|
||||
| Data minimization | Pass | The feature reuses existing metadata and timestamps only; no new secret or payload exposure is planned |
|
||||
| Proportionality (PROP-001) | Pass | Added logic is limited to one narrow tenant backup-health layer plus config-backed freshness semantics |
|
||||
| Persisted truth (PERSIST-001) | Pass | No new table, column, or stored backup-health mirror is introduced |
|
||||
| Behavioral state (STATE-001) | Pass | New posture and reason families are derived only because they change operator guidance and dashboard calmness behavior |
|
||||
| Badge semantics (BADGE-001) | Pass | Existing badge and tag infrastructure remains the semantic source; any new backup-health tone stays inside shared UI primitives rather than local mappings |
|
||||
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament widgets, stats, tables, and shared primitives remain the implementation seams |
|
||||
| UI naming (UI-NAMING-001) | Pass | Operator-facing vocabulary stays bounded to backup health, last backup, stale, degraded, no backups, and schedule follow-up, without `recoverable` or `proven` claims |
|
||||
| Operator surfaces (OPSURF-001) | Pass | Default-visible tenant-dashboard content becomes more operator-first by exposing backup posture before deep diagnostics |
|
||||
| Filament Action Surface Contract | Pass | `BackupSetResource` and `BackupScheduleResource` keep existing inspect models and destructive placement; `TenantDashboard` remains under the current dashboard exemption |
|
||||
| Filament UX-001 | Pass with documented variance | No new create or edit screen is added. Existing backup-set and backup-schedule resources remain the canonical follow-up surfaces, with summary-first truth added where needed |
|
||||
| Filament v5 / Livewire v4 compliance | Pass | The implementation stays inside the current Filament v5 and Livewire v4 stack |
|
||||
| Provider registration location | Pass | No panel or provider changes are planned; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
|
||||
| Asset strategy | Pass | No new panel assets are planned; deployment keeps the existing `php artisan filament:assets` step unchanged |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Derive tenant backup health from existing `BackupSet`, `BackupItem`, `BackupSchedule`, and `BackupQualityResolver` truth instead of introducing persisted backup-health state.
|
||||
- Let the latest relevant completed backup set govern tenant posture rather than allowing older healthier history to calm the dashboard.
|
||||
- Reuse existing backup-quality summaries for degradation truth and add no competing backup-quality taxonomy.
|
||||
- Define backup freshness through one config-backed fallback window on the latest relevant completed backup set, while treating schedule timing as a secondary follow-up signal rather than health proof.
|
||||
- Derive schedule follow-up from enabled schedules whose current `next_run_at` or `last_run_at` semantics indicate missed or overdue execution beyond a small grace window.
|
||||
- Integrate backup health into the existing `DashboardKpis` and `NeedsAttention` widgets and keep healthy wording suppressed unless the backing evidence is fully supportive.
|
||||
- Route dashboard drillthroughs by problem class: no usable backup basis opens the backup-set list, stale or degraded latest backup opens the latest relevant backup-set detail, and schedule follow-up opens the backup-schedules list.
|
||||
- Extend the current widget, truth-alignment, backup-set, schedule, and tenant-scope Pest coverage instead of creating a browser-first harness.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/`:
|
||||
|
||||
- `research.md`: implementation and domain decisions for tenant backup-health derivation
|
||||
- `data-model.md`: existing entities, config inputs, and derived backup-health models
|
||||
- `contracts/tenant-backup-health.openapi.yaml`: internal logical contract for dashboard summary, backup-set confirmation, and schedule follow-up surfaces
|
||||
- `quickstart.md`: focused automated and manual validation workflow for tenant backup-health signals
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required. The design adds only a narrow derived resolver layer and a small config section in `config/tenantpilot.php` for backup-health freshness semantics.
|
||||
- Tenant backup health is derived at render time from the latest relevant completed backup set, existing `BackupQualitySummary`, and enabled-schedule timing. No new `Tenant` field, cache table, or materialized rollup is planned.
|
||||
- Stale versus degraded precedence is deterministic: `absent` outranks everything, `stale` outranks `degraded`, `degraded` outranks `healthy`, and `schedule_follow_up` remains a secondary reason family. When the latest backup basis is fresh and non-degraded, posture may remain `healthy`, but `schedule_follow_up` becomes the active reason and suppresses any positive healthy confirmation until resolved.
|
||||
- `DashboardKpis` owns the primary backup-health stat or card, while `NeedsAttention` owns reason-specific backup follow-up items and the positive healthy backup check.
|
||||
- Backup-set detail remains the confirmation surface for stale and degraded latest-backup posture by combining recency and existing backup-quality summary. Backup-schedules list remains the confirmation surface for schedule-follow-up posture and must foreground one derived follow-up indicator so the missed-run or overdue reason stays scan-fast.
|
||||
- The feature stays Filament v5 and Livewire v4 compliant, introduces no new panel provider, and requires no new asset registration.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/180-tenant-backup-health/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── tenant-backup-health.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root, including planned additions for this feature)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── TenantDashboard.php
|
||||
│ ├── Resources/
|
||||
│ │ ├── BackupScheduleResource.php
|
||||
│ │ └── BackupSetResource.php
|
||||
│ └── Widgets/
|
||||
│ └── Dashboard/
|
||||
│ ├── DashboardKpis.php
|
||||
│ └── NeedsAttention.php
|
||||
├── Models/
|
||||
│ ├── BackupItem.php
|
||||
│ ├── BackupSchedule.php
|
||||
│ ├── BackupSet.php
|
||||
│ └── Tenant.php
|
||||
├── Support/
|
||||
│ ├── BackupHealth/
|
||||
│ │ ├── TenantBackupHealthAssessment.php
|
||||
│ │ ├── BackupFreshnessEvaluation.php
|
||||
│ │ ├── BackupScheduleFollowUpEvaluation.php
|
||||
│ │ ├── BackupHealthActionTarget.php
|
||||
│ │ ├── BackupHealthDashboardSignal.php
|
||||
│ │ └── TenantBackupHealthResolver.php
|
||||
│ ├── BackupQuality/
|
||||
│ │ ├── BackupQualityResolver.php
|
||||
│ │ └── BackupQualitySummary.php
|
||||
│ └── Badges/
|
||||
│ └── [existing shared badge seams only if new backup-health tone mapping is needed]
|
||||
|
||||
config/
|
||||
└── tenantpilot.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── BackupScheduling/
|
||||
│ │ └── BackupScheduleLifecycleTest.php
|
||||
│ └── Filament/
|
||||
│ ├── BackupSetListContinuityTest.php
|
||||
│ ├── BackupSetEnterpriseDetailPageTest.php
|
||||
│ ├── DashboardKpisWidgetTest.php
|
||||
│ ├── NeedsAttentionWidgetTest.php
|
||||
│ ├── TenantDashboardDbOnlyTest.php
|
||||
│ ├── TenantDashboardTenantScopeTest.php
|
||||
│ └── TenantDashboardTruthAlignmentTest.php
|
||||
└── Unit/
|
||||
└── Support/
|
||||
└── BackupHealth/
|
||||
└── TenantBackupHealthResolverTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith. The implementation stays inside existing dashboard widgets, backup resources, shared support helpers, and current test structure. Any new helper types and lightweight dashboard-facing value objects live under `app/Support/BackupHealth/` as a narrow derived layer shared by the dashboard and drillthrough logic.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Introduce Narrow Tenant Backup-Health Derivation
|
||||
|
||||
**Goal**: Create one derived path that can answer absent, stale, degraded, or healthy from existing backup and schedule truth without introducing new persistence.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | New narrow helper(s) under `app/Support/BackupHealth/` | Introduce `TenantBackupHealthResolver` plus lightweight `TenantBackupHealthAssessment`, `BackupFreshnessEvaluation`, `BackupScheduleFollowUpEvaluation`, `BackupHealthActionTarget`, and `BackupHealthDashboardSignal` value objects that derive the latest relevant completed backup basis, posture, primary reason, supporting message, drillthrough target, and healthy-claim boundary with query-bounded latest-basis loading |
|
||||
| A.2 | `app/Support/BackupQuality/BackupQualityResolver.php` plus the new backup-health layer | Explicitly reuse `BackupQualityResolver` and `BackupQualitySummary` output to classify material degradation instead of creating a second backup-quality system |
|
||||
| A.3 | `config/tenantpilot.php` | Add a small `backup_health` config section for canonical freshness hours and schedule overdue grace so stale logic is explicit, testable, and not hard-coded in widgets |
|
||||
|
||||
### Phase B — Integrate Backup Health Into Primary Tenant Dashboard Surfaces
|
||||
|
||||
**Goal**: Make tenant backup posture visible on the dashboard before the operator has to open deep backup pages.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | Add a backup-health stat or card that reflects the derived posture, last relevant backup timing, current reason, color tone, and one reason-driven destination |
|
||||
| B.2 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add backup-health attention items for no usable backup basis, stale latest backup, degraded latest backup, and schedule follow-up |
|
||||
| B.3 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add `Backups are recent and healthy` to the healthy-check set only when the derived assessment positively supports it and no backup-health attention item, including `schedule_follow_up`, remains |
|
||||
|
||||
### Phase C — Preserve Drillthrough Continuity On Backup And Schedule Surfaces
|
||||
|
||||
**Goal**: Ensure the dashboard warning or healthy claim can be rediscovered on the destination surface without guesswork.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `app/Support/BackupHealth/TenantBackupHealthResolver.php` plus `app/Support/BackupHealth/BackupHealthActionTarget.php` | Centralize reason-driven URL selection in the existing backup-health layer so no-basis goes to backup-set index, stale or degraded latest backup goes to the relevant backup-set detail, and schedule follow-up goes to backup-schedules index |
|
||||
| C.2 | `app/Filament/Resources/BackupSetResource.php` | Reuse or slightly harden the backup-set list and detail presentation so the index confirms no usable backup basis and the latest relevant backup-set detail clearly confirms stale or degraded posture on arrival |
|
||||
| C.3 | `app/Filament/Resources/BackupScheduleResource.php` | Add one derived schedule-follow-up confirmation signal on the list surface so existing `last_run_at`, `last_run_status`, and `next_run_at` evidence remains scan-fast on arrival |
|
||||
|
||||
### Phase D — Lock Semantics With Focused Regression Coverage
|
||||
|
||||
**Goal**: Protect resolver truth, dashboard truth, continuity, and tenant safety from regression.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | New unit tests under `tests/Unit/Support/BackupHealth/` | Cover no-backup, stale, degraded, healthy, schedule-follow-up, and latest-history-governs derivation |
|
||||
| D.2 | `tests/Feature/Filament/DashboardKpisWidgetTest.php` | Extend KPI payload and URL assertions for backup-health posture and reason-driven drillthrough |
|
||||
| D.3 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Extend attention and healthy-check coverage for no-backup, stale-backup, degraded-latest-backup, schedule-follow-up, and healthy-backup scenarios |
|
||||
| D.4 | `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Ensure backup-health calmness and caution align with the rest of the tenant dashboard and do not reintroduce calmness leakage |
|
||||
| D.5 | `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php` | Prove that no-basis, stale, degraded, and schedule-follow-up drillthrough destinations confirm the same problem class the dashboard named |
|
||||
| D.6 | `tests/Feature/Filament/TenantDashboardTenantScopeTest.php` or a new RBAC-safe visibility test | Preserve tenant-scope truth and non-member-safe behavior for dashboard summary and backup follow-up routes |
|
||||
| D.7 | `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 — Tenant backup health is derived, not stored
|
||||
|
||||
The product already stores the facts this slice needs: completed backup sets, backup-item quality metadata, and backup schedule timing. The missing piece is a tenant-level interpretation layer for overview truth, not a new persistence model.
|
||||
|
||||
### D-002 — The latest relevant completed backup set governs posture
|
||||
|
||||
Older healthy history cannot calm the dashboard if the latest relevant completed backup is stale or degraded. This keeps the overview aligned with the operator's current recovery starting point.
|
||||
|
||||
### D-003 — Stale and degraded remain distinct, with deterministic precedence
|
||||
|
||||
`absent`, `stale`, `degraded`, and `healthy` are mutually exclusive primary posture states. When the latest relevant backup is both old and degraded, `stale` becomes the primary posture while degradation remains visible as supporting detail rather than disappearing.
|
||||
|
||||
### D-004 — Schedule timing is follow-up truth, not health proof
|
||||
|
||||
An enabled schedule can support the operator's diagnosis, but it cannot prove healthy backup posture. Overdue or never-successful schedules add `schedule_follow_up`; they do not substitute for a recent healthy completed backup basis. If the backup basis is otherwise healthy, posture may stay `healthy`, but `schedule_follow_up` becomes the active reason and suppresses calm confirmation until the schedule concern clears.
|
||||
|
||||
### D-005 — Healthy wording is stricter than mere backup existence
|
||||
|
||||
`Backups are recent and healthy` is reserved for tenants whose latest relevant completed backup exists, meets the freshness window, and carries no material degradation under existing backup-quality truth. Lack of evidence must suppress calmness.
|
||||
|
||||
### D-006 — Existing Filament seams are sufficient
|
||||
|
||||
The current `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, and `BackupScheduleResource` surfaces already provide the right seams. This slice does not need a new page shell, a new dashboard module, or a new front-end state layer.
|
||||
|
||||
### D-007 — Keep the claim boundary below recovery confidence
|
||||
|
||||
The feature can say that backups are absent, stale, degraded, or healthy as backup inputs. It cannot say that the tenant is recoverable, that restore will succeed, or that recovery posture is proven.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Latest-basis selection drifts from operator expectation and lets older history calm the dashboard | High | Medium | Make latest relevant completed backup selection explicit in the resolver and cover mixed-history precedence with unit tests |
|
||||
| Dashboard calmness returns because schedule presence is treated as a proxy for health | High | Medium | Keep schedule follow-up secondary in the resolver and test that schedules never make a tenant healthy on their own |
|
||||
| Backup health duplicates or contradicts existing backup-quality truth | High | Medium | Reuse `BackupQualityResolver` and existing degradation families rather than adding a second backup-quality mapping |
|
||||
| Schedule drillthrough lands on a surface that does not clearly confirm the warning | Medium | Medium | Use the schedule list as the primary follow-up destination and add one scan-fast confirmation signal if timestamps alone are insufficient |
|
||||
| Tight stale thresholds create noise or false calmness over time | Medium | Medium | Externalize fallback freshness and schedule grace in config and pin the semantics with unit and feature tests |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Add unit tests for the narrow backup-health resolver so latest-basis selection, stale precedence, degraded detection reuse, healthy-gate logic, and schedule-follow-up derivation remain deterministic.
|
||||
- Extend `DashboardKpisWidgetTest` to assert the backup-health stat label, value, description, color, and destination across absent, stale, degraded, and healthy scenarios.
|
||||
- Extend `NeedsAttentionWidgetTest` to assert backup-health attention items, healthy-check inclusion or suppression, and safe degraded-link behavior when appropriate.
|
||||
- Extend `TenantDashboardTruthAlignmentTest` so backup-health calmness or caution cannot contradict the rest of the dashboard's operator truth.
|
||||
- Extend backup-set and schedule surface tests so dashboard drillthroughs recover the same problem class on the target page.
|
||||
- Extend tenant-scope or RBAC coverage so entitled users see truthful summary state and non-members receive deny-as-not-found semantics without cross-tenant hints.
|
||||
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a full-suite pass.
|
||||
- Run `vendor/bin/sail bin pint --dirty --format agent` before final verification.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations or exception-driven complexity were identified. The only added structure is a narrow derived backup-health layer and a small derived posture or reason family already justified by the proportionality review.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: The tenant dashboard can look healthy while backup posture is missing, stale, or degraded, which hides a recovery-relevant truth from the operator's primary overview surface.
|
||||
- **Existing structure is insufficient because**: Existing backup-quality truth lives in backup-set, item, version, and restore-adjacent surfaces, but there is no tenant-level rollup that answers the dashboard question directly.
|
||||
- **Narrowest correct implementation**: Add one narrow derived tenant backup-health layer, wire it into the existing dashboard widgets, and reuse current backup and schedule destinations for continuity without creating new persistence or a broader recovery-confidence system.
|
||||
- **Ownership cost created**: A small amount of resolver logic, a small config-backed freshness policy, limited widget wiring, and focused unit and feature tests.
|
||||
- **Alternative intentionally rejected**: A persisted backup-health table, a workspace-wide recovery rollup, or a recovery-confidence score. Each adds broader truth and maintenance cost than the current tenant-dashboard problem requires.
|
||||
- **Release truth**: Current-release truth. The feature corrects a trust gap on already-shipped tenant overview surfaces.
|
||||
|
||||
123
specs/180-tenant-backup-health/quickstart.md
Normal file
123
specs/180-tenant-backup-health/quickstart.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Quickstart: Tenant Backup Health Signals
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that the tenant dashboard now answers the operator's backup question within seconds, that backup-health warnings drill into confirming surfaces, and that positive backup-health wording only appears when the available evidence genuinely supports it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail if it is not already running.
|
||||
2. Ensure the workspace has representative tenant fixtures for:
|
||||
- no usable completed backup basis
|
||||
- one latest completed backup basis that is older than the backup-health freshness window
|
||||
- one latest completed backup basis that is recent but degraded under existing backup-quality truth
|
||||
- one latest completed backup basis that is recent and not materially degraded
|
||||
- one enabled backup schedule whose `next_run_at` is overdue or whose `last_run_at` indicates missing or failed execution
|
||||
- for a deterministic local/testing fixture that reproduces the established-member dashboard-visible but backup-drillthrough-`403` case, run `vendor/bin/sail artisan tenantpilot:backup-health:seed-browser-fixture --no-interaction`, then open `/admin/local/backup-health-browser-fixture-login` when the integrated browser cannot complete Microsoft SSO for the seeded local user
|
||||
3. Ensure the acting users are valid workspace and tenant members.
|
||||
4. Ensure at least one tenant-scoped user exists for positive summary visibility and one non-member or wrong-tenant case exists for tenant-scope isolation checks.
|
||||
|
||||
## Focused Automated Verification
|
||||
|
||||
Run the smallest existing dashboard and backup pack first:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
```
|
||||
|
||||
Expected new or expanded spec-scoped coverage:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php
|
||||
vendor/bin/sail artisan test --compact --filter=backup tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||
vendor/bin/sail artisan test --compact --filter=backup tests/Feature/Filament/BackupSetListContinuityTest.php tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
```
|
||||
|
||||
Use `--filter` for smaller iteration passes while implementing specific scenarios.
|
||||
|
||||
The tenant-scope pack must include one established-member scenario where the dashboard still renders truthful summary state with a disabled or downgraded backup-health action target while the protected downstream route returns `403`.
|
||||
|
||||
## Manual Validation Pass
|
||||
|
||||
### 1. Verify no-backup posture
|
||||
|
||||
Open `/admin/t/{tenant}` for a tenant without a usable completed backup basis and confirm:
|
||||
|
||||
- the tenant dashboard explicitly shows a backup-health warning rather than a calm omission
|
||||
- `NeedsAttention` includes a no-backup item or equivalent primary backup warning
|
||||
- the drillthrough opens the backup-set list, which confirms that no usable completed basis exists
|
||||
|
||||
### 2. Verify stale latest-backup posture
|
||||
|
||||
Open `/admin/t/{tenant}` for a tenant whose latest relevant completed backup basis is older than the configured freshness window and confirm:
|
||||
|
||||
- the dashboard does not show `Backups are recent and healthy`
|
||||
- the stale reason is visible on a primary summary surface
|
||||
- the drillthrough opens the latest relevant backup set and its recency confirms the stale posture immediately
|
||||
- if the latest relevant backup basis is both stale and degraded, the dashboard still leads with stale posture while the destination preserves degradation as supporting detail
|
||||
|
||||
### 3. Verify degraded latest-backup posture
|
||||
|
||||
Open `/admin/t/{tenant}` for a tenant whose latest relevant completed backup basis is recent but degraded and confirm:
|
||||
|
||||
- the dashboard shows degraded backup posture rather than a stale or healthy fallback
|
||||
- the drillthrough opens the latest relevant backup set
|
||||
- the destination confirms degradation through existing backup-quality summary without implying restore safety
|
||||
|
||||
### 4. Verify healthy backup posture
|
||||
|
||||
Open `/admin/t/{tenant}` for a tenant with a recent, non-degraded latest relevant completed backup basis and confirm:
|
||||
|
||||
- the dashboard may show a positive backup-health check
|
||||
- no backup-health attention item, including `schedule_follow_up`, is present
|
||||
- the healthy wording remains bounded to backup posture and does not imply recoverability or restore success
|
||||
|
||||
### 5. Verify schedule-follow-up posture
|
||||
|
||||
Open `/admin/t/{tenant}` for a tenant with an enabled schedule that looks overdue or never-successful and confirm:
|
||||
|
||||
- schedule follow-up appears as a backup-health attention reason when appropriate
|
||||
- a never-successful schedule triggers follow-up only when it is old enough that it should already have produced success evidence
|
||||
- if the latest backup basis is otherwise fresh and non-degraded, the basis posture may remain healthy, but `schedule_follow_up` stays the active reason and no positive healthy backup check appears
|
||||
- schedule presence alone does not make the dashboard healthy and suppresses any positive healthy backup check while the follow-up remains active
|
||||
- the drillthrough opens the backup-schedules list and the destination makes the overdue or missed-execution condition scan-fast
|
||||
|
||||
### 6. Verify tenant-scope and RBAC consistency
|
||||
|
||||
Repeat the dashboard checks as:
|
||||
|
||||
- an entitled tenant member, to confirm backup-health summary truth is visible
|
||||
- an entitled tenant member who can load the dashboard but cannot open one backup destination, to confirm the summary still renders, the affordance degrades safely, and the blocked downstream route fails with `403`; the deterministic local/testing fixture command above seeds exactly this case for `smoke-requester+180@tenantpilot.local`, and `/admin/local/backup-health-browser-fixture-login` starts the local/testing browser session for that user
|
||||
- a non-member or wrong-tenant actor, to confirm tenant dashboard and drillthrough routes remain deny-as-not-found
|
||||
|
||||
## Non-Regression Checks
|
||||
|
||||
Confirm the feature did not change:
|
||||
|
||||
- current backup-set and backup-schedule route identity
|
||||
- existing destructive action confirmation behavior on backup resources
|
||||
- existing backup-quality semantics owned by `BackupQualityResolver`
|
||||
- existing `OperationRun` behavior and operations widgets
|
||||
- current global asset registration and deployment requirements
|
||||
|
||||
## Formatting And Final Verification
|
||||
|
||||
Before finalizing implementation work:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Then rerun the smallest affected test set and offer the full suite only after the focused backup-health pack passes.
|
||||
|
||||
Close the feature only after manual validation confirms:
|
||||
|
||||
- an operator can identify absent, stale, degraded, or healthy backup posture within 10 seconds on the tenant dashboard
|
||||
- warning drillthroughs land on surfaces that confirm the same problem class
|
||||
- positive backup-health wording appears only when the evidence truly supports it
|
||||
73
specs/180-tenant-backup-health/research.md
Normal file
73
specs/180-tenant-backup-health/research.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Research: Tenant Backup Health Signals
|
||||
|
||||
## Decision 1: Derive tenant backup health from existing backup and schedule truth instead of creating a persisted health model
|
||||
|
||||
- Decision: Build tenant backup health from existing `BackupSet`, `BackupItem`, `BackupSchedule`, and `BackupQualityResolver` facts at render time. Do not add a `tenant_backup_health` table, a cached rollup row, or a recovery-confidence ledger.
|
||||
- Rationale: The repository already stores the facts this feature needs: completed backup timestamps, backup-quality degradations, and schedule timing. The product gap is missing tenant-level overview truth, not missing persistence.
|
||||
- Alternatives considered:
|
||||
- Persist a backup-health row per tenant. Rejected because it would create a second source of truth for data that is already derivable.
|
||||
- Piggyback on `Tenant` model columns. Rejected because this slice does not need a new lifecycle-bearing tenant field.
|
||||
|
||||
## Decision 2: Let the latest relevant completed backup set govern posture
|
||||
|
||||
- Decision: The latest relevant completed backup set is the primary tenant backup-health basis. Older healthier backup history cannot override a newer stale or degraded latest basis.
|
||||
- Rationale: The operator's first question is about the current recovery starting point, not whether an older good snapshot exists somewhere in history.
|
||||
- Alternatives considered:
|
||||
- Choose the healthiest recent backup in the tenant. Rejected because it would calm the dashboard while the most recent relevant backup is weak.
|
||||
- Aggregate all backup history into a composite score. Rejected because the feature explicitly avoids a scoring engine.
|
||||
|
||||
## Decision 3: Reuse existing backup-quality truth instead of introducing a second degradation system
|
||||
|
||||
- Decision: Material degradation for tenant backup health is derived from existing `BackupQualitySummary` output, especially degraded item counts and existing degradation families. No new competing backup-quality taxonomy is introduced.
|
||||
- Rationale: Backup-quality truth was already hardened in the dedicated backup-quality work. Re-deriving the same concepts differently at tenant level would create contradiction and extra maintenance.
|
||||
- Alternatives considered:
|
||||
- Add a tenant-specific degradation matrix. Rejected because it would drift from backup-set and item detail truth.
|
||||
- Use only raw backup-item metadata in the dashboard resolver. Rejected because the shared quality resolver already exists and should remain authoritative.
|
||||
|
||||
## Decision 4: Define one config-backed freshness window for backup posture and keep schedule timing secondary
|
||||
|
||||
- Decision: Backup posture freshness is evaluated against the latest relevant completed backup set using one config-backed canonical freshness window in `config/tenantpilot.php`, initially aligned with the repo's existing 24-hour freshness posture for other safety-critical truth. Enabled schedule timing can add follow-up pressure but cannot replace or override that single freshness rule.
|
||||
- Rationale: There is no current backup-health freshness rule in the codebase. Hard-coding a threshold inside widgets would be brittle, while a small config value keeps the semantics explicit and testable.
|
||||
- Alternatives considered:
|
||||
- Make freshness entirely schedule-driven. Rejected because schedule state is secondary in the spec and cannot replace actual backup existence.
|
||||
- Hard-code a stale threshold inside the dashboard widget. Rejected because it would hide an important product rule in presentation code.
|
||||
|
||||
## Decision 5: Detect schedule follow-up from enabled schedules that look overdue or never-successful beyond a grace window
|
||||
|
||||
- Decision: `schedule_follow_up` is derived from enabled schedules whose `next_run_at` is overdue beyond a small grace window, whose `last_run_at` is missing after they should have started producing evidence, or whose last schedule status indicates a failed or non-successful recent run. If the latest backup basis is otherwise fresh and non-degraded, posture may remain `healthy`, but `schedule_follow_up` becomes the active reason and suppresses any positive healthy confirmation.
|
||||
- Rationale: `BackupSchedule` already tracks `last_run_at`, `last_run_status`, and `next_run_at`. Those fields are enough to signal that automation needs attention without conflating it with actual backup health proof.
|
||||
- Alternatives considered:
|
||||
- Ignore schedule state entirely. Rejected because the spec explicitly wants schedule follow-up represented.
|
||||
- Treat any enabled schedule as positive backup evidence. Rejected because schedule existence is not backup proof.
|
||||
|
||||
## Decision 6: Surface backup health through the existing dashboard widgets, not a new page or module
|
||||
|
||||
- Decision: Add backup health to the current `DashboardKpis` and `NeedsAttention` widgets and extend the existing healthy-check pattern instead of building a dedicated dashboard module or alternate tenant overview page.
|
||||
- Rationale: The tenant dashboard is already the operator's primary overview surface, and the spec is explicitly a small hardening slice rather than a new product area.
|
||||
- Alternatives considered:
|
||||
- Build a standalone backup-health page. Rejected because it does not solve the tenant-dashboard truth gap.
|
||||
- Defer backup health until a full recovery-confidence initiative. Rejected because the current dashboard already needs a narrower truth fix.
|
||||
|
||||
## Decision 7: Keep drillthrough reason-driven and use the least surprising existing surface for each reason
|
||||
|
||||
- Decision: Use reason-driven drillthroughs. `no_backup_basis` opens the backup-set list, `latest_backup_stale` and `latest_backup_degraded` open the latest relevant backup-set detail, and `schedule_follow_up` opens the backup-schedules list as the primary confirmation surface.
|
||||
- Rationale: These destinations already exist and are tenant-scoped. The backup-set detail can confirm stale or degraded latest-basis truth through recency and backup-quality summary. The schedule list is safer than an edit screen as the first follow-up destination and already shows last-run and next-run timing.
|
||||
- Alternatives considered:
|
||||
- Send every backup-health state to the backup-set list. Rejected because it weakens reason continuity for degraded latest-backup scenarios.
|
||||
- Send schedule follow-up directly to edit pages. Rejected because the first operator need is confirmation, not mutation.
|
||||
|
||||
## Decision 8: Add only the minimum continuity hardening needed on target surfaces
|
||||
|
||||
- Decision: Reuse the current backup-set detail and list surfaces for stale or degraded continuity and add one minimal schedule-follow-up confirmation signal on the backup-schedules list so the current timestamp columns remain scan-fast enough by themselves.
|
||||
- Rationale: Spec 176 already moved backup quality into backup surfaces. This slice should not rebuild those surfaces again; it should only ensure the dashboard reason can be rediscovered quickly.
|
||||
- Alternatives considered:
|
||||
- Add a banner framework or page-level explanation system. Rejected because it would overgrow the slice.
|
||||
- Leave continuity entirely to raw timestamps. Rejected because schedule follow-up may be too implicit at scan speed.
|
||||
|
||||
## Decision 9: Extend existing widget and dashboard truth tests before introducing new test harnesses
|
||||
|
||||
- Decision: Extend `DashboardKpisWidgetTest`, `NeedsAttentionWidgetTest`, `TenantDashboardTruthAlignmentTest`, and existing backup or schedule feature tests, and add one narrow unit test file for the resolver.
|
||||
- Rationale: The affected behavior is server-driven widget and resource truth, which the current Pest and Livewire suite already covers well.
|
||||
- Alternatives considered:
|
||||
- Rely only on manual validation. Rejected because the feature is specifically about preventing subtle trust regressions.
|
||||
- Add a large browser-only pack. Rejected because the highest-value assertions are deterministic server-side state and rendered truth.
|
||||
267
specs/180-tenant-backup-health/spec.md
Normal file
267
specs/180-tenant-backup-health/spec.md
Normal file
@ -0,0 +1,267 @@
|
||||
# Feature Specification: Tenant Backup Health Signals
|
||||
|
||||
**Feature Branch**: `180-tenant-backup-health`
|
||||
**Created**: 2026-04-07
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 180 — Tenant Backup Health Signals"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}` as the tenant dashboard where `DashboardKpis` and `NeedsAttention` currently establish the first tenant-level posture impression
|
||||
- `/admin/t/{tenant}/backup-sets` and `/admin/t/{tenant}/backup-sets/{record}` as the primary backup truth surfaces for recentness, degradation, and latest-backup follow-up
|
||||
- `/admin/t/{tenant}/backup-schedules` as the primary schedule follow-up confirmation surface when automation exists but does not appear to be running successfully, with `/admin/t/{tenant}/backup-schedules/{record}/edit` remaining a secondary maintenance surface when configuration changes are needed
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned `BackupSet`, `BackupItem`, and existing backup-quality metadata remain the source of truth for whether a usable recent backup basis exists and whether the latest relevant backup is degraded
|
||||
- Tenant-owned `BackupSchedule` remains the source of truth for schedule presence, last successful run timing, and next-run follow-up signals
|
||||
- Tenant dashboard backup health remains a derived tenant summary over those existing tenant-owned records; this feature introduces no new persisted tenant-health record, score table, or confidence ledger
|
||||
- **RBAC**:
|
||||
- Workspace membership plus tenant entitlement remain required to view the tenant dashboard and every backup follow-up destination
|
||||
- Existing backup and backup-schedule viewing permissions remain the enforcement source for drill-through destinations; this feature must not introduce raw capability checks or new role semantics
|
||||
- Tenant dashboard viewers must still receive truthful tenant-level backup-health signals even when a deeper backup destination is unavailable; in that case the signal may remain visible but the affordance must degrade safely rather than becoming a dead-end link
|
||||
|
||||
## 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 backup health summary | Embedded status summary / drill-in surface | One backup-health stat or card opens one matching backup follow-up destination for the strongest current reason | forbidden | none | none | `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/backup-schedules` depending on the reason | `/admin/t/{tenant}/backup-sets/{record}` when the latest relevant backup record is the clearest destination | Active tenant context remains visible on the dashboard and on every destination | Backup health / Backup | Whether the tenant has no usable backup basis, a stale basis, a degraded latest basis, or a healthy recent basis | Dashboard summary exemption |
|
||||
| Tenant dashboard `Needs Attention` backup item | Embedded attention summary | One attention item opens one matching backup or schedule follow-up destination | forbidden | none | none | `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/backup-schedules` depending on the reason | `/admin/t/{tenant}/backup-sets/{record}` when the attention reason is tied to the latest relevant backup set | Active tenant context plus reason-specific copy must stay visible before navigation | Backup health / Backup | The strongest backup problem class and the next place to inspect it | Multi-destination summary item |
|
||||
| Backup sets page | CRUD / list-first resource | Full-row click to the backup-set detail page | required | Existing inline safe shortcuts and More menu remain unchanged | Existing destructive actions remain grouped under existing More placement | `/admin/t/{tenant}/backup-sets` | `/admin/t/{tenant}/backup-sets/{record}` | Tenant context plus backup quality and recency context keep the list anchored to the current tenant | Backup sets / Backup set | Which backup set is the current basis and whether it is recent or degraded enough to explain dashboard posture | none |
|
||||
| Backup schedules page | CRUD / list-first resource | Record confirmation happens on the existing schedule list; edit remains secondary when maintenance is needed | required | Existing inline safe shortcuts and More menu remain unchanged | Existing destructive actions remain grouped under existing More placement | `/admin/t/{tenant}/backup-schedules` | `/admin/t/{tenant}/backup-schedules/{record}/edit` | Tenant context plus schedule timing and success context keep schedule follow-up anchored to the current tenant | Backup schedules / Backup schedule | Whether configured schedules are actually running often enough to support calm automation assumptions | 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 backup health summary | Tenant operator | Embedded status summary / drill-in surface | Are this tenant's backups recent and usable enough that I should feel calm, or do I need to follow up right now? | Backup-health class, last relevant backup timing, concise reason, and one matching next destination | Per-item degradation families, raw metadata, and restore detail remain secondary | backup existence, recency, backup quality, schedule follow-up | none | Open backup sets or backup schedules based on the current reason | none |
|
||||
| Tenant dashboard `Needs Attention` backup item | Tenant operator | Embedded attention summary | Why is backup posture weak right now, and where should I click next? | One reason-focused attention item such as no backups, stale backup, degraded latest backup, or schedule needs follow-up | Deep per-item diagnostics, full history, and raw schedule metadata remain secondary | backup absence, stale posture, degraded posture, schedule follow-up | none | Open the matching follow-up surface for the named reason | none |
|
||||
| Backup sets page | Tenant operator | List / detail | Which backup set is the relevant current basis, and does it confirm the dashboard warning or healthy claim? | Backup-set identity, recency, lifecycle, and quality summary that lets the operator recover the same problem class | Raw item metadata, deep assignment or payload diagnostics, and restore-specific detail remain secondary | capture lifecycle, recency, backup quality | TenantPilot-only existing backup maintenance remains unchanged and secondary to inspection | Open backup set, inspect latest relevant record | Existing archive, restore, or force-delete actions remain unchanged |
|
||||
| Backup schedules page | Tenant operator | List / edit | Is backup automation configured and actually running often enough, or does it need follow-up? | Schedule timing, last-success context, and overdue or missed-run context that confirms schedule follow-up | Low-level schedule configuration detail and related run history remain secondary | schedule presence, schedule freshness, schedule execution follow-up | TenantPilot-only existing schedule maintenance remains unchanged and secondary to inspection | Open the relevant backup schedule | Existing delete or maintenance actions remain unchanged |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: No. Existing `BackupSet`, `BackupItem`, existing backup-quality summaries, and existing `BackupSchedule` timing remain authoritative.
|
||||
- **New persisted entity/table/artifact?**: No. This slice explicitly forbids a new persisted backup-health table, score, or tenant-confidence ledger.
|
||||
- **New abstraction?**: Yes. A narrow derived tenant-level backup-health rollup is justified so the tenant dashboard can present one truthful tenant-level answer instead of forcing operators into multiple deep surfaces.
|
||||
- **New enum/state/reason family?**: Yes, but derived only. The feature needs a small tenant backup-health posture family such as `absent`, `stale`, `degraded`, and `healthy`, plus reason-level follow-up signals such as `no_backup_basis`, `latest_backup_stale`, `latest_backup_degraded`, and `schedule_follow_up`.
|
||||
- **New cross-domain UI framework/taxonomy?**: No. This is a tenant backup hardening slice only, not a new portfolio posture framework or recovery taxonomy.
|
||||
- **Current operator problem**: The tenant dashboard can currently look operationally healthy while the tenant's backup posture is weak, stale, degraded, or simply missing, which means the overview hides a recovery-relevant truth that operators need immediately.
|
||||
- **Existing structure is insufficient because**: Backup-quality truth already exists deeper in backup-set, version, and restore-adjacent surfaces, but there is no tenant-level rollup that answers the operator's first question on the tenant dashboard.
|
||||
- **Narrowest correct implementation**: Derive tenant backup health from the latest relevant completed backup basis, existing backup-quality truth, and existing backup-schedule timing, then inject that derived truth into the current tenant dashboard and attention surfaces without adding new persistence or a broader recovery-confidence system.
|
||||
- **Ownership cost**: The repo takes on one small rollup layer, one small derived posture family, dashboard and drill-through copy alignment, and regression coverage for absent, stale, degraded, healthy, and schedule-follow-up scenarios.
|
||||
- **Alternative intentionally rejected**: A full recovery-confidence framework, restore-proving system, workspace-level recovery score, or new persisted backup-health model was rejected because it solves a larger future problem than the one the current tenant dashboard actually has.
|
||||
- **Release truth**: Current-release truth. The false calmness already exists on the current tenant dashboard.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See Backup Posture Immediately (Priority: P1)
|
||||
|
||||
As a tenant operator, I want the tenant dashboard to tell me within seconds whether this tenant has no usable backups, stale backups, degraded latest backups, or healthy recent backups so that I do not have to open backup-set detail just to know whether backup posture needs attention.
|
||||
|
||||
**Why this priority**: This is the operator's first recovery-relevant question on the tenant dashboard. If the page stays quiet while backup posture is weak, the entire overview becomes less trustworthy.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding one tenant with no backups, stale backups, degraded latest backups, and fresh healthy backups, then verifying that the tenant dashboard surfaces the correct posture class on a primary summary surface.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has no usable completed backup basis, **When** an entitled operator opens the tenant dashboard, **Then** the dashboard explicitly shows that backup posture needs attention because no usable backup basis exists.
|
||||
2. **Given** a tenant has a latest relevant completed backup that is older than the accepted freshness window, **When** the tenant dashboard renders, **Then** the dashboard surfaces a stale-backup state instead of a calm or healthy backup message.
|
||||
3. **Given** a tenant has a latest relevant completed backup that is recent but materially degraded, **When** the tenant dashboard renders, **Then** the dashboard surfaces a degraded-backup state instead of a healthy backup message.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Click The Warning And Recover The Same Reason (Priority: P1)
|
||||
|
||||
As a tenant operator, I want every backup-health KPI, card, or attention item to send me to a surface that confirms the same problem family I clicked, so that the dashboard feels truthful instead of hand-wavy.
|
||||
|
||||
**Why this priority**: Dashboard trust breaks immediately if the operator clicks a backup warning and lands on a neutral or mismatched target page.
|
||||
|
||||
**Independent Test**: Can be fully tested by clicking backup-health summary and attention states in seeded scenarios and verifying that the destination clearly confirms the same problem family through recency, degradation, or schedule timing context.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the dashboard shows a stale-backup warning, **When** the operator opens the linked destination, **Then** the destination visibly confirms that the latest relevant backup basis is stale.
|
||||
2. **Given** the dashboard shows a degraded-latest-backup warning, **When** the operator opens the linked destination, **Then** the destination visibly confirms that the latest relevant backup basis carries the degradation.
|
||||
3. **Given** the dashboard shows a schedule-follow-up warning, **When** the operator opens the linked destination, **Then** the destination visibly confirms that schedule execution timing needs follow-up.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Trust Positive Backup Calmness Only When Earned (Priority: P2)
|
||||
|
||||
As a tenant operator, I want the tenant dashboard to show a positive backup-health message only when the available signals actually support it, so that a green or calm backup state does not overpromise recoverability.
|
||||
|
||||
**Why this priority**: Positive wording is more dangerous than negative wording here. A false healthy signal can make the operator skip backup follow-up entirely.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering the tenant dashboard for one fresh healthy scenario and several almost-healthy scenarios, then verifying that only the fully supported case emits a positive backup-health statement.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has a recent relevant completed backup with no material degradation and no stronger backup-health caution, **When** the tenant dashboard renders, **Then** a positive backup-health confirmation may appear.
|
||||
2. **Given** a tenant has a recent backup but the latest relevant backup is materially degraded, **When** the tenant dashboard renders, **Then** no positive backup-health confirmation appears.
|
||||
3. **Given** a tenant has a configured schedule but no recent successful backup basis, **When** the tenant dashboard renders, **Then** schedule presence does not produce a positive backup-health confirmation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Preserve Truth Under Schedule And Permission Nuance (Priority: P3)
|
||||
|
||||
As a tenant operator, I want backup-health summary truth to remain accurate even when schedules exist, older good backups exist, or I cannot open every downstream surface, so that tenant-level truth does not become calmer under edge conditions or RBAC boundaries.
|
||||
|
||||
**Why this priority**: The feature only improves trust if the tenant-level rollup stays stronger than schedule optimism, older-history optimism, or permission gaps.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding tenants with mixed backup history, schedule-follow-up conditions, and reduced downstream visibility, then verifying that the tenant dashboard still reflects the strongest truthful backup-health state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an older healthy backup exists but the latest relevant completed backup is degraded, **When** the dashboard renders, **Then** the tenant is still shown as degraded rather than healthy.
|
||||
2. **Given** a tenant dashboard viewer can see the dashboard but lacks one downstream destination capability, **When** the backup-health summary renders, **Then** the truth remains visible and the affordance degrades safely instead of becoming a dead-end link.
|
||||
3. **Given** a configured schedule exists but has not run successfully in an expected interval, **When** the dashboard renders, **Then** the tenant may receive schedule follow-up attention without the schedule being treated as proof of healthy backup posture.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Backup history may exist without any completed backup set that can serve as a usable tenant backup basis; the dashboard must not treat mere backup records as healthy backup posture.
|
||||
- The latest relevant completed backup may be degraded even when an older backup looks healthier; the latest relevant completed backup must govern the tenant posture instead of allowing older history to calm the dashboard.
|
||||
- A backup schedule may exist and even have a future `next_run_at`, while the last successful backup basis is already stale; the tenant posture must still remain stale or otherwise attention-worthy.
|
||||
- Multiple schedules may exist for one tenant; schedule follow-up should remain additive and must not erase stronger absent, stale, or degraded backup-health reasons.
|
||||
- Legacy or incomplete backup metadata may be insufficient to positively prove that the latest relevant backup is healthy; in that case the dashboard must avoid a positive healthy claim.
|
||||
- A tenant dashboard viewer may be entitled to summary truth but not to every backup destination; summary truth must remain visible while navigation degrades safely.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, and no new queued or scheduled execution path. It hardens tenant overview truth by reusing already-existing tenant-owned backup and schedule records.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** A narrow derived tenant backup-health rollup and a small derived posture family are justified because direct backup existence, direct schedule presence, or direct backup-quality detail alone do not answer the tenant dashboard question truthfully. The slice must remain derived-first, must not persist a second backup-health truth, and must avoid a broader recovery-confidence framework.
|
||||
|
||||
**Constitution alignment (OPS-UX):** No new `OperationRun` type, progress surface, or execution flow is introduced. Existing backup schedules and backup runs remain the only execution-truth surfaces for actual backup activity. This slice only summarizes and links to those existing truths.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature lives in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members remain `404`. In-scope members who can see the tenant dashboard but lack one deeper destination capability must still receive truthful backup-health summary state, while the drill-through affordance degrades safely or uses an allowed fallback. Authorization for downstream destinations remains server-side and must continue to use the canonical capability registry and existing scoped record resolution. No destructive action is added.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior changes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Existing centralized badge and status semantics for backup lifecycle, snapshot quality, and related status-like signals remain the semantic source. This feature may add backup-health wording or grouping, but it must not introduce page-local color or badge mappings that create a second backup status language.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament dashboard widgets, stats, cards, alerts, resource tables, and shared UI primitives. Semantic emphasis must come from aligned wording and existing shared status primitives rather than custom local markup or ad-hoc color borders.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must remain explicit and bounded to `Backup health`, `Last backup`, `No backups`, `Backup stale`, `Backup degraded`, `Schedule needs follow-up`, and `Backups are recent and healthy` or closely equivalent wording. The feature must not rename this slice into `recovery confidence`, `recoverable`, `proven`, or other stronger claims.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The tenant dashboard backup-health summary is an embedded drill-in surface with one primary destination per current reason. `NeedsAttention` remains a multi-destination summary surface with one reason-focused destination per item. `BackupSetResource` remains the canonical backup inspection surface, and `BackupScheduleResource` remains the canonical schedule follow-up surface. No redundant `View` actions are added, and no destructive placement changes are introduced.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content on the tenant dashboard must answer the operator's backup question within 5 to 10 seconds. Deep diagnostics such as per-item degradation causes, raw payload truth, and long schedule histories remain secondary to the default-visible backup-health class and the next destination.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from `backup exists` or `schedule exists` to calm dashboard wording is insufficient. This feature may introduce one narrow derived tenant backup-health interpretation layer, but only to replace weaker widget-local calmness checks and to keep dashboard truth aligned with deeper backup-quality truth. Tests must focus on the business consequences: absent, stale, degraded, healthy, schedule-follow-up, and drill-through continuity.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied for `BackupSetResource` and `BackupScheduleResource`; their existing inspect models, row-click behavior, and destructive placement remain unchanged. `TenantDashboard` and its widgets remain dashboard summary surfaces covered by the existing dashboard exemption, with no new destructive action, no empty action groups, and no redundant `View` affordances. UI-FIL-001 remains satisfied and no new exception is required.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not add new create or edit flows. It refines the tenant dashboard summary area and existing backup follow-up surfaces. Backup health must appear in the primary tenant-summary zone rather than only in deep diagnostics. Existing backup-set and backup-schedule list screens must retain specific empty states and current table affordances while making the dashboard reason recoverable.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-180-001**: The system MUST derive an explicit tenant-level backup-health assessment from existing tenant-owned backup and schedule records rather than leaving backup posture implicit in deep backup pages.
|
||||
- **FR-180-002**: The tenant-level backup-health assessment MUST determine whether a usable completed backup basis exists, which backup basis is currently relevant, when that basis last completed, whether it is fresh enough, whether it is materially degraded, and whether schedule timing adds follow-up pressure.
|
||||
- **FR-180-003**: The latest relevant completed backup basis MUST be the primary basis for tenant backup-health posture. Older healthier backups MUST NOT override a newer relevant stale or degraded backup basis.
|
||||
- **FR-180-004**: Tenant backup-health summary surfaces MUST distinguish at least `absent`, `stale`, `degraded`, and `healthy` as primary posture classes.
|
||||
- **FR-180-005**: `No backup` or `no usable completed backup basis` MUST remain a first-class attention state and MUST NOT disappear into neutral empty states.
|
||||
- **FR-180-006**: Backup freshness semantics MUST be defined once and used consistently across the tenant dashboard summary, `NeedsAttention`, and any positive healthy backup confirmation.
|
||||
- **FR-180-007**: Tenant backup-health degradation MUST be derived from existing authoritative backup-quality truth and MUST NOT invent a competing backup-quality system.
|
||||
- **FR-180-008**: Material degradation on the latest relevant completed backup basis MUST suppress healthy backup wording and MUST be sufficient to put tenant backup health into attention. If the latest relevant completed backup basis is both stale and materially degraded, `stale` MUST remain the primary posture while degradation remains visible as supporting detail.
|
||||
- **FR-180-009**: A configured backup schedule alone MUST NOT count as proof of healthy backup posture.
|
||||
- **FR-180-010**: Schedule state MAY add a `schedule_follow_up` reason when configured automation appears overdue or no longer successful, but it MUST complement rather than replace real backup-basis truth. When `schedule_follow_up` is the only remaining caution, the latest backup basis MAY keep `healthy` as its primary posture, but `schedule_follow_up` MUST become the active reason and any positive healthy confirmation MUST remain suppressed until the follow-up resolves.
|
||||
- **FR-180-011**: The tenant dashboard MUST expose backup health on a primary summary surface such as a KPI, stat, or summary card rather than only inside backup-set detail.
|
||||
- **FR-180-012**: `NeedsAttention` MUST be able to surface backup-health attention for at least no usable backup basis, stale latest backup basis, degraded latest backup basis, and schedule follow-up.
|
||||
- **FR-180-013**: A positive backup-health message may appear only when the latest relevant completed backup basis exists, satisfies the defined freshness rule, shows no material degradation under existing backup-quality truth, and no stronger backup-health caution remains unresolved.
|
||||
- **FR-180-014**: If existing signals cannot positively prove that the latest relevant backup basis is healthy, the system MUST avoid calm or healthy backup wording.
|
||||
- **FR-180-015**: Backup-health summary surfaces MUST make the current problem class visible to the operator, not just a generic `attention needed` state.
|
||||
- **FR-180-016**: Every backup-health KPI, summary, or attention item that implies follow-up MUST resolve to one semantically matching destination surface where the operator can recover the same problem class without guesswork.
|
||||
- **FR-180-017**: When the matching destination is backup-basis related, the destination MUST preserve or foreground the latest relevant backup basis so that the dashboard reason remains recognizable.
|
||||
- **FR-180-018**: When the matching destination is schedule related, the destination MUST foreground the schedule timing or missed-run reason rather than forcing the operator to infer it from generic schedule metadata.
|
||||
- **FR-180-019**: Summary surfaces on the tenant dashboard MUST NOT appear calmer than the underlying backup-set and backup-quality detail surfaces.
|
||||
- **FR-180-020**: The feature MUST NOT claim that the tenant is recoverable, that restore will succeed, or that backup posture proves recovery confidence.
|
||||
- **FR-180-021**: Tenant-scoped backup-health truth MUST remain visible and accurate for entitled tenant-dashboard users even when not every deeper backup surface is accessible; navigation must degrade safely instead of hiding the truth.
|
||||
- **FR-180-022**: The feature MUST ship without a new table, a persisted backup-health model, a tenant-wide recovery score, or a workspace-level recovery rollup.
|
||||
- **FR-180-023**: Regression coverage MUST prove no-backup, stale-backup, degraded-latest-backup, healthy-backup, schedule-follow-up, mixed-history latest-governs behavior, dashboard surfacing, attention surfacing, healthy-check suppression and allowance, drill-through continuity, and RBAC-safe degradation.
|
||||
|
||||
### Derived State Semantics
|
||||
|
||||
- **Relevant backup basis**: The latest tenant-scoped completed backup set that the product treats as the primary current backup-health basis for the tenant. Backup records that never reached a usable completed state do not qualify as healthy proof.
|
||||
- **Primary posture family**: `absent`, `stale`, `degraded`, `healthy`.
|
||||
- **Reason family**: `no_backup_basis`, `latest_backup_stale`, `latest_backup_degraded`, `schedule_follow_up`. The reason family explains why the posture is not calm or why follow-up still matters, and `schedule_follow_up` may remain the active reason even when the backup basis posture itself is `healthy`.
|
||||
- **Freshness policy**: One consistent freshness window over the latest relevant completed backup basis determines whether backup posture is recent enough. Schedule timing may add follow-up pressure but cannot turn a stale or missing backup basis into a healthy posture.
|
||||
- **Healthy evidence rule**: `healthy` describes the state of the latest relevant completed backup basis. A positive healthy confirmation is reserved for tenants whose latest relevant completed backup basis exists, is recent enough, is not materially degraded under existing backup-quality truth, and carries no unresolved `schedule_follow_up` or other stronger backup-health caution.
|
||||
|
||||
## 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 page composition | `app/Filament/Pages/TenantDashboard.php` | Existing tenant dashboard header actions remain unchanged | n/a | n/a | n/a | n/a | n/a | n/a | no new audit behavior | Composition-only dashboard surface. Backup health becomes a primary summary truth but the existing dashboard action-surface exemption remains in place. |
|
||||
| `DashboardKpis` | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | none | One explicit stat or card click for actionable backup-health states | none | none | Any non-actionable healthy reassurance remains intentionally bounded and must not overpromise recoverability | n/a | n/a | no new audit behavior | One primary destination per backup-health reason. No local recovery-confidence wording. |
|
||||
| `NeedsAttention` | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none | One explicit destination or safe disabled state per backup-health attention item | none | none | Healthy fallback may include positive backup-health copy only when no covered backup-health attention state exists | n/a | n/a | no new audit behavior | Multi-destination summary widget. Backup-health item joins existing attention logic without adding destructive controls. |
|
||||
| `BackupSetResource` | `app/Filament/Resources/BackupSetResource.php` | Existing `Create backup set` header action remains unchanged | `recordUrl()` clickable row to backup-set detail | Existing row actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing empty-state CTA remains unchanged | Existing detail header actions remain unchanged | Existing create flow remains unchanged | existing audit behavior remains authoritative for existing mutations | Target surface may gain reason-confirming framing for last-backup, stale, or degraded continuity only. The action-surface contract remains satisfied. |
|
||||
| `BackupScheduleResource` | `app/Filament/Resources/BackupScheduleResource.php` | Existing schedule-create header action remains unchanged | Existing schedule list inspect or edit affordance remains the primary open model | Existing row actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing empty-state CTA remains unchanged | Existing edit header actions remain unchanged | Existing create and edit save or cancel remain unchanged | existing audit behavior remains authoritative for existing mutations | Target surface may foreground schedule follow-up timing only. Schedule presence itself must not be styled as health proof. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant backup-health assessment**: The derived tenant-level answer to whether the tenant currently has no usable backup basis, a stale basis, a degraded latest basis, or a healthy recent basis.
|
||||
- **Relevant backup basis**: The latest completed backup set that counts as the current tenant backup-health basis and whose recency and quality govern the dashboard posture.
|
||||
- **Backup schedule follow-up**: A tenant-level caution that configured backup automation appears overdue or no longer successfully running and therefore needs operator review.
|
||||
- **Backup-health drill-through contract**: The semantic promise that a dashboard warning or healthy statement can be rediscovered on its destination surface without changing problem class.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-180-001**: In seeded tenant review and acceptance coverage, an entitled operator can determine within 10 seconds whether a tenant is in a no-backup, stale-backup, degraded-latest-backup, or healthy-backup posture.
|
||||
- **SC-180-002**: In 100% of covered regression scenarios, no-backup, stale-backup, degraded-latest-backup, fresh-healthy-backup, and schedule-follow-up cases map to the correct tenant-dashboard summary and attention behavior without contradictory calm wording.
|
||||
- **SC-180-003**: In 100% of covered regression scenarios, positive backup-health wording appears only when the latest relevant completed backup basis is recent enough, not materially degraded, and not superseded by a stronger backup-health caution.
|
||||
- **SC-180-004**: In 100% of covered drill-through tests, backup-health KPI or attention links land on a surface whose default-visible framing confirms the originating problem class.
|
||||
- **SC-180-005**: In RBAC regression coverage, entitled tenant-dashboard users continue to see truthful backup-health summary state even when at least one deeper destination is unavailable, and the UI degrades safely without broken or misleading links.
|
||||
- **SC-180-006**: The feature ships without a required schema migration, a new persisted backup-health model, or a new tenant-wide or workspace-wide recovery-confidence score.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing `BackupSet`, `BackupItem`, backup-quality summary, and `BackupSchedule` timing fields are sufficient to derive a truthful tenant backup-health rollup without introducing new persistence.
|
||||
- Existing backup-set and backup-schedule surfaces remain the correct destinations for dashboard backup-health drill-through.
|
||||
- Older legacy records may not always contain enough metadata to prove a positive healthy posture; in those cases the product should stay non-calm rather than infer health.
|
||||
- The current tenant dashboard composition remains in place for this slice; the work changes truth visibility and continuity, not the broader dashboard layout.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a full recovery-confidence or proved-recoverability framework
|
||||
- Using restore history as the primary basis of tenant backup-health truth
|
||||
- Introducing workspace-level or portfolio-level recovery posture rollups
|
||||
- Creating a new persisted backup-health table, score, or ledger
|
||||
- Reworking backup-quality detail semantics that were already hardened in prior backup-quality work
|
||||
- Redesigning restore safety or restore result truth beyond the minimum truth-boundary wording needed here
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing `TenantDashboard`, `DashboardKpis`, and `NeedsAttention` tenant overview surfaces
|
||||
- Existing `BackupSet`, `BackupItem`, backup-quality summary, and related backup-quality truth work
|
||||
- Existing `BackupSchedule` resource, schedule timing fields, and schedule follow-up surfaces
|
||||
- Existing tenant-scoped RBAC and safe drill-through behavior for backup resources
|
||||
|
||||
## Risks
|
||||
|
||||
- If the tenant backup-health rollup is weaker than the underlying backup-quality truth, the dashboard will remain calmer than the detail surfaces.
|
||||
- If schedule-follow-up is treated as health proof instead of as a secondary caution, the dashboard can still overstate calmness.
|
||||
- If the latest relevant backup basis is not consistently used, older healthier history may hide a newer degraded or stale backup posture.
|
||||
- If healthy wording is allowed when evidence is incomplete, the feature will recreate the same trust problem with nicer copy.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- Recovery confidence or proved-recoverability work after stronger restore evidence exists
|
||||
- Workspace or portfolio backup-posture rollups after tenant backup health is stable and tenant-safe
|
||||
- Backup and restore continuity work that combines backup-health truth with later restore-evidence truth without overclaiming recovery
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Spec 180 is complete when:
|
||||
|
||||
- a tenant-level backup-health derivation exists over current backup and schedule truth,
|
||||
- the tenant dashboard shows backup health on a primary summary surface,
|
||||
- `NeedsAttention` can surface no-backup, stale-backup, degraded-latest-backup, and schedule-follow-up conditions,
|
||||
- positive backup-health wording appears only when supported by current evidence,
|
||||
- no-backup, stale, degraded, and healthy states are clearly distinguishable,
|
||||
- drill-through destinations confirm the same backup-health problem class,
|
||||
- RBAC-safe summary truth remains intact for entitled tenant-dashboard viewers,
|
||||
- and the semantics are protected by focused resolver, dashboard, attention, healthy-state, drill-through, and RBAC regression tests.
|
||||
|
||||
250
specs/180-tenant-backup-health/tasks.md
Normal file
250
specs/180-tenant-backup-health/tasks.md
Normal file
@ -0,0 +1,250 @@
|
||||
# Tasks: Tenant Backup Health Signals
|
||||
|
||||
**Input**: Design documents from `/specs/180-tenant-backup-health/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`.
|
||||
**Operations**: No new `OperationRun`, queue, or scheduled execution flow is introduced. Work stays limited to read-time tenant dashboard truth and existing backup or schedule follow-up surfaces.
|
||||
**RBAC**: Existing workspace membership, tenant entitlement, capability-registry usage, and `404` vs `403` semantics must remain unchanged across `/admin/t/{tenant}`, `/admin/t/{tenant}/backup-sets`, and `/admin/t/{tenant}/backup-schedules`. Tests must cover both positive and negative access paths plus safe degradation when an action target is unavailable.
|
||||
**Operator Surfaces**: The tenant dashboard must expose backup posture in the primary summary zone, `NeedsAttention` must show reason-specific backup follow-up, and backup-set or schedule destinations must make the clicked reason recognizable without forcing the operator into raw diagnostics first.
|
||||
**Filament UI Action Surfaces**: `DashboardKpis` and `NeedsAttention` remain summary-only surfaces with one primary reason-driven destination; `BackupSetResource` and `BackupScheduleResource` keep their current inspect models, row-click behavior, and destructive placement unchanged.
|
||||
**Filament UI UX-001**: No new create or edit flow is introduced. The work is limited to summary-first truth on the existing dashboard and list or detail surfaces.
|
||||
**Badges**: Reuse existing shared badge or tone primitives if backup-health tone mapping is needed; do not add page-local semantic mappings in widgets or resources.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment after the shared backup-health scaffolding is in place.
|
||||
|
||||
## Phase 1: Setup (Shared Backup-Health Scaffolding)
|
||||
|
||||
**Purpose**: Add the narrow derived backup-health types and configuration used by every story.
|
||||
|
||||
- [X] T001 Create the shared backup-health value objects in `app/Support/BackupHealth/TenantBackupHealthAssessment.php`, `app/Support/BackupHealth/BackupFreshnessEvaluation.php`, `app/Support/BackupHealth/BackupScheduleFollowUpEvaluation.php`, `app/Support/BackupHealth/BackupHealthActionTarget.php`, and `app/Support/BackupHealth/BackupHealthDashboardSignal.php`
|
||||
- [X] T002 Create the central backup-health resolver skeleton in `app/Support/BackupHealth/TenantBackupHealthResolver.php`
|
||||
- [X] T003 [P] Add unit test scaffolding for the new backup-health namespace in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`
|
||||
- [X] T004 [P] Add explicit backup-health freshness and schedule grace configuration in `config/tenantpilot.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Shared Wiring)
|
||||
|
||||
**Purpose**: Wire the shared derivation contract before any story-specific dashboard or continuity behavior lands.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T005 Implement query-bounded latest-basis selection, `BackupQualityResolver`-backed degradation reuse, posture precedence, schedule-follow-up derivation, and reason target selection in `app/Support/BackupHealth/TenantBackupHealthResolver.php`
|
||||
- [X] T006 [P] Extend core resolver coverage for absent, stale, degraded, healthy, stale-over-degraded precedence, mixed-history latest-governs, `BackupQualityResolver`-backed degradation reuse, and schedule-follow-up behavior in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`
|
||||
- [X] T007 Add shared backup-health loading helpers and dashboard-signal adapters for dashboard widgets in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T008 [P] Extend DB-only and query-bounded tenant dashboard guard coverage for backup-health rendering in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||
|
||||
**Checkpoint**: The repository has one authoritative derived backup-health contract that dashboard and follow-up surfaces can consume without new persistence.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See Backup Posture Immediately (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Show absent, stale, degraded, or healthy backup posture on the tenant dashboard's primary summary surface.
|
||||
|
||||
**Independent Test**: Seed one tenant each for no usable backup basis, stale latest backup, degraded latest backup, and fresh healthy backup, then verify the tenant dashboard renders the correct primary backup-health posture without opening deeper pages.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T009 [P] [US1] Extend primary backup-health posture coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`
|
||||
- [X] T010 [P] [US1] Extend tenant dashboard calmness-versus-caution coverage for backup-health summary states in `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Render the primary backup-health stat or card with posture, last relevant backup timing, and bounded summary copy in `app/Filament/Widgets/Dashboard/DashboardKpis.php`
|
||||
- [X] T012 [US1] Keep summary wording aligned to `absent`, `stale`, `degraded`, and `healthy` semantics in `app/Support/BackupHealth/TenantBackupHealthAssessment.php` and `app/Filament/Widgets/Dashboard/DashboardKpis.php`
|
||||
- [X] T013 [US1] Run the focused dashboard posture verification pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||
|
||||
**Checkpoint**: The tenant dashboard now answers the backup-health question within seconds on a primary summary surface.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Click The Warning And Recover The Same Reason (Priority: P1)
|
||||
|
||||
**Goal**: Make dashboard backup warnings and attention items open destination surfaces that confirm the same problem family.
|
||||
|
||||
**Independent Test**: Trigger stale, degraded, no-basis, and schedule-follow-up scenarios, click the dashboard KPI or attention destination, and verify the target surface immediately confirms the same reason.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T014 [P] [US2] Extend reason-driven dashboard action coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
- [X] T015 [P] [US2] Create or extend no-basis, stale, degraded, and schedule-follow-up continuity coverage in `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [US2] Add backup-health attention items with safe action-target rendering in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T017 [US2] Route backup-health KPI and attention destinations by reason in `app/Support/BackupHealth/TenantBackupHealthResolver.php` and `app/Support/BackupHealth/BackupHealthActionTarget.php`
|
||||
- [X] T018 [US2] Make no-basis list continuity and stale or degraded latest-basis continuity scan-fast on the backup-set surfaces in `app/Filament/Resources/BackupSetResource.php`
|
||||
- [X] T019 [US2] Make schedule-follow-up continuity scan-fast on the backup-schedules surface in `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- [X] T020 [US2] Run the focused drillthrough continuity pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`
|
||||
|
||||
**Checkpoint**: Backup-health warnings on the tenant dashboard now lead to destinations that confirm the same problem class without guesswork.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Trust Positive Backup Calmness Only When Earned (Priority: P2)
|
||||
|
||||
**Goal**: Allow positive backup-health wording only when the latest relevant backup basis genuinely supports it.
|
||||
|
||||
**Independent Test**: Render the tenant dashboard for one clearly healthy case and several almost-healthy cases, then verify that only the fully supported case emits positive backup-health wording.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T021 [P] [US3] Extend healthy-check inclusion and suppression coverage in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
- [X] T022 [P] [US3] Extend healthy-claim gating coverage in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T023 [US3] Enforce healthy-claim gating and supporting-message rules in `app/Support/BackupHealth/TenantBackupHealthResolver.php` and `app/Support/BackupHealth/TenantBackupHealthAssessment.php`
|
||||
- [X] T024 [US3] Render `Backups are recent and healthy` only when the assessment permits it in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T025 [US3] Keep positive dashboard copy bounded to backup posture rather than recoverability in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T026 [US3] Run the focused healthy-wording pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||
|
||||
**Checkpoint**: Positive backup calmness now appears only when the current evidence earns it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Preserve Truth Under Schedule And Permission Nuance (Priority: P3)
|
||||
|
||||
**Goal**: Keep tenant backup-health truth strong under mixed history, schedule nuance, and restricted downstream access.
|
||||
|
||||
**Independent Test**: Seed mixed-history, overdue-schedule, and reduced-destination-access scenarios, then verify the dashboard still surfaces the strongest truthful backup-health state and degrades navigation safely.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T027 [P] [US4] Extend tenant-scope `404` coverage plus established-member blocked-destination `403` coverage in `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
|
||||
- [X] T028 [P] [US4] Extend mixed-history latest-governs and schedule-secondary coverage in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||
- [X] T029 [P] [US4] Extend disabled-action, member-without-capability, and schedule-follow-up nuance coverage in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T030 [US4] Finalize schedule-follow-up secondary semantics and latest-history precedence in `app/Support/BackupHealth/TenantBackupHealthResolver.php`
|
||||
- [X] T031 [US4] Degrade unavailable backup drillthroughs safely while preserving summary truth in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T032 [US4] Keep backup follow-up routes and record resolution tenant-scoped, capability-registry-backed, and `403`-after-membership authorization-safe in `app/Filament/Resources/BackupSetResource.php` and `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- [X] T033 [US4] Run the focused nuance and RBAC pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
|
||||
|
||||
**Checkpoint**: Backup-health truth remains accurate even when schedules exist, history is mixed, or one downstream destination is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final consistency, formatting, and focused verification across all stories.
|
||||
|
||||
- [X] T034 [P] Review and align operator-facing backup-health copy in `app/Support/BackupHealth/TenantBackupHealthAssessment.php`, `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Resources/BackupSetResource.php`, and `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- [X] T035 [P] Run the focused verification pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`
|
||||
- [X] T036 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` as required by `specs/180-tenant-backup-health/quickstart.md`
|
||||
- [ ] T037 Run the manual validation pass in `specs/180-tenant-backup-health/quickstart.md` for no-backup, stale, stale-over-degraded, degraded, healthy, schedule-follow-up, and tenant-scope scenarios
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and establishes the new backup-health namespace and config.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until one authoritative backup-health derivation contract exists.
|
||||
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the first operator-visible backup-health truth.
|
||||
- **User Story 2 (Phase 4)**: Starts after Foundational and should follow User Story 1 closely because it adds reason continuity to the summary surfaces.
|
||||
- **User Story 3 (Phase 5)**: Starts after Foundational and can proceed once the primary posture contract from User Story 1 is in place.
|
||||
- **User Story 4 (Phase 6)**: Starts after Foundational and should follow User Stories 2 and 3 because it hardens action degradation and final nuance on top of those surfaces.
|
||||
- **Polish (Phase 7)**: Starts after the desired stories are complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Depends only on Setup and Foundational work.
|
||||
- **US2**: Depends on Setup and Foundational work and should reuse the posture contract delivered for US1.
|
||||
- **US3**: Depends on Setup and Foundational work and should reuse the posture contract delivered for US1.
|
||||
- **US4**: Depends on Setup and Foundational work plus the action-target and healthy-claim behavior hardened in US2 and US3.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Test updates should land before the corresponding behavior change is considered complete.
|
||||
- Resolver and value-object changes should land before widget or resource rendering tasks for the same story.
|
||||
- Story-level focused test runs should pass before moving to the next priority slice.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T003` and `T004` can run in parallel after the backup-health namespace from `T001` is agreed.
|
||||
- `T006` and `T008` can run in parallel once `T005` defines the derivation contract.
|
||||
- `T009` and `T010` can run in parallel for US1.
|
||||
- `T014` and `T015` can run in parallel for US2.
|
||||
- `T018` and `T019` can run in parallel for US2 once `T017` fixes the reason-target contract.
|
||||
- `T021` and `T022` can run in parallel for US3.
|
||||
- `T027`, `T028`, and `T029` can run in parallel for US4.
|
||||
- `T034` and `T035` can run in parallel once feature code is stable.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Story 1 tests in parallel:
|
||||
Task: T009 tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
Task: T010 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||
|
||||
# Story 1 implementation split after the resolver contract is stable:
|
||||
Task: T011 app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
Task: T012 app/Support/BackupHealth/TenantBackupHealthAssessment.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Story 2 tests in parallel:
|
||||
Task: T014 tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
Task: T015 tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
|
||||
# Story 2 follow-up surfaces in parallel after reason-target mapping is fixed:
|
||||
Task: T018 app/Filament/Resources/BackupSetResource.php
|
||||
Task: T019 app/Filament/Resources/BackupScheduleResource.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Story 3 tests in parallel:
|
||||
Task: T021 tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
Task: T022 tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php
|
||||
|
||||
# Story 3 implementation split after gating rules are defined:
|
||||
Task: T023 app/Support/BackupHealth/TenantBackupHealthResolver.php
|
||||
Task: T024 app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Story 4 tests in parallel:
|
||||
Task: T027 tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||
Task: T028 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||
Task: T029 tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
|
||||
# Story 4 hardening split after expectations are locked:
|
||||
Task: T031 app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
Task: T032 app/Filament/Resources/BackupSetResource.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
- Complete Phase 1 and Phase 2.
|
||||
- Deliver User Story 1 first so the tenant dashboard stops hiding backup posture at the summary layer.
|
||||
- If the slice is intended to ship immediately after MVP, include User Story 2 before release so dashboard warnings also preserve drillthrough trust.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- Add User Story 2 next to make every warning recoverable on the correct destination surface.
|
||||
- Add User Story 3 after that to prevent positive calm wording from overclaiming health.
|
||||
- Add User Story 4 last to harden mixed-history, schedule nuance, and RBAC-safe degradation.
|
||||
|
||||
### Verification Finish
|
||||
|
||||
- Run Pint on touched files.
|
||||
- Run the focused backup-health pack from `quickstart.md`.
|
||||
- Run the manual quickstart validation pass for absent, stale, degraded, healthy, schedule-follow-up, and tenant-scope cases.
|
||||
- Offer the broader suite only after the focused pack passes.
|
||||
40
tests/Feature/Auth/BackupHealthBrowserFixtureLoginTest.php
Normal file
40
tests/Feature/Auth/BackupHealthBrowserFixtureLoginTest.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('logs into the seeded backup-health browser fixture through the local helper', function (): void {
|
||||
$this->artisan('tenantpilot:backup-health:seed-browser-fixture', ['--no-interaction' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$workspaceConfig = config('tenantpilot.backup_health.browser_smoke_fixture.workspace');
|
||||
$userConfig = config('tenantpilot.backup_health.browser_smoke_fixture.user');
|
||||
$scenarioConfig = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough');
|
||||
$tenantRouteKey = $scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'];
|
||||
|
||||
$workspace = Workspace::query()->where('slug', $workspaceConfig['slug'])->first();
|
||||
$user = User::query()->where('email', $userConfig['email'])->first();
|
||||
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->first();
|
||||
|
||||
expect($workspace)->not->toBeNull();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($tenant)->not->toBeNull();
|
||||
|
||||
$this->get(route('admin.local.backup-health-browser-fixture-login'))
|
||||
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
@ -7,6 +8,7 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@ -341,3 +343,25 @@ function makeBackupScheduleForLifecycle(\App\Models\Tenant $tenant, array $attri
|
||||
|
||||
expect($keptIds)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('confirms schedule follow-up continuity on the backup-schedules list surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, [
|
||||
'name' => 'Overdue continuity schedule',
|
||||
'last_run_at' => null,
|
||||
'last_run_status' => null,
|
||||
'next_run_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('not produced a successful run yet')
|
||||
->assertSee($schedule->name)
|
||||
->assertSee('No successful run has been recorded yet.');
|
||||
});
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('seeds a deterministic blocked drill-through browser fixture for backup health', function (): void {
|
||||
$this->artisan('tenantpilot:backup-health:seed-browser-fixture', ['--no-interaction' => true])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('Fixture login URL')
|
||||
->expectsOutputToContain('Dashboard URL');
|
||||
|
||||
$workspaceConfig = config('tenantpilot.backup_health.browser_smoke_fixture.workspace');
|
||||
$userConfig = config('tenantpilot.backup_health.browser_smoke_fixture.user');
|
||||
$scenarioConfig = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough');
|
||||
$tenantRouteKey = $scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'];
|
||||
|
||||
$workspace = Workspace::query()->where('slug', $workspaceConfig['slug'])->first();
|
||||
$user = User::query()->where('email', $userConfig['email'])->first();
|
||||
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->first();
|
||||
|
||||
expect($workspace)->not->toBeNull();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($tenant)->not->toBeNull();
|
||||
expect(BackupSet::query()->where('tenant_id', $tenant->getKey())->where('name', $scenarioConfig['backup_set_name'])->exists())->toBeTrue();
|
||||
expect(BackupItem::query()->where('tenant_id', $tenant->getKey())->where('policy_identifier', $scenarioConfig['policy_external_id'])->exists())->toBeTrue();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->clearCache();
|
||||
|
||||
expect($resolver->isMember($user, $tenant))->toBeTrue();
|
||||
expect($resolver->can($user, $tenant, Capabilities::TENANT_VIEW))->toBeFalse();
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->get(route('filament.admin.pages.choose-tenant'))
|
||||
->assertOk()
|
||||
->assertSee((string) $tenant->name);
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Stale');
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Latest backup is stale')
|
||||
->assertSee('Open latest backup')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -3,10 +3,17 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('renders backup sets with lifecycle summary, related context, and secondary technical detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -108,3 +115,68 @@
|
||||
->assertSee('1 degraded item')
|
||||
->assertSee('1 metadata-only');
|
||||
});
|
||||
|
||||
it('confirms stale latest-backup continuity on the enterprise detail page', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Stale dashboard backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'payload' => ['id' => 'policy-stale'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', [
|
||||
'record' => $backupSet,
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Latest backup is stale')
|
||||
->assertSee('The latest completed backup was 2 days ago.');
|
||||
});
|
||||
|
||||
it('confirms degraded latest-backup continuity on the enterprise detail page', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Degraded dashboard backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(45),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', [
|
||||
'record' => $backupSet,
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Latest backup is degraded')
|
||||
->assertSee('degraded input quality');
|
||||
});
|
||||
|
||||
37
tests/Feature/Filament/BackupSetListContinuityTest.php
Normal file
37
tests/Feature/Filament/BackupSetListContinuityTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('confirms no usable completed backup basis on the backup-set list surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('No usable completed backup basis is currently available for this tenant.')
|
||||
->assertSee('No backup sets');
|
||||
});
|
||||
|
||||
it('keeps fallback continuity copy readable on the backup-set list when the latest backup detail is unavailable', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('The latest backup detail is no longer available, so this view stays on the backup-set list.');
|
||||
});
|
||||
@ -2,15 +2,23 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
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\Rbac\UiTooltips;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@ -35,6 +43,38 @@ function dashboardKpiStatPayloads($component): array
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
*/
|
||||
function backupPostureStatPayload(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
return dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class))['Backup posture'];
|
||||
}
|
||||
|
||||
function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
|
||||
{
|
||||
return BackupSchedule::query()->create(array_merge([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'KPI schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -180,3 +220,188 @@ function dashboardKpiStatPayloads($component): array
|
||||
'url' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows absent backup posture and routes the KPI to the backup-set list', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$stat = backupPostureStatPayload($tenant);
|
||||
|
||||
expect($stat)->toMatchArray([
|
||||
'value' => 'Absent',
|
||||
'url' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($stat['description'])->toContain('Create or finish a backup set');
|
||||
});
|
||||
|
||||
it('shows stale backup posture and routes the KPI to the latest backup detail', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Stale latest backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'policy-stale'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$stat = backupPostureStatPayload($tenant);
|
||||
|
||||
expect($stat)->toMatchArray([
|
||||
'value' => 'Stale',
|
||||
'url' => BackupSetResource::getUrl('view', [
|
||||
'record' => (int) $backupSet->getKey(),
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($stat['description'])->toContain('2 days');
|
||||
});
|
||||
|
||||
it('shows degraded backup posture and routes the KPI to the latest backup detail', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Degraded latest backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(45),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$stat = backupPostureStatPayload($tenant);
|
||||
|
||||
expect($stat)->toMatchArray([
|
||||
'value' => 'Degraded',
|
||||
'url' => BackupSetResource::getUrl('view', [
|
||||
'record' => (int) $backupSet->getKey(),
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($stat['description'])->toContain('degraded input quality');
|
||||
});
|
||||
|
||||
it('shows healthy backup posture when the latest backup is recent and clean', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy latest backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$stat = backupPostureStatPayload($tenant);
|
||||
|
||||
expect($stat)->toMatchArray([
|
||||
'value' => 'Healthy',
|
||||
'url' => null,
|
||||
]);
|
||||
|
||||
expect($stat['description'])->toContain('20 minutes');
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy latest backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
makeBackupHealthScheduleForKpi($tenant, [
|
||||
'name' => 'Overdue KPI schedule',
|
||||
'last_run_at' => null,
|
||||
'last_run_status' => null,
|
||||
'next_run_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$stat = backupPostureStatPayload($tenant);
|
||||
|
||||
expect($stat)->toMatchArray([
|
||||
'value' => 'Healthy',
|
||||
'url' => BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($stat['description'])->toContain('not produced a successful run');
|
||||
});
|
||||
|
||||
it('keeps backup posture truth visible while disabling backup drill-throughs for members without backup view access', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Stale inaccessible backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'policy-stale'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
|
||||
|
||||
$stat = backupPostureStatPayload($tenant);
|
||||
|
||||
expect($stat)->toMatchArray([
|
||||
'value' => 'Stale',
|
||||
'url' => null,
|
||||
]);
|
||||
|
||||
expect($stat['description'])
|
||||
->toContain('2 days')
|
||||
->toContain(UiTooltips::INSUFFICIENT_PERMISSION);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Latest backup is stale')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
|
||||
});
|
||||
|
||||
@ -2,8 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
@ -11,11 +16,13 @@
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
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\Rbac\UiTooltips;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
@ -44,6 +51,27 @@ function createNeedsAttentionTenant(): array
|
||||
return [$user, $tenant, $profile, $snapshot];
|
||||
}
|
||||
|
||||
function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
|
||||
{
|
||||
return BackupSchedule::query()->create(array_merge([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Needs Attention backup schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('shows a cautionary baseline posture in needs-attention when compare trust is limited', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
@ -93,6 +121,18 @@ function createNeedsAttentionTenant(): array
|
||||
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy compare backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -316,3 +356,211 @@ function createNeedsAttentionTenant(): array
|
||||
->assertSee('Open terminal follow-up')
|
||||
->assertDontSee('Current governance and findings signals look trustworthy.');
|
||||
});
|
||||
|
||||
it('surfaces a no-backup attention item with a backup-sets destination', function (): void {
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('No usable backup basis')
|
||||
->assertSee('Create or finish a backup set before relying on restore input.')
|
||||
->assertSee('Open backup sets')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
|
||||
expect($component->html())->toContain(BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
it('surfaces stale latest-backup attention with the matching latest-backup drill-through', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$staleBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Stale backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($staleBackup)->create([
|
||||
'payload' => ['id' => 'policy-stale'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$staleComponent = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Latest backup is stale')
|
||||
->assertSee('Open latest backup')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
|
||||
expect($staleComponent->html())->toContain(BackupSetResource::getUrl('view', [
|
||||
'record' => (int) $staleBackup->getKey(),
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
it('surfaces degraded latest-backup attention with the matching latest-backup drill-through', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$degradedBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Degraded backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(45),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($degradedBackup)->create([
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$degradedComponent = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Latest backup is degraded')
|
||||
->assertSee('Open latest backup')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
|
||||
expect($degradedComponent->html())->toContain(BackupSetResource::getUrl('view', [
|
||||
'record' => (int) $degradedBackup->getKey(),
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
it('surfaces schedule follow-up instead of a healthy backup check when automation needs review', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
makeBackupHealthScheduleForNeedsAttention($tenant, [
|
||||
'name' => 'Overdue schedule',
|
||||
'last_run_at' => null,
|
||||
'last_run_status' => null,
|
||||
'next_run_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Backup schedules need follow-up')
|
||||
->assertSee('not produced a successful run')
|
||||
->assertSee('Open backup schedules')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
|
||||
expect($component->html())->toContain(BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
it('adds the healthy backup check only when the latest backup basis genuinely earns it', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
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,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertSee('Baseline compare looks trustworthy')
|
||||
->assertDontSee('Backup schedules need follow-up')
|
||||
->assertDontSee('No usable backup basis');
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Stale hidden backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'policy-stale'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Latest backup is stale')
|
||||
->assertSee('Open latest backup')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
|
||||
|
||||
expect($component->html())->not->toContain(BackupSetResource::getUrl('view', [
|
||||
'record' => (int) $backupSet->getKey(),
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
@ -29,6 +31,21 @@
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'DB-only healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Bus::fake();
|
||||
@ -43,6 +60,8 @@
|
||||
// server-rendered HTML.
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Healthy')
|
||||
->assertSee('Active operations')
|
||||
->assertSee('healthy queued or running tenant work');
|
||||
|
||||
|
||||
@ -3,10 +3,23 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
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;
|
||||
|
||||
it('does not leak data across tenants on the dashboard', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -38,3 +51,55 @@
|
||||
->assertOk()
|
||||
->assertDontSee('Other Tenant Policy');
|
||||
});
|
||||
|
||||
it('keeps backup summary truth visible on the dashboard while blocked backup drill-through routes still fail with 403', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Stale scoped backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'policy-stale'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
|
||||
|
||||
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||
$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('Latest backup is stale')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->assertSee('Open latest backup');
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Stale');
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
@ -14,6 +17,7 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -69,6 +73,10 @@ function seedTrustworthyCompare(array $tenantContext): void
|
||||
]);
|
||||
}
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('suppresses calm dashboard wording when stale and terminal operations both need attention', function (): void {
|
||||
$tenantContext = createTruthAlignedDashboardTenant();
|
||||
[$user, $tenant] = $tenantContext;
|
||||
@ -145,6 +153,18 @@ function seedTrustworthyCompare(array $tenantContext): void
|
||||
|
||||
seedTrustworthyCompare($tenantContext);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy truth-aligned backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -219,3 +239,94 @@ function seedTrustworthyCompare(array $tenantContext): void
|
||||
->assertSee('Open findings')
|
||||
->assertDontSee('Aligned');
|
||||
});
|
||||
|
||||
it('suppresses calm dashboard wording when the latest backup basis is stale even if older history looked healthier', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
$tenantContext = createTruthAlignedDashboardTenant();
|
||||
[$user, $tenant] = $tenantContext;
|
||||
$this->actingAs($user);
|
||||
|
||||
seedTrustworthyCompare($tenantContext);
|
||||
|
||||
$olderHealthy = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Older healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($olderHealthy)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$latestStale = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Latest stale backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($latestStale)->create([
|
||||
'payload' => ['id' => 'stale-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Latest backup is stale')
|
||||
->assertDontSee('Backups are recent and healthy')
|
||||
->assertDontSee('Current governance and findings signals look trustworthy.');
|
||||
});
|
||||
|
||||
it('adds positive backup calmness only when the latest backup basis is recent, clean, and schedules do not need follow-up', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
$tenantContext = createTruthAlignedDashboardTenant();
|
||||
[$user, $tenant] = $tenantContext;
|
||||
$this->actingAs($user);
|
||||
|
||||
seedTrustworthyCompare($tenantContext);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertDontSee('Backup schedules need follow-up');
|
||||
|
||||
BackupSchedule::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Overdue dashboard schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'last_run_at' => null,
|
||||
'last_run_status' => null,
|
||||
'next_run_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Backup schedules need follow-up')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
});
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\BackupHealth\BackupHealthActionTarget;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
function makeBackupHealthSchedule(Tenant $tenant, array $attributes = []): BackupSchedule
|
||||
{
|
||||
return BackupSchedule::query()->create(array_merge([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Backup health schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function resolveTenantBackupHealth(Tenant $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
return app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
}
|
||||
|
||||
it('marks the tenant absent when no usable completed backup basis exists', function (): void {
|
||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$assessment = resolveTenantBackupHealth($tenant);
|
||||
|
||||
expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_ABSENT)
|
||||
->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS)
|
||||
->and($assessment->latestRelevantBackupSetId)->toBeNull()
|
||||
->and($assessment->healthyClaimAllowed)->toBeFalse()
|
||||
->and($assessment->primaryActionTarget?->surface)->toBe(BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX);
|
||||
});
|
||||
|
||||
it('lets the latest stale backup basis outrank older healthier history and preserves degradation as supporting detail', function (): void {
|
||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$olderHealthy = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Older healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($olderHealthy)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$latestStale = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Latest stale backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($latestStale)->create([
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$assessment = resolveTenantBackupHealth($tenant);
|
||||
|
||||
expect($assessment->latestRelevantBackupSetId)->toBe((int) $latestStale->getKey())
|
||||
->and($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_STALE)
|
||||
->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE)
|
||||
->and($assessment->qualitySummary?->hasDegradations())->toBeTrue()
|
||||
->and($assessment->supportingMessage)->toContain('The latest completed backup was 2 days ago.')
|
||||
->and($assessment->supportingMessage)->toContain('also degraded');
|
||||
});
|
||||
|
||||
it('reuses the backup quality resolver when the latest recent backup is degraded', function (): void {
|
||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$latestDegraded = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Latest degraded backup',
|
||||
'item_count' => 2,
|
||||
'completed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($latestDegraded)->create([
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($latestDegraded)->create([
|
||||
'payload' => ['id' => 'warning-policy'],
|
||||
'metadata' => [
|
||||
'integrity_warning' => 'Protected values are intentionally hidden.',
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$assessment = resolveTenantBackupHealth($tenant);
|
||||
|
||||
expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_DEGRADED)
|
||||
->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED)
|
||||
->and($assessment->qualitySummary?->degradedItemCount)->toBe(2)
|
||||
->and($assessment->qualitySummary?->compactSummary)->toContain('2 degraded items')
|
||||
->and($assessment->supportingMessage)->toContain('The latest completed backup was 1 hour ago.')
|
||||
->and($assessment->primaryActionTarget?->surface)->toBe(BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW)
|
||||
->and($assessment->primaryActionTarget?->recordId)->toBe((int) $latestDegraded->getKey());
|
||||
});
|
||||
|
||||
it('allows a healthy claim only when the latest backup is recent and free of material degradation', function (): void {
|
||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy backup',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$assessment = resolveTenantBackupHealth($tenant);
|
||||
|
||||
expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY)
|
||||
->and($assessment->primaryReason)->toBeNull()
|
||||
->and($assessment->healthyClaimAllowed)->toBeTrue()
|
||||
->and($assessment->headline)->toBe('Backups are recent and healthy.')
|
||||
->and($assessment->supportingMessage)->toBe('The latest completed backup was 30 minutes ago.')
|
||||
->and($assessment->primaryActionTarget)->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the posture healthy but suppresses the healthy claim when schedules need follow-up', function (): void {
|
||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy backup with overdue schedule',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
makeBackupHealthSchedule($tenant, [
|
||||
'name' => 'Overdue schedule',
|
||||
'next_run_at' => now()->subHours(2),
|
||||
'last_run_at' => null,
|
||||
'last_run_status' => null,
|
||||
]);
|
||||
|
||||
$assessment = resolveTenantBackupHealth($tenant);
|
||||
|
||||
expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY)
|
||||
->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP)
|
||||
->and($assessment->healthyClaimAllowed)->toBeFalse()
|
||||
->and($assessment->scheduleFollowUp->needsFollowUp)->toBeTrue()
|
||||
->and($assessment->scheduleFollowUp->neverSuccessfulCount)->toBe(1)
|
||||
->and($assessment->primaryActionTarget?->surface)->toBe(BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX);
|
||||
});
|
||||
|
||||
it('treats failed recent schedule runs as backup follow-up even when the next run is not overdue yet', function (): void {
|
||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$healthyBackup = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Healthy backup with failed schedule',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
|
||||
'payload' => ['id' => 'healthy-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
makeBackupHealthSchedule($tenant, [
|
||||
'name' => 'Failed schedule',
|
||||
'last_run_at' => now()->subHour(),
|
||||
'last_run_status' => 'failed',
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$assessment = resolveTenantBackupHealth($tenant);
|
||||
|
||||
expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY)
|
||||
->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP)
|
||||
->and($assessment->scheduleFollowUp->failedRecentRunCount)->toBe(1)
|
||||
->and($assessment->scheduleFollowUp->summaryMessage)->toContain('needs follow-up after the last run');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user