942 lines
42 KiB
PHP
942 lines
42 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\Models\BackupItem;
|
|
use App\Models\BackupSet;
|
|
use App\Models\RestoreRun;
|
|
use App\Models\Tenant;
|
|
use App\Services\BulkOperationService;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GroupResolver;
|
|
use App\Services\Intune\RestoreService;
|
|
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\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
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 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
|
|
);
|
|
|
|
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
|
|
$groupId = $group['id'];
|
|
$label = $group['label'];
|
|
|
|
return Forms\Components\Select::make("group_mapping.{$groupId}")
|
|
->label($label)
|
|
->options([
|
|
'SKIP' => 'Skip assignment',
|
|
])
|
|
->searchable()
|
|
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
|
|
->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value))
|
|
->required()
|
|
->helperText('Choose a target group or select Skip.');
|
|
}, $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): void {
|
|
$set('scope_mode', 'all');
|
|
$set('backup_item_ids', null);
|
|
$set('group_mapping', []);
|
|
$set('is_dry_run', true);
|
|
})
|
|
->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, $state): void {
|
|
$set('group_mapping', []);
|
|
|
|
if ($state === 'all') {
|
|
$set('backup_item_ids', null);
|
|
}
|
|
})
|
|
->required(),
|
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
|
->label('Items to restore')
|
|
->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', []))
|
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
|
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
|
->helperText('Search by name, type, or ID. 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');
|
|
$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
|
|
);
|
|
|
|
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
|
|
$groupId = $group['id'];
|
|
$label = $group['label'];
|
|
|
|
return Forms\Components\Select::make("group_mapping.{$groupId}")
|
|
->label($label)
|
|
->options([
|
|
'SKIP' => 'Skip assignment',
|
|
])
|
|
->searchable()
|
|
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
|
|
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
|
|
->helperText('Choose a target group or select Skip.');
|
|
}, $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('Defensive checks (Phase 4)')
|
|
->schema([
|
|
Forms\Components\Placeholder::make('safety_checks_placeholder')
|
|
->label('Status')
|
|
->content('Safety & conflict checks will be added in Phase 4.'),
|
|
]),
|
|
Step::make('Preview')
|
|
->description('Dry-run preview (Phase 5)')
|
|
->schema([
|
|
Forms\Components\Toggle::make('is_dry_run')
|
|
->label('Preview only (dry-run)')
|
|
->default(true)
|
|
->disabled()
|
|
->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'),
|
|
Forms\Components\Placeholder::make('preview_placeholder')
|
|
->label('Preview')
|
|
->content('Preview diff summary will be added in Phase 5.'),
|
|
]),
|
|
Step::make('Confirm & Execute')
|
|
->description('Explicit confirmations (Phase 6)')
|
|
->schema([
|
|
Forms\Components\Placeholder::make('confirm_placeholder')
|
|
->label('Execution')
|
|
->content('Execution confirmations and gating will be added in Phase 6.'),
|
|
]),
|
|
];
|
|
}
|
|
|
|
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(),
|
|
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([
|
|
Actions\Action::make('rerun')
|
|
->label('Rerun')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->visible(function (RestoreRun $record): bool {
|
|
$backupSet = $record->backupSet;
|
|
|
|
return $record->isDeletable()
|
|
&& $backupSet !== null
|
|
&& ! $backupSet->trashed();
|
|
})
|
|
->action(function (
|
|
RestoreRun $record,
|
|
RestoreService $restoreService,
|
|
\App\Services\Intune\AuditLogger $auditLogger
|
|
) {
|
|
$tenant = $record->tenant;
|
|
$backupSet = $record->backupSet;
|
|
|
|
if (! $tenant || ! $backupSet || $backupSet->trashed()) {
|
|
Notification::make()
|
|
->title('Restore run cannot be rerun')
|
|
->body('Backup set is archived or unavailable.')
|
|
->warning()
|
|
->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,
|
|
],
|
|
]
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Restore run started')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Actions\Action::make('restore')
|
|
->label('Restore')
|
|
->color('success')
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->requiresConfirmation()
|
|
->visible(fn (RestoreRun $record) => $record->trashed())
|
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
|
$record->restore();
|
|
|
|
if ($record->tenant) {
|
|
$auditLogger->log(
|
|
tenant: $record->tenant,
|
|
action: 'restore_run.restored',
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $record->id,
|
|
status: 'success',
|
|
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
|
|
);
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Restore run restored')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Actions\Action::make('archive')
|
|
->label('Archive')
|
|
->color('danger')
|
|
->icon('heroicon-o-archive-box-x-mark')
|
|
->requiresConfirmation()
|
|
->visible(fn (RestoreRun $record) => ! $record->trashed())
|
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
|
if (! $record->isDeletable()) {
|
|
Notification::make()
|
|
->title('Restore run cannot be archived')
|
|
->body("Not deletable (status: {$record->status})")
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$record->delete();
|
|
|
|
if ($record->tenant) {
|
|
$auditLogger->log(
|
|
tenant: $record->tenant,
|
|
action: 'restore_run.deleted',
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $record->id,
|
|
status: 'success',
|
|
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
|
|
);
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Restore run archived')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Actions\Action::make('forceDelete')
|
|
->label('Force delete')
|
|
->color('danger')
|
|
->icon('heroicon-o-trash')
|
|
->requiresConfirmation()
|
|
->visible(fn (RestoreRun $record) => $record->trashed())
|
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
|
if ($record->tenant) {
|
|
$auditLogger->log(
|
|
tenant: $record->tenant,
|
|
action: 'restore_run.force_deleted',
|
|
resourceType: 'restore_run',
|
|
resourceId: (string) $record->id,
|
|
status: 'success',
|
|
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
|
|
);
|
|
}
|
|
|
|
$record->forceDelete();
|
|
|
|
Notification::make()
|
|
->title('Restore run permanently deleted')
|
|
->success()
|
|
->send();
|
|
}),
|
|
])->icon('heroicon-o-ellipsis-vertical'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
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')
|
|
->required()
|
|
->in(['DELETE'])
|
|
->validationMessages([
|
|
'in' => 'Please type DELETE to confirm.',
|
|
]),
|
|
];
|
|
}
|
|
|
|
return [];
|
|
})
|
|
->action(function (Collection $records) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
$service = app(BulkOperationService::class);
|
|
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count);
|
|
|
|
if ($count >= 20) {
|
|
Notification::make()
|
|
->title('Bulk delete started')
|
|
->body("Deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
|
|
->icon('heroicon-o-arrow-path')
|
|
->iconColor('warning')
|
|
->info()
|
|
->duration(8000)
|
|
->sendToDatabase($user)
|
|
->send();
|
|
|
|
BulkRestoreRunDeleteJob::dispatch($run->id);
|
|
} else {
|
|
BulkRestoreRunDeleteJob::dispatchSync($run->id);
|
|
}
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
|
|
BulkAction::make('bulk_restore')
|
|
->label('Restore Restore Runs')
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
$value = $trashedFilterState['value'] ?? null;
|
|
|
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
|
|
return ! $isOnlyTrashed;
|
|
})
|
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
|
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
|
->action(function (Collection $records) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
$service = app(BulkOperationService::class);
|
|
$run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count);
|
|
|
|
if ($count >= 20) {
|
|
Notification::make()
|
|
->title('Bulk restore started')
|
|
->body("Restoring {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
|
|
->icon('heroicon-o-arrow-path')
|
|
->iconColor('warning')
|
|
->info()
|
|
->duration(8000)
|
|
->sendToDatabase($user)
|
|
->send();
|
|
|
|
BulkRestoreRunRestoreJob::dispatch($run->id);
|
|
} else {
|
|
BulkRestoreRunRestoreJob::dispatchSync($run->id);
|
|
}
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
|
|
BulkAction::make('bulk_force_delete')
|
|
->label('Force Delete Restore Runs')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
$value = $trashedFilterState['value'] ?? null;
|
|
|
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
|
|
return ! $isOnlyTrashed;
|
|
})
|
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?")
|
|
->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.')
|
|
->form([
|
|
Forms\Components\TextInput::make('confirmation')
|
|
->label('Type DELETE to confirm')
|
|
->required()
|
|
->in(['DELETE'])
|
|
->validationMessages([
|
|
'in' => 'Please type DELETE to confirm.',
|
|
]),
|
|
])
|
|
->action(function (Collection $records) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
$service = app(BulkOperationService::class);
|
|
$run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count);
|
|
|
|
if ($count >= 20) {
|
|
Notification::make()
|
|
->title('Bulk force delete started')
|
|
->body("Force deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
|
|
->icon('heroicon-o-arrow-path')
|
|
->iconColor('warning')
|
|
->info()
|
|
->duration(8000)
|
|
->sendToDatabase($user)
|
|
->send();
|
|
|
|
BulkRestoreRunForceDeleteJob::dispatch($run->id);
|
|
} else {
|
|
BulkRestoreRunForceDeleteJob::dispatchSync($run->id);
|
|
}
|
|
})
|
|
->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(),
|
|
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,
|
|
];
|
|
});
|
|
}
|
|
|
|
public static function createRestoreRun(array $data): RestoreRun
|
|
{
|
|
/** @var Tenant $tenant */
|
|
$tenant = Tenant::current();
|
|
|
|
/** @var BackupSet $backupSet */
|
|
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
|
|
|
if ($backupSet->tenant_id !== $tenant->id) {
|
|
abort(403, 'Backup set does not belong to the active tenant.');
|
|
}
|
|
|
|
/** @var RestoreService $service */
|
|
$service = app(RestoreService::class);
|
|
|
|
$scopeMode = $data['scope_mode'] ?? 'all';
|
|
$selectedItemIds = ($scopeMode === 'selected')
|
|
? ($data['backup_item_ids'] ?? null)
|
|
: null;
|
|
|
|
return $service->execute(
|
|
tenant: $tenant,
|
|
backupSet: $backupSet,
|
|
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
|
dryRun: true,
|
|
actorEmail: auth()->user()?->email,
|
|
actorName: auth()->user()?->name,
|
|
groupMapping: $data['group_mapping'] ?? [],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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 [];
|
|
}
|
|
|
|
$graphOptions = $tenant->graphOptions();
|
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
|
$resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
|
|
|
|
$unresolved = [];
|
|
|
|
foreach ($groupIds as $groupId) {
|
|
$group = $resolved[$groupId] ?? null;
|
|
|
|
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
$label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId);
|
|
$unresolved[] = [
|
|
'id' => $groupId,
|
|
'label' => $label,
|
|
];
|
|
}
|
|
|
|
return $unresolved;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function targetGroupOptions(Tenant $tenant, string $search): array
|
|
{
|
|
if (mb_strlen($search) < 2) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$response = app(GraphClientInterface::class)->request(
|
|
'GET',
|
|
'groups',
|
|
[
|
|
'query' => [
|
|
'$filter' => sprintf(
|
|
"securityEnabled eq true and startswith(displayName,'%s')",
|
|
static::escapeOdataValue($search)
|
|
),
|
|
'$select' => 'id,displayName',
|
|
'$top' => 20,
|
|
],
|
|
] + $tenant->graphOptions()
|
|
);
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
return [];
|
|
}
|
|
|
|
return collect($response->data['value'] ?? [])
|
|
->filter(fn (array $group) => filled($group['id'] ?? null))
|
|
->mapWithKeys(fn (array $group) => [
|
|
$group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string
|
|
{
|
|
if (! $groupId) {
|
|
return $groupId;
|
|
}
|
|
|
|
if ($groupId === 'SKIP') {
|
|
return 'Skip assignment';
|
|
}
|
|
|
|
$graphOptions = $tenant->graphOptions();
|
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
|
$resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions);
|
|
$group = $resolved[$groupId] ?? null;
|
|
|
|
return static::formatGroupLabel($group['displayName'] ?? null, $groupId);
|
|
}
|
|
|
|
private static function formatGroupLabel(?string $displayName, string $id): string
|
|
{
|
|
$suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
|
|
|
|
return trim(($displayName ?: 'Security group').$suffix);
|
|
}
|
|
|
|
private static function escapeOdataValue(string $value): string
|
|
{
|
|
return str_replace("'", "''", $value);
|
|
}
|
|
}
|