From 364b2e9f10c5d3bc381ee6fbbefea24bcedb9ec9 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 22:26:00 +0100 Subject: [PATCH] fix: bulk tenant sync shows progress --- app/Filament/Resources/TenantResource.php | 33 +++- app/Jobs/BulkTenantSyncJob.php | 152 ++++++++++++++++++ .../TenantPortfolioContextSwitchTest.php | 13 +- 3 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 app/Jobs/BulkTenantSyncJob.php diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 1b31a54..f32bd90 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -4,9 +4,11 @@ use App\Filament\Resources\TenantResource\Pages; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Jobs\BulkTenantSyncJob; use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; +use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacHealthService; @@ -207,6 +209,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->success() + ->sendToDatabase(auth()->user()) ->send(); }), Actions\Action::make('openTenant') @@ -391,6 +394,30 @@ public static function table(Table $table): Table ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) ->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant)); + if ($eligible->isEmpty()) { + Notification::make() + ->title('Bulk sync skipped') + ->body('No eligible tenants selected.') + ->icon('heroicon-o-information-circle') + ->info() + ->sendToDatabase($user) + ->send(); + + return; + } + + $tenantContext = Tenant::current() ?? $eligible->first(); + + if (! $tenantContext) { + return; + } + + $ids = $eligible->pluck('id')->toArray(); + $count = $eligible->count(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count); + foreach ($eligible as $tenant) { SyncPoliciesJob::dispatch($tenant->getKey()); @@ -408,11 +435,15 @@ public static function table(Table $table): Table Notification::make() ->title('Bulk sync started') - ->body("Dispatched sync for {$count} tenant(s).") + ->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->success() + ->duration(8000) + ->sendToDatabase($user) ->send(); + + BulkTenantSyncJob::dispatch($run->id); }) ->deselectRecordsAfterCompletion(), ]) diff --git a/app/Jobs/BulkTenantSyncJob.php b/app/Jobs/BulkTenantSyncJob.php new file mode 100644 index 0000000..50fd31e --- /dev/null +++ b/app/Jobs/BulkTenantSyncJob.php @@ -0,0 +1,152 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); + $itemCount = 0; + + $supported = config('tenantpilot.supported_policy_types'); + + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $tenantId) { + $itemCount++; + + try { + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant) { + $service->recordFailure($run, (string) $tenantId, 'Tenant not found'); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $tenant->isActive()) { + $service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active'); + + continue; + } + + if (! $run->user || ! $run->user->canSyncTenant($tenant)) { + $service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant'); + + continue; + } + + $syncService->syncPolicies($tenant, $supported); + + $service->recordSuccess($run); + } catch (Throwable $e) { + $service->recordFailure($run, (string) $tenantId, $e->getMessage()); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Synced {$run->succeeded} tenant(s)"; + + if ($run->skipped > 0) { + $message .= " ({$run->skipped} skipped)"; + } + + if ($run->failed > 0) { + $message .= " ({$run->failed} failed)"; + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Sync Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index 26a6c4c..c95165c 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -1,7 +1,7 @@ assertTableBulkActionVisible('syncSelected') ->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); - Bus::assertDispatchedTimes(SyncPoliciesJob::class, 2); + Bus::assertDispatchedTimes(BulkTenantSyncJob::class, 1); - Bus::assertDispatched(SyncPoliciesJob::class, fn (SyncPoliciesJob $job) => $job->tenantId === $tenantA->id); - Bus::assertDispatched(SyncPoliciesJob::class, fn (SyncPoliciesJob $job) => $job->tenantId === $tenantB->id); + $this->assertDatabaseHas('bulk_operation_runs', [ + 'tenant_id' => $tenantA->id, + 'user_id' => $user->id, + 'resource' => 'tenant', + 'action' => 'sync', + 'total_items' => 2, + ]); }); test('tenant portfolio bulk sync is hidden for readonly users', function () {