TenantAtlas/apps/platform/app/Filament/Resources/RestoreRunResource.php
Ahmed Darrazi a7897fa064
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m49s
Add product process flow for restore create
2026-05-26 02:03:14 +02:00

3838 lines
180 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
use App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter;
use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob;
use App\Jobs\BulkRestoreRunRestoreJob;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\User;
use App\Models\Workspace;
use App\Rules\SkipOrUuidRule;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\BackupQuality\BackupQualitySummary;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Navigation\NavigationScope;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus;
use App\Support\RestoreSafety\ChecksIntegrityState;
use App\Support\RestoreSafety\PreviewIntegrityState;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\RestoreSafety\RestoreSafetyResolver;
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 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\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Support\Exceptions\Halt;
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\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class RestoreRunResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedEnvironmentRoutes;
protected static ?string $model = RestoreRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
public static function shouldRegisterNavigation(): bool
{
return NavigationScope::shouldRegisterEnvironmentNavigation()
&& parent::shouldRegisterNavigation();
}
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
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_MANAGE);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.');
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(fn () => static::restoreBackupSetOptions())
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', []);
$set('is_dry_run', true);
})
->required(),
Forms\Components\CheckboxList::make('backup_item_ids')
->label('Items to restore (optional)')
->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options'])
->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions'])
->columns(1)
->searchable()
->bulkToggleable()
->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->helperText(fn (): string => static::restoreItemQualityHelperText()),
Section::make('Resolve target mappings')
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
);
return [
Forms\Components\ViewField::make('restore_mapping_resolver_summary_form')
->hiddenLabel()
->view('filament.forms.components.restore-run-mapping-resolver-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)),
...array_map(function (array $group) use ($tenant): Forms\Components\TextInput {
$groupId = $group['id'];
$sourceDisplayName = is_string($group['displayName'] ?? null) ? $group['displayName'] : null;
$sourceLabel = filled($sourceDisplayName) ? $sourceDisplayName : 'Unknown source group';
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
->label($sourceLabel)
->placeholder('Target group Object ID (GUID)')
->rules([new SkipOrUuidRule])
->live(onBlur: true)
->helperText(fn (Get $get): HtmlString => static::groupMappingIdentityHelperText(
tenant: $tenant,
sourceGroupId: $groupId,
rawValue: $get("group_mapping.{$groupId}"),
))
->hintActions([
Actions\Action::make('skip_assignment_'.str_replace('-', '_', $groupId))
->label('Skip assignment')
->icon('heroicon-o-minus-circle')
->color('gray')
->link()
->visible(fn (Get $get): bool => strtoupper(trim((string) $get("group_mapping.{$groupId}"))) !== 'SKIP')
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", 'SKIP', shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
Actions\Action::make('undo_skip_assignment_'.str_replace('-', '_', $groupId))
->label('Undo skip')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->link()
->visible(fn (Get $get): bool => strtoupper(trim((string) $get("group_mapping.{$groupId}"))) === 'SKIP')
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", null, shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
])
->required()
->suffixAction(
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
->icon('heroicon-o-magnifying-glass')
->iconButton()
->tooltip('Find target group for this assignment')
->modalHeading('Resolve target group mapping')
->modalWidth('5xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Enter object ID manually')
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
'sourceGroupId' => $groupId,
'sourceGroupDisplayName' => $sourceDisplayName,
'tenantId' => (int) $tenant->getKey(),
]))
);
}, $unresolved),
];
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
) !== [];
})
->collapsible()
->collapsed(),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
]);
}
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('New restore run');
UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return $action;
}
public static function getEloquentQuery(): Builder
{
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
->with(['backupSet', 'operationRun']);
}
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()->withTrashed()->with('backupSet'),
);
}
protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof RestoreRun) {
abort(404);
}
return $resolvedRecord;
}
/**
* @return array<int, int>
*/
protected static function resolveProtectedRestoreRunIds(Collection $records): array
{
return $records
->map(function (mixed $record): int {
$resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0));
return (int) $resolvedRecord->getKey();
})
->filter(fn (int $value): bool => $value > 0)
->unique()
->values()
->all();
}
/**
* @return array<int, Step>
*/
public static function getWizardSteps(): array
{
return [
Step::make('Select Backup Set')
->description('Choose a source and review restore safety from the start of the workflow.')
->schema([
Forms\Components\ViewField::make('restore_safety_decision')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-decision')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)),
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(fn () => static::restoreBackupSetOptions())
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$groupMapping = static::groupMappingPlaceholders(
backupSetId: $get('backup_set_id'),
scopeMode: 'all',
selectedItemIds: null,
tenant: static::resolveTenantContextForCurrentPanel(),
);
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => $groupMapping,
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->required(),
Forms\Components\ViewField::make('restore_backup_quality_summary')
->hiddenLabel()
->view('filament.forms.components.restore-run-backup-quality-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)),
Grid::make([
'default' => 1,
'xl' => 12,
])
->schema([
Forms\Components\ViewField::make('restore_safety_gates')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-gates')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1))
->columnSpan([
'default' => 1,
'xl' => 8,
]),
Forms\Components\ViewField::make('restore_proof_aside')
->hiddenLabel()
->view('filament.forms.components.restore-run-proof-aside')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1))
->columnSpan([
'default' => 1,
'xl' => 4,
]),
]),
]),
Step::make('Define Restore Scope')
->description('Define restore scope and dependency mapping. Safety status stays compact here.')
->afterValidation(function (Get $get, \Filament\Schemas\Components\Wizard\Step $component): void {
$contract = static::restoreWizardViewData($get, currentStep: 2, compactFlow: true);
$mappingResolver = is_array($contract['mappingResolver'] ?? null) ? $contract['mappingResolver'] : [];
if (! ($mappingResolver['requiresResolution'] ?? false)) {
return;
}
$wizard = $component->getContainer()->getParentComponent();
if ($wizard instanceof \Filament\Schemas\Components\Wizard) {
$wizard->goToStep($component->getKey());
}
Notification::make()
->title('Mappings required')
->body('Resolve required mappings before validation can run.')
->danger()
->send();
throw new Halt;
})
->schema([
Forms\Components\Radio::make('scope_mode')
->label('Scope')
->options([
'all' => 'All items (default)',
'selected' => 'Selected items only',
])
->default('all')
->reactive()
->afterStateUpdated(function (Set $set, Get $get, $state): void {
$backupSetId = $get('backup_set_id');
$tenant = static::resolveTenantContextForCurrentPanel();
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
if ($state === 'all') {
$groupMapping = static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'all',
selectedItemIds: null,
tenant: $tenant,
);
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => $groupMapping,
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
return;
}
$set('group_mapping', []);
$set('backup_item_ids', []);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'selected',
'backup_item_ids' => [],
'group_mapping' => [],
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->required(),
Forms\Components\Select::make('backup_item_ids')
->label('Items to restore')
->multiple()
->searchable()
->searchValues()
->searchDebounce(400)
->optionsLimit(300)
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$tenant = static::resolveTenantContextForCurrentPanel();
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
));
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'backup_item_ids' => $selectedItemIds ?? [],
'group_mapping' => static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
),
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
->hintActions([
Actions\Action::make('select_all_backup_items')
->label('Select all')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')) && $get('scope_mode') === 'selected')
->action(function (Get $get, Set $set): void {
$groupedOptions = static::restoreItemGroupedOptions($get('backup_set_id'));
$allItemIds = [];
foreach ($groupedOptions as $options) {
$allItemIds = array_merge($allItemIds, array_keys($options));
}
$set('backup_item_ids', array_values($allItemIds), shouldCallUpdatedHooks: true);
}),
Actions\Action::make('clear_backup_items')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
])
->helperText(fn (): string => static::restoreItemQualityHelperText()),
Forms\Components\ViewField::make('restore_scope_summary')
->hiddenLabel()
->view('filament.forms.components.restore-run-scope-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)),
Section::make('Resolve target mappings')
->description(function (Get $get): string {
$contract = static::restoreWizardViewData($get, currentStep: 2, compactFlow: true);
$mappingResolver = is_array($contract['mappingResolver'] ?? null) ? $contract['mappingResolver'] : [];
$resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0);
$totalCount = (int) ($mappingResolver['totalCount'] ?? 0);
$unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0);
$skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0);
$manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0);
$parts = [
"{$resolvedCount} of {$totalCount} mappings resolved",
"{$unresolvedCount} unresolved",
"{$skippedCount} skipped",
];
if ($manualFallbackCount > 0) {
$parts[] = "{$manualFallbackCount} manual fallback";
}
return implode(' · ', $parts);
})
->extraAttributes([
'data-testid' => 'restore-run-mapping-resolver-section',
])
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [];
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
);
$rows = [];
foreach ($unresolved as $group) {
$groupId = $group['id'];
$sourceDisplayName = is_string($group['displayName'] ?? null) ? $group['displayName'] : null;
$sourceLabel = filled($sourceDisplayName) ? $sourceDisplayName : 'Unknown source group';
$safeGroupToken = str_replace('-', '_', $groupId);
$statePath = "group_mapping.{$groupId}";
$isSkipped = fn (Get $get): bool => strtoupper(trim((string) $get($statePath))) === 'SKIP';
$rows[] = Forms\Components\ViewField::make("group_mapping_skipped_{$safeGroupToken}")
->label($sourceLabel)
->view('filament.forms.components.restore-run-group-mapping-skipped')
->viewData(fn (Get $get): array => [
'identityHtml' => static::groupMappingIdentityHelperText(
tenant: $tenant,
sourceGroupId: $groupId,
rawValue: $get($statePath),
),
])
->visible($isSkipped)
->hintActions([
Actions\Action::make("undo_skip_assignment_{$safeGroupToken}")
->label('Undo skip')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->link()
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", null, shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
]);
$rows[] = Forms\Components\TextInput::make($statePath)
->label($sourceLabel)
->placeholder('Target group Object ID (GUID)')
->rules([new SkipOrUuidRule])
->live(onBlur: true)
->afterStateUpdated(fn (Set $set, Get $get) => static::touchWizardDraftAfterGroupMappingChange($get, $set))
->helperText(fn (Get $get): HtmlString => static::groupMappingIdentityHelperText(
tenant: $tenant,
sourceGroupId: $groupId,
rawValue: $get($statePath),
))
->hintActions([
Actions\Action::make("skip_assignment_{$safeGroupToken}")
->label('Skip assignment')
->icon('heroicon-o-minus-circle')
->color('gray')
->link()
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", 'SKIP', shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
])
->markAsRequired()
->visible(fn (Get $get): bool => ! $isSkipped($get))
->dehydratedWhenHidden()
->suffixAction(
Actions\Action::make("select_from_directory_cache_{$safeGroupToken}")
->icon('heroicon-o-magnifying-glass')
->iconButton()
->tooltip('Find target group for this assignment')
->modalHeading('Resolve target group mapping')
->modalWidth('5xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Enter object ID manually')
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
'sourceGroupId' => $groupId,
'sourceGroupDisplayName' => $sourceDisplayName,
'tenantId' => (int) $tenant->getKey(),
]))
);
}
return [
Forms\Components\ViewField::make('restore_mapping_resolver_summary')
->hiddenLabel()
->view('filament.forms.components.restore-run-mapping-resolver-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)),
...$rows,
];
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return false;
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
) !== [];
})
->collapsible()
->collapsed(),
Grid::make([
'default' => 1,
'xl' => 12,
])
->schema([
Forms\Components\ViewField::make('restore_safety_status_scope')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-gates')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 8,
]),
Forms\Components\ViewField::make('restore_proof_scope')
->hiddenLabel()
->view('filament.forms.components.restore-run-proof-aside')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 4,
]),
]),
]),
Step::make('Safety & Conflict Checks')
->description('Validate impact before execution.')
->afterValidation(function (Get $get, \Filament\Schemas\Components\Wizard\Step $component): void {
$tenant = static::resolveTenantContextForCurrentPanel();
$wizard = $component->getContainer()->getParentComponent();
if ($tenant instanceof ManagedEnvironment) {
$resolution = app(ProviderConnectionResolver::class)->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved) {
if ($wizard instanceof \Filament\Schemas\Components\Wizard) {
$wizard->goToStep($component->getKey());
}
Notification::make()
->title('Validation blocked')
->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.')
->danger()
->actions([
\Filament\Actions\Action::make('review_provider_connection')
->label('Review provider connection')
->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true)
->button(),
])
->send();
throw new Halt;
}
}
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$checksIntegrity = $state['checksIntegrity'] ?? [];
$blockingCount = is_array($checksIntegrity)
? (int) ($checksIntegrity['blocking_count'] ?? 0)
: 0;
if ($blockingCount <= 0) {
return;
}
if ($wizard instanceof \Filament\Schemas\Components\Wizard) {
$wizard->goToStep($component->getKey());
}
Notification::make()
->title('Validation blocked')
->body('Resolve the blocking validation issues before moving to preview.')
->danger()
->send();
throw new Halt;
})
->schema([
Forms\Components\Hidden::make('scope_basis')
->default(null),
Forms\Components\Hidden::make('check_summary')
->default(null),
Forms\Components\Hidden::make('checks_ran_at')
->default(null),
Forms\Components\Hidden::make('check_basis')
->default(null),
Forms\Components\Hidden::make('check_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('check_results')
->label('Checks')
->default([])
->view('filament.forms.components.restore-run-checks')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true))
->hintActions([
Actions\Action::make('run_restore_checks')
->label('Run checks')
->icon('heroicon-o-shield-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->disabled(function (): bool {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
$resolution = app(\App\Services\Providers\ProviderConnectionResolver::class)
->resolveDefault($tenant, 'microsoft');
return ! $resolution->resolved;
})
->tooltip(function (): ?string {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return 'Validation blocked';
}
$resolution = app(\App\Services\Providers\ProviderConnectionResolver::class)
->resolveDefault($tenant, 'microsoft');
if ($resolution->resolved) {
return null;
}
return 'Validation blocked. Provider credentials are not available for this environment.';
})
->action(function (Get $get, Set $set): void {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return;
}
$providerResolution = app(\App\Services\Providers\ProviderConnectionResolver::class)
->resolveDefault($tenant, 'microsoft');
if (! $providerResolution->resolved) {
Notification::make()
->title('Validation blocked')
->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.')
->danger()
->actions([
\Filament\Actions\Action::make('review_provider_connection')
->label('Review provider connection')
->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true)
->button(),
])
->send();
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) {
Notification::make()
->title('Unable to run checks')
->body('Backup set is not available for the active environment.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$groupMapping = static::normalizeGroupMapping($get('group_mapping'));
$checker = app(RestoreRiskChecker::class);
try {
$outcome = $checker->check(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
} catch (\App\Services\Providers\ProviderConfigurationRequiredException) {
Notification::make()
->title('Validation blocked')
->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.')
->danger()
->actions([
\Filament\Actions\Action::make('review_provider_connection')
->label('Review provider connection')
->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true)
->button(),
])
->send();
return;
} catch (\Throwable) {
Notification::make()
->title('Unable to run checks')
->body('Validation could not run due to an unexpected error.')
->danger()
->send();
return;
}
$ranAt = now('UTC')->toIso8601String();
$draft = [
...static::draftDataSnapshot($get),
'check_summary' => $outcome['summary'] ?? [],
'check_results' => $outcome['results'] ?? [],
'checks_ran_at' => $ranAt,
];
$draft['check_basis'] = static::restoreSafetyResolver()->checksBasisFromData($draft);
$draft['check_invalidation_reasons'] = [];
$draft = static::synchronizeRestoreSafetyDraft($draft);
$set('check_summary', $draft['check_summary'], shouldCallUpdatedHooks: true);
$set('check_results', $draft['check_results'], shouldCallUpdatedHooks: true);
$set('checks_ran_at', $ranAt, shouldCallUpdatedHooks: true);
$set('check_basis', $draft['check_basis'], shouldCallUpdatedHooks: true);
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
$summary = $outcome['summary'] ?? [];
$blockers = (int) ($summary['blocking'] ?? 0);
$warnings = (int) ($summary['warning'] ?? 0);
if ($blockers > 0) {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
}
$bodyParts = [];
if ($blockers > 0) {
$bodyParts[] = $blockers.' '.Str::plural('blocker', $blockers);
}
if ($warnings > 0) {
$bodyParts[] = $warnings.' '.Str::plural('warning', $warnings);
}
$notificationTitle = match (true) {
$blockers > 0 => 'Safety checks finished with blockers',
$warnings > 0 => 'Safety checks finished with warnings',
default => 'Safety checks completed',
};
Notification::make()
->title($notificationTitle)
->body($bodyParts === [] ? 'No blockers or warnings' : implode(' · ', $bodyParts))
->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success'))
->send();
}),
Actions\Action::make('clear_restore_checks')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('check_summary', null, shouldCallUpdatedHooks: true);
$set('check_results', [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
$set('check_basis', null, shouldCallUpdatedHooks: true);
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
}),
])
->helperText('Run checks after defining scope and mapping missing groups.'),
Grid::make([
'default' => 1,
'xl' => 12,
])
->schema([
Forms\Components\ViewField::make('restore_safety_status_checks')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-gates')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 8,
]),
Forms\Components\ViewField::make('restore_proof_checks')
->hiddenLabel()
->view('filament.forms.components.restore-run-proof-aside')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 4,
]),
]),
]),
Step::make('Preview')
->description('Dry-run preview')
->afterValidation(function (Get $get): void {
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$previewIntegrity = $state['previewIntegrity'] ?? [];
$checksIntegrity = $state['checksIntegrity'] ?? [];
$executionReadiness = $state['executionReadiness'] ?? [];
$previewIsCurrent = is_array($previewIntegrity)
&& ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT;
$checksAreCurrent = is_array($checksIntegrity)
&& ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT;
$executionAllowed = is_array($executionReadiness)
&& (bool) ($executionReadiness['allowed'] ?? false);
if (! $checksAreCurrent) {
Notification::make()
->title('Safety checks required')
->body('Run the safety checks for the current scope before proceeding to confirmation.')
->warning()
->send();
throw new Halt;
}
if (! $previewIsCurrent) {
Notification::make()
->title('Preview required')
->body('Generate a preview for the current scope before proceeding to confirmation.')
->warning()
->send();
throw new Halt;
}
if (! $executionAllowed) {
Notification::make()
->title('Execution blocked')
->body('Review prerequisites before proceeding to confirmation.')
->danger()
->send();
throw new Halt;
}
})
->schema([
Forms\Components\Hidden::make('preview_summary')
->default(null),
Forms\Components\Hidden::make('preview_ran_at')
->default(null)
->required(),
Forms\Components\Hidden::make('preview_basis')
->default(null),
Forms\Components\Hidden::make('preview_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('preview_diffs')
->label('Preview')
->default([])
->view('filament.forms.components.restore-run-preview')
->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'),
...static::restoreWizardViewData($get, currentStep: 4, compactFlow: true),
])
->hintActions([
Actions\Action::make('run_restore_preview')
->label('Generate preview')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) {
Notification::make()
->title('Unable to generate preview')
->body('Backup set is not available for the active environment.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$generator = app(RestoreDiffGenerator::class);
$outcome = $generator->generate(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
);
$summary = $outcome['summary'] ?? [];
$diffs = $outcome['diffs'] ?? [];
$ranAt = (string) ($summary['generated_at'] ?? now('UTC')->toIso8601String());
$draft = [
...static::draftDataSnapshot($get),
'preview_summary' => $summary,
'preview_diffs' => $diffs,
'preview_ran_at' => $ranAt,
];
$draft['preview_basis'] = static::restoreSafetyResolver()->previewBasisFromData($draft);
$draft['preview_invalidation_reasons'] = [];
$draft = static::synchronizeRestoreSafetyDraft($draft);
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
$set('preview_ran_at', $ranAt, shouldCallUpdatedHooks: true);
$set('preview_basis', $draft['preview_basis'], shouldCallUpdatedHooks: true);
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$previewBody = match (true) {
$policiesTotal <= 0 => 'No policies in scope.',
$policiesChanged <= 0 => 'No policy changes detected.',
$policiesChanged === 1 => '1 policy will be updated during execution.',
default => "{$policiesChanged} policies will be updated during execution.",
};
$previewStatus = match (true) {
$policiesTotal <= 0 => 'info',
$policiesChanged > 0 => 'warning',
default => 'success',
};
Notification::make()
->title('Preview generated')
->body($previewBody)
->status($previewStatus)
->send();
}),
Actions\Action::make('clear_restore_preview')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
$set('preview_basis', null, shouldCallUpdatedHooks: true);
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
}),
])
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
]),
Step::make('Confirm & Execute')
->description('Point of no return')
->schema([
Forms\Components\ViewField::make('restore_confirm_panel')
->hiddenLabel()
->view('filament.forms.components.restore-run-confirm-panel')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true)),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true)
->reactive()
->disabled(function (Get $get): bool {
$contract = static::restoreWizardViewData($get, currentStep: 5, compactFlow: true);
$readiness = $contract['executionReadiness'] ?? null;
return ! is_array($readiness) || ! (bool) ($readiness['allowed'] ?? false);
})
->helperText(function (Get $get): string {
$contract = static::restoreWizardViewData($get, currentStep: 5, compactFlow: true);
$decisionCard = is_array($contract['decisionCard'] ?? null) ? $contract['decisionCard'] : [];
if (! is_array($decisionCard)) {
return 'Turn OFF to queue real execution. Execution requires checks, preview, and confirmation.';
}
return (string) ($decisionCard['impact'] ?? 'Turn OFF to queue real execution. Execution requires checks, preview, and confirmation.');
}),
Forms\Components\Checkbox::make('acknowledged_impact')
->label('I reviewed the impact (checks + preview)')
->accepted()
->visible(fn (Get $get): bool => $get('is_dry_run') === false),
Forms\Components\TextInput::make('tenant_confirm')
->label('Type the environment label to confirm execution')
->required(fn (Get $get): bool => $get('is_dry_run') === false)
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
->in(function (): array {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return [];
}
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
})
->validationMessages([
'in' => 'Confirmation label does not match.',
])
->helperText(function (): string {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return '';
}
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
return "Type: {$expected}";
}),
Grid::make([
'default' => 1,
'xl' => 12,
])
->schema([
Forms\Components\ViewField::make('restore_safety_status_confirm')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-gates')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 8,
]),
Forms\Components\ViewField::make('restore_proof_confirm')
->hiddenLabel()
->view('filament.forms.components.restore-run-proof-aside')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 4,
]),
]),
]),
];
}
public static function table(Table $table): Table
{
return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('backupSet.name')
->label('Backup set')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus))
->sortable(),
Tables\Columns\TextColumn::make('result_attention_summary')
->label('Result attention')
->state(fn (RestoreRun $record): string => static::restoreSafetyResolver()->resultAttentionForRun($record)->summary)
->wrap(),
Tables\Columns\TextColumn::make('summary_total')
->label('Total')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
Tables\Columns\TextColumn::make('summary_succeeded')
->label('Applied')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
Tables\Columns\TextColumn::make('summary_failed')
->label('Failed items')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('requested_by')->label('Requested by')->searchable()->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(FilterOptionCatalog::restoreRunStatuses())
->searchable(),
Tables\Filters\SelectFilter::make('outcome')
->options(FilterOptionCatalog::restoreRunOutcomes())
->query(function (\Illuminate\Database\Eloquent\Builder $query, array $data): \Illuminate\Database\Eloquent\Builder {
$value = $data['value'] ?? null;
return match ((string) $value) {
'succeeded' => $query->whereIn('status', [
\App\Support\RestoreRunStatus::Previewed->value,
\App\Support\RestoreRunStatus::Completed->value,
]),
'partial' => $query->whereIn('status', [
\App\Support\RestoreRunStatus::Partial->value,
\App\Support\RestoreRunStatus::CompletedWithErrors->value,
]),
'failed' => $query->whereIn('status', [
\App\Support\RestoreRunStatus::Failed->value,
\App\Support\RestoreRunStatus::Cancelled->value,
\App\Support\RestoreRunStatus::Aborted->value,
]),
default => $query,
};
}),
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
FilterPresets::archived(),
])
->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record]))
->actions([
ActionGroup::make([
static::rerunActionWithGate(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$record->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'restore_run.restored',
resourceType: 'restore_run',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
);
}
Notification::make()
->title('Restore run restored')
->success()
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if (! $record->isDeletable()) {
Notification::make()
->title('Restore run cannot be archived')
->body("Not deletable (status: {$record->status})")
->warning()
->send();
return;
}
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'restore_run.deleted',
resourceType: 'restore_run',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
);
}
Notification::make()
->title('Restore run archived')
->success()
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'restore_run.force_deleted',
resourceType: 'restore_run',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
);
}
$record->forceDelete();
Notification::make()
->title('Restore run permanently deleted')
->success()
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->preserveVisibility()
->apply(),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Restore Runs')
->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;
})
->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
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: 'restore_run.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkRestoreRunDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.delete')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Restore Runs')
->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()} restore runs?")
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
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: 'restore_run.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
if ($count >= 20) {
BulkRestoreRunRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkRestoreRunRestoreJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.restore')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Restore Runs')
->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()} restore runs?")
->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
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: 'restore_run.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
if ($count >= 20) {
BulkRestoreRunForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkRestoreRunForceDeleteJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.force_delete')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->label('More'),
])
->emptyStateHeading('No restore runs')
->emptyStateDescription('Start a restoration from a backup set.')
->emptyStateIcon('heroicon-o-arrow-path-rounded-square')
->emptyStateActions([
static::makeCreateAction(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
Infolists\Components\TextEntry::make('counts')
->label('Counts')
->state(function (RestoreRun $record): string {
$meta = $record->metadata ?? [];
$total = (int) ($meta['total'] ?? 0);
$succeeded = (int) ($meta['succeeded'] ?? 0);
$failed = (int) ($meta['failed'] ?? 0);
return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
}),
Infolists\Components\TextEntry::make('is_dry_run')
->label('Dry-run')
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
->badge(),
Infolists\Components\TextEntry::make('requested_by'),
Infolists\Components\TextEntry::make('started_at')->dateTime(),
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
Infolists\Components\ViewEntry::make('preview')
->label('Preview')
->view('filament.infolists.entries.restore-preview')
->state(fn (RestoreRun $record): array => static::detailPreviewState($record)),
Infolists\Components\ViewEntry::make('results')
->label('Results')
->view('filament.infolists.entries.restore-results')
->state(fn (RestoreRun $record): array => static::detailResultsState($record)),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRestoreRuns::route('/'),
'create' => Pages\CreateRestoreRun::route('/create'),
'view' => Pages\ViewRestoreRun::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 array{options: array<int, string>, descriptions: array<int, string>}
*/
private static function restoreItemOptionData(?int $backupSetId): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [
'options' => [],
'descriptions' => [],
];
}
$cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
$items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('managed_environment_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', function ($policyQuery): void {
$policyQuery
->whereNull('ignored_at')
->orWhereNotNull('missing_from_provider_at');
});
})
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$name);
});
$options = [];
$descriptions = [];
foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type);
$qualitySummary = static::backupItemQualitySummary($item);
$typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled';
$platform = $item->platform ?? $meta['platform'] ?? null;
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$providerMissingNote = $item->policy?->missing_from_provider_at
? 'current state: provider missing; historical restore available'
: null;
$options[$item->id] = $displayName;
$parts = array_filter([
$category,
$typeLabel,
$platform,
'quality: '.$qualitySummary->compactSummary,
"restore: {$restore}",
$providerMissingNote,
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);
$descriptions[$item->id] = implode(' • ', $parts);
}
return [
'options' => $options,
'descriptions' => $descriptions,
];
});
}
/**
* @return array<string, array<int|string, string>>
*/
private static function restoreItemGroupedOptions(?int $backupSetId): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [];
}
$items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('managed_environment_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', function ($policyQuery): void {
$policyQuery
->whereNull('ignored_at')
->orWhereNotNull('missing_from_provider_at');
});
})
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$typeLabel = $meta['label'] ?? $item->policy_type;
$platform = $item->platform ?? $meta['platform'] ?? null;
$name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
});
$groups = [];
foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type);
$typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies';
$platform = $item->platform ?? $meta['platform'] ?? 'all';
$restoreMode = $meta['restore'] ?? 'enabled';
$groupLabel = implode(' • ', array_filter([
$category,
$typeLabel,
$platform,
$restoreMode === 'preview-only' ? 'preview-only' : null,
]));
$groups[$groupLabel] ??= [];
$groups[$groupLabel][$item->id] = static::restoreItemSelectionLabel($item);
}
return $groups;
}
/**
* @return array<int, string>
*/
private static function restoreBackupSetOptions(): array
{
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return BackupSet::query()
->where('managed_environment_id', $tenantId)
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->orderByDesc('created_at')
->get()
->mapWithKeys(fn (BackupSet $set): array => [
(int) $set->getKey() => static::restoreBackupSetSelectionLabel($set),
])
->all();
}
private static function restoreBackupSetSelectionLabel(BackupSet $set): string
{
$qualitySummary = static::backupSetQualitySummary($set);
return implode(' • ', array_filter([
$set->name,
sprintf('%d items', (int) ($set->item_count ?? 0)),
optional($set->created_at)->format('Y-m-d H:i'),
$qualitySummary->compactSummary,
]));
}
private static function restoreBackupSetHelperText(mixed $backupSetId): string
{
$default = 'Backup quality hints describe input strength only. They do not approve restore execution or prove recoverability.';
if (! is_numeric($backupSetId)) {
return $default;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return $default;
}
$backupSet = BackupSet::query()
->where('managed_environment_id', (int) $tenant->getKey())
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->find((int) $backupSetId);
if (! $backupSet instanceof BackupSet) {
return $default;
}
$summary = static::backupSetQualitySummary($backupSet);
return $summary->compactSummary.'. '.$summary->positiveClaimBoundary;
}
private static function restoreItemSelectionLabel(BackupItem $item): string
{
$summary = static::backupItemQualitySummary($item);
return implode(' • ', array_filter([
$item->resolvedDisplayName(),
$summary->compactSummary,
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
]));
}
private static function restoreItemQualityHelperText(): string
{
return 'Quality hints describe input strength before risk checks. Include foundations with policies when you need ID re-mapping context.';
}
private static function backupSetQualitySummary(BackupSet $backupSet): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->summarizeBackupSet($backupSet);
}
private static function backupItemQualitySummary(BackupItem $backupItem): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forBackupItem($backupItem);
}
private static function restoreWizardViewData(Get $get, int $currentStep, bool $compactFlow = false): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
return RestoreRunCreatePresenter::contract(
data: static::draftDataSnapshot($get),
currentStep: $currentStep,
compactFlow: $compactFlow,
tenant: $tenant instanceof ManagedEnvironment ? $tenant : null,
user: $user instanceof User ? $user : null,
);
}
/**
* @param array<string, mixed> $data
* @return array{
* decisionCard: array<string, mixed>,
* backupQualityCard: array<string, mixed>,
* processFlow: array<string, mixed>,
* proofAside: array<string, mixed>,
* mappingResolver: array<string, mixed>,
* diagnosticsDisclosure: array<string, string>
* }
*/
private static function restoreWizardPresentationState(array $data): array
{
static $memo = [];
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$draft = static::synchronizeRestoreSafetyDraft($data);
$cacheKey = md5(serialize([
'tenant_id' => $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null,
'user_id' => $user instanceof User ? (int) $user->getKey() : null,
'draft' => $draft,
]));
if (array_key_exists($cacheKey, $memo)) {
return $memo[$cacheKey];
}
$wizardSafetyState = static::wizardSafetyState($draft);
$currentScope = is_array($wizardSafetyState['currentScope'] ?? null) ? $wizardSafetyState['currentScope'] : [];
$previewIntegrity = is_array($wizardSafetyState['previewIntegrity'] ?? null) ? $wizardSafetyState['previewIntegrity'] : [];
$checksIntegrity = is_array($wizardSafetyState['checksIntegrity'] ?? null) ? $wizardSafetyState['checksIntegrity'] : [];
$executionReadiness = is_array($wizardSafetyState['executionReadiness'] ?? null) ? $wizardSafetyState['executionReadiness'] : [];
$safetyAssessment = is_array($wizardSafetyState['safetyAssessment'] ?? null) ? $wizardSafetyState['safetyAssessment'] : [];
$backupSetId = is_numeric($currentScope['backup_set_id'] ?? null)
? (int) $currentScope['backup_set_id']
: null;
$scopeMode = ($currentScope['scope_mode'] ?? null) === 'selected' ? 'selected' : 'all';
$selectedItemIds = array_values(array_filter(
is_array($currentScope['selected_item_ids'] ?? null) ? $currentScope['selected_item_ids'] : [],
static fn (mixed $itemId): bool => is_int($itemId) || (is_string($itemId) && ctype_digit($itemId))
));
$groupMapping = static::normalizeGroupMapping($currentScope['group_mapping'] ?? []);
$groupMappingProgress = static::groupMappingProgressValues($currentScope['group_mapping'] ?? []);
$backupSet = static::restoreWizardSelectedBackupSet($backupSetId, $tenant);
$backupQuality = $backupSet instanceof BackupSet
? static::backupSetQualitySummary($backupSet)
: null;
$selectedItemCount = count($selectedItemIds);
$unresolvedGroups = ($tenant instanceof ManagedEnvironment) && $backupSetId !== null
? static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant,
)
: [];
$unresolvedGroupCount = count($unresolvedGroups);
$resolvedGroupCount = 0;
$skippedGroupCount = 0;
$pendingGroupCount = 0;
$manualFallbackCount = 0;
$mappingValues = [];
$resolvedTargetIds = [];
foreach ($unresolvedGroups as $group) {
$groupId = is_string($group['id'] ?? null) ? $group['id'] : null;
$value = $groupId !== null ? trim((string) ($groupMappingProgress[$groupId] ?? '')) : '';
if ($groupId !== null) {
$mappingValues[$groupId] = $value;
}
if ($value === '') {
$pendingGroupCount++;
continue;
}
if (strtoupper($value) === 'SKIP') {
$skippedGroupCount++;
continue;
}
if (! Str::isUuid($value)) {
$pendingGroupCount++;
continue;
}
$resolvedGroupCount++;
$resolvedTargetIds[] = strtolower($value);
}
$cachedTargetLookup = [];
if ($tenant instanceof ManagedEnvironment && $resolvedTargetIds !== []) {
$cachedTargetIds = EntraGroup::query()
->where('managed_environment_id', $tenant->getKey())
->whereIn('entra_id', array_values(array_unique($resolvedTargetIds)))
->pluck('entra_id')
->map(static fn (string $entraId): string => strtolower($entraId))
->values()
->all();
$cachedTargetLookup = array_fill_keys($cachedTargetIds, true);
}
foreach ($mappingValues as $value) {
if ($value === '' || strtoupper($value) === 'SKIP' || ! Str::isUuid($value)) {
continue;
}
if (! array_key_exists(strtolower($value), $cachedTargetLookup)) {
$manualFallbackCount++;
}
}
$mappedGroupCount = $resolvedGroupCount + $skippedGroupCount;
$hasUsableSource = $backupSet instanceof BackupSet
&& $backupQuality instanceof BackupQualitySummary
&& $backupQuality->totalItems > 0
&& ($backupQuality->totalItems - $backupQuality->metadataOnlyCount) > 0;
$targetSelected = $tenant instanceof ManagedEnvironment;
$checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT;
$previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT;
$executionTechnicallyAllowed = (bool) ($executionReadiness['allowed'] ?? false);
$scopeDefined = $backupSetId !== null && ($scopeMode === 'all' || $selectedItemCount > 0);
$scopeDependencyResolved = $scopeDefined
&& $mappedGroupCount >= $unresolvedGroupCount
&& ($checksAreCurrent || $previewIsCurrent);
$executionAvailableAfterConfirmation = $executionTechnicallyAllowed && $checksAreCurrent && $previewIsCurrent;
$scopeDescription = static::restoreWizardScopeDescription(
scopeMode: $scopeMode,
selectedItemCount: $selectedItemCount,
unresolvedGroupCount: $unresolvedGroupCount,
backupSetSelected: $backupSet instanceof BackupSet,
);
$sourceSummary = match (true) {
! ($backupSet instanceof BackupSet) => 'Select a backup set with usable captured items before judging restore viability.',
! ($backupQuality instanceof BackupQualitySummary) => 'Backup quality is unavailable for the selected source.',
$backupQuality->totalItems === 0 => 'The selected backup does not contain any captured items yet.',
! $hasUsableSource => 'The selected backup does not contain a usable captured item yet.',
default => 'A usable source backup is selected for this restore draft.',
};
$scopeDependencySummary = match (true) {
! $scopeDefined => 'Define the restore scope before validation can prove the current draft.',
$unresolvedGroupCount > $mappedGroupCount => sprintf(
'Resolve %d remaining group mapping%s before validation can prove the current draft.',
$unresolvedGroupCount - $mappedGroupCount,
($unresolvedGroupCount - $mappedGroupCount) === 1 ? '' : 's',
),
$scopeDependencyResolved => 'Scope and dependency mapping are resolved for the current draft.',
default => 'Validation has not yet confirmed the current scope and dependency mapping.',
};
$executionGateSummary = match (true) {
! $scopeDefined => 'Define the restore scope before validation can run.',
$unresolvedGroupCount > $mappedGroupCount => 'Resolve required mappings before validation can run.',
! $executionTechnicallyAllowed => 'Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.',
$executionAvailableAfterConfirmation => 'Execution becomes available after explicit confirmation. Post-run evidence starts only after execution.',
default => 'Execution and post-run evidence remain unavailable until required safety gates are complete.',
};
$decisionCard = static::restoreWizardDecisionCard(
backupSet: $backupSet,
backupQuality: $backupQuality,
hasUsableSource: $hasUsableSource,
checksIntegrity: $checksIntegrity,
previewIntegrity: $previewIntegrity,
executionReadiness: $executionReadiness,
safetyAssessment: $safetyAssessment,
);
if ($backupSet instanceof BackupSet && $hasUsableSource && (! $scopeDependencyResolved || ! $checksAreCurrent || ! $previewIsCurrent)) {
$decisionCard['nextAction'] = 'Continue to scope and resolve required mappings.';
} elseif ($backupSet instanceof BackupSet && ! $executionTechnicallyAllowed) {
$decisionCard['nextAction'] = 'Review prerequisites before execution.';
}
$backupQualityCard = static::restoreWizardBackupQualityCard($backupQuality, $hasUsableSource);
$processFlow = [
'compact' => false,
'title' => 'Restore safety gates',
'gatesTotal' => 7,
'gatesComplete' => count(array_filter([
$hasUsableSource,
$targetSelected,
$scopeDependencyResolved,
$checksAreCurrent,
$previewIsCurrent,
])),
'nextGate' => match (true) {
! $hasUsableSource => 'Usable source selected',
! $targetSelected => 'Target selected',
! $scopeDependencyResolved => 'Scope/dependency mapping',
! $checksAreCurrent => 'Validation',
! $previewIsCurrent => 'Preview',
! $executionTechnicallyAllowed => 'Execution prerequisites',
default => 'Confirmation',
},
'executionLabel' => $executionAvailableAfterConfirmation ? 'Available after confirmation' : 'Unavailable',
'executionSummary' => $executionGateSummary,
'steps' => [
[
'step' => 1,
'label' => 'Usable source selected',
'summary' => $sourceSummary,
'status' => static::restoreWizardGateStatus($hasUsableSource, required: true),
],
[
'step' => 2,
'label' => 'Target selected',
'summary' => 'Target environment is the route-bound managed environment.',
'status' => static::restoreWizardGateStatus($targetSelected, required: true),
],
[
'step' => 3,
'label' => 'Scope/dependency mapping',
'summary' => $scopeDependencySummary,
'status' => static::restoreWizardGateStatus($scopeDependencyResolved, required: true),
],
[
'step' => 4,
'label' => 'Validation',
'summary' => $checksAreCurrent
? 'Checks evidence is current for the selected restore scope.'
: 'Run checks for the current scope before confirmation.',
'status' => static::restoreWizardGateStatus($checksAreCurrent, required: true),
],
[
'step' => 5,
'label' => 'Preview',
'summary' => $previewIsCurrent
? 'Preview evidence is current for the selected restore scope.'
: 'Generate a preview for the current scope before confirmation.',
'status' => static::restoreWizardGateStatus($previewIsCurrent, required: true),
],
[
'step' => 6,
'label' => 'Confirmation',
'summary' => $executionAvailableAfterConfirmation
? 'Explicit confirmation is the next required gate before real execution.'
: 'Confirmation stays unavailable until validation and preview evidence are current.',
'status' => static::restoreWizardGateStatus(false, required: $executionAvailableAfterConfirmation),
],
[
'step' => 7,
'label' => 'Execution and evidence',
'summary' => $executionGateSummary,
'status' => static::restoreWizardGateStatus(
false,
required: $executionAvailableAfterConfirmation,
blocked: ! $executionTechnicallyAllowed,
),
],
],
];
$proofAside = [
'title' => 'Restore Proof',
'items' => static::restoreWizardProofItems(
backupSet: $backupSet,
tenant: $tenant,
user: $user instanceof User ? $user : null,
backupQuality: $backupQuality,
hasUsableSource: $hasUsableSource,
scopeDefined: $scopeDefined,
scopeDescription: $scopeDescription,
),
];
$groupSyncUrl = $tenant instanceof ManagedEnvironment
? EntraGroupResource::getUrl('index', tenant: $tenant)
: null;
$groupSyncOperationsUrl = $tenant instanceof ManagedEnvironment
? OperationRunLinks::index($tenant, operationType: 'directory.groups.sync')
: null;
$groupCacheQuery = $tenant instanceof ManagedEnvironment
? EntraGroup::query()->where('managed_environment_id', $tenant->getKey())
: null;
$hasCachedGroups = $groupCacheQuery?->exists() ?? false;
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
$latestSeen = $groupCacheQuery?->max('last_seen_at');
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
$groupCacheNotice = match (true) {
$unresolvedGroupCount === 0 => null,
! $hasCachedGroups => 'No cached directory groups are available for this environment. Open group sync, then return to this mapping.',
$isStale => "Cached directory groups may be stale (>{$stalenessDays} days). Review group sync freshness before choosing a target.",
default => null,
};
return $memo[$cacheKey] = [
'decisionCard' => $decisionCard,
'backupQualityCard' => $backupQualityCard,
'processFlow' => $processFlow,
'proofAside' => $proofAside,
'mappingResolver' => [
'resolvedCount' => $resolvedGroupCount,
'totalCount' => $unresolvedGroupCount,
'unresolvedCount' => $pendingGroupCount,
'skippedCount' => $skippedGroupCount,
'manualFallbackCount' => $manualFallbackCount,
'requiresResolution' => $pendingGroupCount > 0,
'requirementLabel' => $pendingGroupCount > 0
? 'Resolve required mappings before validation can run.'
: 'Mappings are resolved for the current draft.',
'explanation' => 'Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.',
'cacheNotice' => $groupCacheNotice,
'groupSyncUrl' => $groupCacheNotice !== null ? $groupSyncUrl : null,
'groupSyncOperationsUrl' => $groupCacheNotice !== null ? $groupSyncOperationsUrl : null,
],
'diagnosticsDisclosure' => [
'label' => 'Diagnostics - Collapsed',
'summary' => 'Diagnostics stay collapsed by default. Raw payload is intentionally hidden on the create wizard surface unless explicit support review is needed.',
],
];
}
private static function restoreWizardSelectedBackupSet(?int $backupSetId, ?ManagedEnvironment $tenant): ?BackupSet
{
if (! ($tenant instanceof ManagedEnvironment) || $backupSetId === null || $backupSetId <= 0) {
return null;
}
return BackupSet::query()
->where('managed_environment_id', (int) $tenant->getKey())
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->find($backupSetId);
}
/**
* @param array<string, mixed> $checksIntegrity
* @param array<string, mixed> $previewIntegrity
* @param array<string, mixed> $executionReadiness
* @param array<string, mixed> $safetyAssessment
* @return array<string, mixed>
*/
private static function restoreWizardDecisionCard(
?BackupSet $backupSet,
?BackupQualitySummary $backupQuality,
bool $hasUsableSource,
array $checksIntegrity,
array $previewIntegrity,
array $executionReadiness,
array $safetyAssessment,
): array {
$checksAreCurrent = ($checksIntegrity['state'] ?? null) === ChecksIntegrityState::STATE_CURRENT;
$previewIsCurrent = ($previewIntegrity['state'] ?? null) === PreviewIntegrityState::STATE_CURRENT;
$executionTechnicallyAllowed = (bool) ($executionReadiness['allowed'] ?? false);
$safetyState = is_string($safetyAssessment['state'] ?? null) ? $safetyAssessment['state'] : null;
$assessmentNextAction = is_string($safetyAssessment['primary_next_action'] ?? null)
? $safetyAssessment['primary_next_action']
: 'review_scope';
$status = match (true) {
! ($backupSet instanceof BackupSet) => 'Source required',
! $hasUsableSource => 'Source unavailable',
$safetyState !== null => RestoreSafetyCopy::safetyStateLabel($safetyState),
default => 'Unavailable',
};
$reason = match (true) {
! ($backupSet instanceof BackupSet) => 'No backup set is selected yet.',
$backupQuality instanceof BackupQualitySummary && $backupQuality->totalItems === 0 => $backupQuality->summaryMessage,
$backupQuality instanceof BackupQualitySummary && ! $hasUsableSource => 'The selected backup captures metadata only or otherwise lacks a usable payload for restore.',
! $executionTechnicallyAllowed => (string) ($executionReadiness['display_summary'] ?? 'Execution prerequisites are unavailable.'),
! $checksAreCurrent => (string) ($checksIntegrity['display_summary'] ?? 'Checks evidence is not current for the selected scope.'),
! $previewIsCurrent => (string) ($previewIntegrity['display_summary'] ?? 'Preview evidence is not current for the selected scope.'),
default => (string) ($safetyAssessment['summary'] ?? 'Restore safety state is available for the selected scope.'),
};
$impact = match (true) {
! ($backupSet instanceof BackupSet) => 'Restore safety cannot be judged until a source backup is selected.',
! $hasUsableSource && $backupQuality instanceof BackupQualitySummary => $backupQuality->positiveClaimBoundary,
! $executionTechnicallyAllowed => 'Provider readiness or restore prerequisites currently prevent real execution.',
! $checksAreCurrent || ! $previewIsCurrent => 'Confirmation and real execution must stay blocked until current validation and preview evidence exist.',
($safetyAssessment['state'] ?? null) === 'ready_with_caution' => 'Execution can start, but calm safety claims stay suppressed until warnings are reviewed.',
default => 'Execution can move toward confirmation, but recovery is not yet verified before post-run evidence exists.',
};
$primaryNextAction = match (true) {
! ($backupSet instanceof BackupSet) => 'Select a backup set to establish the restore source.',
! $hasUsableSource && $backupQuality instanceof BackupQualitySummary => $backupQuality->nextAction,
default => RestoreSafetyCopy::primaryNextAction($assessmentNextAction),
};
$tone = match (true) {
! ($backupSet instanceof BackupSet) => 'gray',
! $hasUsableSource => 'warning',
($safetyAssessment['state'] ?? null) === 'ready' => 'success',
($safetyAssessment['state'] ?? null) === 'ready_with_caution' => 'warning',
default => 'danger',
};
return [
'title' => 'Restore Safety',
'statusLabel' => 'Status',
'reasonLabel' => 'Reason',
'impactLabel' => 'Impact',
'nextActionLabel' => 'Primary next action',
'status' => $status,
'reason' => $reason,
'impact' => $impact,
'nextAction' => $primaryNextAction,
'helperText' => 'This create flow does not prove recoverability before execution and post-run evidence exist.',
'tone' => $tone,
];
}
/**
* @return array<string, mixed>
*/
private static function restoreWizardBackupQualityCard(?BackupQualitySummary $backupQuality, bool $hasUsableSource): array
{
if (! $backupQuality instanceof BackupQualitySummary) {
return [
'available' => false,
'status' => 'Select a backup set to inspect input quality.',
'summary' => 'Backup quality hints describe input strength only.',
'nextAction' => 'Select a backup set to inspect item counts and degradations.',
'positiveClaimBoundary' => 'Input quality signals do not prove that execution is safe or that recovery is verified.',
'counts' => [],
];
}
$status = match (true) {
! $hasUsableSource => 'No usable captured source yet',
$backupQuality->hasDegradations() => 'Usable source with degradations',
default => 'Usable source available',
};
return [
'available' => true,
'status' => $status,
'summary' => $backupQuality->summaryMessage,
'nextAction' => $backupQuality->nextAction,
'positiveClaimBoundary' => $backupQuality->positiveClaimBoundary,
'counts' => [
['label' => 'Item count', 'value' => $backupQuality->totalItems],
['label' => 'Degraded items', 'value' => $backupQuality->degradedItemCount],
['label' => 'Metadata-only items', 'value' => $backupQuality->metadataOnlyCount],
['label' => 'Assignment issues', 'value' => $backupQuality->assignmentIssueCount],
['label' => 'Orphaned assignments', 'value' => $backupQuality->orphanedAssignmentCount],
],
];
}
private static function restoreWizardScopeDescription(
string $scopeMode,
int $selectedItemCount,
int $unresolvedGroupCount,
bool $backupSetSelected,
): string {
if (! $backupSetSelected) {
return 'Restore scope is unavailable until a backup set is selected.';
}
$scopeDescription = $scopeMode === 'selected'
? ($selectedItemCount > 0
? $selectedItemCount.' selected '.Str::plural('item', $selectedItemCount).' currently define the restore scope.'
: 'Select at least one backup item to define a narrowed restore scope.')
: 'Current restore scope includes all captured items in the selected backup set.';
if ($unresolvedGroupCount > 0) {
return $scopeDescription.' '.$unresolvedGroupCount.' group '.($unresolvedGroupCount === 1 ? 'mapping remains' : 'mappings remain').' for review.';
}
return $scopeDescription.' No unresolved group-based dependencies are currently detected.';
}
/**
* @return list<array{label:string,value:string,description:string,tone:string}>
*/
private static function restoreWizardProofItems(
?BackupSet $backupSet,
?ManagedEnvironment $tenant,
?User $user,
?BackupQualitySummary $backupQuality,
bool $hasUsableSource,
bool $scopeDefined,
string $scopeDescription,
): array {
$sourceDescription = match (true) {
! ($backupSet instanceof BackupSet) => 'No backup source is recorded yet.',
$backupQuality instanceof BackupQualitySummary => implode(' • ', array_filter([
$backupSet->name,
$backupQuality->compactSummary,
])),
default => (string) ($backupSet->name ?? 'Selected backup source'),
};
return [
[
'label' => 'Source backup',
'value' => ! ($backupSet instanceof BackupSet) ? 'Pending' : ($hasUsableSource ? 'Complete' : 'Unavailable'),
'description' => $sourceDescription,
'tone' => ! ($backupSet instanceof BackupSet) ? 'gray' : ($hasUsableSource ? 'success' : 'warning'),
],
[
'label' => 'Target environment',
'value' => $tenant instanceof ManagedEnvironment ? 'Complete' : 'Unavailable',
'description' => $tenant instanceof ManagedEnvironment
? (string) ($tenant->name ?? $tenant->managed_environment_id ?? $tenant->getKey())
: 'Target environment is unavailable.',
'tone' => $tenant instanceof ManagedEnvironment ? 'success' : 'warning',
],
[
'label' => 'Requested by',
'value' => $user instanceof User ? 'Recorded' : 'Unavailable',
'description' => $user instanceof User
? (string) ($user->email ?? $user->name ?? 'Authenticated operator')
: 'The current requestor is unavailable.',
'tone' => $user instanceof User ? 'success' : 'warning',
],
[
'label' => 'Restore scope',
'value' => $scopeDefined ? 'Complete' : 'Pending',
'description' => $scopeDescription,
'tone' => $scopeDefined ? 'success' : 'warning',
],
[
'label' => 'Operation proof',
'value' => 'Unavailable',
'description' => 'Operation proof is unavailable before execution.',
'tone' => 'gray',
],
[
'label' => 'Post-run evidence',
'value' => 'Unavailable',
'description' => 'Post-run evidence is unavailable before execution.',
'tone' => 'gray',
],
];
}
private static function restoreWizardGateStatus(bool $complete, bool $required = false, bool $blocked = false): string
{
if ($blocked) {
return 'blocked';
}
if ($complete) {
return 'complete';
}
return $required ? 'required' : 'unavailable';
}
public static function createRestoreRun(array $data): RestoreRun
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
/** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
if ($backupSet->managed_environment_id !== $tenant->id) {
abort(403, 'Backup set does not belong to the active environment.');
}
/** @var RestoreService $service */
$service = app(RestoreService::class);
$scopeMode = $data['scope_mode'] ?? 'all';
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
$data = static::synchronizeRestoreSafetyDraft([
...$data,
'group_mapping' => $groupMapping,
]);
$restoreSafetyResolver = static::restoreSafetyResolver();
$scopeBasis = is_array($data['scope_basis'] ?? null)
? $data['scope_basis']
: $restoreSafetyResolver->scopeBasisFromData($data);
$checkBasis = is_array($data['check_basis'] ?? null)
? $data['check_basis']
: $restoreSafetyResolver->checksBasisFromData($data);
$previewBasis = is_array($data['preview_basis'] ?? null)
? $data['preview_basis']
: $restoreSafetyResolver->previewBasisFromData($data);
$data = [
...$data,
'scope_basis' => $scopeBasis,
'check_basis' => $checkBasis,
'preview_basis' => $previewBasis,
];
$checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null;
$checksRanAt = $data['checks_ran_at'] ?? null;
$previewSummary = $data['preview_summary'] ?? null;
$previewDiffs = $data['preview_diffs'] ?? null;
$previewRanAt = $data['preview_ran_at'] ?? null;
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
if (! $isDryRun) {
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
$previewIntegrity = $restoreSafetyResolver->previewIntegrityFromData($data);
$checksIntegrity = $restoreSafetyResolver->checksIntegrityFromData($data);
$assessment = $restoreSafetyResolver->safetyAssessment($tenant, $user, $data);
if ($checksIntegrity->state === ChecksIntegrityState::STATE_NOT_RUN) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks before executing.',
]);
}
if ($checksIntegrity->state !== ChecksIntegrityState::STATE_CURRENT) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks again for the current scope before executing.',
]);
}
if ($checksIntegrity->blockingCount > 0 || $assessment->state === 'blocked') {
throw ValidationException::withMessages([
'check_summary' => 'Blocking checks must be resolved before executing.',
]);
}
if ($previewIntegrity->state === PreviewIntegrityState::STATE_NOT_GENERATED) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview before executing.',
]);
}
if ($previewIntegrity->state !== PreviewIntegrityState::STATE_CURRENT) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview again for the current scope before executing.',
]);
}
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
throw ValidationException::withMessages([
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
]);
}
$tenantConfirm = $data['tenant_confirm'] ?? null;
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
throw ValidationException::withMessages([
'tenant_confirm' => 'ManagedEnvironment hard-confirm does not match.',
]);
}
}
if ($isDryRun) {
$restoreRun = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: true,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $groupMapping,
);
$metadata = $restoreRun->metadata ?? [];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$metadata['scope_basis'] = $scopeBasis;
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$restoreRun->update(['metadata' => $metadata]);
return $restoreRun->refresh();
}
$preview = $service->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'scope_basis' => $scopeBasis,
];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$metadata['execution_safety_snapshot'] = $restoreSafetyResolver
->executionSafetySnapshot($tenant, $user, $data)
->toArray();
try {
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if (! in_array($result->status, ['started', 'deduped'], true)) {
throw new \Filament\Support\Exceptions\Halt;
}
if (! $restoreRun instanceof RestoreRun) {
throw new \RuntimeException('Restore execution was accepted without creating a restore run.');
}
return $restoreRun;
}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, mixed> $preview
* @param array<string, mixed> $metadata
* @param array<string, mixed> $groupMapping
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
*/
private static function startQueuedRestoreExecution(
ManagedEnvironment $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
array $preview,
array $metadata,
array $groupMapping,
?string $actorEmail,
?string $actorName,
): array {
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$initiator = auth()->user();
$initiator = $initiator instanceof User ? $initiator : null;
static::guardRestoreExecutionOperationalControl(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
initiator: $initiator,
);
$queuedRestoreRun = null;
$dispatcher = function (OperationRun $run) use (
$tenant,
$backupSet,
$selectedItemIds,
$preview,
$metadata,
$groupMapping,
$actorEmail,
$actorName,
$idempotencyKey,
&$queuedRestoreRun,
): void {
$queuedRestoreRun = RestoreRun::create([
'managed_environment_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'operation_run_id' => $run->getKey(),
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
$context = is_array($run->context) ? $run->context : [];
$context['restore_run_id'] = (int) $queuedRestoreRun->getKey();
$run->forceFill(['context' => $context])->save();
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $queuedRestoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $queuedRestoreRun->id,
status: 'success',
);
$providerConnectionId = is_numeric($context['provider_connection_id'] ?? null)
? (int) $context['provider_connection_id']
: null;
ExecuteRestoreRunJob::dispatch(
restoreRunId: (int) $queuedRestoreRun->getKey(),
actorEmail: $actorEmail,
actorName: $actorName,
operationRun: $run,
providerConnectionId: $providerConnectionId,
)->afterCommit();
};
if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'restore.execute',
dispatcher: $dispatcher,
initiator: $initiator,
extraContext: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
);
} else {
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant,
type: 'restore.execute',
identityInputs: [
'idempotency_key' => $idempotencyKey,
],
context: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
],
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
$dispatcher($run);
$result = ProviderOperationStartResult::started($run, true);
} else {
$result = ProviderOperationStartResult::deduped($run);
}
}
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') {
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
if (is_numeric($restoreRunId)) {
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first();
}
$queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun(
(int) $tenant->getKey(),
$idempotencyKey,
);
}
return [$result, $queuedRestoreRun?->refresh()];
}
/**
* @param array<int>|null $selectedItemIds
*/
private static function guardRestoreExecutionOperationalControl(
ManagedEnvironment $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
?User $initiator,
): void {
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Restore execution requires a workspace context.');
}
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
if (! $decision->isPaused()) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: [
'metadata' => array_filter([
'control_key' => $decision->controlKey,
'scope_type' => $decision->matchedScopeType,
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => $decision->reasonText,
'expires_at' => $decision->expiresAt?->toIso8601String(),
'actor_id' => $initiator?->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null,
'requested_scope' => 'restore.execute',
], static fn (mixed $value): bool => $value !== null && $value !== ''),
],
actor: $initiator,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: OperationCatalog::label('restore.execute'),
summary: 'Restore execution blocked by operational control',
tenant: $tenant,
);
throw OperationalControlBlockedException::forDecision(
decision: $decision,
actionLabel: OperationCatalog::label('restore.execute'),
);
}
/**
* @param array<int>|null $selectedItemIds
*/
private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
{
$query = $backupSet->items()->select(['id', 'policy_type']);
if (is_array($selectedItemIds) && $selectedItemIds !== []) {
$query->whereIn('id', $selectedItemIds);
}
return $query->get()->contains(function (BackupItem $item): bool {
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
return $restoreMode !== 'preview-only';
});
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function synchronizeRestoreSafetyDraft(array $data): array
{
$resolver = static::restoreSafetyResolver();
$scope = $resolver->scopeFingerprintFromData($data);
$data['scope_basis'] = $resolver->scopeBasisFromData($data);
$data['check_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
currentScope: $scope,
basis: is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null,
explicitReasons: $data['check_invalidation_reasons'] ?? null,
);
$data['preview_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
currentScope: $scope,
basis: is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null,
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
);
return $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private static function wizardSafetyState(array $data): array
{
$data = static::synchronizeRestoreSafetyDraft($data);
$resolver = static::restoreSafetyResolver();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$scope = $resolver->scopeFingerprintFromData($data)->toArray();
$previewIntegrity = $resolver->previewIntegrityFromData($data);
$checksIntegrity = $resolver->checksIntegrityFromData($data);
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
return [
'currentScope' => $scope,
'previewIntegrity' => $previewIntegrity->toArray(),
'checksIntegrity' => $checksIntegrity->toArray(),
'executionReadiness' => null,
'safetyAssessment' => null,
'providerConnectionsUrl' => null,
];
}
$assessment = $resolver->safetyAssessment($tenant, $user, $data);
$providerResolution = app(ProviderConnectionResolver::class)->resolveDefault($tenant, 'microsoft');
return [
'currentScope' => $scope,
'previewIntegrity' => $previewIntegrity->toArray(),
'checksIntegrity' => $checksIntegrity->toArray(),
'executionReadiness' => $assessment->executionReadiness->toArray(),
'safetyAssessment' => $assessment->toArray(),
'providerResolution' => [
'resolved' => $providerResolution->resolved,
'reasonCode' => $providerResolution->effectiveReasonCode(),
],
'providerConnectionsUrl' => \App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
];
}
/**
* @return array<string, mixed>
*/
private static function draftDataSnapshot(Get $get): array
{
return [
'backup_set_id' => $get('backup_set_id'),
'scope_mode' => $get('scope_mode'),
'backup_item_ids' => $get('backup_item_ids'),
'group_mapping' => static::normalizeGroupMapping($get('group_mapping')),
'check_summary' => $get('check_summary'),
'check_results' => $get('check_results'),
'checks_ran_at' => $get('checks_ran_at'),
'check_basis' => $get('check_basis'),
'check_invalidation_reasons' => $get('check_invalidation_reasons'),
'preview_summary' => $get('preview_summary'),
'preview_diffs' => $get('preview_diffs'),
'preview_ran_at' => $get('preview_ran_at'),
'preview_basis' => $get('preview_basis'),
'preview_invalidation_reasons' => $get('preview_invalidation_reasons'),
'scope_basis' => $get('scope_basis'),
'is_dry_run' => $get('is_dry_run'),
];
}
/**
* @return array{
* preview: array<int|string, mixed>,
* previewIntegrity: array<string, mixed>,
* checksIntegrity: array<string, mixed>,
* executionSafetySnapshot: array<string, mixed>,
* scopeBasis: array<string, mixed>
* }
*/
private static function detailPreviewState(RestoreRun $record): array
{
$resolver = static::restoreSafetyResolver();
$data = [
'backup_set_id' => $record->backup_set_id,
'scope_mode' => (string) (($record->scopeBasis()['scope_mode'] ?? null) ?: ((is_array($record->requested_items) && $record->requested_items !== []) ? 'selected' : 'all')),
'backup_item_ids' => is_array($record->requested_items) ? $record->requested_items : [],
'group_mapping' => is_array($record->group_mapping) ? $record->group_mapping : [],
'preview_basis' => $record->previewBasis(),
'check_basis' => $record->checkBasis(),
'check_summary' => is_array(($record->metadata ?? [])['check_summary'] ?? null) ? $record->metadata['check_summary'] : [],
'checks_ran_at' => $record->checkBasis()['ran_at'] ?? (($record->metadata ?? [])['checks_ran_at'] ?? null),
'preview_summary' => is_array(($record->metadata ?? [])['preview_summary'] ?? null) ? $record->metadata['preview_summary'] : [],
'preview_ran_at' => $record->previewBasis()['generated_at'] ?? (($record->metadata ?? [])['preview_ran_at'] ?? null),
];
return [
'preview' => is_array($record->preview) ? $record->preview : [],
'previewIntegrity' => $resolver->previewIntegrityFromData($data)->toArray(),
'checksIntegrity' => $resolver->checksIntegrityFromData($data)->toArray(),
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
'scopeBasis' => $record->scopeBasis(),
];
}
/**
* @return array{
* results: array<string, mixed>|array<int|string, mixed>,
* resultAttention: array<string, mixed>,
* executionSafetySnapshot: array<string, mixed>
* }
*/
private static function detailResultsState(RestoreRun $record): array
{
return [
'results' => is_array($record->results) ? $record->results : [],
'resultAttention' => static::restoreSafetyResolver()->resultAttentionForRun($record)->toArray(),
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
];
}
private static function restoreSafetyResolver(): RestoreSafetyResolver
{
return app(RestoreSafetyResolver::class);
}
/**
* @param array<int>|null $selectedItemIds
* @return array<int, array{id:string,displayName:?string}>
*/
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, ManagedEnvironment $tenant): array
{
if (! $backupSetId) {
return [];
}
$query = BackupItem::query()->where('backup_set_id', $backupSetId);
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
$items = $query->get(['assignments']);
$assignments = [];
$sourceNames = [];
foreach ($items as $item) {
if (! is_array($item->assignments) || $item->assignments === []) {
continue;
}
foreach ($item->assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (! in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true)) {
continue;
}
$groupId = $target['groupId'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$assignments[] = $groupId;
$displayName = $target['group_display_name'] ?? null;
if (is_string($displayName) && $displayName !== '') {
$sourceNames[$groupId] = $displayName;
}
}
}
$groupIds = array_values(array_unique($assignments));
if ($groupIds === []) {
return [];
}
$resolver = app(EntraGroupLabelResolver::class);
$descriptions = $resolver->describeMany($tenant, $groupIds, $sourceNames);
$unresolved = [];
foreach ($groupIds as $groupId) {
$description = $descriptions[$groupId] ?? null;
if (is_array($description) && (bool) ($description['resolved'] ?? false)) {
continue;
}
$displayName = is_array($description) ? ($description['display_name'] ?? null) : null;
$unresolved[] = [
'id' => $groupId,
'displayName' => is_string($displayName) && $displayName !== '' ? $displayName : null,
];
}
return $unresolved;
}
private static function groupMappingIdentityHelperText(?ManagedEnvironment $tenant, string $sourceGroupId, mixed $rawValue): HtmlString
{
$value = is_string($rawValue) ? trim($rawValue) : '';
$lines = [
'Source ID: '.$sourceGroupId,
];
if ($value === '') {
return new HtmlString(implode('<br>', array_map('e', $lines)));
}
if (strtoupper($value) === 'SKIP') {
$lines[] = 'Skipped';
$lines[] = 'This assignment will not be restored.';
return new HtmlString(implode('<br>', array_map('e', $lines)));
}
if (! Str::isUuid($value)) {
$lines[] = 'Invalid group object ID (GUID).';
return new HtmlString(implode('<br>', array_map('e', $lines)));
}
$normalizedTargetId = strtolower($value);
$targetDisplayName = null;
if ($tenant instanceof ManagedEnvironment) {
$targetDisplayName = EntraGroup::query()
->where('managed_environment_id', $tenant->getKey())
->where('entra_id', $normalizedTargetId)
->value('display_name');
}
if (is_string($targetDisplayName) && $targetDisplayName !== '') {
$lines[] = 'Target group: '.$targetDisplayName;
$lines[] = 'Target ID: '.$value;
return new HtmlString(implode('<br>', array_map('e', $lines)));
}
$lines[] = 'Manual target object ID';
$lines[] = $value;
$lines[] = 'Badge: Manual fallback';
return new HtmlString(implode('<br>', array_map('e', $lines)));
}
private static function touchWizardDraftAfterGroupMappingChange(Get $get, Set $set): void
{
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get));
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
}
/**
* @param array<int>|null $selectedItemIds
* @return array<string, string|null>
*/
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?ManagedEnvironment $tenant): array
{
if (! $tenant || ! $backupSetId) {
return [];
}
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant,
);
$placeholders = [];
foreach ($unresolved as $group) {
$groupId = $group['id'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$placeholders[$groupId] = null;
}
return $placeholders;
}
/**
* @return array<string, ?string>
*/
private static function groupMappingProgressValues(mixed $mapping): array
{
if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$mapping = $mapping->toArray();
}
if ($mapping instanceof \stdClass) {
$mapping = (array) $mapping;
}
if (! is_array($mapping)) {
return [];
}
$result = [];
if (array_key_exists('group_mapping', $mapping)) {
$nested = $mapping['group_mapping'];
if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) {
$nested = $nested->toArray();
}
if ($nested instanceof \stdClass) {
$nested = (array) $nested;
}
if (is_array($nested)) {
$mapping = $nested;
}
}
foreach ($mapping as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$sourceGroupId = str_starts_with($key, 'group_mapping.')
? substr($key, strlen('group_mapping.'))
: $key;
if ($sourceGroupId === '') {
continue;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if (is_array($value) || $value instanceof \stdClass) {
$value = (array) $value;
$value = $value['value'] ?? $value['id'] ?? null;
}
if (is_string($value)) {
$trimmed = trim($value);
$result[$sourceGroupId] = $trimmed !== '' ? $trimmed : null;
continue;
}
$result[$sourceGroupId] = null;
}
return $result;
}
/**
* @return array<string, string>
*/
private static function normalizeGroupMapping(mixed $mapping): array
{
if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$mapping = $mapping->toArray();
}
if ($mapping instanceof \stdClass) {
$mapping = (array) $mapping;
}
if (! is_array($mapping)) {
return [];
}
$result = [];
if (array_key_exists('group_mapping', $mapping)) {
$nested = $mapping['group_mapping'];
if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) {
$nested = $nested->toArray();
}
if ($nested instanceof \stdClass) {
$nested = (array) $nested;
}
if (is_array($nested)) {
$mapping = $nested;
}
}
foreach ($mapping as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$sourceGroupId = str_starts_with($key, 'group_mapping.')
? substr($key, strlen('group_mapping.'))
: $key;
if ($sourceGroupId === '') {
continue;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if (is_array($value) || $value instanceof \stdClass) {
$value = (array) $value;
$value = $value['value'] ?? $value['id'] ?? null;
}
if (is_string($value)) {
$value = trim($value);
$result[$sourceGroupId] = $value !== '' ? $value : null;
continue;
}
$result[$sourceGroupId] = null;
}
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
}
/**
* Build the rerun table action with UiEnforcement + write gate disabled state.
*
* UiEnforcement::apply() overrides ->disabled() and ->tooltip(), so the gate
* check must compose on top of the enforcement action AFTER apply(). This method
* extracts the rerun action into its own builder to keep the table definition clean.
*/
private static function rerunActionWithGate(): Actions\Action|BulkAction
{
/** @var Actions\Action $action */
$action = UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet;
return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$tenant = $record->tenant;
$backupSet = $record->backupSet;
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make()
->title('Restore run cannot be rerun')
->body('Restore run or backup set is archived or unavailable.')
->warning()
->send();
return;
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
resourceType: 'restore_run',
resourceId: (string) $record->getKey(),
context: [
'metadata' => [
'operation_type' => 'restore.rerun',
'reason_code' => $e->reasonCode,
'backup_set_id' => $backupSet?->getKey(),
'original_restore_run_id' => $record->getKey(),
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'rerun_of_restore_run_id' => $record->id,
];
$metadata['rerun_of_restore_run_id'] = $record->id;
try {
[$result, $newRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
return;
}
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) {
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
return;
}
try {
$newRun = $restoreService->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $record->requested_items ?? null,
dryRun: (bool) $record->is_dry_run,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
groupMapping: $record->group_mapping ?? []
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
]
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply();
// Compose write gate disabled/tooltip on top of UiEnforcement's RBAC check.
// UiEnforcement::apply() sets its own ->disabled() / ->tooltip();
// we override here to merge both concerns.
$action->disabled(function (?Model $record = null): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
// Check RBAC capability first (mirrors UiEnforcement logic)
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
// Then check write gate
return app(WriteGateInterface::class)->wouldBlock($tenant);
});
$action->tooltip(function (?Model $record = null): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return 'ManagedEnvironment unavailable';
}
// Check RBAC capability first
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
// Then check write gate
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
return $e->reasonMessage;
}
return null;
});
return $action;
}
}