1854 lines
88 KiB
PHP
1854 lines
88 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\RestoreRunResource\Pages;
|
|
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\RestoreRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Rules\SkipOrUuidRule;
|
|
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\Support\Auth\Capabilities;
|
|
use App\Support\Auth\UiEnforcement;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\RestoreRunIdempotency;
|
|
use App\Support\RestoreRunStatus;
|
|
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\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\Tables;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\TrashedFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use UnitEnum;
|
|
|
|
class RestoreRunResource extends Resource
|
|
{
|
|
protected static ?string $model = RestoreRun::class;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
|
|
|
public static function canCreate(): bool
|
|
{
|
|
return UiEnforcement::for(Capabilities::TENANT_MANAGE)->isAllowed();
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Forms\Components\Select::make('backup_set_id')
|
|
->label('Backup set')
|
|
->options(function () {
|
|
$tenantId = Tenant::current()->getKey();
|
|
|
|
return BackupSet::query()
|
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
|
->orderByDesc('created_at')
|
|
->get()
|
|
->mapWithKeys(function (BackupSet $set) {
|
|
$label = sprintf(
|
|
'%s • %s items • %s',
|
|
$set->name,
|
|
$set->item_count ?? 0,
|
|
optional($set->created_at)->format('Y-m-d H:i')
|
|
);
|
|
|
|
return [$set->id => $label];
|
|
});
|
|
})
|
|
->reactive()
|
|
->afterStateUpdated(function (Set $set): void {
|
|
$set('scope_mode', 'all');
|
|
$set('backup_item_ids', null);
|
|
$set('group_mapping', []);
|
|
$set('is_dry_run', true);
|
|
})
|
|
->required(),
|
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
|
->label('Items to restore (optional)')
|
|
->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options'])
|
|
->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions'])
|
|
->columns(1)
|
|
->searchable()
|
|
->bulkToggleable()
|
|
->reactive()
|
|
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
|
->helperText('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'),
|
|
Section::make('Group mapping')
|
|
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
|
->schema(function (Get $get): array {
|
|
$backupSetId = $get('backup_set_id');
|
|
$selectedItemIds = $get('backup_item_ids');
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant || ! $backupSetId) {
|
|
return [];
|
|
}
|
|
|
|
$unresolved = static::unresolvedGroups(
|
|
backupSetId: $backupSetId,
|
|
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
|
tenant: $tenant
|
|
);
|
|
|
|
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
|
$hasCachedGroups = $groupCacheQuery->exists();
|
|
|
|
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
|
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
|
$latestSeen = $groupCacheQuery->max('last_seen_at');
|
|
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
|
|
|
|
$cacheNotice = match (true) {
|
|
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
|
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
|
default => null,
|
|
};
|
|
|
|
return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput {
|
|
$groupId = $group['id'];
|
|
$label = $group['label'];
|
|
|
|
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
|
|
->label($label)
|
|
->placeholder('SKIP or target group Object ID (GUID)')
|
|
->rules([new SkipOrUuidRule])
|
|
->required()
|
|
->suffixAction(
|
|
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
|
|
->icon('heroicon-o-magnifying-glass')
|
|
->iconButton()
|
|
->tooltip('Select from Directory cache')
|
|
->modalHeading('Select from Directory cache')
|
|
->modalWidth('5xl')
|
|
->modalSubmitAction(false)
|
|
->modalCancelActionLabel('Close')
|
|
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
|
|
'sourceGroupId' => $groupId,
|
|
]))
|
|
)
|
|
->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
|
->hintAction(
|
|
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
|
->label('Sync Groups')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('warning')
|
|
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
|
->visible(fn (): bool => $cacheNotice !== null)
|
|
);
|
|
}, $unresolved);
|
|
})
|
|
->visible(function (Get $get): bool {
|
|
$backupSetId = $get('backup_set_id');
|
|
$selectedItemIds = $get('backup_item_ids');
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant || ! $backupSetId) {
|
|
return false;
|
|
}
|
|
|
|
return static::unresolvedGroups(
|
|
backupSetId: $backupSetId,
|
|
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
|
tenant: $tenant
|
|
) !== [];
|
|
}),
|
|
Forms\Components\Toggle::make('is_dry_run')
|
|
->label('Preview only (dry-run)')
|
|
->default(true),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Step>
|
|
*/
|
|
public static function getWizardSteps(): array
|
|
{
|
|
return [
|
|
Step::make('Select Backup Set')
|
|
->description('What are we restoring from?')
|
|
->schema([
|
|
Forms\Components\Select::make('backup_set_id')
|
|
->label('Backup set')
|
|
->options(function () {
|
|
$tenantId = Tenant::current()->getKey();
|
|
|
|
return BackupSet::query()
|
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
|
->orderByDesc('created_at')
|
|
->get()
|
|
->mapWithKeys(function (BackupSet $set) {
|
|
$label = sprintf(
|
|
'%s • %s items • %s',
|
|
$set->name,
|
|
$set->item_count ?? 0,
|
|
optional($set->created_at)->format('Y-m-d H:i')
|
|
);
|
|
|
|
return [$set->id => $label];
|
|
});
|
|
})
|
|
->reactive()
|
|
->afterStateUpdated(function (Set $set, Get $get): void {
|
|
$set('scope_mode', 'all');
|
|
$set('backup_item_ids', null);
|
|
$set('group_mapping', static::groupMappingPlaceholders(
|
|
backupSetId: $get('backup_set_id'),
|
|
scopeMode: 'all',
|
|
selectedItemIds: null,
|
|
tenant: Tenant::current(),
|
|
));
|
|
$set('is_dry_run', true);
|
|
$set('acknowledged_impact', false);
|
|
$set('tenant_confirm', null);
|
|
$set('check_summary', null);
|
|
$set('check_results', []);
|
|
$set('checks_ran_at', null);
|
|
$set('preview_summary', null);
|
|
$set('preview_diffs', []);
|
|
$set('preview_ran_at', null);
|
|
})
|
|
->required(),
|
|
]),
|
|
Step::make('Define Restore Scope')
|
|
->description('What exactly should be restored?')
|
|
->schema([
|
|
Forms\Components\Radio::make('scope_mode')
|
|
->label('Scope')
|
|
->options([
|
|
'all' => 'All items (default)',
|
|
'selected' => 'Selected items only',
|
|
])
|
|
->default('all')
|
|
->reactive()
|
|
->afterStateUpdated(function (Set $set, Get $get, $state): void {
|
|
$backupSetId = $get('backup_set_id');
|
|
$tenant = Tenant::current();
|
|
$set('is_dry_run', true);
|
|
$set('acknowledged_impact', false);
|
|
$set('tenant_confirm', null);
|
|
$set('check_summary', null);
|
|
$set('check_results', []);
|
|
$set('checks_ran_at', null);
|
|
$set('preview_summary', null);
|
|
$set('preview_diffs', []);
|
|
$set('preview_ran_at', null);
|
|
|
|
if ($state === 'all') {
|
|
$set('backup_item_ids', null);
|
|
$set('group_mapping', static::groupMappingPlaceholders(
|
|
backupSetId: $backupSetId,
|
|
scopeMode: 'all',
|
|
selectedItemIds: null,
|
|
tenant: $tenant,
|
|
));
|
|
|
|
return;
|
|
}
|
|
|
|
$set('group_mapping', []);
|
|
$set('backup_item_ids', []);
|
|
})
|
|
->required(),
|
|
Forms\Components\Select::make('backup_item_ids')
|
|
->label('Items to restore')
|
|
->multiple()
|
|
->searchable()
|
|
->searchValues()
|
|
->searchDebounce(400)
|
|
->optionsLimit(300)
|
|
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
|
|
->reactive()
|
|
->afterStateUpdated(function (Set $set, Get $get): void {
|
|
$backupSetId = $get('backup_set_id');
|
|
$selectedItemIds = $get('backup_item_ids');
|
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
|
$tenant = Tenant::current();
|
|
|
|
$set('group_mapping', static::groupMappingPlaceholders(
|
|
backupSetId: $backupSetId,
|
|
scopeMode: 'selected',
|
|
selectedItemIds: $selectedItemIds,
|
|
tenant: $tenant,
|
|
));
|
|
$set('is_dry_run', true);
|
|
$set('acknowledged_impact', false);
|
|
$set('tenant_confirm', null);
|
|
$set('check_summary', null);
|
|
$set('check_results', []);
|
|
$set('checks_ran_at', null);
|
|
$set('preview_summary', null);
|
|
$set('preview_diffs', []);
|
|
$set('preview_ran_at', null);
|
|
})
|
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
|
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
|
->hintActions([
|
|
Actions\Action::make('select_all_backup_items')
|
|
->label('Select all')
|
|
->icon('heroicon-o-check')
|
|
->color('gray')
|
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')) && $get('scope_mode') === 'selected')
|
|
->action(function (Get $get, Set $set): void {
|
|
$groupedOptions = static::restoreItemGroupedOptions($get('backup_set_id'));
|
|
|
|
$allItemIds = [];
|
|
|
|
foreach ($groupedOptions as $options) {
|
|
$allItemIds = array_merge($allItemIds, array_keys($options));
|
|
}
|
|
|
|
$set('backup_item_ids', array_values($allItemIds), shouldCallUpdatedHooks: true);
|
|
}),
|
|
Actions\Action::make('clear_backup_items')
|
|
->label('Clear')
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
|
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
|
|
])
|
|
->helperText('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'),
|
|
Section::make('Group mapping')
|
|
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
|
->schema(function (Get $get): array {
|
|
$backupSetId = $get('backup_set_id');
|
|
$scopeMode = $get('scope_mode') ?? 'all';
|
|
$selectedItemIds = $get('backup_item_ids');
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant || ! $backupSetId) {
|
|
return [];
|
|
}
|
|
|
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
|
|
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
|
return [];
|
|
}
|
|
|
|
$unresolved = static::unresolvedGroups(
|
|
backupSetId: $backupSetId,
|
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
|
tenant: $tenant
|
|
);
|
|
|
|
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
|
$hasCachedGroups = $groupCacheQuery->exists();
|
|
|
|
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
|
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
|
$latestSeen = $groupCacheQuery->max('last_seen_at');
|
|
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
|
|
|
|
$cacheNotice = match (true) {
|
|
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
|
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
|
default => null,
|
|
};
|
|
|
|
return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput {
|
|
$groupId = $group['id'];
|
|
$label = $group['label'];
|
|
|
|
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
|
|
->label($label)
|
|
->placeholder('SKIP or target group Object ID (GUID)')
|
|
->rules([new SkipOrUuidRule])
|
|
->reactive()
|
|
->afterStateUpdated(function (Set $set): void {
|
|
$set('check_summary', null);
|
|
$set('check_results', []);
|
|
$set('checks_ran_at', null);
|
|
$set('preview_summary', null);
|
|
$set('preview_diffs', []);
|
|
$set('preview_ran_at', null);
|
|
})
|
|
->required()
|
|
->suffixAction(
|
|
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
|
|
->icon('heroicon-o-magnifying-glass')
|
|
->iconButton()
|
|
->tooltip('Select from Directory cache')
|
|
->modalHeading('Select from Directory cache')
|
|
->modalWidth('5xl')
|
|
->modalSubmitAction(false)
|
|
->modalCancelActionLabel('Close')
|
|
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
|
|
'sourceGroupId' => $groupId,
|
|
]))
|
|
)
|
|
->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
|
->hintAction(
|
|
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
|
->label('Sync Groups')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('warning')
|
|
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
|
->visible(fn (): bool => $cacheNotice !== null)
|
|
);
|
|
}, $unresolved);
|
|
})
|
|
->visible(function (Get $get): bool {
|
|
$backupSetId = $get('backup_set_id');
|
|
$scopeMode = $get('scope_mode') ?? 'all';
|
|
$selectedItemIds = $get('backup_item_ids');
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant || ! $backupSetId) {
|
|
return false;
|
|
}
|
|
|
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
|
|
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
|
return false;
|
|
}
|
|
|
|
return static::unresolvedGroups(
|
|
backupSetId: $backupSetId,
|
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
|
tenant: $tenant
|
|
) !== [];
|
|
}),
|
|
]),
|
|
Step::make('Safety & Conflict Checks')
|
|
->description('Is this dangerous?')
|
|
->schema([
|
|
Forms\Components\Hidden::make('check_summary')
|
|
->default(null),
|
|
Forms\Components\Hidden::make('checks_ran_at')
|
|
->default(null),
|
|
Forms\Components\ViewField::make('check_results')
|
|
->label('Checks')
|
|
->default([])
|
|
->view('filament.forms.components.restore-run-checks')
|
|
->viewData(fn (Get $get): array => [
|
|
'summary' => $get('check_summary'),
|
|
'ranAt' => $get('checks_ran_at'),
|
|
])
|
|
->hintActions([
|
|
Actions\Action::make('run_restore_checks')
|
|
->label('Run checks')
|
|
->icon('heroicon-o-shield-check')
|
|
->color('gray')
|
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
|
->action(function (Get $get, Set $set): void {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant) {
|
|
return;
|
|
}
|
|
|
|
$backupSetId = $get('backup_set_id');
|
|
|
|
if (! $backupSetId) {
|
|
return;
|
|
}
|
|
|
|
$backupSet = BackupSet::find($backupSetId);
|
|
|
|
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
|
Notification::make()
|
|
->title('Unable to run checks')
|
|
->body('Backup set is not available for the active tenant.')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$scopeMode = $get('scope_mode') ?? 'all';
|
|
$selectedItemIds = ($scopeMode === 'selected')
|
|
? ($get('backup_item_ids') ?? null)
|
|
: null;
|
|
|
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
|
|
|
$groupMapping = static::normalizeGroupMapping($get('group_mapping'));
|
|
|
|
$checker = app(RestoreRiskChecker::class);
|
|
$outcome = $checker->check(
|
|
tenant: $tenant,
|
|
backupSet: $backupSet,
|
|
selectedItemIds: $selectedItemIds,
|
|
groupMapping: $groupMapping,
|
|
);
|
|
|
|
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
|
|
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
|
|
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
|
|
|
$summary = $outcome['summary'] ?? [];
|
|
$blockers = (int) ($summary['blocking'] ?? 0);
|
|
$warnings = (int) ($summary['warning'] ?? 0);
|
|
|
|
if ($blockers > 0) {
|
|
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Safety checks completed')
|
|
->body("Blocking: {$blockers} • Warnings: {$warnings}")
|
|
->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success'))
|
|
->send();
|
|
}),
|
|
Actions\Action::make('clear_restore_checks')
|
|
->label('Clear')
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary')))
|
|
->action(function (Set $set): void {
|
|
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
|
|
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
|
|
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
|
|
$set('check_summary', null, shouldCallUpdatedHooks: true);
|
|
$set('check_results', [], shouldCallUpdatedHooks: true);
|
|
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
|
|
}),
|
|
])
|
|
->helperText('Run checks after defining scope and mapping missing groups.'),
|
|
]),
|
|
Step::make('Preview')
|
|
->description('Dry-run preview')
|
|
->schema([
|
|
Forms\Components\Hidden::make('preview_summary')
|
|
->default(null),
|
|
Forms\Components\Hidden::make('preview_ran_at')
|
|
->default(null)
|
|
->required(),
|
|
Forms\Components\ViewField::make('preview_diffs')
|
|
->label('Preview')
|
|
->default([])
|
|
->view('filament.forms.components.restore-run-preview')
|
|
->viewData(fn (Get $get): array => [
|
|
'summary' => $get('preview_summary'),
|
|
'ranAt' => $get('preview_ran_at'),
|
|
])
|
|
->hintActions([
|
|
Actions\Action::make('run_restore_preview')
|
|
->label('Generate preview')
|
|
->icon('heroicon-o-eye')
|
|
->color('gray')
|
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
|
->action(function (Get $get, Set $set): void {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant) {
|
|
return;
|
|
}
|
|
|
|
$backupSetId = $get('backup_set_id');
|
|
|
|
if (! $backupSetId) {
|
|
return;
|
|
}
|
|
|
|
$backupSet = BackupSet::find($backupSetId);
|
|
|
|
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
|
Notification::make()
|
|
->title('Unable to generate preview')
|
|
->body('Backup set is not available for the active tenant.')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$scopeMode = $get('scope_mode') ?? 'all';
|
|
$selectedItemIds = ($scopeMode === 'selected')
|
|
? ($get('backup_item_ids') ?? null)
|
|
: null;
|
|
|
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
|
|
|
$generator = app(RestoreDiffGenerator::class);
|
|
$outcome = $generator->generate(
|
|
tenant: $tenant,
|
|
backupSet: $backupSet,
|
|
selectedItemIds: $selectedItemIds,
|
|
);
|
|
|
|
$summary = $outcome['summary'] ?? [];
|
|
$diffs = $outcome['diffs'] ?? [];
|
|
|
|
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
|
|
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
|
|
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
|
|
|
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
|
|
|
Notification::make()
|
|
->title('Preview generated')
|
|
->body("Policies: {$policiesChanged}/{$policiesTotal} changed")
|
|
->status($policiesChanged > 0 ? 'warning' : 'success')
|
|
->send();
|
|
}),
|
|
Actions\Action::make('clear_restore_preview')
|
|
->label('Clear')
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
|
|
->action(function (Set $set): void {
|
|
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
|
|
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
|
|
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
|
|
$set('preview_summary', null, shouldCallUpdatedHooks: true);
|
|
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
|
|
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
|
|
}),
|
|
])
|
|
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
|
|
]),
|
|
Step::make('Confirm & Execute')
|
|
->description('Point of no return')
|
|
->schema([
|
|
Forms\Components\Placeholder::make('confirm_environment')
|
|
->label('Environment')
|
|
->content(fn (): string => app()->environment('production') ? 'prod' : 'test'),
|
|
Forms\Components\Placeholder::make('confirm_tenant_label')
|
|
->label('Tenant hard-confirm label')
|
|
->content(function (): string {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant) {
|
|
return '';
|
|
}
|
|
|
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
|
|
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
|
}),
|
|
Forms\Components\Toggle::make('is_dry_run')
|
|
->label('Preview only (dry-run)')
|
|
->default(true)
|
|
->reactive()
|
|
->disabled(function (Get $get): bool {
|
|
if (! filled($get('checks_ran_at'))) {
|
|
return true;
|
|
}
|
|
|
|
$summary = $get('check_summary');
|
|
|
|
if (! is_array($summary)) {
|
|
return false;
|
|
}
|
|
|
|
return (int) ($summary['blocking'] ?? 0) > 0;
|
|
})
|
|
->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'),
|
|
Forms\Components\Checkbox::make('acknowledged_impact')
|
|
->label('I reviewed the impact (checks + preview)')
|
|
->accepted()
|
|
->visible(fn (Get $get): bool => $get('is_dry_run') === false),
|
|
Forms\Components\TextInput::make('tenant_confirm')
|
|
->label('Type the tenant label to confirm execution')
|
|
->required(fn (Get $get): bool => $get('is_dry_run') === false)
|
|
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
|
|
->in(function (): array {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant) {
|
|
return [];
|
|
}
|
|
|
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
|
|
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
|
|
})
|
|
->validationMessages([
|
|
'in' => 'Tenant hard-confirm does not match.',
|
|
])
|
|
->helperText(function (): string {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant) {
|
|
return '';
|
|
}
|
|
|
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
|
|
|
return "Type: {$expected}";
|
|
}),
|
|
]),
|
|
];
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
|
|
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
|
|
Tables\Columns\TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
|
|
Tables\Columns\TextColumn::make('summary_total')
|
|
->label('Total')
|
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
|
Tables\Columns\TextColumn::make('summary_succeeded')
|
|
->label('Succeeded')
|
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
|
Tables\Columns\TextColumn::make('summary_failed')
|
|
->label('Failed')
|
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
|
Tables\Columns\TextColumn::make('started_at')->dateTime()->since(),
|
|
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
|
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
|
])
|
|
->filters([
|
|
TrashedFilter::make()
|
|
->label('Archived')
|
|
->placeholder('Active')
|
|
->trueLabel('All')
|
|
->falseLabel('Archived'),
|
|
])
|
|
->actions([
|
|
Actions\ViewAction::make(),
|
|
ActionGroup::make([
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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
|
|
) {
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
|
|
$tenant = $record->tenant;
|
|
$backupSet = $record->backupSet;
|
|
|
|
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
|
|
Notification::make()
|
|
->title('Restore run cannot be rerun')
|
|
->body('Restore run or backup set is archived or unavailable.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! (bool) $record->is_dry_run) {
|
|
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
|
|
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
|
|
$actorEmail = auth()->user()?->email;
|
|
$actorName = auth()->user()?->name;
|
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
|
|
|
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
|
|
$metadata = [
|
|
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
|
'environment' => app()->environment('production') ? 'prod' : 'test',
|
|
'highlander_label' => $highlanderLabel,
|
|
'confirmed_at' => now()->toIso8601String(),
|
|
'confirmed_by' => $actorEmail,
|
|
'confirmed_by_name' => $actorName,
|
|
'rerun_of_restore_run_id' => $record->id,
|
|
];
|
|
|
|
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
|
tenantId: (int) $tenant->getKey(),
|
|
backupSetId: (int) $backupSet->getKey(),
|
|
selectedItemIds: $selectedItemIds,
|
|
groupMapping: $groupMapping,
|
|
);
|
|
|
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
|
|
if ($existing) {
|
|
Notification::make()
|
|
->title('Restore already queued')
|
|
->body('Reusing the active restore run.')
|
|
->info()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$newRun = RestoreRun::create([
|
|
'tenant_id' => $tenant->id,
|
|
'backup_set_id' => $backupSet->id,
|
|
'requested_by' => $actorEmail,
|
|
'is_dry_run' => false,
|
|
'status' => RestoreRunStatus::Queued->value,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'requested_items' => $selectedItemIds,
|
|
'preview' => $preview,
|
|
'metadata' => $metadata,
|
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
|
]);
|
|
} catch (QueryException $exception) {
|
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
|
|
if ($existing) {
|
|
Notification::make()
|
|
->title('Restore already queued')
|
|
->body('Reusing the active restore run.')
|
|
->info()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'restore.queued',
|
|
context: [
|
|
'metadata' => [
|
|
'restore_run_id' => $newRun->id,
|
|
'backup_set_id' => $backupSet->id,
|
|
'rerun_of_restore_run_id' => $record->id,
|
|
],
|
|
],
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $newRun->id,
|
|
status: 'success',
|
|
);
|
|
|
|
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName);
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'restore_run.rerun',
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $newRun->id,
|
|
status: 'success',
|
|
context: [
|
|
'metadata' => [
|
|
'original_restore_run_id' => $record->id,
|
|
'backup_set_id' => $backupSet->id,
|
|
],
|
|
],
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
);
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
OperationUxPresenter::queuedToast('restore.execute')
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$newRun = $restoreService->execute(
|
|
tenant: $tenant,
|
|
backupSet: $backupSet,
|
|
selectedItemIds: $record->requested_items ?? null,
|
|
dryRun: (bool) $record->is_dry_run,
|
|
actorEmail: auth()->user()?->email,
|
|
actorName: auth()->user()?->name,
|
|
groupMapping: $record->group_mapping ?? []
|
|
);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()
|
|
->title('Restore run failed to start')
|
|
->body($throwable->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'restore_run.rerun',
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $newRun->id,
|
|
status: 'success',
|
|
context: [
|
|
'metadata' => [
|
|
'original_restore_run_id' => $record->id,
|
|
'backup_set_id' => $backupSet->id,
|
|
],
|
|
]
|
|
);
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
OperationUxPresenter::queuedToast('restore.execute')
|
|
->send();
|
|
})),
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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) {
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
|
|
$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();
|
|
})),
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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) {
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
|
|
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();
|
|
})),
|
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(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) {
|
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
|
|
|
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();
|
|
})),
|
|
])->icon('heroicon-o-ellipsis-vertical'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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) {
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
$initiator = $user instanceof User ? $user : null;
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'restore_run.delete',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
|
BulkRestoreRunDeleteJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) ($initiator?->getKey() ?? 0),
|
|
restoreRunIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $initiator,
|
|
extraContext: [
|
|
'restore_run_count' => $count,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
OperationUxPresenter::queuedToast('restore_run.delete')
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion()),
|
|
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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) {
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
$initiator = $user instanceof User ? $user : null;
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'restore_run.restore',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
|
|
if ($count >= 20) {
|
|
BulkRestoreRunRestoreJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) ($initiator?->getKey() ?? 0),
|
|
restoreRunIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
BulkRestoreRunRestoreJob::dispatchSync(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) ($initiator?->getKey() ?? 0),
|
|
restoreRunIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $initiator,
|
|
extraContext: [
|
|
'restore_run_count' => $count,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
OperationUxPresenter::queuedToast('restore_run.restore')
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion()),
|
|
|
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(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) {
|
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
|
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
$initiator = $user instanceof User ? $user : null;
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'restore_run.force_delete',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
|
|
if ($count >= 20) {
|
|
BulkRestoreRunForceDeleteJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) ($initiator?->getKey() ?? 0),
|
|
restoreRunIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
BulkRestoreRunForceDeleteJob::dispatchSync(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) ($initiator?->getKey() ?? 0),
|
|
restoreRunIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $initiator,
|
|
extraContext: [
|
|
'restore_run_count' => $count,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
OperationUxPresenter::queuedToast('restore_run.force_delete')
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion()),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
|
|
Infolists\Components\TextEntry::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
|
|
Infolists\Components\TextEntry::make('counts')
|
|
->label('Counts')
|
|
->state(function (RestoreRun $record): string {
|
|
$meta = $record->metadata ?? [];
|
|
$total = (int) ($meta['total'] ?? 0);
|
|
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
|
$failed = (int) ($meta['failed'] ?? 0);
|
|
|
|
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed);
|
|
}),
|
|
Infolists\Components\TextEntry::make('is_dry_run')
|
|
->label('Dry-run')
|
|
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
|
|
->badge(),
|
|
Infolists\Components\TextEntry::make('requested_by'),
|
|
Infolists\Components\TextEntry::make('started_at')->dateTime(),
|
|
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
|
Infolists\Components\ViewEntry::make('preview')
|
|
->label('Preview')
|
|
->view('filament.infolists.entries.restore-preview')
|
|
->state(fn ($record) => $record->preview ?? []),
|
|
Infolists\Components\ViewEntry::make('results')
|
|
->label('Results')
|
|
->view('filament.infolists.entries.restore-results')
|
|
->state(fn ($record) => $record->results ?? []),
|
|
]);
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListRestoreRuns::route('/'),
|
|
'create' => Pages\CreateRestoreRun::route('/create'),
|
|
'view' => Pages\ViewRestoreRun::route('/{record}'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<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 = Tenant::current();
|
|
|
|
if (! $tenant || ! $backupSetId) {
|
|
return [
|
|
'options' => [],
|
|
'descriptions' => [],
|
|
];
|
|
}
|
|
|
|
$cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
|
|
|
|
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
|
|
$items = BackupItem::query()
|
|
->where('backup_set_id', $backupSetId)
|
|
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
|
->where(function ($query) {
|
|
$query->whereNull('policy_id')
|
|
->orWhereDoesntHave('policy')
|
|
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
|
})
|
|
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
|
->get()
|
|
->sortBy(function (BackupItem $item) {
|
|
$meta = static::typeMeta($item->policy_type);
|
|
$category = $meta['category'] ?? 'Policies';
|
|
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
|
$name = strtolower($item->resolvedDisplayName());
|
|
|
|
return strtolower($categoryKey.'-'.$name);
|
|
});
|
|
|
|
$options = [];
|
|
$descriptions = [];
|
|
|
|
foreach ($items as $item) {
|
|
$meta = static::typeMeta($item->policy_type);
|
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
|
$category = $meta['category'] ?? 'Policies';
|
|
$restore = $meta['restore'] ?? 'enabled';
|
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
|
$displayName = $item->resolvedDisplayName();
|
|
$identifier = $item->policy_identifier ?? null;
|
|
$versionNumber = $item->policyVersion?->version_number;
|
|
|
|
$options[$item->id] = $displayName;
|
|
|
|
$parts = array_filter([
|
|
$category,
|
|
$typeLabel,
|
|
$platform,
|
|
"restore: {$restore}",
|
|
$versionNumber ? "version: {$versionNumber}" : null,
|
|
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
|
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
|
]);
|
|
|
|
$descriptions[$item->id] = implode(' • ', $parts);
|
|
}
|
|
|
|
return [
|
|
'options' => $options,
|
|
'descriptions' => $descriptions,
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<int|string, string>>
|
|
*/
|
|
private static function restoreItemGroupedOptions(?int $backupSetId): array
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant || ! $backupSetId) {
|
|
return [];
|
|
}
|
|
|
|
$items = BackupItem::query()
|
|
->where('backup_set_id', $backupSetId)
|
|
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
|
->where(function ($query) {
|
|
$query->whereNull('policy_id')
|
|
->orWhereDoesntHave('policy')
|
|
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
|
})
|
|
->with(['policy:id,display_name'])
|
|
->get()
|
|
->sortBy(function (BackupItem $item) {
|
|
$meta = static::typeMeta($item->policy_type);
|
|
$category = $meta['category'] ?? 'Policies';
|
|
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
|
$name = strtolower($item->resolvedDisplayName());
|
|
|
|
return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
|
|
});
|
|
|
|
$groups = [];
|
|
|
|
foreach ($items as $item) {
|
|
$meta = static::typeMeta($item->policy_type);
|
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
|
$category = $meta['category'] ?? 'Policies';
|
|
$platform = $item->platform ?? $meta['platform'] ?? 'all';
|
|
$restoreMode = $meta['restore'] ?? 'enabled';
|
|
|
|
$groupLabel = implode(' • ', array_filter([
|
|
$category,
|
|
$typeLabel,
|
|
$platform,
|
|
$restoreMode === 'preview-only' ? 'preview-only' : null,
|
|
]));
|
|
|
|
$groups[$groupLabel] ??= [];
|
|
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
public static function createRestoreRun(array $data): RestoreRun
|
|
{
|
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
|
|
/** @var Tenant $tenant */
|
|
$tenant = Tenant::current();
|
|
|
|
/** @var BackupSet $backupSet */
|
|
$backupSet = BackupSet::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->findOrFail($data['backup_set_id']);
|
|
|
|
/** @var RestoreService $service */
|
|
$service = app(RestoreService::class);
|
|
|
|
$scopeMode = $data['scope_mode'] ?? 'all';
|
|
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
|
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
|
|
|
$actorEmail = auth()->user()?->email;
|
|
$actorName = auth()->user()?->name;
|
|
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
|
|
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
|
|
|
|
$checkSummary = $data['check_summary'] ?? null;
|
|
$checkResults = $data['check_results'] ?? null;
|
|
$checksRanAt = $data['checks_ran_at'] ?? null;
|
|
$previewSummary = $data['preview_summary'] ?? null;
|
|
$previewDiffs = $data['preview_diffs'] ?? null;
|
|
$previewRanAt = $data['preview_ran_at'] ?? null;
|
|
|
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
|
|
|
if (! $isDryRun) {
|
|
if (! is_array($checkSummary) || ! filled($checksRanAt)) {
|
|
throw ValidationException::withMessages([
|
|
'check_summary' => 'Run safety checks before executing.',
|
|
]);
|
|
}
|
|
|
|
$blocking = (int) ($checkSummary['blocking'] ?? 0);
|
|
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0));
|
|
|
|
if ($blocking > 0 || $hasBlockers) {
|
|
throw ValidationException::withMessages([
|
|
'check_summary' => 'Blocking checks must be resolved before executing.',
|
|
]);
|
|
}
|
|
|
|
if (! filled($previewRanAt)) {
|
|
throw ValidationException::withMessages([
|
|
'preview_ran_at' => 'Generate preview before executing.',
|
|
]);
|
|
}
|
|
|
|
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
|
|
throw ValidationException::withMessages([
|
|
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
|
|
]);
|
|
}
|
|
|
|
$tenantConfirm = $data['tenant_confirm'] ?? null;
|
|
|
|
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
|
|
throw ValidationException::withMessages([
|
|
'tenant_confirm' => 'Tenant hard-confirm does not match.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
if ($isDryRun) {
|
|
$restoreRun = $service->execute(
|
|
tenant: $tenant,
|
|
backupSet: $backupSet,
|
|
selectedItemIds: $selectedItemIds,
|
|
dryRun: true,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
groupMapping: $groupMapping,
|
|
);
|
|
|
|
$metadata = $restoreRun->metadata ?? [];
|
|
|
|
if (is_array($checkSummary)) {
|
|
$metadata['check_summary'] = $checkSummary;
|
|
}
|
|
|
|
if (is_array($checkResults)) {
|
|
$metadata['check_results'] = $checkResults;
|
|
}
|
|
|
|
if (is_string($checksRanAt) && $checksRanAt !== '') {
|
|
$metadata['checks_ran_at'] = $checksRanAt;
|
|
}
|
|
|
|
if (is_array($previewSummary)) {
|
|
$metadata['preview_summary'] = $previewSummary;
|
|
}
|
|
|
|
if (is_array($previewDiffs)) {
|
|
$metadata['preview_diffs'] = $previewDiffs;
|
|
}
|
|
|
|
if (is_string($previewRanAt) && $previewRanAt !== '') {
|
|
$metadata['preview_ran_at'] = $previewRanAt;
|
|
}
|
|
|
|
$restoreRun->update(['metadata' => $metadata]);
|
|
|
|
return $restoreRun->refresh();
|
|
}
|
|
|
|
$preview = $service->preview($tenant, $backupSet, $selectedItemIds);
|
|
|
|
$metadata = [
|
|
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
|
'environment' => app()->environment('production') ? 'prod' : 'test',
|
|
'highlander_label' => $highlanderLabel,
|
|
'confirmed_at' => now()->toIso8601String(),
|
|
'confirmed_by' => $actorEmail,
|
|
'confirmed_by_name' => $actorName,
|
|
];
|
|
|
|
if (is_array($checkSummary)) {
|
|
$metadata['check_summary'] = $checkSummary;
|
|
}
|
|
|
|
if (is_array($checkResults)) {
|
|
$metadata['check_results'] = $checkResults;
|
|
}
|
|
|
|
if (is_string($checksRanAt) && $checksRanAt !== '') {
|
|
$metadata['checks_ran_at'] = $checksRanAt;
|
|
}
|
|
|
|
if (is_array($previewSummary)) {
|
|
$metadata['preview_summary'] = $previewSummary;
|
|
}
|
|
|
|
if (is_array($previewDiffs)) {
|
|
$metadata['preview_diffs'] = $previewDiffs;
|
|
}
|
|
|
|
if (is_string($previewRanAt) && $previewRanAt !== '') {
|
|
$metadata['preview_ran_at'] = $previewRanAt;
|
|
}
|
|
|
|
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
|
tenantId: (int) $tenant->getKey(),
|
|
backupSetId: (int) $backupSet->getKey(),
|
|
selectedItemIds: $selectedItemIds,
|
|
groupMapping: $groupMapping,
|
|
);
|
|
|
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
|
|
if ($existing) {
|
|
Notification::make()
|
|
->title('Restore already queued')
|
|
->body('Reusing the active restore run.')
|
|
->info()
|
|
->send();
|
|
|
|
return $existing;
|
|
}
|
|
|
|
try {
|
|
$restoreRun = RestoreRun::create([
|
|
'tenant_id' => $tenant->id,
|
|
'backup_set_id' => $backupSet->id,
|
|
'requested_by' => $actorEmail,
|
|
'is_dry_run' => false,
|
|
'status' => RestoreRunStatus::Queued->value,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'requested_items' => $selectedItemIds,
|
|
'preview' => $preview,
|
|
'metadata' => $metadata,
|
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
|
]);
|
|
} catch (QueryException $exception) {
|
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
|
|
if ($existing) {
|
|
Notification::make()
|
|
->title('Restore already queued')
|
|
->body('Reusing the active restore run.')
|
|
->info()
|
|
->send();
|
|
|
|
return $existing;
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
app(AuditLogger::class)->log(
|
|
tenant: $tenant,
|
|
action: 'restore.queued',
|
|
context: [
|
|
'metadata' => [
|
|
'restore_run_id' => $restoreRun->id,
|
|
'backup_set_id' => $backupSet->id,
|
|
],
|
|
],
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $restoreRun->id,
|
|
status: 'success',
|
|
);
|
|
|
|
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
|
|
|
|
return $restoreRun->refresh();
|
|
}
|
|
|
|
/**
|
|
* @param array<int>|null $selectedItemIds
|
|
* @return array<int, array{id:string,label:string}>
|
|
*/
|
|
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array
|
|
{
|
|
if (! $backupSetId) {
|
|
return [];
|
|
}
|
|
|
|
$query = BackupItem::query()->where('backup_set_id', $backupSetId);
|
|
|
|
if ($selectedItemIds !== null) {
|
|
$query->whereIn('id', $selectedItemIds);
|
|
}
|
|
|
|
$items = $query->get(['assignments']);
|
|
$assignments = [];
|
|
$sourceNames = [];
|
|
|
|
foreach ($items as $item) {
|
|
if (! is_array($item->assignments) || $item->assignments === []) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($item->assignments as $assignment) {
|
|
if (! is_array($assignment)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $assignment['target'] ?? [];
|
|
$odataType = $target['@odata.type'] ?? '';
|
|
|
|
if (! in_array($odataType, [
|
|
'#microsoft.graph.groupAssignmentTarget',
|
|
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
|
], true)) {
|
|
continue;
|
|
}
|
|
|
|
$groupId = $target['groupId'] ?? null;
|
|
|
|
if (! is_string($groupId) || $groupId === '') {
|
|
continue;
|
|
}
|
|
|
|
$assignments[] = $groupId;
|
|
$displayName = $target['group_display_name'] ?? null;
|
|
|
|
if (is_string($displayName) && $displayName !== '') {
|
|
$sourceNames[$groupId] = $displayName;
|
|
}
|
|
}
|
|
}
|
|
|
|
$groupIds = array_values(array_unique($assignments));
|
|
|
|
if ($groupIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$resolver = app(EntraGroupLabelResolver::class);
|
|
$cached = $resolver->lookupMany($tenant, $groupIds);
|
|
|
|
return array_map(function (string $groupId) use ($sourceNames, $cached): array {
|
|
$cachedName = $cached[strtolower($groupId)] ?? null;
|
|
$fallbackName = $cachedName ?? ($sourceNames[$groupId] ?? null);
|
|
|
|
return [
|
|
'id' => $groupId,
|
|
'label' => EntraGroupLabelResolver::formatLabel($fallbackName, $groupId),
|
|
];
|
|
}, $groupIds);
|
|
}
|
|
|
|
/**
|
|
* @param array<int>|null $selectedItemIds
|
|
* @return array<string, string|null>
|
|
*/
|
|
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $tenant): array
|
|
{
|
|
if (! $tenant || ! $backupSetId) {
|
|
return [];
|
|
}
|
|
|
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
|
return [];
|
|
}
|
|
|
|
$unresolved = static::unresolvedGroups(
|
|
backupSetId: $backupSetId,
|
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
|
tenant: $tenant,
|
|
);
|
|
|
|
$placeholders = [];
|
|
|
|
foreach ($unresolved as $group) {
|
|
$groupId = $group['id'] ?? null;
|
|
|
|
if (! is_string($groupId) || $groupId === '') {
|
|
continue;
|
|
}
|
|
|
|
$placeholders[$groupId] = null;
|
|
}
|
|
|
|
return $placeholders;
|
|
}
|
|
|
|
/**
|
|
* @return array<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 !== '');
|
|
}
|
|
}
|