fix: bulk tenant sync shows progress

This commit is contained in:
Ahmed Darrazi 2026-01-04 22:26:00 +01:00
parent 8d90550abe
commit 364b2e9f10
3 changed files with 193 additions and 5 deletions

View File

@ -4,9 +4,11 @@
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService; use App\Services\Intune\RbacHealthService;
@ -207,6 +209,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->iconColor('warning') ->iconColor('warning')
->success() ->success()
->sendToDatabase(auth()->user())
->send(); ->send();
}), }),
Actions\Action::make('openTenant') 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 ($record) => $record instanceof Tenant && $record->isActive())
->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant)); ->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) { foreach ($eligible as $tenant) {
SyncPoliciesJob::dispatch($tenant->getKey()); SyncPoliciesJob::dispatch($tenant->getKey());
@ -408,11 +435,15 @@ public static function table(Table $table): Table
Notification::make() Notification::make()
->title('Bulk sync started') ->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') ->icon('heroicon-o-arrow-path')
->iconColor('warning') ->iconColor('warning')
->success() ->success()
->duration(8000)
->sendToDatabase($user)
->send(); ->send();
BulkTenantSyncJob::dispatch($run->id);
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
]) ])

View File

@ -0,0 +1,152 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification;
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 BulkTenantSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {}
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
{
$run = BulkOperationRun::with(['tenant', 'user'])->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;
}
}
}

View File

@ -1,7 +1,7 @@
<?php <?php
use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Jobs\SyncPoliciesJob; use App\Jobs\BulkTenantSyncJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Events\TenantSet; use Filament\Events\TenantSet;
@ -65,10 +65,15 @@
->assertTableBulkActionVisible('syncSelected') ->assertTableBulkActionVisible('syncSelected')
->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); ->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); $this->assertDatabaseHas('bulk_operation_runs', [
Bus::assertDispatched(SyncPoliciesJob::class, fn (SyncPoliciesJob $job) => $job->tenantId === $tenantB->id); '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 () { test('tenant portfolio bulk sync is hidden for readonly users', function () {