From c93b6e8bb500eaf48bcd19d844796df4afa4a147 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 12 Jan 2026 00:13:55 +0100 Subject: [PATCH 1/2] fix(bulk): clamp total_items and UI percent cap to avoid >100% progress display --- app/Services/BulkOperationService.php | 14 ++++++++++++-- .../livewire/bulk-operation-progress.blade.php | 8 +++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index b85193a..6530c55 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -45,6 +45,8 @@ public function createRun( array $itemIds, int $totalItems ): BulkOperationRun { + $effectiveTotalItems = max($totalItems, count($itemIds)); + $run = BulkOperationRun::create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, @@ -52,7 +54,7 @@ public function createRun( 'action' => $action, 'status' => 'pending', 'item_ids' => $itemIds, - 'total_items' => $totalItems, + 'total_items' => $effectiveTotalItems, 'processed_items' => 0, 'succeeded' => 0, 'failed' => 0, @@ -66,7 +68,7 @@ public function createRun( context: [ 'metadata' => [ 'bulk_run_id' => $run->id, - 'total_items' => $totalItems, + 'total_items' => $effectiveTotalItems, ], ], actorId: $user->id, @@ -139,6 +141,14 @@ public function complete(BulkOperationRun $run): void { $run->refresh(); + if ($run->processed_items > $run->total_items) { + BulkOperationRun::query() + ->whereKey($run->id) + ->update(['total_items' => $run->processed_items]); + + $run->refresh(); + } + if (! in_array($run->status, ['pending', 'running'], true)) { return; } diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index faf75fb..864d872 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -3,6 +3,8 @@ @if($runs->isNotEmpty())
@foreach ($runs as $run) + @php($effectiveTotal = max((int) $run->total_items, (int) $run->processed_items)) + @php($percent = $effectiveTotal > 0 ? min(100, round(($run->processed_items / $effectiveTotal) * 100)) : 0)
@@ -38,17 +40,17 @@
- {{ $run->processed_items }} / {{ $run->total_items }} + {{ $run->processed_items }} / {{ $effectiveTotal }}
- {{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}% + {{ $percent }}%
+ style="width: {{ $percent }}%">
From 6ac4b45c68dbaa06430b5e0a58786b933dc4bbba Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 12 Jan 2026 00:14:42 +0100 Subject: [PATCH 2/2] feat(restore): add Entra group cache picker modal, Livewire table, wiring and tests --- app/Filament/Resources/RestoreRunResource.php | 79 ++++++- .../Pages/CreateRestoreRun.php | 20 ++ app/Livewire/EntraGroupCachePickerTable.php | 203 ++++++++++++++++++ .../modals/entra-group-cache-picker.blade.php | 3 + .../entra-group-cache-picker-table.blade.php | 3 + tests/Feature/RestoreGroupMappingTest.php | 29 +++ tests/Unit/BulkOperationRunProgressTest.php | 30 +++ 7 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 app/Livewire/EntraGroupCachePickerTable.php create mode 100644 resources/views/filament/modals/entra-group-cache-picker.blade.php create mode 100644 resources/views/livewire/entra-group-cache-picker-table.blade.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 87e03b7..8fc1e50 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -9,6 +9,7 @@ use App\Jobs\ExecuteRestoreRunJob; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\EntraGroup; use App\Models\RestoreRun; use App\Models\Tenant; use App\Rules\SkipOrUuidRule; @@ -112,7 +113,21 @@ public static function form(Schema $schema): Schema tenant: $tenant ); - return array_map(function (array $group): Forms\Components\TextInput { + $groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey()); + $hasCachedGroups = $groupCacheQuery->exists(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + + $cacheNotice = match (true) { + ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', + $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", + default => null, + }; + + return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; @@ -121,7 +136,28 @@ public static function form(Schema $schema): Schema ->placeholder('SKIP or target group Object ID (GUID)') ->rules([new SkipOrUuidRule]) ->required() - ->helperText('Paste the target Entra ID group Object ID (GUID). Labels use the cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.'); + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Select from Directory cache') + ->modalHeading('Select from Directory cache') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + ])) + ) + ->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') + ->hintAction( + Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => $cacheNotice !== null) + ); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -318,7 +354,21 @@ public static function getWizardSteps(): array tenant: $tenant ); - return array_map(function (array $group): Forms\Components\TextInput { + $groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey()); + $hasCachedGroups = $groupCacheQuery->exists(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + + $cacheNotice = match (true) { + ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', + $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", + default => null, + }; + + return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; @@ -336,7 +386,28 @@ public static function getWizardSteps(): array $set('preview_ran_at', null); }) ->required() - ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Select from Directory cache') + ->modalHeading('Select from Directory cache') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + ])) + ) + ->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') + ->hintAction( + Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => $cacheNotice !== null) + ); }, $unresolved); }) ->visible(function (Get $get): bool { diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2e4e6de..b799644 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -9,6 +9,7 @@ use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; +use Livewire\Attributes\On; class CreateRestoreRun extends CreateRecord { @@ -119,4 +120,23 @@ protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); } + + #[On('entra-group-cache-picked')] + public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void + { + data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId); + + $this->data['check_summary'] = null; + $this->data['check_results'] = []; + $this->data['checks_ran_at'] = null; + $this->data['preview_summary'] = null; + $this->data['preview_diffs'] = []; + $this->data['preview_ran_at'] = null; + + $this->form->fill($this->data); + + if (method_exists($this, 'unmountAction')) { + $this->unmountAction(); + } + } } diff --git a/app/Livewire/EntraGroupCachePickerTable.php b/app/Livewire/EntraGroupCachePickerTable.php new file mode 100644 index 0000000..f79834d --- /dev/null +++ b/app/Livewire/EntraGroupCachePickerTable.php @@ -0,0 +1,203 @@ +sourceGroupId = $sourceGroupId; + } + + public function table(Table $table): Table + { + $tenantId = Tenant::current()?->getKey(); + + $query = EntraGroup::query(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereRaw('1 = 0'); + } + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + + return $table + ->queryStringIdentifier('entraGroupCachePicker') + ->query($query) + ->defaultSort('display_name') + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->searchable() + ->searchPlaceholder('Search groups…') + ->deferLoading(! app()->runningUnitTests()) + ->columns([ + TextColumn::make('display_name') + ->label('Name') + ->searchable() + ->sortable() + ->wrap() + ->limit(60), + TextColumn::make('type') + ->label('Type') + ->badge() + ->state(fn (EntraGroup $record): string => $this->groupTypeLabel($this->groupType($record))) + ->color(fn (EntraGroup $record): string => $this->groupTypeColor($this->groupType($record))) + ->toggleable(), + TextColumn::make('entra_id') + ->label('ID') + ->formatStateUsing(fn (?string $state): string => filled($state) ? ('…'.substr($state, -8)) : '—') + ->extraAttributes(['class' => 'font-mono']) + ->toggleable(), + TextColumn::make('last_seen_at') + ->label('Last seen') + ->since() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('stale') + ->label('Stale') + ->options([ + '1' => 'Stale', + '0' => 'Fresh', + ]) + ->query(function (Builder $query, array $data) use ($cutoff): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + if ((string) $value === '1') { + return $query->where(function (Builder $q) use ($cutoff): void { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $cutoff); + }); + } + + return $query->where('last_seen_at', '>=', $cutoff); + }), + + SelectFilter::make('group_type') + ->label('Type') + ->options([ + 'security' => 'Security', + 'microsoft365' => 'Microsoft 365', + 'mail' => 'Mail-enabled', + 'unknown' => 'Unknown', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? ''); + + if ($value === '') { + return $query; + } + + return match ($value) { + 'microsoft365' => $query->whereJsonContains('group_types', 'Unified'), + 'security' => $query + ->where('security_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'mail' => $query + ->where('mail_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'unknown' => $query + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }) + ->where('security_enabled', false) + ->where('mail_enabled', false), + default => $query, + }; + }), + ]) + ->actions([ + Action::make('select') + ->label('Select') + ->icon('heroicon-o-check') + ->color('primary') + ->action(function (EntraGroup $record): void { + $this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id); + }), + ]) + ->emptyStateHeading('No cached groups found') + ->emptyStateDescription('Run “Sync Groups” first, then come back here.') + ->emptyStateActions([ + Action::make('open_groups') + ->label('Directory Groups') + ->icon('heroicon-o-user-group') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())), + Action::make('open_sync_runs') + ->label('Group Sync Runs') + ->icon('heroicon-o-clock') + ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())), + ]); + } + + public function render(): View + { + return view('livewire.entra-group-cache-picker-table'); + } + + private function groupType(EntraGroup $record): string + { + $groupTypes = $record->group_types; + + if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) { + return 'microsoft365'; + } + + if ($record->security_enabled) { + return 'security'; + } + + if ($record->mail_enabled) { + return 'mail'; + } + + return 'unknown'; + } + + private function groupTypeLabel(string $type): string + { + return match ($type) { + 'microsoft365' => 'Microsoft 365', + 'security' => 'Security', + 'mail' => 'Mail-enabled', + default => 'Unknown', + }; + } + + private function groupTypeColor(string $type): string + { + return match ($type) { + 'microsoft365' => 'info', + 'security' => 'success', + 'mail' => 'warning', + default => 'gray', + }; + } +} diff --git a/resources/views/filament/modals/entra-group-cache-picker.blade.php b/resources/views/filament/modals/entra-group-cache-picker.blade.php new file mode 100644 index 0000000..3475afb --- /dev/null +++ b/resources/views/filament/modals/entra-group-cache-picker.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/livewire/entra-group-cache-picker-table.blade.php b/resources/views/livewire/entra-group-cache-picker-table.blade.php new file mode 100644 index 0000000..b7a760b --- /dev/null +++ b/resources/views/livewire/entra-group-cache-picker-table.blade.php @@ -0,0 +1,3 @@ +
+ {{ $this->table }} +
diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index a07e792..b713bca 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -202,3 +202,32 @@ 'action' => 'restore.group_mapping.applied', ]); }); + +test('restore wizard can fill a group mapping entry from directory cache picker', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + $sourceGroupId = fake()->uuid(); + $targetGroupId = fake()->uuid(); + + Livewire::test(CreateRestoreRun::class) + ->set('data.check_summary', 'old') + ->set('data.check_results', ['x']) + ->call('applyEntraGroupCachePick', sourceGroupId: $sourceGroupId, entraId: $targetGroupId) + ->assertSet("data.group_mapping.{$sourceGroupId}", $targetGroupId) + ->assertSet('data.check_summary', null) + ->assertSet('data.check_results', []); +}); diff --git a/tests/Unit/BulkOperationRunProgressTest.php b/tests/Unit/BulkOperationRunProgressTest.php index 0a9e71e..a36a66f 100644 --- a/tests/Unit/BulkOperationRunProgressTest.php +++ b/tests/Unit/BulkOperationRunProgressTest.php @@ -29,3 +29,33 @@ ->and($run->failures)->toBeArray() ->and($run->failures)->toHaveCount(1); }); + +test('bulk operation run total_items is at least the item_ids count', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['a', 'b', 'c', 'd'], 1); + + expect($run->total_items)->toBe(4); +}); + +test('bulk operation completion clamps total_items up to processed_items', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['only-one'], 1); + + $service->start($run); + $service->recordSuccess($run); + $service->recordSuccess($run); + + $run->refresh(); + expect($run->processed_items)->toBe(2)->and($run->total_items)->toBe(1); + + $service->complete($run); + $run->refresh(); + + expect($run->total_items)->toBe(2); +});