feat/049-backup-restore-job-orchestration #56
138
app/Filament/Resources/BulkOperationRunResource.php
Normal file
138
app/Filament/Resources/BulkOperationRunResource.php
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BulkOperationRunResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = BulkOperationRun::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Run')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('user.name')
|
||||||
|
->label('Initiator')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('resource')->badge(),
|
||||||
|
TextEntry::make('action')->badge(),
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)),
|
||||||
|
TextEntry::make('total_items')->label('Total')->numeric(),
|
||||||
|
TextEntry::make('processed_items')->label('Processed')->numeric(),
|
||||||
|
TextEntry::make('succeeded')->numeric(),
|
||||||
|
TextEntry::make('failed')->numeric(),
|
||||||
|
TextEntry::make('skipped')->numeric(),
|
||||||
|
TextEntry::make('created_at')->dateTime(),
|
||||||
|
TextEntry::make('updated_at')->dateTime(),
|
||||||
|
TextEntry::make('idempotency_key')->label('Idempotency key')->copyable()->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Items')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('item_ids')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (BulkOperationRun $record) => $record->item_ids ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Failures')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('failures')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (BulkOperationRun $record) => $record->failures ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('id', 'desc')
|
||||||
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
|
$tenantId = Tenant::current()->getKey();
|
||||||
|
|
||||||
|
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->label('Initiator')
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('resource')->badge(),
|
||||||
|
Tables\Columns\TextColumn::make('action')->badge(),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
|
Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(),
|
||||||
|
Tables\Columns\TextColumn::make('failed')->numeric(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('user')
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListBulkOperationRuns::route('/'),
|
||||||
|
'view' => Pages\ViewBulkOperationRun::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function statusColor(?string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'completed' => 'success',
|
||||||
|
'completed_with_errors' => 'warning',
|
||||||
|
'failed' => 'danger',
|
||||||
|
'running' => 'info',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBulkOperationRuns extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BulkOperationRunResource::class;
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewBulkOperationRun extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BulkOperationRunResource::class;
|
||||||
|
}
|
||||||
@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Services\Intune\VersionService;
|
use App\Jobs\CapturePolicySnapshotJob;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Filament\Support\Enums\Width;
|
use Filament\Support\Enums\Width;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ViewPolicy extends ViewRecord
|
class ViewPolicy extends ViewRecord
|
||||||
{
|
{
|
||||||
@ -23,7 +27,7 @@ protected function getActions(): array
|
|||||||
->label('Capture snapshot')
|
->label('Capture snapshot')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Capture snapshot now')
|
->modalHeading('Capture snapshot now')
|
||||||
->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.')
|
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Checkbox::make('include_assignments')
|
Forms\Components\Checkbox::make('include_assignments')
|
||||||
->label('Include assignments')
|
->label('Include assignments')
|
||||||
@ -37,51 +41,79 @@ protected function getActions(): array
|
|||||||
->action(function (array $data) {
|
->action(function (array $data) {
|
||||||
$policy = $this->record;
|
$policy = $this->record;
|
||||||
|
|
||||||
try {
|
|
||||||
$tenant = $policy->tenant;
|
$tenant = $policy->tenant;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant || ! $user) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy has no tenant associated.')
|
->title('Missing tenant or user context.')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$version = app(VersionService::class)->captureFromGraph(
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
tenant: $tenant,
|
tenantId: $tenant->getKey(),
|
||||||
policy: $policy,
|
operationType: 'policy.capture_snapshot',
|
||||||
createdBy: auth()->user()?->email ?? null,
|
targetId: $policy->getKey()
|
||||||
includeAssignments: $data['include_assignments'] ?? false,
|
|
||||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (($version->metadata['source'] ?? null) === 'metadata_only') {
|
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
||||||
$status = $version->metadata['original_status'] ?? null;
|
tenantId: $tenant->getKey(),
|
||||||
|
idempotencyKey: $idempotencyKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Snapshot already in progress')
|
||||||
|
->body('An active run already exists for this policy. Opening run details.')
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
||||||
|
])
|
||||||
|
->info()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkOperationService = app(BulkOperationService::class);
|
||||||
|
|
||||||
|
$run = $bulkOperationService->createRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
resource: 'policies',
|
||||||
|
action: 'capture_snapshot',
|
||||||
|
itemIds: [(string) $policy->getKey()],
|
||||||
|
totalItems: 1
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->update(['idempotency_key' => $idempotencyKey]);
|
||||||
|
|
||||||
|
CapturePolicySnapshotJob::dispatch(
|
||||||
|
bulkOperationRunId: $run->getKey(),
|
||||||
|
policyId: $policy->getKey(),
|
||||||
|
includeAssignments: (bool) ($data['include_assignments'] ?? false),
|
||||||
|
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
|
||||||
|
createdBy: $user->email ? Str::limit($user->email, 255, '') : null
|
||||||
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Snapshot captured (metadata only)')
|
->title('Snapshot queued')
|
||||||
->body(sprintf(
|
->body('A background job has been queued. You can monitor progress in the run details.')
|
||||||
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
|
->actions([
|
||||||
$status ?? 'an error'
|
\Filament\Actions\Action::make('view_run')
|
||||||
))
|
->label('View run')
|
||||||
->warning()
|
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||||
->send();
|
])
|
||||||
} else {
|
|
||||||
Notification::make()
|
|
||||||
->title('Snapshot captured successfully.')
|
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
|
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant));
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Failed to capture snapshot: '.$e->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
->color('primary'),
|
->color('primary'),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -11,12 +11,14 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Rules\SkipOrUuidRule;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RestoreDiffGenerator;
|
use App\Services\Intune\RestoreDiffGenerator;
|
||||||
use App\Services\Intune\RestoreRiskChecker;
|
use App\Services\Intune\RestoreRiskChecker;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -36,6 +38,7 @@
|
|||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -115,31 +118,7 @@ public static function form(Schema $schema): Schema
|
|||||||
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
|
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
|
||||||
->label($label)
|
->label($label)
|
||||||
->placeholder('SKIP or target group Object ID (GUID)')
|
->placeholder('SKIP or target group Object ID (GUID)')
|
||||||
->rules([
|
->rules([new SkipOrUuidRule])
|
||||||
static function (string $attribute, mixed $value, \Closure $fail): void {
|
|
||||||
if (! is_string($value)) {
|
|
||||||
$fail('Please enter SKIP or a valid UUID.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
$fail('Please enter SKIP or a valid UUID.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strtoupper($value) === 'SKIP') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Str::isUuid($value)) {
|
|
||||||
$fail('Please enter SKIP or a valid UUID.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->required()
|
->required()
|
||||||
->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.');
|
->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.');
|
||||||
}, $unresolved);
|
}, $unresolved);
|
||||||
@ -345,31 +324,7 @@ public static function getWizardSteps(): array
|
|||||||
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
|
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
|
||||||
->label($label)
|
->label($label)
|
||||||
->placeholder('SKIP or target group Object ID (GUID)')
|
->placeholder('SKIP or target group Object ID (GUID)')
|
||||||
->rules([
|
->rules([new SkipOrUuidRule])
|
||||||
static function (string $attribute, mixed $value, \Closure $fail): void {
|
|
||||||
if (! is_string($value)) {
|
|
||||||
$fail('Please enter SKIP or a valid UUID.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
$fail('Please enter SKIP or a valid UUID.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strtoupper($value) === 'SKIP') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Str::isUuid($value)) {
|
|
||||||
$fail('Please enter SKIP or a valid UUID.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->reactive()
|
->reactive()
|
||||||
->afterStateUpdated(function (Set $set): void {
|
->afterStateUpdated(function (Set $set): void {
|
||||||
$set('check_summary', null);
|
$set('check_summary', null);
|
||||||
@ -678,6 +633,15 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
|
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
|
||||||
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
|
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
|
||||||
Tables\Columns\TextColumn::make('status')->badge(),
|
Tables\Columns\TextColumn::make('status')->badge(),
|
||||||
|
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('started_at')->dateTime()->since(),
|
||||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
||||||
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
||||||
@ -722,6 +686,116 @@ public static function table(Table $table): Table
|
|||||||
return;
|
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 = RunIdempotency::restoreExecuteKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
$existing = RunIdempotency::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 = RunIdempotency::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,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run queued')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$newRun = $restoreService->execute(
|
$newRun = $restoreService->execute(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -1008,6 +1082,16 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
|
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
|
||||||
Infolists\Components\TextEntry::make('status')->badge(),
|
Infolists\Components\TextEntry::make('status')->badge(),
|
||||||
|
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')
|
Infolists\Components\TextEntry::make('is_dry_run')
|
||||||
->label('Dry-run')
|
->label('Dry-run')
|
||||||
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
|
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
|
||||||
@ -1327,17 +1411,53 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
$metadata['preview_ran_at'] = $previewRanAt;
|
$metadata['preview_ran_at'] = $previewRanAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::restoreExecuteKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
$existing = RunIdempotency::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([
|
$restoreRun = RestoreRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
'requested_by' => $actorEmail,
|
'requested_by' => $actorEmail,
|
||||||
'is_dry_run' => false,
|
'is_dry_run' => false,
|
||||||
'status' => RestoreRunStatus::Queued->value,
|
'status' => RestoreRunStatus::Queued->value,
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
'requested_items' => $selectedItemIds,
|
'requested_items' => $selectedItemIds,
|
||||||
'preview' => $preview,
|
'preview' => $preview,
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
]);
|
]);
|
||||||
|
} catch (QueryException $exception) {
|
||||||
|
$existing = RunIdempotency::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(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
|
|||||||
107
app/Jobs/CapturePolicySnapshotJob.php
Normal file
107
app/Jobs/CapturePolicySnapshotJob.php
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Intune\VersionService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CapturePolicySnapshotJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $bulkOperationRunId,
|
||||||
|
public int $policyId,
|
||||||
|
public bool $includeAssignments = true,
|
||||||
|
public bool $includeScopeTags = true,
|
||||||
|
public ?string $createdBy = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void
|
||||||
|
{
|
||||||
|
$run = BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkOperationRunId);
|
||||||
|
|
||||||
|
if (! $run) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = Policy::query()->with('tenant')->find($this->policyId);
|
||||||
|
|
||||||
|
if (! $policy || ! $policy->tenant) {
|
||||||
|
$bulkOperationService->abort($run, 'policy_not_found');
|
||||||
|
$this->notifyStatus($run, 'failed');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifyStatus($run, 'queued');
|
||||||
|
$bulkOperationService->start($run);
|
||||||
|
$this->notifyStatus($run, 'running');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$versionService->captureFromGraph(
|
||||||
|
tenant: $policy->tenant,
|
||||||
|
policy: $policy,
|
||||||
|
createdBy: $this->createdBy,
|
||||||
|
includeAssignments: $this->includeAssignments,
|
||||||
|
includeScopeTags: $this->includeScopeTags,
|
||||||
|
);
|
||||||
|
|
||||||
|
$bulkOperationService->recordSuccess($run);
|
||||||
|
$bulkOperationService->complete($run);
|
||||||
|
|
||||||
|
$this->notifyStatus($run, $run->refresh()->status);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$bulkOperationService->recordFailure(
|
||||||
|
run: $run,
|
||||||
|
itemId: (string) $policy->getKey(),
|
||||||
|
reason: $bulkOperationService->sanitizeFailureReason($e->getMessage())
|
||||||
|
);
|
||||||
|
|
||||||
|
$bulkOperationService->complete($run);
|
||||||
|
|
||||||
|
$this->notifyStatus($run->refresh(), $run->status);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyStatus(BulkOperationRun $run, string $status): void
|
||||||
|
{
|
||||||
|
if (! $run->relationLoaded('user')) {
|
||||||
|
$run->loadMissing('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run->user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedStatus = $status === 'pending' ? 'queued' : $status;
|
||||||
|
|
||||||
|
$run->user->notify(new RunStatusChangedNotification([
|
||||||
|
'tenant_id' => (int) $run->tenant_id,
|
||||||
|
'run_type' => 'bulk_operation',
|
||||||
|
'run_id' => (int) $run->getKey(),
|
||||||
|
'status' => (string) $normalizedStatus,
|
||||||
|
'counts' => [
|
||||||
|
'total' => (int) $run->total_items,
|
||||||
|
'processed' => (int) $run->processed_items,
|
||||||
|
'succeeded' => (int) $run->succeeded,
|
||||||
|
'failed' => (int) $run->failed,
|
||||||
|
'skipped' => (int) $run->skipped,
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,9 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
@ -24,7 +27,7 @@ public function __construct(
|
|||||||
public ?string $actorName = null,
|
public ?string $actorName = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
|
public function handle(RestoreService $restoreService, AuditLogger $auditLogger, BulkOperationService $bulkOperationService): void
|
||||||
{
|
{
|
||||||
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
|
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
|
||||||
|
|
||||||
@ -36,6 +39,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->notifyStatus($restoreRun, 'queued');
|
||||||
|
|
||||||
$tenant = $restoreRun->tenant;
|
$tenant = $restoreRun->tenant;
|
||||||
$backupSet = $restoreRun->backupSet;
|
$backupSet = $restoreRun->backupSet;
|
||||||
|
|
||||||
@ -46,6 +51,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
'completed_at' => CarbonImmutable::now(),
|
'completed_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->notifyStatus($restoreRun->refresh(), 'failed');
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -74,6 +81,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
'failure_reason' => null,
|
'failure_reason' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->notifyStatus($restoreRun->refresh(), 'running');
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'restore.started',
|
action: 'restore.started',
|
||||||
@ -98,17 +107,23 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
actorEmail: $this->actorEmail,
|
actorEmail: $this->actorEmail,
|
||||||
actorName: $this->actorName,
|
actorName: $this->actorName,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
$restoreRun->refresh();
|
$restoreRun->refresh();
|
||||||
|
|
||||||
|
$safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage());
|
||||||
|
|
||||||
if ($restoreRun->status === RestoreRunStatus::Running->value) {
|
if ($restoreRun->status === RestoreRunStatus::Running->value) {
|
||||||
$restoreRun->update([
|
$restoreRun->update([
|
||||||
'status' => RestoreRunStatus::Failed->value,
|
'status' => RestoreRunStatus::Failed->value,
|
||||||
'failure_reason' => $throwable->getMessage(),
|
'failure_reason' => $safeReason,
|
||||||
'completed_at' => CarbonImmutable::now(),
|
'completed_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -117,7 +132,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
'metadata' => [
|
'metadata' => [
|
||||||
'restore_run_id' => $restoreRun->id,
|
'restore_run_id' => $restoreRun->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
'reason' => $throwable->getMessage(),
|
'reason' => $safeReason,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorEmail: $this->actorEmail,
|
actorEmail: $this->actorEmail,
|
||||||
@ -131,4 +146,45 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
throw $throwable;
|
throw $throwable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function notifyStatus(RestoreRun $restoreRun, string $status): void
|
||||||
|
{
|
||||||
|
$email = $this->actorEmail;
|
||||||
|
|
||||||
|
if (! is_string($email) || $email === '') {
|
||||||
|
$email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($email) || $email === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->where('email', $email)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||||
|
$counts = [];
|
||||||
|
|
||||||
|
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
|
||||||
|
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
|
||||||
|
$counts[$key] = (int) $metadata[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'tenant_id' => (int) $restoreRun->tenant_id,
|
||||||
|
'run_type' => 'restore',
|
||||||
|
'run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($counts !== []) {
|
||||||
|
$payload['counts'] = $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->notify(new RunStatusChangedNotification($payload));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class BulkOperationRun extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'resource',
|
'resource',
|
||||||
'action',
|
'action',
|
||||||
|
'idempotency_key',
|
||||||
'status',
|
'status',
|
||||||
'total_items',
|
'total_items',
|
||||||
'processed_items',
|
'processed_items',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class RestoreRun extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_dry_run' => 'boolean',
|
'is_dry_run' => 'boolean',
|
||||||
|
'idempotency_key' => 'string',
|
||||||
'requested_items' => 'array',
|
'requested_items' => 'array',
|
||||||
'preview' => 'array',
|
'preview' => 'array',
|
||||||
'results' => 'array',
|
'results' => 'array',
|
||||||
@ -104,6 +105,15 @@ public function getAssignmentRestoreOutcomes(): array
|
|||||||
return $results['assignment_outcomes'];
|
return $results['assignment_outcomes'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($results['items']) && is_array($results['items'])) {
|
||||||
|
return collect($results['items'])
|
||||||
|
->pluck('assignment_outcomes')
|
||||||
|
->flatten(1)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
if (! is_array($results)) {
|
if (! is_array($results)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
94
app/Notifications/RunStatusChangedNotification.php
Normal file
94
app/Notifications/RunStatusChangedNotification.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class RunStatusChangedNotification extends Notification
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* tenant_id:int,
|
||||||
|
* run_type:string,
|
||||||
|
* run_id:int,
|
||||||
|
* status:string,
|
||||||
|
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
|
||||||
|
* } $metadata
|
||||||
|
*/
|
||||||
|
public function __construct(public array $metadata) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toDatabase(object $notifiable): array
|
||||||
|
{
|
||||||
|
$status = (string) ($this->metadata['status'] ?? 'queued');
|
||||||
|
$runType = (string) ($this->metadata['run_type'] ?? 'run');
|
||||||
|
$tenantId = (int) ($this->metadata['tenant_id'] ?? 0);
|
||||||
|
$runId = (int) ($this->metadata['run_id'] ?? 0);
|
||||||
|
|
||||||
|
$title = match ($status) {
|
||||||
|
'queued' => 'Run queued',
|
||||||
|
'running' => 'Run started',
|
||||||
|
'completed', 'succeeded' => 'Run completed',
|
||||||
|
'partial', 'completed_with_errors' => 'Run completed (partial)',
|
||||||
|
'failed' => 'Run failed',
|
||||||
|
default => 'Run updated',
|
||||||
|
};
|
||||||
|
|
||||||
|
$body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status);
|
||||||
|
|
||||||
|
$color = match ($status) {
|
||||||
|
'queued', 'running' => 'gray',
|
||||||
|
'completed', 'succeeded' => 'success',
|
||||||
|
'partial', 'completed_with_errors' => 'warning',
|
||||||
|
'failed' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
if (in_array($runType, ['bulk_operation', 'restore'], true) && $tenantId > 0 && $runId > 0) {
|
||||||
|
$tenant = Tenant::query()->find($tenantId);
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
$url = $runType === 'bulk_operation'
|
||||||
|
? BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
|
||||||
|
: RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant);
|
||||||
|
|
||||||
|
$actions[] = Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($url)
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'format' => 'filament',
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'color' => $color,
|
||||||
|
'duration' => 'persistent',
|
||||||
|
'actions' => $actions,
|
||||||
|
'icon' => null,
|
||||||
|
'iconColor' => null,
|
||||||
|
'status' => null,
|
||||||
|
'view' => null,
|
||||||
|
'viewData' => [
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Policies/BulkOperationRunPolicy.php
Normal file
39
app/Policies/BulkOperationRunPolicy.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class BulkOperationRunPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, BulkOperationRun $run): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $run->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -107,5 +107,6 @@ public function boot(): void
|
|||||||
});
|
});
|
||||||
|
|
||||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||||
|
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/Rules/SkipOrUuidRule.php
Normal file
37
app/Rules/SkipOrUuidRule.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SkipOrUuidRule implements ValidationRule
|
||||||
|
{
|
||||||
|
public function __construct(public bool $allowSkip = true) {}
|
||||||
|
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
$fail('Please enter SKIP or a valid UUID.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
$fail('Please enter SKIP or a valid UUID.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->allowSkip && strtoupper($value) === 'SKIP') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('Please enter SKIP or a valid UUID.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,30 @@ public function __construct(
|
|||||||
protected AuditLogger $auditLogger
|
protected AuditLogger $auditLogger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function sanitizeFailureReason(string $reason): string
|
||||||
|
{
|
||||||
|
$reason = trim($reason);
|
||||||
|
|
||||||
|
if ($reason === '') {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lower = mb_strtolower($reason);
|
||||||
|
|
||||||
|
if (
|
||||||
|
str_contains($lower, 'bearer ') ||
|
||||||
|
str_contains($lower, 'access_token') ||
|
||||||
|
str_contains($lower, 'client_secret') ||
|
||||||
|
str_contains($lower, 'authorization')
|
||||||
|
) {
|
||||||
|
return 'redacted';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = preg_replace("/\s+/u", ' ', $reason) ?? $reason;
|
||||||
|
|
||||||
|
return mb_substr($reason, 0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
public function createRun(
|
public function createRun(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
User $user,
|
User $user,
|
||||||
@ -70,6 +94,8 @@ public function recordSuccess(BulkOperationRun $run): void
|
|||||||
|
|
||||||
public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void
|
public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void
|
||||||
{
|
{
|
||||||
|
$reason = $this->sanitizeFailureReason($reason);
|
||||||
|
|
||||||
$failures = $run->failures ?? [];
|
$failures = $run->failures ?? [];
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'item_id' => $itemId,
|
'item_id' => $itemId,
|
||||||
@ -92,6 +118,8 @@ public function recordSkipped(BulkOperationRun $run): void
|
|||||||
|
|
||||||
public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void
|
public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void
|
||||||
{
|
{
|
||||||
|
$reason = $this->sanitizeFailureReason($reason);
|
||||||
|
|
||||||
$failures = $run->failures ?? [];
|
$failures = $run->failures ?? [];
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'item_id' => $itemId,
|
'item_id' => $itemId,
|
||||||
@ -164,6 +192,8 @@ public function fail(BulkOperationRun $run, string $reason): void
|
|||||||
{
|
{
|
||||||
$run->update(['status' => 'failed']);
|
$run->update(['status' => 'failed']);
|
||||||
|
|
||||||
|
$reason = $this->sanitizeFailureReason($reason);
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $run->tenant,
|
tenant: $run->tenant,
|
||||||
action: "bulk.{$run->resource}.{$run->action}.failed",
|
action: "bulk.{$run->resource}.{$run->action}.failed",
|
||||||
@ -184,6 +214,8 @@ public function abort(BulkOperationRun $run, string $reason): void
|
|||||||
{
|
{
|
||||||
$run->update(['status' => 'aborted']);
|
$run->update(['status' => 'aborted']);
|
||||||
|
|
||||||
|
$reason = $this->sanitizeFailureReason($reason);
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $run->tenant,
|
tenant: $run->tenant,
|
||||||
action: "bulk.{$run->resource}.{$run->action}.aborted",
|
action: "bulk.{$run->resource}.{$run->action}.aborted",
|
||||||
|
|||||||
@ -327,6 +327,26 @@ public function execute(
|
|||||||
$foundationEntries = $foundationOutcome['entries'] ?? [];
|
$foundationEntries = $foundationOutcome['entries'] ?? [];
|
||||||
$foundationFailures = (int) ($foundationOutcome['failed'] ?? 0);
|
$foundationFailures = (int) ($foundationOutcome['failed'] ?? 0);
|
||||||
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
|
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
|
||||||
|
|
||||||
|
$foundationBackupItemIdsBySourceId = $foundationItems
|
||||||
|
->pluck('id', 'policy_identifier')
|
||||||
|
->map(fn ($id) => is_int($id) ? $id : null)
|
||||||
|
->filter()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$foundationEntries = array_values(array_map(function (mixed $entry) use ($foundationBackupItemIdsBySourceId): mixed {
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceId = $entry['sourceId'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($sourceId) && isset($foundationBackupItemIdsBySourceId[$sourceId])) {
|
||||||
|
$entry['backup_item_id'] = (int) $foundationBackupItemIdsBySourceId[$sourceId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entry;
|
||||||
|
}, $foundationEntries));
|
||||||
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
|
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
|
||||||
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
|
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
|
||||||
$scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries);
|
$scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries);
|
||||||
@ -866,14 +886,62 @@ public function execute(
|
|||||||
default => 'completed',
|
default => 'completed',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$isFoundationEntry = static function (mixed $item): bool {
|
||||||
|
return is_array($item)
|
||||||
|
&& array_key_exists('decision', $item)
|
||||||
|
&& array_key_exists('sourceId', $item);
|
||||||
|
};
|
||||||
|
|
||||||
|
$foundationResults = collect($results)->filter($isFoundationEntry)->values();
|
||||||
|
$policyResults = collect($results)->reject($isFoundationEntry)->values();
|
||||||
|
|
||||||
|
$itemsByBackupItemId = $policyResults
|
||||||
|
->filter(fn (mixed $item): bool => is_array($item) && isset($item['backup_item_id']) && is_numeric($item['backup_item_id']))
|
||||||
|
->keyBy(fn (array $item): string => (string) $item['backup_item_id'])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$policyStatusCounts = collect($itemsByBackupItemId)
|
||||||
|
->map(fn (array $item): string => (string) ($item['status'] ?? 'unknown'))
|
||||||
|
->countBy();
|
||||||
|
|
||||||
|
$foundationDecisionCounts = $foundationResults
|
||||||
|
->map(fn (array $entry): string => (string) ($entry['decision'] ?? 'unknown'))
|
||||||
|
->countBy();
|
||||||
|
|
||||||
|
$policyTotal = count($itemsByBackupItemId);
|
||||||
|
$foundationTotal = $foundationResults->count();
|
||||||
|
$total = $policyTotal + $foundationTotal;
|
||||||
|
|
||||||
|
$succeeded = (int) ($policyStatusCounts['applied'] ?? 0)
|
||||||
|
+ $foundationDecisionCounts
|
||||||
|
->except(['failed', 'skipped'])
|
||||||
|
->sum();
|
||||||
|
|
||||||
|
$failed = (int) ($policyStatusCounts['failed'] ?? 0)
|
||||||
|
+ (int) ($foundationDecisionCounts['failed'] ?? 0);
|
||||||
|
|
||||||
|
$skipped = (int) ($policyStatusCounts['skipped'] ?? 0)
|
||||||
|
+ (int) ($foundationDecisionCounts['skipped'] ?? 0);
|
||||||
|
|
||||||
|
$partialCount = (int) ($policyStatusCounts['partial'] ?? 0)
|
||||||
|
+ (int) ($policyStatusCounts['manual_required'] ?? 0);
|
||||||
|
|
||||||
|
$persistedResults = [
|
||||||
|
'foundations' => $foundationResults->all(),
|
||||||
|
'items' => $itemsByBackupItemId,
|
||||||
|
];
|
||||||
|
|
||||||
$restoreRun->update([
|
$restoreRun->update([
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'results' => $results,
|
'results' => $persistedResults,
|
||||||
'completed_at' => CarbonImmutable::now(),
|
'completed_at' => CarbonImmutable::now(),
|
||||||
'metadata' => array_merge($restoreRun->metadata ?? [], [
|
'metadata' => array_merge($restoreRun->metadata ?? [], [
|
||||||
'failed' => $hardFailures,
|
'total' => $total,
|
||||||
|
'succeeded' => $succeeded,
|
||||||
|
'failed' => $failed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'partial' => $partialCount,
|
||||||
'non_applied' => $nonApplied,
|
'non_applied' => $nonApplied,
|
||||||
'total' => $totalCount,
|
|
||||||
'foundations_skipped' => $foundationSkipped,
|
'foundations_skipped' => $foundationSkipped,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
95
app/Support/RunIdempotency.php
Normal file
95
app/Support/RunIdempotency.php
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
final class RunIdempotency
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function buildKey(int $tenantId, string $operationType, string|int|null $targetId = null, array $context = []): string
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'operation_type' => trim($operationType),
|
||||||
|
'target_id' => $targetId === null ? null : (string) $targetId,
|
||||||
|
'context' => self::canonicalize($context),
|
||||||
|
];
|
||||||
|
|
||||||
|
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findActiveBulkOperationRun(int $tenantId, string $idempotencyKey): ?BulkOperationRun
|
||||||
|
{
|
||||||
|
return BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->whereIn('status', ['pending', 'running'])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun
|
||||||
|
{
|
||||||
|
return RestoreRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->whereIn('status', ['queued', 'running'])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic idempotency key for a live restore execution.
|
||||||
|
*
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @param array<string, string> $groupMapping
|
||||||
|
*/
|
||||||
|
public static function restoreExecuteKey(
|
||||||
|
int $tenantId,
|
||||||
|
int $backupSetId,
|
||||||
|
?array $selectedItemIds,
|
||||||
|
array $groupMapping = [],
|
||||||
|
): string {
|
||||||
|
$scopeIds = $selectedItemIds;
|
||||||
|
|
||||||
|
if (is_array($scopeIds)) {
|
||||||
|
$scopeIds = array_values(array_unique(array_map('intval', $scopeIds)));
|
||||||
|
sort($scopeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::buildKey(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
operationType: 'restore.execute',
|
||||||
|
targetId: (string) $backupSetId,
|
||||||
|
context: [
|
||||||
|
'scope' => $scopeIds,
|
||||||
|
'group_mapping' => $groupMapping,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $value
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function canonicalize(array $value): array
|
||||||
|
{
|
||||||
|
$value = Arr::map($value, function (mixed $item): mixed {
|
||||||
|
if (is_array($item)) {
|
||||||
|
/** @var array<string, mixed> $item */
|
||||||
|
return static::canonicalize($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
});
|
||||||
|
|
||||||
|
ksort($value);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->string('idempotency_key', 64)->nullable()->after('action');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->index(['tenant_id', 'idempotency_key'], 'bulk_runs_tenant_idempotency');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement("CREATE UNIQUE INDEX bulk_runs_idempotency_active ON bulk_operation_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS bulk_runs_idempotency_active');
|
||||||
|
|
||||||
|
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('bulk_runs_tenant_idempotency');
|
||||||
|
$table->dropColumn('idempotency_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('restore_runs', function (Blueprint $table) {
|
||||||
|
$table->string('idempotency_key', 64)->nullable()->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('restore_runs', function (Blueprint $table) {
|
||||||
|
$table->index(['tenant_id', 'idempotency_key'], 'restore_runs_tenant_idempotency');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement("CREATE UNIQUE INDEX restore_runs_idempotency_active ON restore_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('queued', 'running')");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS restore_runs_idempotency_active');
|
||||||
|
|
||||||
|
Schema::table('restore_runs', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('restore_runs_tenant_idempotency');
|
||||||
|
$table->dropColumn('idempotency_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,14 +1,21 @@
|
|||||||
@php
|
@php
|
||||||
$results = $getState() ?? [];
|
$state = $getState() ?? [];
|
||||||
$foundationItems = collect($results)->filter(function ($item) {
|
$isFoundationEntry = function ($item) {
|
||||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||||
});
|
};
|
||||||
$policyItems = collect($results)->reject(function ($item) {
|
|
||||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
if (is_array($state) && array_key_exists('items', $state)) {
|
||||||
});
|
$foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry);
|
||||||
|
$policyItems = collect($state['items'] ?? [])->values();
|
||||||
|
$results = $state;
|
||||||
|
} else {
|
||||||
|
$results = $state;
|
||||||
|
$foundationItems = collect($results)->filter($isFoundationEntry);
|
||||||
|
$policyItems = collect($results)->reject($isFoundationEntry);
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (empty($results))
|
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
|
||||||
<p class="text-sm text-gray-600">No results recorded.</p>
|
<p class="text-sm text-gray-600">No results recorded.</p>
|
||||||
@else
|
@else
|
||||||
@php
|
@php
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user