3838 lines
180 KiB
PHP
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;
|
|
}
|
|
}
|