user(); if (! $tenant instanceof Tenant || ! $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 form(Schema $schema): Schema { return $schema ->schema([ Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { $tenantId = Tenant::currentOrFail()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) ->orderByDesc('created_at') ->get() ->mapWithKeys(function (BackupSet $set) { $label = sprintf( '%s • %s items • %s', $set->name, $set->item_count ?? 0, optional($set->created_at)->format('Y-m-d H:i') ); return [$set->id => $label]; }); }) ->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('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'), Section::make('Group mapping') ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') ->schema(function (Get $get): array { $backupSetId = $get('backup_set_id'); $selectedItemIds = $get('backup_item_ids'); $tenant = Tenant::current(); if (! $tenant || ! $backupSetId) { return []; } $unresolved = static::unresolvedGroups( backupSetId: $backupSetId, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, tenant: $tenant ); $groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey()); $hasCachedGroups = $groupCacheQuery->exists(); $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); $cacheNotice = match (true) { ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", default => null, }; return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; return Forms\Components\TextInput::make("group_mapping.{$groupId}") ->label($label) ->placeholder('SKIP or target group Object ID (GUID)') ->rules([new SkipOrUuidRule]) ->required() ->suffixAction( Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) ->icon('heroicon-o-magnifying-glass') ->iconButton() ->tooltip('Select from Directory cache') ->modalHeading('Select from Directory cache') ->modalWidth('5xl') ->modalSubmitAction(false) ->modalCancelActionLabel('Close') ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ 'sourceGroupId' => $groupId, ])) ) ->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') ->hintAction( Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) ->label('Sync Groups') ->icon('heroicon-o-arrow-path') ->color('warning') ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) ->visible(fn (): bool => $cacheNotice !== null) ); }, $unresolved); }) ->visible(function (Get $get): bool { $backupSetId = $get('backup_set_id'); $selectedItemIds = $get('backup_item_ids'); $tenant = Tenant::current(); if (! $tenant || ! $backupSetId) { return false; } return static::unresolvedGroups( backupSetId: $backupSetId, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, tenant: $tenant ) !== []; }), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true), ]); } /** * @return array */ public static function getWizardSteps(): array { return [ Step::make('Select Backup Set') ->description('What are we restoring from?') ->schema([ Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { $tenantId = Tenant::currentOrFail()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) ->orderByDesc('created_at') ->get() ->mapWithKeys(function (BackupSet $set) { $label = sprintf( '%s • %s items • %s', $set->name, $set->item_count ?? 0, optional($set->created_at)->format('Y-m-d H:i') ); return [$set->id => $label]; }); }) ->reactive() ->afterStateUpdated(function (Set $set, Get $get): void { $set('scope_mode', 'all'); $set('backup_item_ids', null); $set('group_mapping', static::groupMappingPlaceholders( backupSetId: $get('backup_set_id'), scopeMode: 'all', selectedItemIds: null, tenant: Tenant::current(), )); $set('is_dry_run', true); $set('acknowledged_impact', false); $set('tenant_confirm', null); $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); $set('preview_summary', null); $set('preview_diffs', []); $set('preview_ran_at', null); }) ->required(), ]), Step::make('Define Restore Scope') ->description('What exactly should be restored?') ->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 = Tenant::current(); $set('is_dry_run', true); $set('acknowledged_impact', false); $set('tenant_confirm', null); $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); $set('preview_summary', null); $set('preview_diffs', []); $set('preview_ran_at', null); if ($state === 'all') { $set('backup_item_ids', null); $set('group_mapping', static::groupMappingPlaceholders( backupSetId: $backupSetId, scopeMode: 'all', selectedItemIds: null, tenant: $tenant, )); return; } $set('group_mapping', []); $set('backup_item_ids', []); }) ->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 = Tenant::current(); $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); $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); $set('preview_summary', null); $set('preview_diffs', []); $set('preview_ran_at', null); }) ->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('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'), Section::make('Group mapping') ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') ->schema(function (Get $get): array { $backupSetId = $get('backup_set_id'); $scopeMode = $get('scope_mode') ?? 'all'; $selectedItemIds = $get('backup_item_ids'); $tenant = Tenant::current(); 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 ); $groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey()); $hasCachedGroups = $groupCacheQuery->exists(); $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); $cacheNotice = match (true) { ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", default => null, }; return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; return Forms\Components\TextInput::make("group_mapping.{$groupId}") ->label($label) ->placeholder('SKIP or target group Object ID (GUID)') ->rules([new SkipOrUuidRule]) ->reactive() ->afterStateUpdated(function (Set $set): void { $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); $set('preview_summary', null); $set('preview_diffs', []); $set('preview_ran_at', null); }) ->required() ->suffixAction( Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) ->icon('heroicon-o-magnifying-glass') ->iconButton() ->tooltip('Select from Directory cache') ->modalHeading('Select from Directory cache') ->modalWidth('5xl') ->modalSubmitAction(false) ->modalCancelActionLabel('Close') ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ 'sourceGroupId' => $groupId, ])) ) ->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') ->hintAction( Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) ->label('Sync Groups') ->icon('heroicon-o-arrow-path') ->color('warning') ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) ->visible(fn (): bool => $cacheNotice !== null) ); }, $unresolved); }) ->visible(function (Get $get): bool { $backupSetId = $get('backup_set_id'); $scopeMode = $get('scope_mode') ?? 'all'; $selectedItemIds = $get('backup_item_ids'); $tenant = Tenant::current(); 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 ) !== []; }), ]), Step::make('Safety & Conflict Checks') ->description('Is this dangerous?') ->schema([ Forms\Components\Hidden::make('check_summary') ->default(null), Forms\Components\Hidden::make('checks_ran_at') ->default(null), Forms\Components\ViewField::make('check_results') ->label('Checks') ->default([]) ->view('filament.forms.components.restore-run-checks') ->viewData(fn (Get $get): array => [ 'summary' => $get('check_summary'), 'ranAt' => $get('checks_ran_at'), ]) ->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'))) ->action(function (Get $get, Set $set): void { $tenant = Tenant::current(); if (! $tenant) { return; } $backupSetId = $get('backup_set_id'); if (! $backupSetId) { return; } $backupSet = BackupSet::find($backupSetId); if (! $backupSet || $backupSet->tenant_id !== $tenant->id) { Notification::make() ->title('Unable to run checks') ->body('Backup set is not available for the active tenant.') ->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); $outcome = $checker->check( tenant: $tenant, backupSet: $backupSet, selectedItemIds: $selectedItemIds, groupMapping: $groupMapping, ); $set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true); $set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true); $set('checks_ran_at', now()->toIso8601String(), 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); } Notification::make() ->title('Safety checks completed') ->body("Blocking: {$blockers} • Warnings: {$warnings}") ->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); }), ]) ->helperText('Run checks after defining scope and mapping missing groups.'), ]), Step::make('Preview') ->description('Dry-run preview') ->schema([ Forms\Components\Hidden::make('preview_summary') ->default(null), Forms\Components\Hidden::make('preview_ran_at') ->default(null) ->required(), 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'), ]) ->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 = Tenant::current(); if (! $tenant) { return; } $backupSetId = $get('backup_set_id'); if (! $backupSetId) { return; } $backupSet = BackupSet::find($backupSetId); if (! $backupSet || $backupSet->tenant_id !== $tenant->id) { Notification::make() ->title('Unable to generate preview') ->body('Backup set is not available for the active tenant.') ->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'] ?? []; $set('preview_summary', $summary, shouldCallUpdatedHooks: true); $set('preview_diffs', $diffs, shouldCallUpdatedHooks: true); $set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true); $policiesChanged = (int) ($summary['policies_changed'] ?? 0); $policiesTotal = (int) ($summary['policies_total'] ?? 0); Notification::make() ->title('Preview generated') ->body("Policies: {$policiesChanged}/{$policiesTotal} changed") ->status($policiesChanged > 0 ? 'warning' : 'success') ->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); }), ]) ->helperText('Generate a normalized diff preview before creating the dry-run restore.'), ]), Step::make('Confirm & Execute') ->description('Point of no return') ->schema([ Forms\Components\Placeholder::make('confirm_environment') ->label('Environment') ->content(fn (): string => app()->environment('production') ? 'prod' : 'test'), Forms\Components\Placeholder::make('confirm_tenant_label') ->label('Tenant hard-confirm label') ->content(function (): string { $tenant = Tenant::current(); if (! $tenant) { return ''; } $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); }), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true) ->reactive() ->disabled(function (Get $get): bool { if (! filled($get('checks_ran_at'))) { return true; } $summary = $get('check_summary'); if (! is_array($summary)) { return false; } return (int) ($summary['blocking'] ?? 0) > 0; }) ->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'), 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 tenant 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 = Tenant::current(); if (! $tenant) { return []; } $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())]; }) ->validationMessages([ 'in' => 'Tenant hard-confirm does not match.', ]) ->helperText(function (): string { $tenant = Tenant::current(); if (! $tenant) { return ''; } $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); return "Type: {$expected}"; }), ]), ]; } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), 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)), 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('Succeeded') ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)), Tables\Columns\TextColumn::make('summary_failed') ->label('Failed') ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)), Tables\Columns\TextColumn::make('started_at')->dateTime()->since(), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), ]) ->filters([ TrashedFilter::make() ->label('Archived') ->placeholder('Active') ->trueLabel('All') ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make(), ActionGroup::make([ 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 ) { $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; } 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->tenant_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, ]; $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( tenantId: (int) $tenant->getKey(), backupSetId: (int) $backupSet->getKey(), selectedItemIds: $selectedItemIds, groupMapping: $groupMapping, ); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() ->title('Restore already queued') ->body('Reusing the active restore run.') ->info() ->send(); return; } try { $newRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, '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, ]); } catch (QueryException $exception) { $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() ->title('Restore already queued') ->body('Reusing the active restore run.') ->info() ->send(); return; } throw $exception; } $auditLogger->log( tenant: $tenant, action: 'restore.queued', context: [ 'metadata' => [ 'restore_run_id' => $newRun->id, 'backup_set_id' => $backupSet->id, 'rerun_of_restore_run_id' => $record->id, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $newRun->id, status: 'success', ); ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); $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, ); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast('restore.execute') ->send(); 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 () => Tenant::current(), ) ->requireCapability(Capabilities::TENANT_MANAGE) ->preserveVisibility() ->apply(), 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->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 () => Tenant::current(), ) ->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) { 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 () => Tenant::current(), ) ->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) { 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 () => Tenant::current(), ) ->requireCapability(Capabilities::TENANT_DELETE) ->preserveVisibility() ->apply(), ])->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 = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); if (! $tenant instanceof Tenant) { 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->tenant_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('View run') ->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 = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); if (! $tenant instanceof Tenant) { 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->tenant_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('View run') ->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 = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); if (! $tenant instanceof Tenant) { 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->tenant_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('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); }) ->deselectRecordsAfterCompletion(), ) ->requireCapability(Capabilities::TENANT_DELETE) ->apply(), ]), ]); } 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 • Succeeded: %d • Failed: %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 ($record) => $record->preview ?? []), Infolists\Components\ViewEntry::make('results') ->label('Results') ->view('filament.infolists.entries.restore-results') ->state(fn ($record) => $record->results ?? []), ]); } 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 */ 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, descriptions: array} */ private static function restoreItemOptionData(?int $backupSetId): array { $tenant = Tenant::current(); 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('tenant_id', $tenant->getKey())) ->where(function ($query) { $query->whereNull('policy_id') ->orWhereDoesntHave('policy') ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); }) ->with(['policy:id,display_name', '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); $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; $options[$item->id] = $displayName; $parts = array_filter([ $category, $typeLabel, $platform, "restore: {$restore}", $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> */ private static function restoreItemGroupedOptions(?int $backupSetId): array { $tenant = Tenant::current(); if (! $tenant || ! $backupSetId) { return []; } $items = BackupItem::query() ->where('backup_set_id', $backupSetId) ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) ->where(function ($query) { $query->whereNull('policy_id') ->orWhereDoesntHave('policy') ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); }) ->with(['policy:id,display_name']) ->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] = $item->resolvedDisplayName(); } return $groups; } public static function createRestoreRun(array $data): RestoreRun { $tenant = Tenant::current(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $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->tenant_id !== $tenant->id) { abort(403, 'Backup set does not belong to the active tenant.'); } /** @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); $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->tenant_id ?? $tenant->external_id; $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); if (! $isDryRun) { if (! is_array($checkSummary) || ! filled($checksRanAt)) { throw ValidationException::withMessages([ 'check_summary' => 'Run safety checks before executing.', ]); } $blocking = (int) ($checkSummary['blocking'] ?? 0); $hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0)); if ($blocking > 0 || $hasBlockers) { throw ValidationException::withMessages([ 'check_summary' => 'Blocking checks must be resolved before executing.', ]); } if (! filled($previewRanAt)) { throw ValidationException::withMessages([ 'preview_ran_at' => 'Generate preview 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' => 'Tenant 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; } $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, ]; 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; } $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( tenantId: (int) $tenant->getKey(), backupSetId: (int) $backupSet->getKey(), selectedItemIds: $selectedItemIds, groupMapping: $groupMapping, ); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() ->title('Restore already queued') ->body('Reusing the active restore run.') ->info() ->send(); return $existing; } try { $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, '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, ]); } catch (QueryException $exception) { $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() ->title('Restore already queued') ->body('Reusing the active restore run.') ->info() ->send(); return $existing; } throw $exception; } app(AuditLogger::class)->log( tenant: $tenant, action: 'restore.queued', context: [ 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'backup_set_id' => $backupSet->id, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $restoreRun->id, status: 'success', ); ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); return $restoreRun->refresh(); } /** * @param array|null $selectedItemIds * @return array */ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $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); $cached = $resolver->lookupMany($tenant, $groupIds); return array_map(function (string $groupId) use ($sourceNames, $cached): array { $cachedName = $cached[strtolower($groupId)] ?? null; $fallbackName = $cachedName ?? ($sourceNames[$groupId] ?? null); return [ 'id' => $groupId, 'label' => EntraGroupLabelResolver::formatLabel($fallbackName, $groupId), ]; }, $groupIds); } /** * @param array|null $selectedItemIds * @return array */ private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $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 */ 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 !== ''); } }