Compare commits
2 Commits
185-worksp
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fbd3e5ec7 | |||
| 53e799fea7 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -157,6 +157,8 @@ ## Active Technologies
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned (184-dashboard-recovery-honesty)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces (185-workspace-recovery-posture-visibility)
|
||||
- PostgreSQL unchanged; no schema change, new cache table, or persisted workspace recovery artifact is planned (185-workspace-recovery-posture-visibility)
|
||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure (186-tenant-registry-recovery-triage)
|
||||
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -191,8 +193,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
|
||||
- 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces
|
||||
- 184-dashboard-recovery-honesty: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
|
||||
- 183-website-workspace-foundation: Added PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
@ -30,6 +31,8 @@
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
@ -39,12 +42,14 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\TenantActionDescriptor;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -94,6 +99,16 @@ class TenantResource extends Resource
|
||||
*/
|
||||
protected static array $tenantActionCatalogCache = [];
|
||||
|
||||
private const string POSTURE_SNAPSHOT_REQUEST_KEY = 'tenant_resource.posture_snapshot';
|
||||
|
||||
/**
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private const array TRIAGE_SORT_VALUES = [
|
||||
TenantRecoveryTriagePresentation::TRIAGE_SORT_DEFAULT => true,
|
||||
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST => true,
|
||||
];
|
||||
|
||||
/**
|
||||
* Tenant creation is handled exclusively by the onboarding wizard.
|
||||
* The CRUD create page has been removed.
|
||||
@ -241,7 +256,19 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->defaultSort(function (Builder $query, string $direction, mixed $livewire): Builder {
|
||||
if (static::triageSortIsWorstFirst(static::triageSortState($livewire))) {
|
||||
return static::applyWorstFirstTriageOrdering($query);
|
||||
}
|
||||
|
||||
$nameColumn = (new Tenant)->qualifyColumn('name');
|
||||
|
||||
if (! method_exists($livewire, 'getTableSortColumn') || $livewire->getTableSortColumn() !== 'name') {
|
||||
$query->orderBy($nameColumn, $direction);
|
||||
}
|
||||
|
||||
return $query;
|
||||
})
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
@ -251,6 +278,28 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('backup_posture')
|
||||
->label('Backup posture')
|
||||
->badge()
|
||||
->state(fn (Tenant $record): ?string => static::backupHealthAssessmentForTenant($record)?->posture)
|
||||
->formatStateUsing(fn (mixed $state, Tenant $record): string => TenantRecoveryTriagePresentation::backupPostureLabel(
|
||||
static::backupHealthAssessmentForTenant($record),
|
||||
))
|
||||
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::backupPostureTone(
|
||||
static::backupHealthAssessmentForTenant($record),
|
||||
)),
|
||||
Tables\Columns\TextColumn::make('recovery_evidence')
|
||||
->label('Recovery evidence')
|
||||
->badge()
|
||||
->state(fn (Tenant $record): ?string => TenantRecoveryTriagePresentation::recoveryEvidenceState(
|
||||
static::recoveryEvidenceForTenant($record),
|
||||
))
|
||||
->formatStateUsing(fn (mixed $state, Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceLabel(
|
||||
static::recoveryEvidenceForTenant($record),
|
||||
))
|
||||
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceTone(
|
||||
static::recoveryEvidenceForTenant($record),
|
||||
)),
|
||||
Tables\Columns\TextColumn::make('tenant_id')
|
||||
->label('Tenant ID')
|
||||
->copyable()
|
||||
@ -303,6 +352,32 @@ public static function table(Table $table): Table
|
||||
'staging' => 'STAGING',
|
||||
'other' => 'Other',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('backup_posture')
|
||||
->label('Backup posture')
|
||||
->multiple()
|
||||
->options(TenantRecoveryTriagePresentation::backupPostureOptions())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return static::applySnapshotTenantSubsetFilter(
|
||||
$query,
|
||||
static::sanitizeBackupPostures($data['values'] ?? []),
|
||||
snapshotKey: 'backup_posture_ids',
|
||||
);
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('recovery_evidence')
|
||||
->label('Recovery evidence')
|
||||
->multiple()
|
||||
->options(TenantRecoveryTriagePresentation::recoveryEvidenceOptions())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return static::applyRecoveryEvidenceFilter(
|
||||
$query,
|
||||
static::sanitizeRecoveryEvidenceStates($data['values'] ?? []),
|
||||
);
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('triage_sort')
|
||||
->label('Sort order')
|
||||
->options(TenantRecoveryTriagePresentation::triageSortOptions())
|
||||
->placeholder('Default order')
|
||||
->query(static fn (Builder $query): Builder => $query),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('related_onboarding')
|
||||
@ -310,6 +385,12 @@ public static function table(Table $table): Table
|
||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record): string => TenantDashboard::getUrl(panel: 'tenant', tenant: $record))
|
||||
->visible(fn (Tenant $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||
ActionGroup::make([
|
||||
Actions\Action::make('related_onboarding_overflow')
|
||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||
@ -317,12 +398,6 @@ public static function table(Table $table): Table
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
@ -808,6 +883,311 @@ public static function table(Table $table): Table
|
||||
->emptyStateIcon('heroicon-o-building-office-2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function sanitizeBackupPostures(mixed $value): array
|
||||
{
|
||||
return static::sanitizeRequestedValues(
|
||||
$value,
|
||||
array_keys(TenantRecoveryTriagePresentation::backupPostureOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function sanitizeRecoveryEvidenceStates(mixed $value): array
|
||||
{
|
||||
return static::sanitizeRequestedValues(
|
||||
$value,
|
||||
array_keys(TenantRecoveryTriagePresentation::recoveryEvidenceOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
public static function sanitizeTriageSort(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || ! isset(self::TRIAGE_SORT_VALUES[$value])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowedValues
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function sanitizeRequestedValues(mixed $value, array $allowedValues): array
|
||||
{
|
||||
$allowed = array_fill_keys($allowedValues, true);
|
||||
|
||||
return array_values(array_filter(
|
||||
array_unique(array_filter(static::normalizeRequestedValues($value), static fn (string $item): bool => isset($allowed[$item]))),
|
||||
static fn (string $item): bool => isset($allowed[$item]),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeRequestedValues(mixed $value): array
|
||||
{
|
||||
if (is_string($value) && $value !== '') {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($value, static fn (mixed $item): bool => is_string($item) && $item !== ''));
|
||||
}
|
||||
|
||||
private static function triageSortState(mixed $livewire): ?string
|
||||
{
|
||||
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = $livewire->getTableFilterState('triage_sort');
|
||||
$value = is_array($state) ? ($state['value'] ?? null) : null;
|
||||
|
||||
return static::sanitizeTriageSort($value);
|
||||
}
|
||||
|
||||
private static function triageSortIsWorstFirst(?string $value): bool
|
||||
{
|
||||
return $value === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
||||
}
|
||||
|
||||
private static function backupHealthAssessmentForTenant(Tenant $tenant): ?TenantBackupHealthAssessment
|
||||
{
|
||||
return static::postureSnapshot()['backup_health'][(int) $tenant->getKey()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function recoveryEvidenceForTenant(Tenant $tenant): ?array
|
||||
{
|
||||
return static::postureSnapshot()['recovery_evidence'][(int) $tenant->getKey()] ?? null;
|
||||
}
|
||||
|
||||
private static function applySnapshotTenantSubsetFilter(
|
||||
Builder $query,
|
||||
array $selectedValues,
|
||||
string $snapshotKey,
|
||||
): Builder {
|
||||
if ($selectedValues === []) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$snapshot = static::postureSnapshot();
|
||||
$tenantIds = collect($selectedValues)
|
||||
->flatMap(static fn (string $value): array => $snapshot[$snapshotKey][$value] ?? [])
|
||||
->map(static fn (mixed $tenantId): int => (int) $tenantId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
|
||||
}
|
||||
|
||||
private static function applyRecoveryEvidenceFilter(Builder $query, array $selectedValues): Builder
|
||||
{
|
||||
if ($selectedValues === []) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$tenantIds = collect(static::postureSnapshot()['recovery_evidence'])
|
||||
->filter(static fn (array $evidence): bool => in_array(
|
||||
TenantRecoveryTriagePresentation::recoveryEvidenceState($evidence),
|
||||
$selectedValues,
|
||||
true,
|
||||
))
|
||||
->keys()
|
||||
->map(static fn (mixed $tenantId): int => (int) $tenantId)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
|
||||
}
|
||||
|
||||
private static function applyWorstFirstTriageOrdering(Builder $query): Builder
|
||||
{
|
||||
$tiers = static::postureSnapshot()['triage_tiers'];
|
||||
|
||||
if ($tiers === []) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$qualifiedId = (new Tenant)->qualifyColumn('id');
|
||||
$qualifiedName = (new Tenant)->qualifyColumn('name');
|
||||
$clauses = [];
|
||||
$bindings = [];
|
||||
|
||||
foreach ([1, 2, 3, 4, 5] as $tier) {
|
||||
$tenantIds = array_keys(array_filter($tiers, static fn (int $value): bool => $value === $tier));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$placeholders = implode(', ', array_fill(0, count($tenantIds), '?'));
|
||||
$clauses[] = "when {$qualifiedId} in ({$placeholders}) then {$tier}";
|
||||
array_push($bindings, ...$tenantIds);
|
||||
}
|
||||
|
||||
if ($clauses === []) {
|
||||
return $query->orderBy($qualifiedName);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByRaw('case '.implode(' ', $clauses).' else 6 end', $bindings)
|
||||
->orderBy($qualifiedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* tenant_ids: list<int>,
|
||||
* backup_health: array<int, TenantBackupHealthAssessment>,
|
||||
* recovery_evidence: array<int, array<string, mixed>>,
|
||||
* backup_posture_ids: array<string, list<int>>,
|
||||
* recovery_evidence_ids: array<string, list<int>>,
|
||||
* triage_tiers: array<int, int>
|
||||
* }
|
||||
*/
|
||||
private static function postureSnapshot(): array
|
||||
{
|
||||
$request = request();
|
||||
$snapshot = $request->attributes->get(self::POSTURE_SNAPSHOT_REQUEST_KEY);
|
||||
|
||||
if (is_array($snapshot)) {
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
$snapshot = static::buildPostureSnapshot();
|
||||
$request->attributes->set(self::POSTURE_SNAPSHOT_REQUEST_KEY, $snapshot);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* tenant_ids: list<int>,
|
||||
* backup_health: array<int, TenantBackupHealthAssessment>,
|
||||
* recovery_evidence: array<int, array<string, mixed>>,
|
||||
* backup_posture_ids: array<string, list<int>>,
|
||||
* recovery_evidence_ids: array<string, list<int>>,
|
||||
* triage_tiers: array<int, int>
|
||||
* }
|
||||
*/
|
||||
private static function buildPostureSnapshot(): array
|
||||
{
|
||||
$tenantIds = static::visibleTenantIdsForPostureSnapshot();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return [
|
||||
'tenant_ids' => [],
|
||||
'backup_health' => [],
|
||||
'recovery_evidence' => [],
|
||||
'backup_posture_ids' => [],
|
||||
'recovery_evidence_ids' => [],
|
||||
'triage_tiers' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/** @var TenantBackupHealthResolver $backupHealthResolver */
|
||||
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
||||
$backupHealth = $backupHealthResolver->assessMany($tenantIds);
|
||||
|
||||
/** @var RestoreSafetyResolver $restoreSafetyResolver */
|
||||
$restoreSafetyResolver = app(RestoreSafetyResolver::class);
|
||||
$recoveryEvidence = $restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealth);
|
||||
|
||||
$triageTiers = [];
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$triageTiers[$tenantId] = TenantRecoveryTriagePresentation::triageTier(
|
||||
$backupHealth[$tenantId] ?? null,
|
||||
$recoveryEvidence[$tenantId] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'tenant_ids' => $tenantIds,
|
||||
'backup_health' => $backupHealth,
|
||||
'recovery_evidence' => $recoveryEvidence,
|
||||
'backup_posture_ids' => static::snapshotTenantIdsByBackupPosture($backupHealth),
|
||||
'recovery_evidence_ids' => static::snapshotTenantIdsByRecoveryEvidence($recoveryEvidence),
|
||||
'triage_tiers' => $triageTiers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function visibleTenantIdsForPostureSnapshot(): array
|
||||
{
|
||||
/** @var Builder $query */
|
||||
$query = clone static::getEloquentQuery();
|
||||
|
||||
return $query
|
||||
->reorder()
|
||||
->orderBy((new Tenant)->qualifyColumn('name'))
|
||||
->orderBy((new Tenant)->qualifyColumn('id'))
|
||||
->pluck((new Tenant)->qualifyColumn('id'))
|
||||
->map(static fn (mixed $tenantId): int => (int) $tenantId)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantBackupHealthAssessment> $assessments
|
||||
* @return array<string, list<int>>
|
||||
*/
|
||||
private static function snapshotTenantIdsByBackupPosture(array $assessments): array
|
||||
{
|
||||
$tenantIdsByPosture = [];
|
||||
|
||||
foreach ($assessments as $tenantId => $assessment) {
|
||||
$tenantIdsByPosture[$assessment->posture] ??= [];
|
||||
$tenantIdsByPosture[$assessment->posture][] = (int) $tenantId;
|
||||
}
|
||||
|
||||
return $tenantIdsByPosture;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $recoveryEvidence
|
||||
* @return array<string, list<int>>
|
||||
*/
|
||||
private static function snapshotTenantIdsByRecoveryEvidence(array $recoveryEvidence): array
|
||||
{
|
||||
$tenantIdsByState = [];
|
||||
|
||||
foreach ($recoveryEvidence as $tenantId => $evidence) {
|
||||
$state = TenantRecoveryTriagePresentation::recoveryEvidenceState($evidence);
|
||||
|
||||
if ($state === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantIdsByState[$state] ??= [];
|
||||
$tenantIdsByState[$state][] = (int) $tenantId;
|
||||
}
|
||||
|
||||
return $tenantIdsByState;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
// ... [Infolist Omitted - No Change] ...
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
@ -16,6 +17,12 @@ class ListTenants extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
parent::mount();
|
||||
$this->applyRequestedTriageIntent();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
@ -26,9 +33,33 @@ protected function getHeaderActions(): array
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
$actions = [
|
||||
$this->makeOnboardingEntryAction(),
|
||||
];
|
||||
|
||||
if ($this->hasActiveTriageEmptyState()) {
|
||||
array_unshift($actions, $this->clearTriageFiltersAction());
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateHeading(): ?string
|
||||
{
|
||||
if ($this->hasActiveTriageEmptyState()) {
|
||||
return 'No visible tenants match this triage slice';
|
||||
}
|
||||
|
||||
return parent::getTableEmptyStateHeading();
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
if ($this->hasActiveTriageEmptyState()) {
|
||||
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.';
|
||||
}
|
||||
|
||||
return parent::getTableEmptyStateDescription();
|
||||
}
|
||||
|
||||
private function makeOnboardingEntryAction(): Actions\Action
|
||||
@ -41,6 +72,61 @@ private function makeOnboardingEntryAction(): Actions\Action
|
||||
->url(route('admin.onboarding'));
|
||||
}
|
||||
|
||||
private function clearTriageFiltersAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('clear_triage_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-funnel')
|
||||
->color('gray')
|
||||
->action('resetTableFiltersForm');
|
||||
}
|
||||
|
||||
private function applyRequestedTriageIntent(): void
|
||||
{
|
||||
$hasIntent = request()->query->has('backup_posture')
|
||||
|| request()->query->has('recovery_evidence')
|
||||
|| request()->query->has('triage_sort');
|
||||
|
||||
if (! $hasIntent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
|
||||
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
|
||||
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
|
||||
|
||||
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) {
|
||||
data_forget($this->tableFilters, $filterName);
|
||||
data_forget($this->tableDeferredFilters, $filterName);
|
||||
}
|
||||
|
||||
if ($backupPostures !== []) {
|
||||
$this->tableFilters['backup_posture']['values'] = $backupPostures;
|
||||
$this->tableDeferredFilters['backup_posture']['values'] = $backupPostures;
|
||||
}
|
||||
|
||||
if ($recoveryEvidence !== []) {
|
||||
$this->tableFilters['recovery_evidence']['values'] = $recoveryEvidence;
|
||||
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
|
||||
}
|
||||
|
||||
if ($triageSort !== null) {
|
||||
$this->tableFilters['triage_sort']['value'] = $triageSort;
|
||||
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveTriageEmptyState(): bool
|
||||
{
|
||||
$backupPostures = TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', []));
|
||||
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', []));
|
||||
$triageSort = TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value'));
|
||||
|
||||
return $backupPostures !== []
|
||||
|| $recoveryEvidence !== []
|
||||
|| $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
||||
}
|
||||
|
||||
private function accessibleResumableDraftCount(): int
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -18,10 +18,10 @@
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RecoveryReadiness extends Widget
|
||||
{
|
||||
@ -58,16 +58,16 @@ protected function getViewData(): array
|
||||
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
|
||||
'backupPosture' => [
|
||||
'label' => 'Backup posture',
|
||||
'value' => Str::headline($backupHealth->posture),
|
||||
'description' => $this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']),
|
||||
'color' => $backupHealth->tone(),
|
||||
'value' => TenantRecoveryTriagePresentation::backupPostureLabel($backupHealth),
|
||||
'description' => TenantRecoveryTriagePresentation::backupPostureDescription($backupHealth, $backupHealthAction['helperText']),
|
||||
'color' => TenantRecoveryTriagePresentation::backupPostureTone($backupHealth),
|
||||
'url' => $backupHealthAction['actionUrl'],
|
||||
],
|
||||
'recoveryEvidence' => [
|
||||
'label' => 'Recovery evidence',
|
||||
'value' => $this->recoveryEvidenceValue($recoveryEvidence['overview_state']),
|
||||
'description' => $this->recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
|
||||
'color' => $this->recoveryEvidenceTone($recoveryEvidence),
|
||||
'value' => TenantRecoveryTriagePresentation::recoveryEvidenceLabel($recoveryEvidence),
|
||||
'description' => TenantRecoveryTriagePresentation::recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
|
||||
'color' => TenantRecoveryTriagePresentation::recoveryEvidenceTone($recoveryEvidence),
|
||||
'url' => $recoveryAction['actionUrl'],
|
||||
],
|
||||
];
|
||||
@ -211,64 +211,6 @@ private function resolveRecoveryAction(Tenant $tenant, array $recoveryEvidence):
|
||||
}
|
||||
}
|
||||
|
||||
private function recoveryEvidenceValue(string $overviewState): string
|
||||
{
|
||||
return match ($overviewState) {
|
||||
'unvalidated' => 'Unvalidated',
|
||||
'weakened' => 'Weakened',
|
||||
'no_recent_issues_visible' => 'No recent issues visible',
|
||||
default => Str::headline($overviewState),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
*/
|
||||
private function recoveryEvidenceDescription(array $recoveryEvidence, ?string $helperText): string
|
||||
{
|
||||
$parts = [
|
||||
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
|
||||
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
|
||||
$helperText,
|
||||
];
|
||||
|
||||
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
*/
|
||||
private function recoveryEvidenceTone(array $recoveryEvidence): string
|
||||
{
|
||||
$attentionState = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: null;
|
||||
|
||||
return match ($recoveryEvidence['overview_state'] ?? null) {
|
||||
'unvalidated' => 'warning',
|
||||
'weakened' => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
|
||||
'no_recent_issues_visible' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
|
||||
{
|
||||
$parts = [
|
||||
$assessment->supportingMessage ?? $assessment->headline,
|
||||
];
|
||||
|
||||
if ($assessment->posture === TenantBackupHealthAssessment::POSTURE_HEALTHY) {
|
||||
$parts[] = $assessment->positiveClaimBoundary;
|
||||
}
|
||||
|
||||
if ($helperText !== null) {
|
||||
$parts[] = $helperText;
|
||||
}
|
||||
|
||||
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
}
|
||||
|
||||
private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Tenants;
|
||||
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class TenantRecoveryTriagePresentation
|
||||
{
|
||||
public const RECOVERY_EVIDENCE_WEAKENED = 'weakened';
|
||||
|
||||
public const RECOVERY_EVIDENCE_UNVALIDATED = 'unvalidated';
|
||||
|
||||
public const RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE = 'no_recent_issues_visible';
|
||||
|
||||
public const TRIAGE_SORT_DEFAULT = 'default';
|
||||
|
||||
public const TRIAGE_SORT_WORST_FIRST = 'worst_first';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function backupPostureOptions(): array
|
||||
{
|
||||
return [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT => 'Absent',
|
||||
TenantBackupHealthAssessment::POSTURE_STALE => 'Stale',
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED => 'Degraded',
|
||||
TenantBackupHealthAssessment::POSTURE_HEALTHY => 'Healthy',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function recoveryEvidenceOptions(): array
|
||||
{
|
||||
return [
|
||||
self::RECOVERY_EVIDENCE_WEAKENED => 'Weakened',
|
||||
self::RECOVERY_EVIDENCE_UNVALIDATED => 'Unvalidated',
|
||||
self::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE => 'No recent issues visible',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function triageSortOptions(): array
|
||||
{
|
||||
return [
|
||||
self::TRIAGE_SORT_DEFAULT => 'Default order',
|
||||
self::TRIAGE_SORT_WORST_FIRST => 'Worst first',
|
||||
];
|
||||
}
|
||||
|
||||
public static function backupPostureState(?TenantBackupHealthAssessment $assessment): ?string
|
||||
{
|
||||
return $assessment?->posture;
|
||||
}
|
||||
|
||||
public static function backupPostureLabel(?TenantBackupHealthAssessment $assessment): string
|
||||
{
|
||||
$state = self::backupPostureState($assessment);
|
||||
|
||||
if ($state === null) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return self::backupPostureOptions()[$state] ?? Str::headline($state);
|
||||
}
|
||||
|
||||
public static function backupPostureTone(?TenantBackupHealthAssessment $assessment): string
|
||||
{
|
||||
return $assessment?->tone() ?? 'gray';
|
||||
}
|
||||
|
||||
public static function backupPostureDescription(?TenantBackupHealthAssessment $assessment, ?string $helperText = null): ?string
|
||||
{
|
||||
if (! $assessment instanceof TenantBackupHealthAssessment) {
|
||||
return $helperText;
|
||||
}
|
||||
|
||||
$parts = [
|
||||
$assessment->supportingMessage ?? $assessment->headline,
|
||||
$assessment->posture === TenantBackupHealthAssessment::POSTURE_HEALTHY
|
||||
? $assessment->positiveClaimBoundary
|
||||
: null,
|
||||
$helperText,
|
||||
];
|
||||
|
||||
$description = trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
|
||||
return $description !== '' ? $description : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public static function recoveryEvidenceState(?array $recoveryEvidence): ?string
|
||||
{
|
||||
$state = $recoveryEvidence['overview_state'] ?? null;
|
||||
|
||||
return is_string($state) && $state !== '' ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public static function recoveryEvidenceLabel(?array $recoveryEvidence): string
|
||||
{
|
||||
$state = self::recoveryEvidenceState($recoveryEvidence);
|
||||
|
||||
if ($state === null) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return self::recoveryEvidenceOptions()[$state] ?? Str::headline($state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public static function recoveryEvidenceDescription(?array $recoveryEvidence, ?string $helperText = null): ?string
|
||||
{
|
||||
if (! is_array($recoveryEvidence)) {
|
||||
return $helperText;
|
||||
}
|
||||
|
||||
$parts = [
|
||||
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
|
||||
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
|
||||
$helperText,
|
||||
];
|
||||
|
||||
$description = trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
|
||||
return $description !== '' ? $description : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public static function recoveryEvidenceTone(?array $recoveryEvidence): string
|
||||
{
|
||||
if (! is_array($recoveryEvidence)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$attentionState = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: null;
|
||||
|
||||
return match (self::recoveryEvidenceState($recoveryEvidence)) {
|
||||
self::RECOVERY_EVIDENCE_UNVALIDATED => 'warning',
|
||||
self::RECOVERY_EVIDENCE_WEAKENED => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
|
||||
self::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public static function triageTier(
|
||||
?TenantBackupHealthAssessment $backupHealth,
|
||||
?array $recoveryEvidence,
|
||||
): int {
|
||||
$backupPosture = self::backupPostureState($backupHealth);
|
||||
$recoveryState = self::recoveryEvidenceState($recoveryEvidence);
|
||||
|
||||
return match (true) {
|
||||
$backupPosture === TenantBackupHealthAssessment::POSTURE_ABSENT => 1,
|
||||
$recoveryState === self::RECOVERY_EVIDENCE_WEAKENED => 2,
|
||||
$backupPosture === TenantBackupHealthAssessment::POSTURE_STALE => 3,
|
||||
$recoveryState === self::RECOVERY_EVIDENCE_UNVALIDATED => 4,
|
||||
$backupPosture === TenantBackupHealthAssessment::POSTURE_DEGRADED => 5,
|
||||
default => 6,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
@ -33,6 +34,7 @@
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@ -321,8 +323,7 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
|
||||
'latest_relevant_restore_run_id' => is_array($recoveryEvidence) && is_numeric($recoveryEvidence['latest_relevant_restore_run_id'] ?? null)
|
||||
? (int) $recoveryEvidence['latest_relevant_restore_run_id']
|
||||
: null,
|
||||
'has_recovery_attention' => ! $hasBackupAttention
|
||||
&& in_array($recoveryOverviewState, ['weakened', 'unvalidated'], true),
|
||||
'has_recovery_attention' => in_array($recoveryOverviewState, ['weakened', 'unvalidated'], true),
|
||||
'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
|
||||
'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0),
|
||||
'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0),
|
||||
@ -939,7 +940,24 @@ private function attentionMetricDestination(array $tenantContexts, User $user, s
|
||||
}
|
||||
|
||||
if (count($affectedContexts) > 1) {
|
||||
return $this->chooseTenantTarget('Choose tenant');
|
||||
return match ($stateKey) {
|
||||
'has_backup_attention' => $this->filteredTenantRegistryTarget([
|
||||
'backup_posture' => [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
],
|
||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
], 'Choose tenant'),
|
||||
'has_recovery_attention' => $this->filteredTenantRegistryTarget([
|
||||
'recovery_evidence' => [
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||
],
|
||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
], 'Choose tenant'),
|
||||
default => $this->chooseTenantTarget('Choose tenant'),
|
||||
};
|
||||
}
|
||||
|
||||
$tenant = $affectedContexts[0]['tenant'] ?? null;
|
||||
@ -1193,6 +1211,20 @@ private function chooseTenantTarget(string $label = 'Choose tenant'): array
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function filteredTenantRegistryTarget(array $filters, string $label = 'Choose tenant'): array
|
||||
{
|
||||
return $this->destination(
|
||||
kind: 'choose_tenant',
|
||||
url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters),
|
||||
label: $label,
|
||||
filters: $filters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\BackupHealth\BackupFreshnessEvaluation;
|
||||
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
function tenantRegistryBaseContext(string $anchorName = 'Anchor Tenant'): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => $anchorName,
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
role: 'owner',
|
||||
workspaceRole: 'readonly',
|
||||
);
|
||||
|
||||
return [$user, $tenant];
|
||||
}
|
||||
|
||||
function tenantRegistryPeer(User $user, Tenant $workspaceTenant, string $name): Tenant
|
||||
{
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $workspaceTenant->workspace_id,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'readonly',
|
||||
);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
function tenantRegistryList(Tenant $workspaceTenant, User $user, array $query = [])
|
||||
{
|
||||
test()->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceTenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
request()->attributes->remove('tenant_resource.posture_snapshot');
|
||||
session()->forget('tables.'.md5(ListTenants::class).'_filters');
|
||||
session()->forget('tables.'.md5(ListTenants::class).'_search');
|
||||
session()->forget('tables.'.md5(ListTenants::class).'_sort');
|
||||
|
||||
$factory = $query !== []
|
||||
? Livewire::withQueryParams($query)->actingAs($user)
|
||||
: Livewire::actingAs($user);
|
||||
|
||||
return $factory->test(ListTenants::class);
|
||||
}
|
||||
|
||||
function tenantRegistryBackupAssessment(
|
||||
int $tenantId,
|
||||
string $posture,
|
||||
?string $reason = null,
|
||||
?string $supportingMessage = null,
|
||||
): TenantBackupHealthAssessment {
|
||||
return new TenantBackupHealthAssessment(
|
||||
tenantId: $tenantId,
|
||||
posture: $posture,
|
||||
primaryReason: $reason,
|
||||
headline: str($posture)->headline()->toString(),
|
||||
supportingMessage: $supportingMessage,
|
||||
latestRelevantBackupSetId: null,
|
||||
latestRelevantCompletedAt: now()->subMinutes(10),
|
||||
qualitySummary: null,
|
||||
freshnessEvaluation: new BackupFreshnessEvaluation(
|
||||
latestCompletedAt: now()->subMinutes(10),
|
||||
cutoffAt: now()->subHour(),
|
||||
isFresh: true,
|
||||
),
|
||||
scheduleFollowUp: new BackupScheduleFollowUpEvaluation(
|
||||
hasEnabledSchedules: false,
|
||||
enabledScheduleCount: 0,
|
||||
overdueScheduleCount: 0,
|
||||
failedRecentRunCount: 0,
|
||||
neverSuccessfulCount: 0,
|
||||
needsFollowUp: false,
|
||||
primaryScheduleId: null,
|
||||
summaryMessage: null,
|
||||
),
|
||||
healthyClaimAllowed: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
|
||||
primaryActionTarget: null,
|
||||
positiveClaimBoundary: 'Recent backup history does not prove tenant recovery.',
|
||||
);
|
||||
}
|
||||
|
||||
function tenantRegistryRecoveryEvidence(
|
||||
string $overviewState,
|
||||
string $summary = 'Bounded recovery evidence summary.',
|
||||
string $reason = 'no_recent_issues_visible',
|
||||
): array {
|
||||
return [
|
||||
'overview_state' => $overviewState,
|
||||
'summary' => $summary,
|
||||
'claim_boundary' => 'Tenant-wide recovery is not proven.',
|
||||
'reason' => $reason,
|
||||
'latest_relevant_restore_run' => null,
|
||||
'latest_relevant_attention' => null,
|
||||
'latest_relevant_attention_state' => null,
|
||||
];
|
||||
}
|
||||
|
||||
it('shows separate backup posture and recovery evidence signals without turning metadata into recovery truth', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $absentTenant] = tenantRegistryBaseContext('Absent Backup Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($absentTenant);
|
||||
|
||||
$weakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Weakened Recovery Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
|
||||
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
|
||||
|
||||
$metadataTenant = tenantRegistryPeer($user, $absentTenant, 'Metadata Drift Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($metadataTenant);
|
||||
$metadataBackup = workspaceOverviewSeedHealthyBackup($metadataTenant, [
|
||||
'completed_at' => now()->subMinutes(15),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($metadataTenant, $metadataBackup, 'completed');
|
||||
Policy::factory()->for($metadataTenant)->create([
|
||||
'display_name' => 'Stale sync policy',
|
||||
'last_synced_at' => now()->subDays(14),
|
||||
]);
|
||||
|
||||
$component = tenantRegistryList($absentTenant, $user)
|
||||
->assertTableColumnExists('backup_posture')
|
||||
->assertTableColumnExists('recovery_evidence')
|
||||
->assertTableColumnVisible('backup_posture')
|
||||
->assertTableColumnVisible('recovery_evidence')
|
||||
->assertTableColumnFormattedStateSet('backup_posture', 'Absent', $absentTenant)
|
||||
->assertTableColumnFormattedStateSet('recovery_evidence', 'Weakened', $weakenedTenant)
|
||||
->assertTableColumnFormattedStateSet('backup_posture', 'Healthy', $metadataTenant)
|
||||
->assertTableColumnFormattedStateSet('recovery_evidence', 'No recent issues visible', $metadataTenant)
|
||||
->assertTableActionVisible('openTenant', $weakenedTenant)
|
||||
->assertTableActionHasUrl('openTenant', TenantDashboard::getUrl(panel: 'tenant', tenant: $weakenedTenant), $weakenedTenant)
|
||||
->assertDontSee('recoverable')
|
||||
->assertDontSee('recovery proven')
|
||||
->assertDontSee('validated overall');
|
||||
|
||||
expect($component->instance()->getTable()->getRecordUrl($weakenedTenant))
|
||||
->toBe(TenantResource::getUrl('view', ['record' => $weakenedTenant], panel: 'admin'));
|
||||
});
|
||||
|
||||
it('filters the registry to exact backup and recovery posture slices', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $calmTenant] = tenantRegistryBaseContext('Calm Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($calmTenant);
|
||||
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
||||
|
||||
$degradedTenant = tenantRegistryPeer($user, $calmTenant, 'Degraded Backup Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
|
||||
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
|
||||
'completed_at' => now()->subMinutes(12),
|
||||
'item_count' => 2,
|
||||
], [
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
|
||||
|
||||
$unvalidatedTenant = tenantRegistryPeer($user, $calmTenant, 'Unvalidated Recovery Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
|
||||
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
|
||||
'completed_at' => now()->subMinutes(11),
|
||||
]);
|
||||
|
||||
tenantRegistryList($calmTenant, $user)
|
||||
->assertTableColumnFormattedStateSet('recovery_evidence', 'Unvalidated', $unvalidatedTenant);
|
||||
|
||||
$tenantResourceReflection = new ReflectionClass(TenantResource::class);
|
||||
$postureSnapshot = $tenantResourceReflection->getMethod('postureSnapshot');
|
||||
$postureSnapshot->setAccessible(true);
|
||||
|
||||
expect($postureSnapshot->invoke(null)['recovery_evidence_ids']['unvalidated'] ?? [])
|
||||
->toBe([(int) $unvalidatedTenant->getKey()]);
|
||||
|
||||
$backupFiltered = tenantRegistryList($calmTenant, $user)
|
||||
->filterTable('backup_posture', [TenantBackupHealthAssessment::POSTURE_DEGRADED]);
|
||||
|
||||
expect($backupFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
|
||||
->toBe(['Degraded Backup Tenant']);
|
||||
|
||||
$recoveryFiltered = tenantRegistryList($calmTenant, $user)
|
||||
->filterTable('recovery_evidence', ['unvalidated'])
|
||||
->assertSet('tableFilters.recovery_evidence.values', ['unvalidated']);
|
||||
|
||||
expect($recoveryFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
|
||||
->toBe(['Unvalidated Recovery Tenant']);
|
||||
});
|
||||
|
||||
it('orders the visible tenant registry worst-first with stable tenant-name tie breaks when triage sorting is requested', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $absentTenant] = tenantRegistryBaseContext('Absent Backup Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($absentTenant);
|
||||
|
||||
$alphaWeakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Alpha Weakened Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($alphaWeakenedTenant);
|
||||
$alphaWeakenedBackup = workspaceOverviewSeedHealthyBackup($alphaWeakenedTenant, [
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($alphaWeakenedTenant, $alphaWeakenedBackup, 'follow_up');
|
||||
|
||||
$zetaWeakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Zeta Weakened Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($zetaWeakenedTenant);
|
||||
$zetaWeakenedBackup = workspaceOverviewSeedHealthyBackup($zetaWeakenedTenant, [
|
||||
'completed_at' => now()->subMinutes(21),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($zetaWeakenedTenant, $zetaWeakenedBackup, 'failed');
|
||||
|
||||
$staleTenant = tenantRegistryPeer($user, $absentTenant, 'Stale Backup Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($staleTenant);
|
||||
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
|
||||
|
||||
$unvalidatedTenant = tenantRegistryPeer($user, $absentTenant, 'Unvalidated Recovery Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
|
||||
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
|
||||
'completed_at' => now()->subMinutes(18),
|
||||
]);
|
||||
|
||||
$degradedTenant = tenantRegistryPeer($user, $absentTenant, 'Degraded Backup Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
|
||||
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
|
||||
'completed_at' => now()->subMinutes(17),
|
||||
'item_count' => 2,
|
||||
], [
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
|
||||
|
||||
$calmTenant = tenantRegistryPeer($user, $absentTenant, 'Calm Tenant');
|
||||
workspaceOverviewSeedQuietTenantTruth($calmTenant);
|
||||
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
|
||||
'completed_at' => now()->subMinutes(14),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
||||
|
||||
tenantRegistryList($absentTenant, $user, [
|
||||
'triage_sort' => 'worst_first',
|
||||
])
|
||||
->assertSet('tableFilters.triage_sort.value', 'worst_first')
|
||||
->assertCanSeeTableRecords([
|
||||
$absentTenant,
|
||||
$alphaWeakenedTenant,
|
||||
$zetaWeakenedTenant,
|
||||
$staleTenant,
|
||||
$unvalidatedTenant,
|
||||
$degradedTenant,
|
||||
$calmTenant,
|
||||
], inOrder: true);
|
||||
});
|
||||
|
||||
it('loads backup posture and recovery evidence with one batch per registry render instead of per-row fanout', function (): void {
|
||||
[$user, $firstTenant] = tenantRegistryBaseContext('Batch Tenant Alpha');
|
||||
$secondTenant = tenantRegistryPeer($user, $firstTenant, 'Batch Tenant Beta');
|
||||
|
||||
$backupAssessments = [
|
||||
(int) $firstTenant->getKey() => tenantRegistryBackupAssessment(
|
||||
tenantId: (int) $firstTenant->getKey(),
|
||||
posture: TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
supportingMessage: 'The latest backup is outside the configured freshness window.',
|
||||
),
|
||||
(int) $secondTenant->getKey() => tenantRegistryBackupAssessment(
|
||||
tenantId: (int) $secondTenant->getKey(),
|
||||
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
|
||||
),
|
||||
];
|
||||
|
||||
$expectedTenantIds = [
|
||||
(int) $firstTenant->getKey(),
|
||||
(int) $secondTenant->getKey(),
|
||||
];
|
||||
|
||||
$backupResolver = new class($expectedTenantIds, $backupAssessments)
|
||||
{
|
||||
public int $assessManyCalls = 0;
|
||||
|
||||
/**
|
||||
* @param list<int> $expectedTenantIds
|
||||
* @param array<int, TenantBackupHealthAssessment> $assessments
|
||||
*/
|
||||
public function __construct(
|
||||
private array $expectedTenantIds,
|
||||
private array $assessments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, TenantBackupHealthAssessment>
|
||||
*/
|
||||
public function assessMany(iterable $tenantIds): array
|
||||
{
|
||||
$this->assessManyCalls++;
|
||||
|
||||
expect(array_values(is_array($tenantIds) ? $tenantIds : iterator_to_array($tenantIds, false)))
|
||||
->toBe($this->expectedTenantIds);
|
||||
|
||||
return $this->assessments;
|
||||
}
|
||||
};
|
||||
|
||||
$restoreSafetyResolver = new class($expectedTenantIds, $backupAssessments)
|
||||
{
|
||||
public int $dashboardEvidenceCalls = 0;
|
||||
|
||||
/**
|
||||
* @param list<int> $expectedTenantIds
|
||||
* @param array<int, TenantBackupHealthAssessment> $expectedAssessments
|
||||
*/
|
||||
public function __construct(
|
||||
private array $expectedTenantIds,
|
||||
private array $expectedAssessments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantBackupHealthAssessment> $resolvedAssessments
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function dashboardRecoveryEvidenceForTenants(array $tenantIds, array $resolvedAssessments): array
|
||||
{
|
||||
$this->dashboardEvidenceCalls++;
|
||||
|
||||
expect($tenantIds)->toBe($this->expectedTenantIds)
|
||||
->and($resolvedAssessments)->toBe($this->expectedAssessments);
|
||||
|
||||
return [
|
||||
$tenantIds[0] => tenantRegistryRecoveryEvidence(
|
||||
overviewState: 'unvalidated',
|
||||
summary: 'No recent restore history is available for this tenant.',
|
||||
reason: 'no_history',
|
||||
),
|
||||
$tenantIds[1] => tenantRegistryRecoveryEvidence(
|
||||
overviewState: 'no_recent_issues_visible',
|
||||
),
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
app()->instance(TenantBackupHealthResolver::class, $backupResolver);
|
||||
app()->instance(RestoreSafetyResolver::class, $restoreSafetyResolver);
|
||||
|
||||
tenantRegistryList($firstTenant, $user)
|
||||
->assertCanSeeTableRecords([$firstTenant, $secondTenant])
|
||||
->assertTableColumnFormattedStateSet('backup_posture', 'Stale', $firstTenant)
|
||||
->assertTableColumnFormattedStateSet('recovery_evidence', 'No recent issues visible', $secondTenant);
|
||||
|
||||
expect($backupResolver->assessManyCalls)->toBe(1)
|
||||
->and($restoreSafetyResolver->dashboardEvidenceCalls)->toBe(1);
|
||||
});
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -105,3 +107,57 @@
|
||||
->assertSet('tableSort', 'name:desc')
|
||||
->assertSet('tableFilters.environment.value', 'prod');
|
||||
});
|
||||
|
||||
it('keeps posture filters scoped to visible workspace tenants only', function (): void {
|
||||
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($visibleTenant);
|
||||
workspaceOverviewSeedHealthyBackup($visibleTenant, [
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$hiddenRecoveryTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||
'name' => 'Hidden Recovery Tenant',
|
||||
]);
|
||||
workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant);
|
||||
workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [
|
||||
'completed_at' => now()->subMinutes(9),
|
||||
]);
|
||||
|
||||
$hiddenBackupTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||
'name' => 'Hidden Degraded Tenant',
|
||||
]);
|
||||
workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant);
|
||||
workspaceOverviewSeedHealthyBackup($hiddenBackupTenant, [
|
||||
'completed_at' => now()->subMinutes(8),
|
||||
], [
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$recoveryFiltered = Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->filterTable('recovery_evidence', ['unvalidated']);
|
||||
|
||||
expect($recoveryFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
|
||||
->toBe([(string) $visibleTenant->name]);
|
||||
|
||||
$backupFiltered = Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->filterTable('backup_posture', [TenantBackupHealthAssessment::POSTURE_DEGRADED]);
|
||||
|
||||
expect($backupFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
|
||||
->toBe([]);
|
||||
});
|
||||
|
||||
@ -81,6 +81,10 @@
|
||||
$backupTenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($backupTenant);
|
||||
$backupTenantSet = workspaceOverviewSeedHealthyBackup($backupTenant, [
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($backupTenant, $backupTenantSet, 'completed');
|
||||
|
||||
$recoveryTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
@ -119,3 +123,63 @@
|
||||
->and($metrics->get('recovery_attention_tenants')['destination']['disabled'])->toBeFalse()
|
||||
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
|
||||
});
|
||||
|
||||
it('falls back to the visible tenant dashboard when hidden peers are excluded from backup and recovery metric drill-through', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
||||
|
||||
$visibleBackupTenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $visibleBackupTenant] = createUserWithTenant($visibleBackupTenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($visibleBackupTenant);
|
||||
$visibleBackupSet = workspaceOverviewSeedHealthyBackup($visibleBackupTenant, [
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($visibleBackupTenant, $visibleBackupSet, 'completed');
|
||||
|
||||
$visibleRecoveryTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
|
||||
'name' => 'Visible Recovery Tenant',
|
||||
]);
|
||||
createUserWithTenant($visibleRecoveryTenant, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($visibleRecoveryTenant);
|
||||
$visibleRecoveryBackup = workspaceOverviewSeedHealthyBackup($visibleRecoveryTenant, [
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($visibleRecoveryTenant, $visibleRecoveryBackup, 'follow_up');
|
||||
|
||||
$hiddenBackupTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
|
||||
'name' => 'Hidden Backup Tenant',
|
||||
]);
|
||||
workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant);
|
||||
|
||||
$hiddenRecoveryTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
|
||||
'name' => 'Hidden Recovery Tenant',
|
||||
]);
|
||||
workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant);
|
||||
$hiddenRecoveryBackup = workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [
|
||||
'completed_at' => now()->subMinutes(18),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($hiddenRecoveryTenant, $hiddenRecoveryBackup, 'follow_up');
|
||||
|
||||
mock(CapabilityResolver::class, function ($mock): void {
|
||||
$mock->shouldReceive('primeMemberships')->once();
|
||||
$mock->shouldReceive('isMember')->andReturnTrue();
|
||||
$mock->shouldReceive('can')->andReturnFalse();
|
||||
});
|
||||
|
||||
$metrics = collect(
|
||||
app(\App\Support\Workspaces\WorkspaceOverviewBuilder::class)
|
||||
->build($visibleBackupTenant->workspace()->firstOrFail(), $user)['summary_metrics'],
|
||||
)->keyBy('key');
|
||||
|
||||
expect($metrics->get('backup_attention_tenants')['value'])->toBe(1)
|
||||
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
|
||||
->and($metrics->get('backup_attention_tenants')['destination_url'])->toContain('/admin/t/')
|
||||
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
|
||||
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
|
||||
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention as TenantNeedsAttention;
|
||||
@ -13,10 +14,17 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void {
|
||||
$tenantDashboard = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly');
|
||||
@ -200,6 +208,127 @@
|
||||
->toContain($reviewUrl);
|
||||
});
|
||||
|
||||
it('hydrates filtered tenant-registry triage state from multi-tenant workspace backup and recovery metrics', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
||||
|
||||
$absentTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Absent Backup Tenant',
|
||||
]);
|
||||
[$user, $absentTenant] = createUserWithTenant($absentTenant, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($absentTenant);
|
||||
|
||||
$staleTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $absentTenant->workspace_id,
|
||||
'name' => 'Stale Backup Tenant',
|
||||
]);
|
||||
createUserWithTenant($staleTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($staleTenant);
|
||||
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
|
||||
|
||||
$degradedTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $absentTenant->workspace_id,
|
||||
'name' => 'Degraded Backup Tenant',
|
||||
]);
|
||||
createUserWithTenant($degradedTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
|
||||
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
], [
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
|
||||
|
||||
$weakenedTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $absentTenant->workspace_id,
|
||||
'name' => 'Weakened Recovery Tenant',
|
||||
]);
|
||||
createUserWithTenant($weakenedTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
|
||||
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
|
||||
'completed_at' => now()->subMinutes(18),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
|
||||
|
||||
$unvalidatedTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $absentTenant->workspace_id,
|
||||
'name' => 'Unvalidated Recovery Tenant',
|
||||
]);
|
||||
createUserWithTenant($unvalidatedTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
|
||||
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
|
||||
'completed_at' => now()->subMinutes(16),
|
||||
]);
|
||||
|
||||
$calmTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $absentTenant->workspace_id,
|
||||
'name' => 'Calm Tenant',
|
||||
]);
|
||||
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($calmTenant);
|
||||
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
|
||||
'completed_at' => now()->subMinutes(14),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
||||
|
||||
$workspace = $absentTenant->workspace()->firstOrFail();
|
||||
$metrics = collect(app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['summary_metrics'])->keyBy('key');
|
||||
|
||||
$this->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
parse_str((string) parse_url((string) $metrics->get('backup_attention_tenants')['destination_url'], PHP_URL_QUERY), $backupQuery);
|
||||
|
||||
$backupRegistry = Livewire::withQueryParams($backupQuery)
|
||||
->actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->assertSet('tableFilters.backup_posture.values', [
|
||||
'absent',
|
||||
'stale',
|
||||
'degraded',
|
||||
])
|
||||
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
||||
|
||||
expect($backupRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
|
||||
->toBe([
|
||||
'Absent Backup Tenant',
|
||||
'Stale Backup Tenant',
|
||||
'Degraded Backup Tenant',
|
||||
]);
|
||||
|
||||
parse_str((string) parse_url((string) $metrics->get('recovery_attention_tenants')['destination_url'], PHP_URL_QUERY), $recoveryQuery);
|
||||
|
||||
$recoveryRegistry = Livewire::withQueryParams($recoveryQuery)
|
||||
->actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->assertSet('tableFilters.recovery_evidence.values', [
|
||||
'weakened',
|
||||
'unvalidated',
|
||||
])
|
||||
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
||||
|
||||
expect($recoveryRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
|
||||
->toBe([
|
||||
'Absent Backup Tenant',
|
||||
'Weakened Recovery Tenant',
|
||||
'Unvalidated Recovery Tenant',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes backup and recovery workspace attention into tenant dashboards that still show the same weakness', function (): void {
|
||||
$backupTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
|
||||
@ -133,39 +133,63 @@
|
||||
it('counts backup and recovery attention tenants separately and chooses precise or fallback destinations', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
||||
|
||||
$singleRecoveryTenant = Tenant::factory()->create([
|
||||
$recoveryTenantA = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Single Recovery Tenant',
|
||||
'name' => 'Recovery Tenant A',
|
||||
]);
|
||||
[$user, $singleRecoveryTenant] = createUserWithTenant($singleRecoveryTenant, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($singleRecoveryTenant);
|
||||
$singleRecoveryBackup = workspaceOverviewSeedHealthyBackup($singleRecoveryTenant, [
|
||||
[$user, $recoveryTenantA] = createUserWithTenant($recoveryTenantA, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($recoveryTenantA);
|
||||
$recoveryTenantABackup = workspaceOverviewSeedHealthyBackup($recoveryTenantA, [
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($singleRecoveryTenant, $singleRecoveryBackup, 'follow_up');
|
||||
workspaceOverviewSeedRestoreHistory($recoveryTenantA, $recoveryTenantABackup, 'follow_up');
|
||||
|
||||
$recoveryTenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $recoveryTenantA->workspace_id,
|
||||
'name' => 'Recovery Tenant B',
|
||||
]);
|
||||
createUserWithTenant($recoveryTenantB, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($recoveryTenantB);
|
||||
$recoveryTenantBBackup = workspaceOverviewSeedHealthyBackup($recoveryTenantB, [
|
||||
'completed_at' => now()->subMinutes(22),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($recoveryTenantB, $recoveryTenantBBackup, 'failed');
|
||||
|
||||
$backupTenantA = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
|
||||
'workspace_id' => (int) $recoveryTenantA->workspace_id,
|
||||
'name' => 'Backup Tenant A',
|
||||
]);
|
||||
createUserWithTenant($backupTenantA, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($backupTenantA);
|
||||
$backupTenantABackup = workspaceOverviewSeedHealthyBackup($backupTenantA, [
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($backupTenantA, $backupTenantABackup, 'completed');
|
||||
|
||||
$backupTenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
|
||||
'workspace_id' => (int) $recoveryTenantA->workspace_id,
|
||||
'name' => 'Backup Tenant B',
|
||||
]);
|
||||
createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
workspaceOverviewSeedQuietTenantTruth($backupTenantB);
|
||||
workspaceOverviewSeedHealthyBackup($backupTenantB, [
|
||||
'completed_at' => now()->subDays(2),
|
||||
$backupTenantBBackup = workspaceOverviewSeedHealthyBackup($backupTenantB, [
|
||||
'completed_at' => now()->subMinutes(18),
|
||||
], [
|
||||
'payload' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'assignments_fetch_failed' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($backupTenantB, $backupTenantBBackup, 'completed');
|
||||
|
||||
$calmTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
|
||||
'workspace_id' => (int) $recoveryTenantA->workspace_id,
|
||||
'name' => 'Calm Tenant',
|
||||
]);
|
||||
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
||||
@ -175,18 +199,28 @@
|
||||
]);
|
||||
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
||||
|
||||
$workspace = $singleRecoveryTenant->workspace()->firstOrFail();
|
||||
$workspace = $recoveryTenantA->workspace()->firstOrFail();
|
||||
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
||||
$metrics = collect($overview['summary_metrics'])->keyBy('key');
|
||||
$backupDestination = $metrics->get('backup_attention_tenants')['destination'];
|
||||
$recoveryDestination = $metrics->get('recovery_attention_tenants')['destination'];
|
||||
|
||||
expect($metrics->get('backup_attention_tenants')['value'])->toBe(2)
|
||||
->and($metrics->get('backup_attention_tenants')['category'])->toBe('backup_health')
|
||||
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('choose_tenant')
|
||||
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
|
||||
->and($metrics->get('backup_attention_tenants')['destination_url'])->toStartWith(\App\Filament\Resources\TenantResource::getUrl(panel: 'admin'))
|
||||
->and($backupDestination['filters'])->toBe([
|
||||
'backup_posture' => ['absent', 'stale', 'degraded'],
|
||||
'triage_sort' => 'worst_first',
|
||||
])
|
||||
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(2)
|
||||
->and($metrics->get('recovery_attention_tenants')['category'])->toBe('recovery_evidence')
|
||||
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
|
||||
->and($metrics->get('recovery_attention_tenants')['destination']['tenant_route_key'])->toBe((string) $singleRecoveryTenant->external_id)
|
||||
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
|
||||
->and($recoveryDestination['kind'])->toBe('choose_tenant')
|
||||
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toStartWith(\App\Filament\Resources\TenantResource::getUrl(panel: 'admin'))
|
||||
->and($recoveryDestination['filters'])->toBe([
|
||||
'recovery_evidence' => ['weakened', 'unvalidated'],
|
||||
'triage_sort' => 'worst_first',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps backup and recovery attention counts at zero for calm visible tenants', function (): void {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
@ -53,6 +54,7 @@
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
@ -564,6 +566,25 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->assertSeeLivewire(TenantMembershipsRelationManager::class);
|
||||
});
|
||||
|
||||
it('keeps the tenant registry action surface on row inspect plus one safe dashboard shortcut for active tenants', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$list = Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->assertTableActionVisible('openTenant', $tenant)
|
||||
->assertTableActionHidden('related_onboarding', $tenant)
|
||||
->assertTableActionHasUrl('openTenant', TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), $tenant);
|
||||
|
||||
expect($list->instance()->getTable()->getRecordUrl($tenant))
|
||||
->toBe(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'));
|
||||
});
|
||||
|
||||
it('renders the backup items relation manager on the backup set detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -336,3 +336,42 @@
|
||||
|
||||
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
|
||||
});
|
||||
|
||||
it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
|
||||
$patternByPath = [
|
||||
'app/Filament/Resources/TenantResource.php' => [
|
||||
"TextColumn::make('backup_posture')",
|
||||
"TextColumn::make('recovery_evidence')",
|
||||
"SelectFilter::make('backup_posture')",
|
||||
"SelectFilter::make('recovery_evidence')",
|
||||
"SelectFilter::make('triage_sort')",
|
||||
'applyWorstFirstTriageOrdering',
|
||||
'postureSnapshot',
|
||||
],
|
||||
'app/Filament/Resources/TenantResource/Pages/ListTenants.php' => [
|
||||
'applyRequestedTriageIntent',
|
||||
'getTableEmptyStateHeading',
|
||||
'getTableEmptyStateDescription',
|
||||
],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($patternByPath as $relativePath => $patterns) {
|
||||
$contents = file_get_contents(base_path($relativePath));
|
||||
|
||||
if (! is_string($contents)) {
|
||||
$missing[] = $relativePath;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (! str_contains($contents, $pattern)) {
|
||||
$missing[] = "{$relativePath} ({$pattern})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($missing)->toBeEmpty('Missing tenant registry triage table contracts: '.implode(', ', $missing));
|
||||
});
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Tenant Registry Recovery Triage
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-09
|
||||
**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-09 after correcting the feature number to 186.
|
||||
- No clarification markers remain; the spec is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,119 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tenant Registry Recovery Triage Route Contract
|
||||
version: 0.1.0
|
||||
summary: Internal navigation contract for workspace backup and recovery drilldowns into the tenant registry.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
|
||||
paths:
|
||||
/admin/tenants:
|
||||
get:
|
||||
operationId: openTenantRegistryTriage
|
||||
summary: Open the tenant registry with exact triage intent preserved.
|
||||
description: |
|
||||
Multi-tenant workspace backup and recovery drilldowns open this route with exact posture filter semantics.
|
||||
The route renders HTML, stays scoped to the current visible workspace slice, and must not reveal out-of-scope tenants.
|
||||
parameters:
|
||||
- name: backup_posture
|
||||
in: query
|
||||
required: false
|
||||
description: Exact backup-posture filter values to apply on initial registry load.
|
||||
style: form
|
||||
explode: true
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- absent
|
||||
- stale
|
||||
- degraded
|
||||
- healthy
|
||||
- name: recovery_evidence
|
||||
in: query
|
||||
required: false
|
||||
description: Exact recovery-evidence filter values to apply on initial registry load.
|
||||
style: form
|
||||
explode: true
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- weakened
|
||||
- unvalidated
|
||||
- no_recent_issues_visible
|
||||
- name: triage_sort
|
||||
in: query
|
||||
required: false
|
||||
description: Optional registry ordering mode. Workspace drilldowns default to worst-first.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- default
|
||||
- worst_first
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant registry HTML rendered with the requested exact triage intent over the visible tenant set.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: Workspace scope or tenant visibility is not established.
|
||||
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
operationId: openSingleTenantDashboardTriage
|
||||
summary: Open the tenant dashboard as the single-tenant triage destination.
|
||||
description: |
|
||||
Workspace backup or recovery drilldowns may resolve directly here when exactly one visible tenant is affected.
|
||||
This route remains the canonical safe fallback when deeper backup or restore surfaces are not appropriate.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
description: Tenant external route key.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant dashboard HTML rendered inside the current workspace and tenant scope.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: Tenant is not visible in the current workspace or the actor is not a member.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
TenantRegistryTriageIntent:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
backup_posture:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- absent
|
||||
- stale
|
||||
- degraded
|
||||
- healthy
|
||||
recovery_evidence:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- weakened
|
||||
- unvalidated
|
||||
- no_recent_issues_visible
|
||||
triage_sort:
|
||||
type: string
|
||||
enum:
|
||||
- default
|
||||
- worst_first
|
||||
description: Exact filter and ordering intent used to open the tenant registry in recovery-triage mode.
|
||||
167
specs/186-tenant-registry-recovery-triage/data-model.md
Normal file
167
specs/186-tenant-registry-recovery-triage/data-model.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Data Model: Tenant Registry Recovery Triage
|
||||
|
||||
## Overview
|
||||
|
||||
Spec 186 adds no new persisted entity. The feature is a derived registry-view slice over existing tenant, backup-health, and recovery-evidence truth.
|
||||
|
||||
## Existing Persisted Inputs
|
||||
|
||||
### Tenant
|
||||
|
||||
- **Purpose**: Canonical tenant identity and lifecycle record for the workspace-scoped registry.
|
||||
- **Key fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
- `name`
|
||||
- `tenant_id`
|
||||
- `environment`
|
||||
- `status`
|
||||
- `domain`
|
||||
- `created_at`
|
||||
- **Derived fields already used by the registry**:
|
||||
- `policies_count`
|
||||
- `last_policy_sync_at`
|
||||
- **Relationships**:
|
||||
- belongs to one workspace
|
||||
- has many memberships
|
||||
- has many policies
|
||||
- has many backup sets
|
||||
- has many restore runs
|
||||
|
||||
### TenantBackupHealthAssessment
|
||||
|
||||
- **Purpose**: Existing derived tenant backup-health truth used by dashboard and workspace overview.
|
||||
- **Persistence**: Not persisted.
|
||||
- **Key fields**:
|
||||
- `tenantId`
|
||||
- `posture` = `absent | stale | degraded | healthy`
|
||||
- `primaryReason` = `no_backup_basis | latest_backup_stale | latest_backup_degraded | schedule_follow_up | null`
|
||||
- `headline`
|
||||
- `supportingMessage`
|
||||
- `latestRelevantBackupSetId`
|
||||
- `latestRelevantCompletedAt`
|
||||
- `positiveClaimBoundary`
|
||||
- `primaryActionTarget`
|
||||
- **Behavioral role in Spec 186**:
|
||||
- provides the registry’s `Backup posture` value
|
||||
- provides the backup-attention set for workspace drilldown intent
|
||||
- contributes to triage rank
|
||||
|
||||
### Dashboard Recovery Evidence Payload
|
||||
|
||||
- **Purpose**: Existing derived tenant recovery-evidence truth built from restore history.
|
||||
- **Persistence**: Not persisted.
|
||||
- **Key fields**:
|
||||
- `backup_posture`
|
||||
- `overview_state` = `unvalidated | weakened | no_recent_issues_visible`
|
||||
- `headline`
|
||||
- `summary`
|
||||
- `claim_boundary`
|
||||
- `latest_relevant_restore_run_id`
|
||||
- `latest_relevant_attention_state`
|
||||
- `reason` = `no_history | failed | partial | completed_with_follow_up | no_recent_issues_visible`
|
||||
- **Behavioral role in Spec 186**:
|
||||
- provides the registry’s `Recovery evidence` value
|
||||
- provides the recovery-attention set for workspace drilldown intent
|
||||
- contributes to triage rank
|
||||
|
||||
## New Derived Read Models
|
||||
|
||||
### TenantRegistryTriageRow
|
||||
|
||||
- **Purpose**: Request-scoped row projection for the tenant registry.
|
||||
- **Persistence**: Not persisted.
|
||||
- **Fields**:
|
||||
- `tenant_id`
|
||||
- `tenant_route_key`
|
||||
- `tenant_name`
|
||||
- `environment`
|
||||
- `lifecycle_status`
|
||||
- `policies_count`
|
||||
- `last_policy_sync_at`
|
||||
- `backup_posture`
|
||||
- `backup_reason`
|
||||
- `recovery_evidence`
|
||||
- `recovery_reason`
|
||||
- `triage_rank`
|
||||
- `next_action_url`
|
||||
- **Validation rules**:
|
||||
- `backup_posture` must come directly from `TenantBackupHealthAssessment::posture`
|
||||
- `recovery_evidence` must come directly from `dashboardRecoveryEvidenceForTenants(...)[tenant]['overview_state']`
|
||||
- `triage_rank` must be derived from the shared registry priority table, not hard-coded independently per column or filter
|
||||
- `next_action_url` must resolve to an allowed destination in the current workspace and tenant scope
|
||||
|
||||
### TenantRegistryTriageIntent
|
||||
|
||||
- **Purpose**: Query-string driven list intent for opening the registry in a prefiltered triage state.
|
||||
- **Persistence**: Not persisted.
|
||||
- **Fields**:
|
||||
- `backup_posture[]` optional array of `absent | stale | degraded | healthy`
|
||||
- `recovery_evidence[]` optional array of `weakened | unvalidated | no_recent_issues_visible`
|
||||
- `triage_sort` optional enum `default | worst_first`
|
||||
- **Validation rules**:
|
||||
- every `backup_posture[]` entry must be one of the canonical backup posture values
|
||||
- every `recovery_evidence[]` entry must be one of the canonical recovery-evidence values
|
||||
- if `backup_posture[]` is present for workspace backup attention, its intended set is `absent`, `stale`, and `degraded`
|
||||
- if `recovery_evidence[]` is present for workspace recovery attention, its intended set is `weakened` and `unvalidated`
|
||||
- workspace drilldowns that need weak-first behavior must set `triage_sort=worst_first` explicitly; manual registry filtering alone does not change the default calm-browsing sort
|
||||
|
||||
### WorkspaceAttentionDestination
|
||||
|
||||
- **Purpose**: Derived navigation target from workspace overview metrics or attention items.
|
||||
- **Persistence**: Not persisted.
|
||||
- **Fields**:
|
||||
- `kind` = existing workspace-drilldown contract value already used by the current widgets; unchanged by this feature
|
||||
- `url`
|
||||
- `tenant_route_key` optional
|
||||
- `filters` optional exact registry intent payload
|
||||
- **Validation rules**:
|
||||
- one affected visible tenant resolves to the existing single-tenant dashboard kind and opens `/admin/t/{tenant}`
|
||||
- more than one affected visible tenant preserves the existing multi-tenant admin-plane kind while `url` now opens filtered `/admin/tenants`
|
||||
- any registry destination must carry exact posture filter semantics, not a vague `needs attention` label
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Tenant** has one derived `TenantBackupHealthAssessment` in registry context.
|
||||
- **Tenant** has one derived dashboard recovery-evidence payload in registry context.
|
||||
- **TenantRegistryTriageRow** combines one `Tenant` with one backup-health assessment and one recovery-evidence payload.
|
||||
- **WorkspaceAttentionDestination** references either one specific tenant or one registry triage intent, but never both at once.
|
||||
|
||||
## Triage Ranking Model
|
||||
|
||||
### Canonical rank tiers
|
||||
|
||||
1. `backup_posture = absent`
|
||||
2. `recovery_evidence = weakened`
|
||||
3. `backup_posture = stale`
|
||||
4. `recovery_evidence = unvalidated`
|
||||
5. `backup_posture = degraded`
|
||||
6. calm rows: `backup_posture = healthy` and `recovery_evidence = no_recent_issues_visible`
|
||||
|
||||
### Tie-breaker
|
||||
|
||||
- Rows inside the same tier are secondarily ordered by `tenant_name` ascending.
|
||||
|
||||
### Rule for dual-signal tenants
|
||||
|
||||
- If a tenant is weak in both domains, the highest active tier governs its `triage_rank`, but both posture signals remain visible in the row.
|
||||
|
||||
## State Transitions
|
||||
|
||||
There are no persisted state transitions in this feature.
|
||||
|
||||
### Derived transition rules
|
||||
|
||||
- A registry row moves between triage tiers only when the underlying backup-health or recovery-evidence truth changes.
|
||||
- Registry intent transitions are URL-driven:
|
||||
- unfiltered registry
|
||||
- backup-filtered registry
|
||||
- recovery-filtered registry
|
||||
- manually refined registry
|
||||
- cleared back to the default registry view
|
||||
|
||||
## Notes
|
||||
|
||||
- The registry continues to be the source of truth for tenant identity and lifecycle metadata in workspace context.
|
||||
- The registry does not become the source of truth for full backup diagnostics, restore proof, or overall recoverability.
|
||||
257
specs/186-tenant-registry-recovery-triage/plan.md
Normal file
257
specs/186-tenant-registry-recovery-triage/plan.md
Normal file
@ -0,0 +1,257 @@
|
||||
# Implementation Plan: Tenant Registry Recovery Triage
|
||||
|
||||
**Branch**: `186-tenant-registry-recovery-triage` | **Date**: 2026-04-09 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Turn the existing tenant registry into the operator’s portfolio recovery-triage surface without creating a second recovery-truth system. The implementation will keep `TenantBackupHealthResolver` and `RestoreSafetyResolver` as the only truth sources for tenant backup posture and tenant recovery evidence, project those states onto `TenantResource` as bounded list columns, add exact posture filters and deterministic worst-first ordering, and change workspace backup and recovery multi-tenant drilldowns from `ChooseTenant` to the filtered tenant registry while preserving single-tenant direct-to-dashboard behavior. The slice stays read-only, introduces no schema change, no new persistence, no new resolver truth, no new panel or asset registration, and no broader dashboard or chooser redesign.
|
||||
|
||||
Key approach: work inside the existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, and workspace widget seams; batch-load posture state via `assessMany()` and `dashboardRecoveryEvidenceForTenants()` across the current visible workspace tenant set; preserve intent through exact query-string filter parameters on `/admin/tenants`; keep the Filament list action-surface contract intact; and validate the result with focused Pest, Livewire, truthfulness, and RBAC coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
|
||||
**Storage**: PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned
|
||||
**Testing**: Pest feature tests, Livewire resource-page tests, and focused unit coverage only if a narrow shared presentation or ranking seam is extracted, 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 rooted at `apps/platform`
|
||||
**Performance Goals**: Keep registry rendering DB-only and query-bounded, avoid per-row resolver N+1 behavior, preserve current table pagination and session-persisted filter or sort behavior, and keep the operator’s first-scan triage answer within 5 to 10 seconds
|
||||
**Constraints**: No new persisted portfolio posture table, no new recovery score, no chooser redesign, no tenant-level resolver truth change, no cross-tenant leakage, no new dashboard widgets, no extra registry inspect action, no page-local badge language, and no new Filament assets
|
||||
**Scale/Scope**: One workspace overview builder, one tenant registry resource and page pair, one existing workspace summary or attention destination contract, optional shared badge or copy extension for existing posture states, and focused regression coverage across filters, ordering, drilldowns, truthfulness, and RBAC
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Inventory-first | Pass | The feature reads existing tenant backup and restore evidence only; no new source of truth or snapshot behavior is introduced |
|
||||
| Read/write separation | Pass | This is a read-first list and drilldown hardening slice with no new write path |
|
||||
| Graph contract path | Pass | No Microsoft Graph call or `config/graph_contracts.php` change is required |
|
||||
| Deterministic capabilities | Pass | Existing capability registry and current tenant or workspace scope rules remain authoritative |
|
||||
| RBAC-UX planes and 404 vs 403 | Pass | Workspace surfaces stay under `/admin`, tenant follow-up stays under `/admin/t/{tenant}`, non-members remain `404`, and deeper capability checks remain server-side |
|
||||
| Workspace isolation | Pass | Only current-workspace and in-scope tenants may appear in the registry or drilldowns |
|
||||
| Tenant isolation | Pass | Registry posture is still derived per tenant and filtered to visible memberships only |
|
||||
| Destructive confirmation standard | Pass | No new destructive action is introduced; existing destructive tenant actions remain unchanged and confirmed |
|
||||
| Global search safety | Pass | TenantResource already has view and edit pages; this slice does not broaden global-search scope or semantics |
|
||||
| Run observability | Pass | No new queued work, no new `OperationRun`, and no lifecycle transition is added |
|
||||
| Ops-UX 3-surface feedback | Pass | No run feedback surface is added or changed |
|
||||
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` remain untouched |
|
||||
| Ops-UX summary counts | Pass | No `summary_counts` change is needed |
|
||||
| Data minimization | Pass | Registry posture uses existing derived summaries and does not expose deeper payload detail |
|
||||
| Proportionality (PROP-001) | Pass | Changes stay inside existing list and builder seams; no new persistence, no new page shell, and no new portfolio framework |
|
||||
| No premature abstraction (ABSTR-001) | Pass | The plan prefers local resource or page logic plus existing resolvers; any shared badge or copy extraction stays within already-existing seams |
|
||||
| Persisted truth (PERSIST-001) | Pass | No new table, column, cache, or materialized posture mirror is introduced |
|
||||
| Behavioral state (STATE-001) | Pass | Registry reuses existing posture states only; no new domain state family is added |
|
||||
| UI semantics (UI-SEM-001) | Pass | The registry projects existing domain truth directly; no new explanation or confidence framework is introduced |
|
||||
| Badge semantics (BADGE-001) | Pass | Status-like registry badges must use shared badge or copy semantics rather than ad-hoc local colors or labels |
|
||||
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament table columns, filters, and widgets remain the implementation seams |
|
||||
| UI Action Surface Contract | Pass | `TenantResource` remains a list-first resource with row-click inspect and at most one inline safe shortcut; no redundant View action is added |
|
||||
| Filament UX-001 | Pass with documented variance | No create or edit layout change is involved; this slice refines list scanability, filters, and drillthrough continuity only |
|
||||
| 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 change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
|
||||
| Asset strategy | Pass | No new assets are planned; existing deployment `filament:assets` behavior remains unchanged |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Use workspace-scoped batch posture maps built from `TenantBackupHealthResolver::assessMany()` and `RestoreSafetyResolver::dashboardRecoveryEvidenceForTenants()` instead of per-row resolver calls or a persisted registry summary.
|
||||
- Preserve workspace drilldown intent through exact `/admin/tenants` query parameters and `ListTenants` initialization rather than `ChooseTenant`, hidden session-only state, or a new page shell.
|
||||
- Use multi-select posture filters so workspace backup attention can preselect `absent`, `stale`, and `degraded`, and workspace recovery attention can preselect `weakened` and `unvalidated`, without inventing a pseudo `needs attention` filter value.
|
||||
- Keep the TenantResource action-surface contract intact: full-row click remains inspect, and the existing one-inline-shortcut pattern remains the fast next click.
|
||||
- Reuse or extract one shared mapping seam from `RecoveryReadiness` for label, tone, and fallback-URL semantics so the registry and dashboard cannot drift into parallel local mapping tables for the same posture states.
|
||||
- Extend the current workspace summary-metric tests, workspace continuity tests, tenant registry scope tests, and table-guard coverage rather than introducing a browser-first harness.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/`:
|
||||
|
||||
- `research.md`: implementation and design decisions for posture loading, exact filter intent, and continuity behavior
|
||||
- `data-model.md`: existing persisted inputs plus the derived registry row and triage-intent model
|
||||
- `contracts/tenant-registry-recovery-triage.openapi.yaml`: internal route contract for filtered registry and single-tenant dashboard drilldowns
|
||||
- `quickstart.md`: focused automated and manual validation workflow for registry posture triage
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required. The registry will consume existing tenant truth and derive row-level posture and rank at render time.
|
||||
- `TenantResource` remains workspace-scoped and continues to own the canonical `/admin/tenants` surface. The feature adds columns, filters, and triage ordering, not a new resource or a new shell.
|
||||
- Triage filtering should be exact, not vague. Backup drilldowns preselect `absent`, `stale`, and `degraded`; recovery drilldowns preselect `weakened` and `unvalidated`; both default to worst-first sorting.
|
||||
- Worst-first ordering must operate over the filtered visible tenant set before pagination, using a deterministic tier map and a stable secondary order.
|
||||
- `WorkspaceOverviewBuilder` is the only place where backup and recovery multi-tenant drilldown destinations need to change. Existing workspace widgets already honor `destination_url` and should not need a new interaction model.
|
||||
- The tenant dashboard remains the safe fallback next step whenever deeper backup or restore surfaces would lose the same posture reason or fail permission checks.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/186-tenant-registry-recovery-triage/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── tenant-registry-recovery-triage.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── WorkspaceOverview.php
|
||||
│ │ ├── Resources/
|
||||
│ │ │ ├── TenantResource.php
|
||||
│ │ │ └── TenantResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ListTenants.php
|
||||
│ │ └── Widgets/
|
||||
│ │ ├── Dashboard/
|
||||
│ │ │ └── RecoveryReadiness.php
|
||||
│ │ └── Workspace/
|
||||
│ │ ├── WorkspaceNeedsAttention.php
|
||||
│ │ └── WorkspaceSummaryStats.php
|
||||
│ └── Support/
|
||||
│ ├── BackupHealth/
|
||||
│ │ ├── TenantBackupHealthAssessment.php
|
||||
│ │ └── TenantBackupHealthResolver.php
|
||||
│ ├── RestoreSafety/
|
||||
│ │ └── RestoreSafetyResolver.php
|
||||
│ ├── Workspaces/
|
||||
│ │ └── WorkspaceOverviewBuilder.php
|
||||
│ └── Badges/
|
||||
│ ├── BadgeCatalog.php
|
||||
│ ├── BadgeDomain.php
|
||||
│ └── BadgeRenderer.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ └── Filament/
|
||||
│ ├── TenantResourceIndexIsWorkspaceScopedTest.php
|
||||
│ ├── WorkspaceOverviewAuthorizationTest.php
|
||||
│ ├── WorkspaceOverviewDrilldownContinuityTest.php
|
||||
│ ├── WorkspaceOverviewSummaryMetricsTest.php
|
||||
│ └── TenantRegistryRecoveryTriageTest.php
|
||||
└── Feature/
|
||||
└── Guards/
|
||||
├── ActionSurfaceContractTest.php
|
||||
└── FilamentTableStandardsGuardTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith under `apps/platform`. The implementation stays inside the current tenant registry resource, current workspace overview builder, existing workspace widgets, and existing tests. If a shared presentation seam is required for the new registry badges, it must extend an existing badge or copy surface rather than introducing a new framework.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Build Workspace-Scoped Posture Snapshots For The Registry
|
||||
|
||||
**Goal**: Make backup posture, recovery evidence, filter sets, and triage rank available to the registry without per-row resolver calls or a new persisted summary.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add one request-scoped posture snapshot path over the already scoped tenant set using `assessMany()` and `dashboardRecoveryEvidenceForTenants()`, keyed by tenant ID and reusable by columns, filters, and worst-first ordering |
|
||||
| A.2 | `apps/platform/app/Filament/Resources/TenantResource.php` | Derive exact filter buckets and triage rank buckets from the snapshot map so filtering and ordering operate on the same truth source |
|
||||
| A.3 | `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php` and `apps/platform/app/Filament/Resources/TenantResource.php` | Reuse or extract one shared bounded label or tone mapping seam for `absent`, `stale`, `degraded`, `healthy`, `weakened`, `unvalidated`, and `no recent issues visible` rather than local page-only mappings |
|
||||
|
||||
### Phase B — Turn TenantResource Into A Working Triage Surface
|
||||
|
||||
**Goal**: Add visible posture truth, exact filters, and deterministic weak-first ordering while keeping the current list action surface stable.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add default-visible `Backup posture` and `Recovery evidence` columns near tenant identity and lifecycle, keeping metadata and recovery truth visually separate |
|
||||
| B.2 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add multi-select backup-posture and recovery-evidence filters using exact state values and workspace-scoped `whereIn` filtering over snapshot-derived tenant ID sets |
|
||||
| B.3 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add deterministic worst-first ordering using the triage-rank map and stable secondary ordering by tenant name while preserving current default calm browsing when triage ordering is not requested |
|
||||
| B.4 | `apps/platform/app/Filament/Resources/TenantResource.php` | Keep row click as the canonical inspect model and reuse the existing safe inline shortcut for fast next-step clarity instead of adding a new View action |
|
||||
|
||||
### Phase C — Preserve Drilldown Intent On `/admin/tenants`
|
||||
|
||||
**Goal**: Ensure workspace backup and recovery drilldowns open the registry in a visibly filtered, weak-first state rather than losing cause in `ChooseTenant`.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Add narrow query-string initialization for exact posture filter arrays and `triage_sort=worst_first`, translating them into the page’s table filter or sort state without changing the core page model |
|
||||
| C.2 | `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Preserve existing session-backed list state while allowing explicit query-string intent to override it on first load for workspace drilldowns |
|
||||
| C.3 | `apps/platform/app/Filament/Resources/TenantResource.php` and `ListTenants.php` | Add a bounded empty-state or subheading strategy for zero-match filtered triage views so the registry stays scoped and honest even when no visible tenant matches the current filter |
|
||||
|
||||
### Phase D — Change Workspace Backup And Recovery Multi-Tenant Destinations
|
||||
|
||||
**Goal**: Move multi-tenant backup and recovery attention drilldowns from `ChooseTenant` to the filtered registry while keeping exact single-tenant behavior.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Replace multi-tenant `choose_tenant` destinations for `backup_attention_tenants` and `recovery_attention_tenants` with exact `/admin/tenants` URLs carrying posture filter arrays and `triage_sort=worst_first` |
|
||||
| D.2 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Keep single-tenant backup or recovery drilldowns routed directly to the tenant dashboard when only one visible tenant is affected |
|
||||
| D.3 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` and existing workspace widgets | Keep the existing `destination_url` and destination-kind contract unchanged and do not introduce a new `tenant_registry` kind label for this slice |
|
||||
|
||||
### Phase E — Lock Semantics With Focused Regression Coverage
|
||||
|
||||
**Goal**: Protect list truth, triage ordering, workspace continuity, and scope safety against regression.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` | Add focused coverage for row posture rendering, exact backup filters, exact recovery filters, worst-first ordering, overclaim avoidance, and metadata separation |
|
||||
| E.2 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` | Update the backup-attention multi-tenant expectation from `choose_tenant` to the filtered tenant registry and add equivalent multi-tenant recovery coverage where needed |
|
||||
| E.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` | Extend continuity coverage so backup and recovery attention preserve meaning through the tenant-registry destination as well as single-tenant dashboard destinations |
|
||||
| E.4 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php` and `TenantResourceIndexIsWorkspaceScopedTest.php` | Verify no hidden-tenant leakage across posture filters, triage ordering, or workspace drilldowns |
|
||||
| E.5 | `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `FilamentTableStandardsGuardTest.php` | Keep existing list-surface and table-standards guards passing after the registry column and filter changes |
|
||||
| E.6 | `cd apps/platform && ./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 — Registry posture stays fully derived
|
||||
|
||||
The tenant registry consumes existing backup and recovery truth at render time. There is no new tenant summary table, no cached portfolio score, and no new recovery-confidence persistence.
|
||||
|
||||
### D-002 — Exact states beat vague attention flags
|
||||
|
||||
Workspace drilldowns should preselect exact posture states, not a generic `needs attention` filter. That keeps the registry honest and makes the visible filter state itself the explanation.
|
||||
|
||||
### D-003 — Weak-first ordering must happen before pagination
|
||||
|
||||
If ranking happens only on the current page, the worst tenant may remain hidden on another page. The rank map therefore needs to shape the filtered query order over the full scoped tenant set before pagination.
|
||||
|
||||
### D-004 — Existing list interaction model remains intact
|
||||
|
||||
`TenantResource` already satisfies the list-first inspect model. The feature must not turn it into a multi-action dashboard or add a redundant View affordance.
|
||||
|
||||
### D-005 — Recovery-readiness semantics should not fork
|
||||
|
||||
The codebase already has one place that renders backup posture and recovery evidence together: `RecoveryReadiness`. The registry should call the same shared bounded mapping seam, either by reusing existing methods or by extracting a narrow helper into the same seam, rather than creating another local mapping language inside `TenantResource`.
|
||||
|
||||
### D-006 — Workspace widgets keep the existing destination contract
|
||||
|
||||
The workspace widgets already consume `destination_url` correctly. This slice changes those URLs for multi-tenant backup and recovery drilldowns, but it does not add a new destination-kind value or require a widget interaction redesign.
|
||||
|
||||
### D-007 — Workspace overview only changes destination logic
|
||||
|
||||
The workspace overview already computes backup and recovery attention correctly. This slice changes where multi-tenant clicks land, not what those metrics mean.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Registry filtering or ranking is computed only for the current page and hides weaker tenants on later pages | High | Medium | Build filter buckets and triage rank across the full scoped tenant set before pagination and cover with ordering tests |
|
||||
| Registry adds new local badge or tone mappings that drift from existing dashboard posture semantics | High | Medium | Reuse existing shared badge or copy seams and add truthfulness coverage for labels and tones |
|
||||
| Multi-tenant workspace drilldowns still lose cause because filter intent is not visibly applied on first load | High | Medium | Initialize ListTenants from exact query parameters and cover continuity through summary-metric and drilldown tests |
|
||||
| Metadata such as lifecycle status or last sync visually reads like recovery truth after the new columns are added | Medium | Medium | Place backup posture and recovery evidence explicitly, keep them separately labeled, and add no-metadata-substitution coverage |
|
||||
| Batch posture loading over the scoped tenant set becomes too expensive in larger workspaces | Medium | Medium | Reuse existing batch resolvers, keep query shape bounded to the current scoped tenant set, and cover the feature with query-bounded regression expectations |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Add a focused tenant-registry triage feature test as the primary acceptance harness for row rendering, filters, worst-first ordering, and truthfulness.
|
||||
- Add explicit query-bounded regression coverage so registry posture loading does not degrade into uncontrolled per-row resolver fanout.
|
||||
- Extend current workspace summary-metric and drilldown continuity tests so multi-tenant backup and recovery destinations move from `ChooseTenant` to the filtered registry without regressing the single-tenant dashboard path.
|
||||
- Extend workspace authorization and tenant registry scope coverage so posture filters and registry drilldowns remain bounded to visible tenants only.
|
||||
- Keep existing action-surface and table-standards guards green so the tenant registry stays compliant with the Filament list contract after the new columns and filters are added.
|
||||
- Prefer Livewire or feature-level verification over browser-first testing because the codebase already has direct coverage seams for `ListTenants`, `WorkspaceOverviewBuilder`, and related widgets.
|
||||
- Run all focused tests through Sail and finish with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
86
specs/186-tenant-registry-recovery-triage/quickstart.md
Normal file
86
specs/186-tenant-registry-recovery-triage/quickstart.md
Normal file
@ -0,0 +1,86 @@
|
||||
# Quickstart: Tenant Registry Recovery Triage
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate that the tenant registry becomes a useful recovery-triage surface and that workspace backup or recovery drilldowns preserve their meaning.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the local stack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Work on branch `186-tenant-registry-recovery-triage`.
|
||||
|
||||
3. Use an owner or readonly workspace member who can see multiple tenants in the same workspace.
|
||||
|
||||
## Primary Automated Verification
|
||||
|
||||
Run the focused test set that covers the registry, workspace drilldowns, and scope safety:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||
```
|
||||
|
||||
Expected outcomes:
|
||||
|
||||
- Tenant registry rows show separate `Backup posture` and `Recovery evidence` signals.
|
||||
- Exact backup and recovery filters return only matching visible tenants.
|
||||
- Worst-first triage ordering places weak tenants ahead of calm rows.
|
||||
- Query-bounded registry posture loading does not regress into uncontrolled per-row resolver fanout.
|
||||
- Multi-tenant workspace backup and recovery metrics open the filtered tenant registry instead of `ChooseTenant`.
|
||||
- Single-tenant workspace drilldowns still open the tenant dashboard.
|
||||
- No out-of-scope tenant leaks into registry rows, filters, or destinations.
|
||||
|
||||
## Manual Smoke Validation
|
||||
|
||||
Use a workspace that already contains a mixed visible tenant set with at least these cases:
|
||||
|
||||
- one tenant with `absent` backup posture
|
||||
- one tenant with `stale` backup posture
|
||||
- one tenant with `degraded` backup posture
|
||||
- one tenant with `weakened` recovery evidence
|
||||
- one tenant with `unvalidated` recovery evidence
|
||||
- one calm tenant with `healthy` backup posture and `no recent issues visible`
|
||||
|
||||
Then verify:
|
||||
|
||||
1. Open `/admin` and confirm `Backup attention` and `Recovery attention` counts still appear separately.
|
||||
2. If `Backup attention > 1`, click the metric and confirm the destination is `/admin/tenants` with backup posture filter intent preserved and weak tenants first.
|
||||
3. If `Recovery attention > 1`, click the metric and confirm the destination is `/admin/tenants` with recovery-evidence filter intent preserved and weak tenants first.
|
||||
4. Open `/admin/tenants` directly and confirm the registry shows separate `Backup posture` and `Recovery evidence` columns next to metadata and lifecycle fields.
|
||||
5. Apply a backup filter such as `degraded` and confirm only degraded visible tenants remain.
|
||||
6. Apply a recovery filter such as `unvalidated` and confirm only unvalidated visible tenants remain.
|
||||
7. Confirm calm rows do not erase weak rows when worst-first ordering is active.
|
||||
8. Open a prioritized row and confirm the registry detail route preserves the same bounded weakness truth shown in the list.
|
||||
9. Use the safe dashboard shortcut for that prioritized tenant and confirm the tenant dashboard still shows the same bounded weakness truth.
|
||||
10. Confirm the registry never says `recoverable`, `recovery proven`, or `validated overall`.
|
||||
11. Time the first `/admin/tenants` triage scan and confirm a workspace operator can identify which visible tenants are weak on backup posture, which are weak on recovery evidence, and which tenant to open first within 10 seconds.
|
||||
|
||||
## Optional Fixture Note
|
||||
|
||||
The existing fixture command below is useful for backup-health smoke work, but it does not seed the full multi-tenant mixed posture matrix required for Spec 186 by itself:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan tenantpilot:backup-health:seed-browser-fixture --force-refresh
|
||||
```
|
||||
|
||||
For Spec 186, the focused Pest tests remain the primary acceptance harness.
|
||||
|
||||
## Formatting
|
||||
|
||||
Before handing off implementation, run:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## No Migration Expected
|
||||
|
||||
This feature should complete with no schema migration and no new persisted posture summary.
|
||||
52
specs/186-tenant-registry-recovery-triage/research.md
Normal file
52
specs/186-tenant-registry-recovery-triage/research.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Research: Tenant Registry Recovery Triage
|
||||
|
||||
## Decision 1: Use workspace-scoped batch posture maps from existing resolvers
|
||||
|
||||
- **Decision**: Build tenant-registry posture data from `TenantBackupHealthResolver::assessMany()` and `RestoreSafetyResolver::dashboardRecoveryEvidenceForTenants()` over the already scoped visible tenant set instead of resolving posture per row.
|
||||
- **Rationale**: Both resolvers already provide the exact truth the registry needs and are already used together in `WorkspaceOverviewBuilder`. Reusing them keeps backup posture and recovery evidence aligned with existing workspace and tenant surfaces and avoids N+1 list rendering.
|
||||
- **Alternatives considered**:
|
||||
- Per-row resolver calls inside table column closures: rejected because it creates predictable N+1 behavior and duplicates work between columns, filters, and sorting.
|
||||
- Reimplement posture logic in SQL joins or subqueries: rejected because it duplicates domain truth outside the existing resolver path and risks semantic drift.
|
||||
- Persist a tenant posture summary table: rejected because the spec explicitly forbids a second persisted truth.
|
||||
|
||||
## Decision 2: Preserve multi-tenant drilldown intent through exact query parameters on `/admin/tenants`
|
||||
|
||||
- **Decision**: Change workspace backup and recovery multi-tenant destinations to `/admin/tenants` with query parameters that encode exact posture selections and `triage_sort=worst_first`.
|
||||
- **Rationale**: The codebase already uses query-string intent on list and page surfaces, and this approach preserves meaning directly in the URL without adding a new page, hidden state, or session-only transfer mechanism.
|
||||
- **Alternatives considered**:
|
||||
- Keep `ChooseTenant` for multi-tenant drilldowns: rejected because it drops the recovery or backup cause and sends the operator back to manual tenant-by-tenant inspection.
|
||||
- Use only session state and no URL signal: rejected because it makes drilldown state invisible and harder to test.
|
||||
- Add a dedicated portfolio recovery page: rejected because it introduces a larger IA change than the current release needs.
|
||||
|
||||
## Decision 3: Use multi-select posture filters with exact state values
|
||||
|
||||
- **Decision**: Represent triage filters as exact state arrays rather than introducing `needs attention` or other pseudo-filter values.
|
||||
- **Rationale**: The spec explicitly requires clear posture-based filtering and rejects semantically vague attention filters. Multi-select filters allow workspace backup drilldowns to preselect `absent`, `stale`, and `degraded`, and recovery drilldowns to preselect `weakened` and `unvalidated`, while keeping the filter chips themselves truthful.
|
||||
- **Alternatives considered**:
|
||||
- Single-select filters plus an `attention` option: rejected because it hides the underlying state composition.
|
||||
- Custom tabs or page variants per posture family: rejected because they expand the surface model unnecessarily.
|
||||
- Hard-coded workspace-only registry variants: rejected because the registry should stay one canonical collection route.
|
||||
|
||||
## Decision 4: Keep the TenantResource list action surface contract intact
|
||||
|
||||
- **Decision**: Preserve full-row click as the one primary inspect model and keep at most one inline safe shortcut for fast next-step navigation.
|
||||
- **Rationale**: `TenantResource` is already governed by the current action-surface contract and guard tests. The triage enhancement should be expressed through visible posture truth, filters, and ordering rather than additional row actions.
|
||||
- **Alternatives considered**:
|
||||
- Add a dedicated View action: rejected because it duplicates the existing row-click inspect model.
|
||||
- Add multiple inline shortcuts to backup sets, restore runs, and dashboard: rejected because it would turn the registry into a crowded multi-action surface and violate the list hierarchy rules.
|
||||
|
||||
## Decision 5: Reuse existing recovery-readiness presentation semantics instead of inventing registry-local posture language
|
||||
|
||||
- **Decision**: Reuse or absorb the label, tone, and fallback-navigation semantics already present in `RecoveryReadiness` for `Backup posture` and `Recovery evidence` when the registry renders the same states.
|
||||
- **Rationale**: The codebase already has one operator-facing composition that normalizes these exact states. Reusing that semantics avoids drift between dashboard and registry surfaces and keeps any new status-like rendering inside existing shared seams.
|
||||
- **Alternatives considered**:
|
||||
- Local `match` statements inside `TenantResource`: rejected because they would create another page-local status language.
|
||||
- A new presentation framework or registry-specific presenter: rejected because there are only two concrete surfaces and the constitution prefers small extensions of existing seams over new frameworks.
|
||||
|
||||
## Decision 6: Use existing feature and guard tests as the primary acceptance harness
|
||||
|
||||
- **Decision**: Extend current workspace overview and tenant resource tests and add one focused registry triage test file instead of introducing a browser-first harness.
|
||||
- **Rationale**: The repo already has precise seams for workspace summary metrics, drilldown continuity, tenant registry scope, and Filament table standards. Those seams are sufficient to prove truthfulness, scope safety, and ordering.
|
||||
- **Alternatives considered**:
|
||||
- New browser tests: rejected because the relevant behaviors are already observable at the builder and Livewire list layers.
|
||||
- Manual-only QA: rejected because the spec explicitly requires regression-safe automated coverage.
|
||||
260
specs/186-tenant-registry-recovery-triage/spec.md
Normal file
260
specs/186-tenant-registry-recovery-triage/spec.md
Normal file
@ -0,0 +1,260 @@
|
||||
# Feature Specification: Tenant Registry Recovery Triage
|
||||
|
||||
**Feature Branch**: `186-tenant-registry-recovery-triage`
|
||||
**Created**: 2026-04-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 186 — Tenant Registry Recovery Triage"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin` as the workspace overview that currently surfaces backup and recovery attention counts and needs a more useful portfolio drill-through
|
||||
- `/admin/tenants` as the canonical tenant registry collection route that becomes the working recovery-triage surface for visible tenants
|
||||
- `/admin/tenants/{tenant}` as the existing tenant-registry inspect route opened by full-row click from the registry
|
||||
- `/admin/choose-tenant` only as the displaced multi-tenant fallback for backup and recovery attention drilldowns; this feature removes that fallback for those posture-specific cases
|
||||
- `/admin/t/{tenant}` as the canonical tenant-dashboard follow-up opened by the existing safe shortcut when one affected tenant is in scope or when deeper tenant surfaces are not the safest truthful destination
|
||||
- `/admin/t/{tenant}/backup-sets` and `/admin/t/{tenant}/restore-runs` only when an existing tenant-scoped destination preserves the same posture reason without overclaiming or breaking permissions
|
||||
- **Data Ownership**:
|
||||
- Tenant identity, lifecycle, and directory metadata remain authoritative on the tenant registry row and continue to belong to the existing tenant record
|
||||
- Tenant backup posture remains derived from the existing tenant-level backup-health source of truth built from current backup and schedule records; this feature adds no new persisted portfolio posture table or score
|
||||
- Tenant recovery evidence remains derived from the existing tenant-level recovery-evidence source of truth built from restore history; this feature adds no new persisted recovery registry summary or portfolio confidence artifact
|
||||
- Registry filters, sorting, and workspace drilldowns remain derived views over visible tenant truth only; this feature does not create a second domain model for backup or recovery posture
|
||||
- **RBAC**:
|
||||
- Workspace membership remains required to render `/admin` and `/admin/tenants`
|
||||
- Only tenants visible inside the current workspace and tenant-membership scope may appear in the registry, contribute to posture filters, or appear behind backup and recovery drilldowns
|
||||
- The tenant dashboard and any deeper backup or restore surfaces continue to enforce existing tenant-scoped read and mutation permissions; registry visibility does not upgrade access
|
||||
- Non-members remain deny-as-not-found, and any deeper destination that is not available must fall back to a safe allowed tenant surface rather than a broken or misleading 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 registry list | CRUD / list-first resource | Full-row click remains the canonical inspect path for tenant registry detail, while one safe inline shortcut continues the triage flow into the tenant dashboard | required | One inline safe shortcut plus existing More menu | Existing More and bulk More placement remain unchanged | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace plus visible-tenant scope stay explicit | Tenants / Tenant | Backup posture and recovery evidence are visible beside lifecycle metadata | Working-surface registry augmentation |
|
||||
| Workspace overview backup and recovery drilldowns | Embedded status summary / drill-in surface | Explicit stat or attention click opens a filtered tenant registry for multi-tenant cases or the tenant dashboard for a single-tenant case | forbidden | none | none | `/admin` | `/admin/tenants` or `/admin/t/{tenant}` depending on affected-tenant count | Active workspace plus visible-tenant scope stay explicit before navigation | Backup attention / Recovery attention | Which visible tenants need backup or recovery follow-up and whether the cause is preserved through the next click | Mixed-count drilldown surface |
|
||||
| Tenant dashboard follow-up landing | Tenant landing / detail-first follow-up | Direct page open from a registry shortcut or single-tenant workspace drilldown | forbidden | Existing dashboard actions only | none added by this feature | `/admin/t/{tenant}` | `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/restore-runs` remain secondary destinations | Workspace context plus tenant context remain explicit | Tenant dashboard | Tenant-level backup health and recovery evidence confirm why the registry prioritized the tenant | Existing tenant dashboard pattern |
|
||||
|
||||
## 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 registry list | Workspace operator | List-first working surface | Which visible tenants are weak on backups or recovery evidence, and which one should I open first? | Tenant identity, environment, lifecycle, backup posture, recovery evidence, and visible triage filter or sort state | Full backup-quality diagnostics, restore-run outcome detail, and deep explanations remain downstream | lifecycle, backup posture, recovery evidence | none added by this feature; existing tenant maintenance actions stay secondary | Filter by posture, sort worst-first, inspect the registry detail route by row click, use the tenant-dashboard shortcut when needed | Existing sync, archive, or other destructive or operational actions remain unchanged and secondary |
|
||||
| Workspace overview backup and recovery drilldowns | Workspace operator | Embedded summary / drill-in | Which registry slice should I open from this count, and will it preserve why I clicked? | Backup-attention or recovery-attention count plus one bounded destination | Tenant-by-tenant diagnostics remain secondary to the drill-through | backup attention volume, recovery attention volume | none | Open filtered tenant registry or direct tenant dashboard | none |
|
||||
| Tenant dashboard follow-up landing | Workspace operator after drill-through | Tenant landing page | Why was this tenant prioritized, and what deeper surface should I open next? | Tenant-level backup health, recovery evidence, and the same bounded posture context that triggered the registry ranking | Full backup-set and restore-run diagnostics remain deeper tenant surfaces | backup posture, recovery evidence, tenant-local follow-up | none added by this feature | Continue to backup sets or restore runs when needed | none added by this feature |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: No. Tenant backup-health truth and tenant recovery-evidence truth remain authoritative.
|
||||
- **New persisted entity/table/artifact?**: No. The feature explicitly forbids a new persisted portfolio posture table, score, or confidence ledger.
|
||||
- **New abstraction?**: No. The narrow implementation is to extend the existing tenant registry and workspace drill-through behavior with derived posture columns, filters, and ordering.
|
||||
- **New enum/state/reason family?**: No. The feature reuses the existing tenant backup posture and recovery-evidence states and only defines ordering over them in registry context.
|
||||
- **New cross-domain UI framework/taxonomy?**: No. The slice keeps the existing backup and recovery vocabulary and avoids a new confidence or triage framework.
|
||||
- **Current operator problem**: Workspace operators can already see that some tenants are weak on backup health or recovery evidence, but they still cannot answer from one working surface which tenants are absent, stale, degraded, weakened, or unvalidated, nor which tenant should be opened first.
|
||||
- **Existing structure is insufficient because**: The current tenant registry is still metadata-first and the current workspace backup and recovery drilldowns lose cause by sending multi-tenant cases to a generic tenant chooser instead of a filtered registry slice.
|
||||
- **Narrowest correct implementation**: Reuse the existing tenant registry as the canonical portfolio triage surface, add posture columns and posture filters backed by existing truth, add a consistent worst-first ordering, and redirect backup and recovery drilldowns into that filtered registry without introducing new persistence or a broader portfolio matrix.
|
||||
- **Ownership cost**: The repo takes on additional registry rendering logic, posture filter and ordering rules, drill-through preservation rules, query-bounded loading, and regression coverage for truthfulness, RBAC, and list usability.
|
||||
- **Alternative intentionally rejected**: A new portfolio recovery dashboard, a persisted posture summary table, a new global score, or a redesigned tenant chooser were rejected because they add a second truth or a larger IA shift before the existing registry surface is used correctly.
|
||||
- **Release truth**: Current-release workflow hardening. The domain truth already exists; this slice makes the portfolio workflow usable.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See Weak Tenants On One Registry Surface (Priority: P1)
|
||||
|
||||
As a workspace operator, I want the tenant registry to show backup posture and recovery evidence directly on each visible row so that I can identify weak tenants within seconds without opening every tenant dashboard.
|
||||
|
||||
**Why this priority**: This is the core workflow gap. Without visible posture on the registry itself, the product still forces tenant-by-tenant inspection.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding visible tenants with mixed backup and recovery states, opening `/admin/tenants`, and verifying that each row shows bounded backup posture and recovery evidence without turning metadata into recovery truth.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** visible tenants include `absent`, `stale`, `degraded`, and `healthy` backup posture cases, **When** the operator opens `/admin/tenants`, **Then** each row shows a scan-friendly backup-posture signal.
|
||||
2. **Given** visible tenants include `weakened`, `unvalidated`, and `no_recent_issues_visible` recovery evidence cases, **When** the operator opens `/admin/tenants`, **Then** each row shows a separate recovery-evidence signal.
|
||||
3. **Given** a tenant has healthy backups and `no_recent_issues_visible` recovery evidence, **When** the row renders, **Then** the registry does not claim that the tenant is recovery proven, fully recoverable, or healthy overall.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Filter And Rank The Weakest Tenants First (Priority: P1)
|
||||
|
||||
As a workspace operator, I want posture filters and a worst-first ordering so that I can reduce the registry to the exact weak slice I need and start with the highest-priority tenants.
|
||||
|
||||
**Why this priority**: Visibility alone is not enough. The registry must behave like a triage surface, not just a directory.
|
||||
|
||||
**Independent Test**: Can be fully tested by applying backup and recovery filters and by invoking posture-oriented ordering in a mixed visible tenant set.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** visible tenants have mixed backup posture, **When** the operator filters for `degraded`, **Then** only visible degraded tenants remain in the registry.
|
||||
2. **Given** visible tenants have mixed recovery evidence, **When** the operator filters for `unvalidated`, **Then** only visible unvalidated tenants remain in the registry.
|
||||
3. **Given** the registry contains calm and weak tenants, **When** the operator uses worst-first triage ordering, **Then** weak tenants appear before calm tenants in a consistent, testable order.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Workspace Meaning During Drilldown (Priority: P1)
|
||||
|
||||
As a workspace operator, I want backup and recovery attention counts to open a filtered tenant registry instead of a generic tenant chooser so that the reason I clicked remains visible after navigation.
|
||||
|
||||
**Why this priority**: The workspace overview is already honest about counts, but it still drops the operator into a surface that does not preserve the cause.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening backup and recovery attention drilldowns for single-tenant and multi-tenant cases and verifying that the destination keeps the same meaning.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** more than one visible tenant contributes to backup attention, **When** the operator opens that drilldown, **Then** the destination is `/admin/tenants` with backup-posture intent already preserved.
|
||||
2. **Given** more than one visible tenant contributes to recovery attention, **When** the operator opens that drilldown, **Then** the destination is `/admin/tenants` with recovery-evidence intent already preserved.
|
||||
3. **Given** exactly one visible tenant contributes to a backup or recovery attention drilldown, **When** the operator opens it, **Then** the destination may go directly to `/admin/t/{tenant}` instead of the registry.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Keep Scope And Truth Boundaries Honest (Priority: P3)
|
||||
|
||||
As a workspace operator with partial tenant visibility, I want the registry and its drilldowns to remain truthful without leaking hidden tenants or overstating what deeper surfaces prove.
|
||||
|
||||
**Why this priority**: Portfolio aggregation is only acceptable if it stays bounded by workspace and tenant scope and does not turn partial evidence into stronger claims.
|
||||
|
||||
**Independent Test**: Can be fully tested by mixing visible and hidden tenants with posture issues and verifying that only visible tenants appear in the registry, filters, and drilldown results.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** hidden tenants have backup or recovery issues, **When** the operator uses registry filters or workspace drilldowns, **Then** hidden tenants do not appear in rows, counts, or labels.
|
||||
2. **Given** a user can see registry posture but cannot open a deeper backup or restore surface, **When** the user continues triage from the registry, **Then** the UI offers an allowed fallback such as the tenant dashboard instead of a broken link.
|
||||
3. **Given** a visible tenant has stale `last sync` metadata but healthy backup posture, **When** the registry renders, **Then** lifecycle and metadata signals remain distinct from backup posture and do not override it.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A visible tenant may have healthy backups but `unvalidated` recovery evidence; the registry must keep that tenant discoverable through the recovery filter instead of letting healthy backup posture hide the weaker recovery truth.
|
||||
- A visible tenant may have both backup attention and recovery-evidence weakness at the same time; the registry must show both domains independently while triage ordering uses one consistent highest-priority tier.
|
||||
- A filtered registry view may return zero visible rows because the active filter no longer matches any in-scope tenant; the empty state must remain bounded to the visible workspace slice and must not imply that hidden tenants are calm.
|
||||
- A tenant may be archived, inactive, or otherwise operationally unusual while still having calm backup and recovery posture; lifecycle presentation must not be used as a substitute for recovery triage.
|
||||
- A user may be able to see posture truth on the registry but lack permission for deeper backup or restore surfaces; triage must still have one safe next click.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
This feature introduces no new Microsoft Graph calls, no new queued or scheduled work, no new `OperationRun`, and no new persistence. It is a read-first workspace triage slice that reuses the existing tenant backup-health and recovery-evidence truth inside the tenant registry and workspace drilldown flow.
|
||||
|
||||
Authorization spans the workspace/admin plane at `/admin` and `/admin/tenants`, with read-only drill-through into the tenant plane at `/admin/t/{tenant}` and, only when already allowed, deeper backup or restore surfaces. Non-members remain `404`. Established members continue to rely on existing server-side capability checks for deeper destinations. Registry visibility and filter state must never broaden tenant access.
|
||||
|
||||
The feature changes status-like list signals, so centralized badge semantics remain authoritative. Backup posture and recovery evidence may be rendered on the registry with shared status primitives or equivalent central mappings, but the registry must not introduce page-local status language or new claim families. Labels must stay bounded to `Backup posture`, `Recovery evidence`, `absent`, `stale`, `degraded`, `healthy`, `weakened`, `unvalidated`, and `no recent issues visible`, or closely equivalent wording that carries the same limits.
|
||||
|
||||
The affected Filament surfaces keep one primary inspect or open model. The tenant registry remains the canonical collection route. Full-row click remains the canonical inspect affordance for the registry, while one safe inline dashboard shortcut remains the fast triage continuation. No redundant view action, empty action group, or destructive placement change is introduced. The workspace overview remains a read-only drill-in surface. UX-001 create and edit form rules are unchanged because this slice only refines list and drilldown behavior.
|
||||
|
||||
The feature must not create a second recovery-confidence truth. It may only derive registry ordering and filter behavior from existing tenant backup-health and recovery-evidence states. Tests must focus on business consequences such as wrong rows, wrong filters, wrong ordering, lost drilldown meaning, hidden-tenant leakage, metadata substitution, and overclaiming language.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-186-001**: The system MUST add a visible backup-posture signal to each tenant-registry row and MUST derive that signal from the existing tenant-level backup-health source of truth.
|
||||
- **FR-186-002**: The registry MUST recognize at least `absent`, `stale`, `degraded`, and `healthy` as backup-posture states without redefining how those states are determined.
|
||||
- **FR-186-003**: Backup posture on the registry MUST remain a bounded summary signal and MUST NOT claim or imply restore success.
|
||||
- **FR-186-004**: The system MUST add a visible recovery-evidence signal to each tenant-registry row and MUST derive that signal from the existing tenant-level recovery-evidence source of truth.
|
||||
- **FR-186-005**: The registry MUST recognize at least `weakened`, `unvalidated`, and `no_recent_issues_visible` as recovery-evidence states without redefining how those states are determined.
|
||||
- **FR-186-006**: Recovery evidence on the registry MUST remain a bounded summary signal and MUST NOT claim or imply that recovery is proven, guaranteed, or fully validated.
|
||||
- **FR-186-007**: The tenant registry MUST keep tenant identity and lifecycle metadata visually and semantically separate from backup posture and recovery evidence.
|
||||
- **FR-186-008**: The tenant registry MUST provide a backup-posture filter with at least `absent`, `stale`, `degraded`, and `healthy` as selectable states.
|
||||
- **FR-186-009**: The tenant registry MUST provide a recovery-evidence filter with at least `weakened`, `unvalidated`, and `no_recent_issues_visible` as selectable states.
|
||||
- **FR-186-010**: Registry posture filters MUST act only on visible rows inside the current workspace and tenant scope and MUST NOT reveal out-of-scope tenants.
|
||||
- **FR-186-011**: The tenant registry MUST provide a consistent worst-first triage ordering for mixed posture rows.
|
||||
- **FR-186-012**: Combined triage ordering MUST rank tenants by the highest active weakness tier using this order: backup `absent`, recovery `weakened`, backup `stale`, recovery `unvalidated`, backup `degraded`, then calm rows.
|
||||
- **FR-186-013**: When multiple tenants share the same weakness tier, the registry MUST break ties by tenant name in ascending order.
|
||||
- **FR-186-014**: A multi-tenant workspace backup-attention drilldown MUST land on `/admin/tenants` with backup-posture intent already preserved.
|
||||
- **FR-186-015**: A multi-tenant workspace recovery-attention drilldown MUST land on `/admin/tenants` with recovery-evidence intent already preserved.
|
||||
- **FR-186-016**: A single-tenant backup-attention or recovery-attention drilldown MAY continue directly to `/admin/t/{tenant}` when only one visible tenant is affected.
|
||||
- **FR-186-017**: Drilldown intent MUST remain recoverable at the destination through visible filter state, visible ordering, or equivalent default-visible context.
|
||||
- **FR-186-018**: Each posture-relevant tenant-registry row MUST expose at least one fast, permission-safe next step, with the tenant dashboard as the canonical fallback destination.
|
||||
- **FR-186-019**: Deeper backup-set or restore-run destinations MAY be used only when they preserve the same posture reason and remain permission-safe for the current user.
|
||||
- **FR-186-020**: The registry MUST NOT substitute lifecycle status, last sync, policy count, or general tenant status for backup posture or recovery evidence.
|
||||
- **FR-186-021**: Registry rendering MUST remain query-bounded and MUST avoid uncontrolled per-row resolver fanout when posture data is loaded for a visible page of tenants.
|
||||
- **FR-186-022**: Only visible tenants inside the current workspace and tenant scope may contribute to registry posture, posture filters, or workspace drilldown targets.
|
||||
- **FR-186-023**: When a user can see registry posture but cannot open a deeper backup or restore destination, the registry MUST remain truthful and MUST fall back to an allowed tenant surface instead of offering a broken or misleading path.
|
||||
- **FR-186-024**: Registry wording MUST NOT claim `recoverable`, `recovery proven`, `validated overall`, or any equivalent stronger statement that exceeds the underlying backup and recovery evidence.
|
||||
- **FR-186-025**: The feature MUST ship without a schema migration, without a new persisted portfolio posture summary, without a new global recovery score, and without changing tenant-level resolver truth.
|
||||
- **FR-186-026**: Regression coverage MUST prove posture-column rendering, backup filters, recovery filters, worst-first ordering, workspace backup drilldown, workspace recovery drilldown, single-tenant drilldown fallback, RBAC-safe visibility, no metadata substitution, and no overclaiming labels.
|
||||
|
||||
## Derived Registry Semantics
|
||||
|
||||
- **Visible backup posture**: The registry consumes the current tenant backup-health posture exactly as it already exists at tenant level. This slice does not redefine backup-health truth.
|
||||
- **Visible recovery evidence**: The registry consumes the current tenant recovery-evidence overview state exactly as it already exists at tenant level. This slice does not redefine recovery-evidence truth.
|
||||
- **Registry backup attention set**: The set of visible tenants whose backup posture is `absent`, `stale`, or `degraded`.
|
||||
- **Registry recovery attention set**: The set of visible tenants whose recovery evidence is `weakened` or `unvalidated`.
|
||||
- **Registry calm row**: A visible tenant row whose backup posture is `healthy` and whose recovery evidence is `no_recent_issues_visible`. A calm row is not a proof of recoverability.
|
||||
- **Registry drilldown intent**: The preserved explanation of why the operator opened the registry, expressed through visible backup or recovery filter state, visible worst-first ordering, or an equivalent bounded context indicator.
|
||||
|
||||
## Triage Priority Rules
|
||||
|
||||
- **Backup-only order**: `absent` before `stale` before `degraded` before `healthy`.
|
||||
- **Recovery-only order**: `weakened` before `unvalidated` before `no_recent_issues_visible`.
|
||||
- **Combined registry order**: backup `absent`, recovery `weakened`, backup `stale`, recovery `unvalidated`, backup `degraded`, then calm rows.
|
||||
- **Dual-signal rule**: If one tenant is weak in both posture domains, the highest active tier governs ordering while both posture signals remain visible.
|
||||
- **Secondary order**: Ties inside one tier resolve by tenant name ascending.
|
||||
- **Bounded-claim rule**: Even the calm tier may say only that no recent issues are visible; it may not say that recovery is proven.
|
||||
|
||||
## 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 registry list | Tenant registry list surface | Existing tenant-entry header action remains unchanged | Full-row click remains the canonical registry inspect affordance and opens the existing registry detail route | Existing inline dashboard shortcut remains the fast triage continuation into the tenant dashboard; existing conditional onboarding shortcut stays conditional | Existing grouped tenant maintenance actions remain unchanged | Existing tenant-entry empty-state CTA remains unchanged | Existing tenant detail header actions remain unchanged | Existing edit flow remains unchanged | no new audit behavior | This feature changes default-visible posture truth, filters, and ordering only. The action-surface contract remains satisfied with one primary inspect model and one safe dashboard follow-up shortcut, with no new destructive placement. |
|
||||
| Workspace overview backup and recovery drilldowns | Workspace overview summary and attention surface | none | Explicit stat or attention click only | none | none | Existing workspace empty-state behavior remains bounded to visible scope | n/a | n/a | no new audit behavior | Multi-tenant backup and recovery drilldowns now open the filtered tenant registry. Single-tenant drilldowns may still open the tenant dashboard directly. |
|
||||
| Tenant dashboard fallback landing | Tenant dashboard follow-up landing | Existing dashboard actions remain unchanged | Direct page open only | none added by this feature | none | Existing dashboard empty states remain unchanged | n/a | n/a | no new audit behavior | The tenant dashboard is the canonical safe fallback when deeper backup or restore surfaces would lose the same posture reason or violate permissions. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant registry row**: The visible portfolio record that combines tenant identity and lifecycle metadata with bounded backup posture and bounded recovery evidence.
|
||||
- **Backup posture summary**: The existing tenant-level backup-health assessment rendered in a scan-friendly registry form.
|
||||
- **Recovery evidence summary**: The existing tenant-level recovery-evidence overview rendered in a scan-friendly registry form.
|
||||
- **Registry drilldown intent**: The preserved explanation of why the registry was opened, including the active posture filter and worst-first ordering context.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-186-001**: In acceptance review, a workspace operator can identify within 10 seconds which visible tenants are weak on backup posture, which visible tenants are weak on recovery evidence, and which tenant should be opened first from `/admin/tenants`.
|
||||
- **SC-186-002**: In 100% of covered regression scenarios, backup posture and recovery evidence render as separate signals on tenant-registry rows and never collapse into one ambiguous recovery score.
|
||||
- **SC-186-003**: In 100% of covered filter scenarios, backup and recovery filters return only visible in-scope tenants matching the selected posture state.
|
||||
- **SC-186-004**: In 100% of covered ordering scenarios, worst-first triage ordering places every weak visible tenant ahead of calm rows according to the defined priority rules.
|
||||
- **SC-186-005**: In 100% of covered workspace drilldown scenarios, multi-tenant backup and recovery drilldowns preserve cause by landing on the filtered registry, while single-tenant drilldowns preserve the direct tenant-dashboard shortcut.
|
||||
- **SC-186-006**: In 100% of covered truthfulness scenarios, the registry never uses labels that imply recovery proof, guaranteed recoverability, or an overall validated state.
|
||||
- **SC-186-007**: In targeted query-bounded regression coverage, representative mixed-tenant registry rendering does not degrade into uncontrolled per-row resolver fanout when posture signals are enabled.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing tenant-level backup-health truth and tenant-level recovery-evidence truth are already stable enough to be reused on the registry without redefining either domain.
|
||||
- The tenant dashboard remains the correct canonical fallback destination when a more specific backup or restore surface would not preserve the same posture reason or would not be permitted.
|
||||
- The current tenant-registry inspect and maintenance flows remain in place; this slice hardens triage usefulness rather than redesigning the whole resource.
|
||||
- The workspace overview keeps its existing backup-attention and recovery-attention meaning; this slice changes the destination for multi-tenant drilldowns, not the existence of those metrics.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing workspace overview counts and attention drilldowns on `/admin`
|
||||
- Existing tenant registry on `/admin/tenants`
|
||||
- Existing tenant dashboard on `/admin/t/{tenant}`
|
||||
- Existing tenant-level backup-health truth and tenant-level recovery-evidence truth
|
||||
- Existing workspace and tenant RBAC boundaries and safe destination fallback patterns
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning `Choose tenant` as a posture-triage surface
|
||||
- Redesigning the workspace overview itself beyond changing backup and recovery drilldown targets
|
||||
- Redesigning tenant detail pages or introducing a new tenant-registry visual language outside the recovery-triage goal
|
||||
- Changing tenant-level resolver logic for backup health or recovery evidence
|
||||
- Adding a new persisted portfolio posture table, a new global recovery score, or new dashboard widgets
|
||||
- Changing backup, restore, or domain persistence models
|
||||
|
||||
## Risks
|
||||
|
||||
- If the registry visually blends lifecycle metadata with backup or recovery posture, operators may still misread directory metadata as recovery truth.
|
||||
- If drilldown intent is not visibly preserved, the workspace overview will continue to feel semantically sharper than the registry it opens.
|
||||
- If ordering rules are inconsistent or unstable, weak tenants will still be lost in normal directory browsing.
|
||||
- If posture loading is implemented as naive per-row resolution, registry performance may regress under moderate visible-tenant counts.
|
||||
- If registry wording overreaches, operators may treat healthy backups and calm recovery evidence as proof of successful recovery.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
This feature is complete when:
|
||||
|
||||
- the tenant registry shows backup posture per visible row,
|
||||
- the tenant registry shows recovery evidence per visible row,
|
||||
- both posture domains are filterable,
|
||||
- the registry supports a consistent weak-first triage ordering,
|
||||
- multi-tenant workspace backup and recovery drilldowns land on a useful filtered tenant registry surface,
|
||||
- single-tenant workspace backup and recovery drilldowns may still go directly to the tenant dashboard,
|
||||
- the registry never overclaims recoverability or recovery proof,
|
||||
- RBAC and query-bounded behavior are protected by focused regression coverage.
|
||||
247
specs/186-tenant-registry-recovery-triage/tasks.md
Normal file
247
specs/186-tenant-registry-recovery-triage/tasks.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Tasks: Tenant Registry Recovery Triage
|
||||
|
||||
**Input**: Design documents from `/specs/186-tenant-registry-recovery-triage/`
|
||||
**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 Pest coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`.
|
||||
**Operations**: This feature does not create a new `OperationRun`, change run lifecycle ownership, or introduce queued or scheduled work. The work is limited to workspace registry rendering, filter continuity, and drilldown destinations.
|
||||
**RBAC**: Existing workspace membership, tenant visibility, and deny-as-not-found semantics remain authoritative. Tasks must preserve visible-tenant-only posture buckets, visible-tenant-only drilldown destinations, and the tenant dashboard as the permission-safe fallback surface.
|
||||
**Operator Surfaces**: `TenantResource` remains the canonical registry working surface, `WorkspaceOverview` remains the backup and recovery drilldown source, and `/admin/t/{tenant}` remains the canonical safe fallback destination.
|
||||
**Filament UI Action Surfaces**: No new destructive or redundant inspect actions are added. Full-row click remains the registry inspect model, the existing safe dashboard shortcut remains the only inline fast-next-step action, and workspace summary widgets keep their current destination contract.
|
||||
**Filament UI UX-001**: No create, edit, or view-page layout changes are introduced. This slice is limited to table truth, filters, ordering, and drilldown continuity.
|
||||
**Badges**: Backup posture and recovery evidence must reuse existing bounded label and tone semantics from shared badge or readiness surfaces; no page-local status language is introduced.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment where the underlying surface coupling allows it.
|
||||
|
||||
## Phase 1: Setup (Focused Regression Harness)
|
||||
|
||||
**Purpose**: Create the focused acceptance harness for the registry triage slice and the workspace drilldowns it changes.
|
||||
|
||||
- [X] T001 Create the focused registry triage regression scaffold in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
|
||||
- [X] T002 [P] Stage workspace backup and recovery drilldown regression assertions in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Shared Posture Semantics)
|
||||
|
||||
**Purpose**: Establish one request-scoped posture snapshot and one list-surface contract baseline before story work expands the registry.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T003 Implement request-scoped visible-tenant posture snapshot loading and canonical triage tier mapping in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T004 [P] Add failing list-surface and table-standards guard scenarios for the forthcoming posture columns and filters in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
|
||||
**Checkpoint**: The registry has one shared posture source for columns, filters, and ranking, and the Filament table contract is guarded before story-specific UI work lands.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See Weak Tenants On One Registry Surface (Priority: P1) 🎯
|
||||
|
||||
**Goal**: Make `/admin/tenants` show bounded backup posture and recovery evidence directly on each visible row.
|
||||
|
||||
**Independent Test**: Seed visible tenants with mixed backup and recovery states, open `/admin/tenants`, and verify that every row shows separate `Backup posture` and `Recovery evidence` signals without turning metadata into recovery truth.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T005 [US1] Add row-level backup posture, recovery evidence, dashboard-registry mapping parity, dual-signal rendering, metadata-separation, and no-overclaim coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T006 [P] [US1] Reuse or extract one shared bounded backup and recovery mapping seam for registry rendering in `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php` and `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T007 [US1] Add default-visible `Backup posture` and `Recovery evidence` columns beside tenant identity and lifecycle metadata in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T008 [US1] Keep full-row inspect pointed at the existing registry detail route and the safe dashboard shortcut as the only primary next steps while surfacing posture truth in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T009 [US1] Run focused US1 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
|
||||
|
||||
**Checkpoint**: The tenant registry reads as a truthful at-a-glance recovery triage surface even before filters and workspace drilldowns are changed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Filter And Rank The Weakest Tenants First (Priority: P1)
|
||||
|
||||
**Goal**: Turn the registry from a readable directory into a usable triage surface with exact posture filters and deterministic worst-first ordering.
|
||||
|
||||
**Independent Test**: Apply backup and recovery posture filters in a mixed visible tenant set and verify that weak tenants sort ahead of calm rows with stable tenant-name tie breaks.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T010 [US2] Add exact posture-filter and worst-first ordering coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T011 [US2] Add multi-select `backup_posture` and `recovery_evidence` filters over snapshot-derived visible tenant ID sets in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T012 [US2] Implement canonical worst-first triage ordering and stable tenant-name tie breaks in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T013 [P] [US2] Add filtered empty-state context for manual posture filtering without changing the default calm-browsing sort in `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
|
||||
- [X] T014 [US2] Run focused US2 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
|
||||
|
||||
**Checkpoint**: Operators can reduce the registry to the exact weak slice they need and start with the highest-priority tenants first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Workspace Meaning During Drilldown (Priority: P1)
|
||||
|
||||
**Goal**: Make workspace backup and recovery attention drilldowns land on a filtered registry that preserves why the operator clicked.
|
||||
|
||||
**Independent Test**: Open backup and recovery attention drilldowns for both multi-tenant and single-tenant cases and verify that multi-tenant cases land on filtered `/admin/tenants` while single-tenant cases still go directly to `/admin/t/{tenant}`.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T015 [P] [US3] Add multi-tenant backup and recovery destination URL and unchanged destination-kind assertions in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`
|
||||
- [X] T016 [P] [US3] Add registry drilldown continuity and single-tenant dashboard fallback coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T017 [P] [US3] Initialize `backup_posture`, `recovery_evidence`, and `triage_sort` query intent on first registry load in `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
|
||||
- [X] T018 [P] [US3] Replace multi-tenant backup and recovery `choose_tenant` destinations with filtered `/admin/tenants` URLs while preserving single-tenant dashboard routing and the existing widget destination-kind contract in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T019 [US3] Run focused US3 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||
|
||||
**Checkpoint**: Workspace backup and recovery counts now open a destination that keeps the same explanation visible after navigation.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Keep Scope And Truth Boundaries Honest (Priority: P3)
|
||||
|
||||
**Goal**: Keep posture rows, filters, and drilldowns bounded to visible tenants and bounded claims even when the member has partial visibility or limited follow-up permissions.
|
||||
|
||||
**Independent Test**: Mix visible and hidden tenants with posture issues, open the registry and workspace drilldowns as a partially entitled member, and verify that only visible tenants appear and only permission-safe destinations are offered.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T020 [P] [US4] Add hidden-tenant leakage and posture-filter scope coverage in `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`
|
||||
- [X] T021 [P] [US4] Add deny-as-not-found and permission-safe drillthrough fallback coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T022 [US4] Restrict posture buckets and filtered tenant ID sets to the already visible workspace tenant query in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T023 [P] [US4] Keep workspace attention candidate selection and dashboard fallback destinations scoped to visible tenants only in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T024 [US4] Enforce bounded wording and safe next-step fallback when deeper backup or restore destinations are unavailable in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php`
|
||||
- [X] T025 [US4] Run focused US4 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||
|
||||
**Checkpoint**: The registry and its workspace drilldowns stay truthful, scoped, and permission-safe even for partially entitled members.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish guard coverage, formatting, and the final focused acceptance pack.
|
||||
|
||||
- [X] T026 [P] Run final list-surface and table-standards guard verification for the added registry columns and filters against `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- [X] T027 Add explicit query-bounded rendering and no per-row resolver fanout coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
|
||||
- [X] T028 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/186-tenant-registry-recovery-triage/quickstart.md`
|
||||
- [X] T029 Run the focused verification pack from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||
- [X] T030 Run the timed 10-second operator-scan and manual mixed-tenant smoke workflow from `specs/186-tenant-registry-recovery-triage/quickstart.md` on `/admin` and `/admin/tenants`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and creates the focused regression harness.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user-story work until the registry has one shared posture snapshot and the list contract baseline is guarded.
|
||||
- **User Stories (Phase 3+)**: Depend on Foundational completion.
|
||||
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Starts after Foundational and delivers the truthful row-level registry surface.
|
||||
- **User Story 2 (P1)**: Starts after User Story 1 because exact filters and ranking extend the same registry surface and depend on the posture columns already being in place.
|
||||
- **User Story 3 (P1)**: Starts after User Story 2 because workspace drilldowns must land on registry filters that already exist.
|
||||
- **User Story 4 (P3)**: Starts after User Story 3 because the scope and fallback hardening must cover the finished registry and drilldown behavior together.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Story tests should be written before or alongside implementation and should fail before the story is considered complete.
|
||||
- Shared posture semantics should land before any row-level label or filter rendering that depends on them.
|
||||
- Registry resource changes should land before story-level verification runs.
|
||||
- Workspace drilldown destination changes should land before continuity verification runs.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` and `T002` can run in parallel during Setup.
|
||||
- `T003` and `T004` can run in parallel after the target files are identified.
|
||||
- `T006` can run in parallel with `T007` after `T005` defines the acceptance cases.
|
||||
- `T013` can run in parallel with `T011` and `T012` once the filter contract is fixed.
|
||||
- `T015` and `T016` can run in parallel for User Story 3.
|
||||
- `T017` and `T018` can run in parallel for User Story 3 after the expected query contract is defined.
|
||||
- `T020` and `T021` can run in parallel for User Story 4.
|
||||
- `T022` and `T023` can run in parallel for User Story 4 once the scope assertions are in place.
|
||||
- `T026` and `T027` can run in parallel before the final focused verification commands are executed.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# After T005 defines the acceptance cases:
|
||||
Task: T006 apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php
|
||||
Task: T007 apps/platform/app/Filament/Resources/TenantResource.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# After T010 locks the filter and ordering expectations:
|
||||
Task: T011 apps/platform/app/Filament/Resources/TenantResource.php
|
||||
Task: T013 apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# User Story 3 tests in parallel:
|
||||
Task: T015 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
|
||||
Task: T016 apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||
|
||||
# User Story 3 implementation in parallel after tests exist:
|
||||
Task: T017 apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
|
||||
Task: T018 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# User Story 4 tests in parallel:
|
||||
Task: T020 apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php
|
||||
Task: T021 apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||
|
||||
# User Story 4 implementation in parallel after the scope assertions exist:
|
||||
Task: T022 apps/platform/app/Filament/Resources/TenantResource.php
|
||||
Task: T023 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (Registry Triage Surface)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Complete Phase 4: User Story 2.
|
||||
5. **STOP and VALIDATE**: Confirm `/admin/tenants` is a truthful and usable triage surface before changing workspace drilldowns.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver Setup + Foundational to lock one posture source and the list contract.
|
||||
2. Deliver User Story 1 so the registry shows bounded backup and recovery truth.
|
||||
3. Deliver User Story 2 so operators can filter and rank the weak slice.
|
||||
4. Deliver User Story 3 so workspace summary clicks preserve their meaning.
|
||||
5. Deliver User Story 4 so scope, fallback, and truth boundaries remain enforced.
|
||||
6. Finish with guard updates, formatting, focused verification, and the manual smoke pass.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One developer can build the focused registry acceptance test while another prepares the workspace drilldown regression cases.
|
||||
2. After Foundational work, one developer can extend `TenantResource.php` while another keeps `RecoveryReadiness.php` semantics aligned for registry rendering.
|
||||
3. Once User Story 2 expectations are fixed, one developer can wire `/admin/tenants` filter and ordering behavior while another handles `ListTenants.php` query-intent defaults.
|
||||
4. User Story 3 and User Story 4 both split cleanly between registry-page work and workspace-builder work before final verification.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks target different files or safe concurrent work after their prerequisite expectations are in place.
|
||||
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` labels map directly to the feature specification user stories.
|
||||
- The suggested MVP scope is Phase 1 through Phase 4, ending with a fully usable tenant-registry triage surface with explicit query-bounded coverage before workspace drilldowns are switched over.
|
||||
- No task in this plan adds a schema migration, a new persisted truth model, a new panel or provider registration, a new asset pipeline step, or a new destructive action.
|
||||
Loading…
Reference in New Issue
Block a user