TenantAtlas/apps/platform/app/Filament/Resources/BackupSetResource.php
ahmido 8713b35da5 feat(ui): implement core operator view surfaces productization for backup sets (#442)
Applied the decision-first global surface IA contract to BackupSet views. Includes decision summary header, usability status, and separation of technical metadata.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #442
2026-06-11 07:38:33 +00:00

986 lines
44 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
use App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\BulkBackupSetDeleteJob;
use App\Jobs\BulkBackupSetForceDeleteJob;
use App\Jobs\BulkBackupSetRestoreJob;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\BackupQuality\BackupQualitySummary;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\NavigationScope;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use UnitEnum;
class BackupSetResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedEnvironmentRoutes;
protected static ?string $model = BackupSet::class;
protected static bool $isGloballySearchable = false;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function shouldRegisterNavigation(): bool
{
return NavigationScope::shouldRegisterEnvironmentNavigation()
&& parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
}
public static function canViewAny(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canCreate(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery();
}
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->label('Backup name')
->default(fn () => now()->format('Y-m-d H:i:s').' backup')
->required(),
]);
}
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('Create backup set');
UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return $action;
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
'items' => fn ($itemQuery) => $itemQuery->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]))
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('backup_quality')
->label('Restore-point decision')
->state(fn (BackupSet $record): string => static::backupSetListDecision($record))
->description(fn (BackupSet $record): string => static::backupSetListGuidance($record))
->wrap(),
Tables\Columns\TextColumn::make('item_count')->label('Items captured')->numeric()->sortable(),
Tables\Columns\TextColumn::make('status')
->label('Lifecycle')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived'),
])
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
->actions([
static::primaryRelatedAction(),
ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = static::resolveTenantContextForCurrentPanel();
$record->restore();
$record->items()->withTrashed()->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.restored',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
Notification::make()
->title('Backup set restored')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = static::resolveTenantContextForCurrentPanel();
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
Notification::make()
->title('Backup set archived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = static::resolveTenantContextForCurrentPanel();
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
->title('Cannot force delete backup set')
->body('Backup sets referenced by restore runs cannot be removed.')
->danger()
->send();
return;
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.force_deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
$record->items()->withTrashed()->forceDelete();
$record->forceDelete();
Notification::make()
->title('Backup set permanently deleted')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Backup Sets')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.delete')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Backup Sets')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.restore')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Backup Sets')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->label('More'),
])
->emptyStateHeading('No backup sets')
->emptyStateDescription('Create a backup set to capture policy versions and assignments for later restore review.')
->emptyStateIcon('heroicon-o-archive-box')
->emptyStateActions([
static::makeCreateAction(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Infolists\Components\ViewEntry::make('enterprise_detail')
->label('')
->view('filament.infolists.entries.enterprise-detail.layout')
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray())
->columnSpanFull(),
]);
}
public static function getRelations(): array
{
return [
BackupItemsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBackupSets::route('/'),
'create' => Pages\CreateBackupSet::route('/create'),
'view' => Pages\ViewBackupSet::route('/{record}'),
];
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
return collect($types)
->firstWhere('type', $type) ?? [];
}
/**
* @return list<array{
* key: string,
* label: string,
* value: string,
* secondaryValue: ?string,
* targetUrl: ?string,
* targetKind: string,
* availability: string,
* unavailableReason: ?string,
* contextBadge: ?string,
* priority: int,
* actionLabel: string
* }>
*/
public static function relatedContextEntries(BackupSet $record): array
{
return app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
}
private static function primaryRelatedAction(): Actions\Action
{
return Actions\Action::make('primary_drill_down')
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
->color('gray');
}
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
}
/**
* Create a backup set via the domain service instead of direct model mass-assignment.
*/
public static function createBackupSet(array $data): BackupSet
{
/** @var ManagedEnvironment $tenant */
$tenant = static::resolveTenantContextForCurrentPanel();
/** @var BackupService $service */
$service = app(BackupService::class);
return $service->createBackupSet(
tenant: $tenant,
policyIds: $data['policy_ids'] ?? [],
name: $data['name'] ?? null,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
);
}
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
{
$factory = new EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
$metadata = is_array($record->metadata) ? $record->metadata : [];
$metadataKeyCount = count($metadata);
$relatedContext = static::relatedContextEntries($record);
$isArchived = $record->trashed();
$qualitySummary = static::backupQualitySummary($record);
$backupHealthAssessment = static::backupHealthContinuityAssessment($record);
$backupHealthBadge = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->statusBadge(
static::backupHealthContinuityLabel($backupHealthAssessment),
$backupHealthAssessment->tone(),
'heroicon-m-exclamation-triangle',
)
: null;
$descriptionHint = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? trim($backupHealthAssessment->headline.' '.($backupHealthAssessment->supportingMessage ?? ''))
: null;
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
->header(new SummaryHeaderData(
title: (string) $record->name,
subtitle: 'Backup set #'.$record->getKey(),
statusBadges: array_values(array_filter([
$backupHealthBadge,
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
$isArchived
? $factory->statusBadge('Archived', 'warning')
: null,
])),
descriptionHint: $descriptionHint,
))
->decisionZone($factory->decisionZone(
facts: static::backupDecisionFacts($factory, $qualitySummary, $backupHealthAssessment, $backupHealthBadge),
primaryNextStep: $factory->primaryNextStep(
static::backupDecisionNextAction($qualitySummary),
'Restore-point decision',
),
description: 'Start here to decide whether this backup set is usable for restore review before reading lifecycle, operation, or raw metadata.',
compactCounts: static::backupDecisionCounts($factory, $qualitySummary),
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
title: 'Restore-point decision',
))
->addSection(
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related operation and evidence',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => $relatedContext],
description: 'Operation links stay available for traceability, but they are secondary to backup usability and included-item truth.',
emptyState: $factory->emptyState('No related context is available for this record.'),
collapsible: true,
collapsed: true,
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Technical and lifecycle detail',
entries: [
$factory->keyFact('Lifecycle status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Record state', $isArchived ? 'Archived' : 'Active'),
$factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Metadata keys', $metadataKeyCount),
],
description: 'Lifecycle, timing, IDs, and raw metadata stay available here without taking over the restore-point decision.',
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
viewData: ['payload' => $metadata],
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
),
)
->build();
}
private static function backupSetListDecision(BackupSet $record): string
{
$summary = static::backupQualitySummary($record);
if ($summary->totalItems === 0) {
return 'Blocked: no items captured';
}
if ($summary->hasDegradations()) {
return 'Action needed before restore review';
}
return 'Usable for restore review';
}
private static function backupSetListGuidance(BackupSet $record): string
{
$summary = static::backupQualitySummary($record);
if ($summary->totalItems === 0) {
return 'No backup items were captured. Create or refresh a backup set before restore review.';
}
if ($summary->hasDegradations()) {
return $summary->compactSummary.'. Inspect degraded items before relying on this backup.';
}
return sprintf(
'%s captured. Review included items before relying on this backup.',
static::formatItemCount($summary->totalItems),
);
}
private static function backupUsabilityLabel(BackupQualitySummary $summary): string
{
if ($summary->totalItems === 0) {
return 'Blocked until items are captured';
}
if ($summary->hasDegradations()) {
return 'Action needed before restore review';
}
return 'Usable for restore review';
}
/**
* @return list<array<string, mixed>>
*/
private static function backupDecisionFacts(
EnterpriseDetailSectionFactory $factory,
BackupQualitySummary $summary,
?TenantBackupHealthAssessment $assessment,
?array $backupHealthBadge,
): array {
return array_values(array_filter([
$factory->keyFact('Usability', static::backupUsabilityLabel($summary)),
$factory->keyFact('Reason', static::backupDecisionReason($summary)),
$factory->keyFact('Impact', static::backupDecisionImpact($summary)),
$factory->keyFact('Items captured', static::formatItemCount($summary->totalItems)),
$summary->totalItems === 0
? $factory->keyFact('Current blocker', 'No backup items were captured.')
: null,
$summary->hasDegradations()
? $factory->keyFact('Current degradation', $summary->compactSummary)
: null,
$assessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($assessment), badge: $backupHealthBadge)
: null,
]));
}
private static function backupDecisionReason(BackupQualitySummary $summary): string
{
if ($summary->totalItems === 0) {
return 'No backup item inventory is available on this restore point.';
}
if ($summary->hasDegradations()) {
return $summary->summaryMessage;
}
return 'Captured item inventory is available for operator review.';
}
private static function backupDecisionImpact(BackupQualitySummary $summary): string
{
if ($summary->totalItems === 0) {
return 'Restore review should wait for a backup set with captured items.';
}
if ($summary->hasDegradations()) {
return 'Treat this backup as investigation input until degraded items are reviewed.';
}
return 'Use this as restore review input only after confirming included items below.';
}
private static function backupDecisionNextAction(BackupQualitySummary $summary): string
{
if ($summary->totalItems === 0) {
return 'Create or refresh a backup set before starting restore review.';
}
if ($summary->hasDegradations()) {
return 'Review degraded included items and source operation before continuing into restore.';
}
return 'Review included items below before starting any separate restore workflow.';
}
/**
* @return array{
* summaryLine?: ?string,
* primaryFacts: list<array<string, mixed>>,
* diagnosticFacts: list<array<string, mixed>>
* }|null
*/
private static function backupDecisionCounts(EnterpriseDetailSectionFactory $factory, BackupQualitySummary $summary): ?array
{
$facts = static::nonZeroQualityFacts($factory, $summary);
if ($facts === [] && $summary->totalItems > 0 && ! $summary->hasDegradations()) {
return null;
}
return $factory->countPresentation(
summaryLine: $summary->summaryMessage,
primaryFacts: $facts,
);
}
/**
* @return list<array<string, mixed>>
*/
private static function nonZeroQualityFacts(EnterpriseDetailSectionFactory $factory, BackupQualitySummary $summary): array
{
return array_values(array_filter([
$summary->degradedItemCount > 0
? $factory->keyFact('Degraded items', $summary->degradedItemCount)
: null,
$summary->metadataOnlyCount > 0
? $factory->keyFact('Metadata-only items', $summary->metadataOnlyCount)
: null,
$summary->assignmentIssueCount > 0
? $factory->keyFact('Assignment issues', $summary->assignmentIssueCount)
: null,
$summary->orphanedAssignmentCount > 0
? $factory->keyFact('Orphaned assignments', $summary->orphanedAssignmentCount)
: null,
$summary->integrityWarningCount > 0
? $factory->keyFact('Integrity warnings', $summary->integrityWarningCount)
: null,
$summary->unknownQualityCount > 0
? $factory->keyFact('Unknown quality', $summary->unknownQualityCount)
: null,
]));
}
private static function formatItemCount(int $count): string
{
return sprintf('%d item%s', $count, $count === 1 ? '' : 's');
}
private static function formatDetailTimestamp(mixed $value): string
{
if (! $value instanceof Carbon) {
return '—';
}
return $value->toDayDateTimeString();
}
private static function backupQualitySummary(BackupSet $record): \App\Support\BackupQuality\BackupQualitySummary
{
if ($record->trashed()) {
$record->setRelation('items', $record->items()->withTrashed()->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
])->get());
} elseif (! $record->relationLoaded('items')) {
$record->loadMissing([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]);
}
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
}
private static function backupHealthContinuityAssessment(BackupSet $record): ?TenantBackupHealthAssessment
{
$requestedReason = request()->string('backup_health_reason')->toString();
if (! in_array($requestedReason, [
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], true)) {
return null;
}
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
$assessment = $resolver->assess((int) $record->managed_environment_id);
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
return null;
}
if ($assessment->primaryReason !== $requestedReason) {
return null;
}
return $assessment;
}
private static function backupHealthContinuityLabel(TenantBackupHealthAssessment $assessment): string
{
return match ($assessment->primaryReason) {
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
default => ucfirst($assessment->posture),
};
}
}