From eef9618889fdf6b9d97079e614e39434146e28e9 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:18:12 +0100 Subject: [PATCH] feat: configurable bulk ops polling + chunking - Add tenantpilot.bulk_operations config (chunk size, poll interval) - Use config chunk size across all bulk jobs - Make progress widget polling interval configurable - Document settings in README + feature quickstart; mark tasks done --- README.md | 11 +++++++++++ app/Jobs/BulkBackupSetDeleteJob.php | 2 +- app/Jobs/BulkBackupSetForceDeleteJob.php | 2 +- app/Jobs/BulkBackupSetRestoreJob.php | 2 +- app/Jobs/BulkPolicyDeleteJob.php | 4 ++-- app/Jobs/BulkPolicyExportJob.php | 2 +- app/Jobs/BulkPolicySyncJob.php | 2 +- app/Jobs/BulkPolicyUnignoreJob.php | 2 +- app/Jobs/BulkPolicyVersionForceDeleteJob.php | 2 +- app/Jobs/BulkPolicyVersionPruneJob.php | 2 +- app/Jobs/BulkPolicyVersionRestoreJob.php | 2 +- app/Jobs/BulkRestoreRunDeleteJob.php | 2 +- app/Jobs/BulkRestoreRunForceDeleteJob.php | 2 +- app/Jobs/BulkRestoreRunRestoreJob.php | 2 +- app/Livewire/BulkOperationProgress.php | 3 +++ config/tenantpilot.php | 5 +++++ .../views/livewire/bulk-operation-progress.blade.php | 2 +- specs/005-bulk-operations/quickstart.md | 11 +++++++++++ specs/005-bulk-operations/tasks.md | 8 ++++---- 19 files changed, 49 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e9213f9..77c54c9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,17 @@ ## TenantPilot setup - Ensure queue workers are running for jobs (e.g., policy sync) after deploy. - Keep secrets/env in Dokploy, never in code. +## Bulk operations (Feature 005) + +- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs). +- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`). +- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs. + +### Configuration + +- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size. +- `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`): Livewire polling interval for the progress widget (clamped to 1–10s). + ## Intune RBAC Onboarding Wizard - Entry point: Tenant detail in Filament (`Setup Intune RBAC` in the ⋯ ActionGroup). Visible only for active tenants with `app_client_id`. diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php index 370bb77..22bc167 100644 --- a/app/Jobs/BulkBackupSetDeleteJob.php +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkBackupSetForceDeleteJob.php b/app/Jobs/BulkBackupSetForceDeleteJob.php index 0cd3d22..b53a1e4 100644 --- a/app/Jobs/BulkBackupSetForceDeleteJob.php +++ b/app/Jobs/BulkBackupSetForceDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkBackupSetRestoreJob.php b/app/Jobs/BulkBackupSetRestoreJob.php index e028892..0d39cea 100644 --- a/app/Jobs/BulkBackupSetRestoreJob.php +++ b/app/Jobs/BulkBackupSetRestoreJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicyDeleteJob.php b/app/Jobs/BulkPolicyDeleteJob.php index 4cf6827..20fb6b1 100644 --- a/app/Jobs/BulkPolicyDeleteJob.php +++ b/app/Jobs/BulkPolicyDeleteJob.php @@ -44,7 +44,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $failures = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); @@ -129,7 +129,7 @@ public function handle(BulkOperationService $service): void } - // Refresh the run from database every 10 items to avoid stale data + // Refresh the run from database every $chunkSize items to avoid stale data if ($itemCount % $chunkSize === 0) { diff --git a/app/Jobs/BulkPolicyExportJob.php b/app/Jobs/BulkPolicyExportJob.php index eabfe37..76b51c3 100644 --- a/app/Jobs/BulkPolicyExportJob.php +++ b/app/Jobs/BulkPolicyExportJob.php @@ -51,7 +51,7 @@ public function handle(BulkOperationService $service): void $succeeded = 0; $failed = 0; $failures = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicySyncJob.php b/app/Jobs/BulkPolicySyncJob.php index 559eaf5..0ec2f4d 100644 --- a/app/Jobs/BulkPolicySyncJob.php +++ b/app/Jobs/BulkPolicySyncJob.php @@ -31,7 +31,7 @@ public function handle(BulkOperationService $service, PolicySyncService $syncSer $service->start($run); try { - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $itemCount = 0; $totalItems = $run->total_items ?: count($run->item_ids ?? []); diff --git a/app/Jobs/BulkPolicyUnignoreJob.php b/app/Jobs/BulkPolicyUnignoreJob.php index b07a5cc..edbf4eb 100644 --- a/app/Jobs/BulkPolicyUnignoreJob.php +++ b/app/Jobs/BulkPolicyUnignoreJob.php @@ -35,7 +35,7 @@ public function handle(BulkOperationService $service): void $failed = 0; $skipped = 0; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); foreach ($run->item_ids as $policyId) { $itemCount++; diff --git a/app/Jobs/BulkPolicyVersionForceDeleteJob.php b/app/Jobs/BulkPolicyVersionForceDeleteJob.php index 6c40173..2275408 100644 --- a/app/Jobs/BulkPolicyVersionForceDeleteJob.php +++ b/app/Jobs/BulkPolicyVersionForceDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicyVersionPruneJob.php b/app/Jobs/BulkPolicyVersionPruneJob.php index 2bcce1d..d8009cf 100644 --- a/app/Jobs/BulkPolicyVersionPruneJob.php +++ b/app/Jobs/BulkPolicyVersionPruneJob.php @@ -38,7 +38,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicyVersionRestoreJob.php b/app/Jobs/BulkPolicyVersionRestoreJob.php index 7170820..6e6c17d 100644 --- a/app/Jobs/BulkPolicyVersionRestoreJob.php +++ b/app/Jobs/BulkPolicyVersionRestoreJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkRestoreRunDeleteJob.php b/app/Jobs/BulkRestoreRunDeleteJob.php index e5e3ec3..4864e8e 100644 --- a/app/Jobs/BulkRestoreRunDeleteJob.php +++ b/app/Jobs/BulkRestoreRunDeleteJob.php @@ -38,7 +38,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php index d96d5a0..5f8dbc5 100644 --- a/app/Jobs/BulkRestoreRunForceDeleteJob.php +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php index 8bf4229..2b8efe4 100644 --- a/app/Jobs/BulkRestoreRunRestoreJob.php +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php index c8e6193..975a619 100644 --- a/app/Livewire/BulkOperationProgress.php +++ b/app/Livewire/BulkOperationProgress.php @@ -11,8 +11,11 @@ class BulkOperationProgress extends Component { public $runs; + public int $pollSeconds = 3; + public function mount() { + $this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); $this->loadRuns(); } diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 6f64d4d..6de7643 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -118,4 +118,9 @@ 'features' => [ 'conditional_access' => true, ], + + 'bulk_operations' => [ + 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), + 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), + ], ]; diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index 18c0289..c254211 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -1,4 +1,4 @@ -
+
@if($runs->isNotEmpty())
diff --git a/specs/005-bulk-operations/quickstart.md b/specs/005-bulk-operations/quickstart.md index 000c74f..5712cea 100644 --- a/specs/005-bulk-operations/quickstart.md +++ b/specs/005-bulk-operations/quickstart.md @@ -120,6 +120,17 @@ ### Browser Tests (Pest v4) --- +## Configuration + +These defaults are safe for staging/production, but can be tuned per environment. + +- **Chunk size** (job refresh/progress cadence): + - `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`) +- **Progress polling interval** (UI updates): + - `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`, clamped to 1–10 seconds) +- **Policy version prune retention window**: + - Default `90` days (editable in the prune modal as “Retention Days”) + ## Manual Testing Workflow ### Scenario 1: Bulk Delete Policies (< 20 items) diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index aad316c..c2932cc 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -166,7 +166,7 @@ ### Implementation for User Story 6 - [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) - [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` -- [ ] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured +- [x] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured - [ ] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior - [ ] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4) @@ -288,8 +288,8 @@ ## Phase 10: Polish & Cross-Cutting Concerns **Purpose**: Documentation, cleanup, performance optimization -- [ ] T086 [P] Update README.md with bulk operations feature description -- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) +- [x] T086 [P] Update README.md with bulk operations feature description +- [x] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) - [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic - [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes) - [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources) @@ -298,7 +298,7 @@ ## Phase 10: Polish & Cross-Cutting Concerns - [ ] T093 Run full test suite: `./vendor/bin/sail artisan test` - [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint` - [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md -- [ ] T096 Document configuration options (chunk size, polling interval, retention days) +- [x] T096 Document configuration options (chunk size, polling interval, retention days) - [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed) - [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate)