From bd6df1f343e07451e4837d11c3d7f2871aeb0515 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 18 Jan 2026 14:50:15 +0000 Subject: [PATCH] 055-ops-ux-rollout (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kurzbeschreibung Implementiert Feature 055 — Ops‑UX Constitution Rollout v1.3.0. Behebt: globales BulkOperationProgress-Widget benötigt keinen manuellen Refresh mehr; ETA/Elapsed aktualisieren korrekt; Widget verschwindet automatisch. Verbesserungen: zuverlässiges polling (Alpine factory + Livewire fallback), sofortiger Enqueue‑Signal-Dispatch, Failure‑Message‑Sanitization, neue Guard‑ und Regressionstests, Specs/Tasks aktualisiert. Was geändert wurde (Auszug) InventoryLanding.php bulk-operation-progress.blade.php OperationUxPresenter.php SyncRestoreRunToOperationRun.php PolicyResource.php PolicyVersionResource.php RestoreRunResource.php tests/Feature/OpsUx/* (PollerRegistration, TerminalNotificationFailureMessageTest, CanonicalViewRunLinksTest, OperationCatalogCoverageTest, UnknownOperationTypeLabelTest) InventorySyncButtonTest.php tasks.md Tests Neue Tests hinzugefügt; php artisan test --group=ops-ux lokal grün (alle relevanten Tests laufen). How to verify manually Auf Branch wechseln: 055-ops-ux-rollout In Filament: Inventory → Sync (oder relevante Bulk‑Aktion) auslösen. Beobachten: Progress‑Widget erscheint sofort, ETA/Elapsed aktualisiert, Widget verschwindet nach Fertigstellung ohne Browser‑Refresh. Optional: ./vendor/bin/sail exec app php artisan test --filter=OpsUx oder php artisan test --group=ops-ux Besonderheiten / Hinweise Einzelne, synchrone Policy‑Actions (ignore/restore/PolicyVersion single archive/restore/forceDelete) sind absichtlich inline und erzeugen kein OperationRun. Bulk‑Aktionen und restore.execute werden als Runs modelliert. Wenn gewünscht, kann ich die inline‑Actions auf OperationRunService umstellen, damit sie in Monitoring → Operations sichtbar werden. Remote: Branch ist bereits gepusht (origin/055-ops-ux-rollout). PR kann in Gitea erstellt werden. Links Specs & tasks: tasks.md Monitoring page: Operations.php Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/64 --- app/Filament/Pages/DriftLanding.php | 9 +- app/Filament/Pages/InventoryLanding.php | 44 ++-- app/Filament/Pages/Monitoring/Operations.php | 2 + .../Resources/BackupScheduleResource.php | 41 +-- app/Filament/Resources/BackupSetResource.php | 43 ++-- .../BackupItemsRelationManager.php | 16 +- .../Resources/OperationRunResource.php | 37 ++- .../Pages/ViewOperationRun.php | 2 + app/Filament/Resources/PolicyResource.php | 22 +- .../PolicyResource/Pages/ListPolicies.php | 12 +- .../PolicyResource/Pages/ViewPolicy.php | 6 +- .../Resources/PolicyVersionResource.php | 43 ++-- app/Filament/Resources/RestoreRunResource.php | 15 +- app/Filament/Resources/TenantResource.php | 15 +- app/Jobs/ExecuteRestoreRunJob.php | 12 + app/Livewire/BackupSetPolicyPickerTable.php | 13 +- app/Livewire/BulkOperationProgress.php | 168 +++++------- app/Notifications/OperationRunCompleted.php | 23 +- app/Notifications/OperationRunQueued.php | 9 +- app/Services/OperationRunService.php | 14 +- app/Support/OperationCatalog.php | 64 +++++ app/Support/OpsUx/OperationRunUrl.php | 22 ++ .../OpsUx/OperationStatusNormalizer.php | 49 ++++ app/Support/OpsUx/OperationSummaryKeys.php | 26 ++ app/Support/OpsUx/OperationUxPresenter.php | 111 ++++++++ app/Support/OpsUx/OpsUxBrowserEvents.php | 31 +++ app/Support/OpsUx/RunDetailPolling.php | 35 +++ app/Support/OpsUx/RunDurationInsights.php | 145 +++++++++++ app/Support/OpsUx/SummaryCountsNormalizer.php | 69 +++++ .../bulk-operation-progress.blade.php | 240 +++++++++++++----- .../checklists/requirements.md | 34 +++ .../contracts/ux-contracts.md | 73 ++++++ specs/055-ops-ux-rollout/data-model.md | 104 ++++++++ specs/055-ops-ux-rollout/plan.md | 110 ++++++++ specs/055-ops-ux-rollout/quickstart.md | 48 ++++ specs/055-ops-ux-rollout/research.md | 72 ++++++ specs/055-ops-ux-rollout/spec.md | 213 ++++++++++++++++ specs/055-ops-ux-rollout/tasks.md | 208 +++++++++++++++ .../RunNowRetryActionsTest.php | 4 +- .../Inventory/InventorySyncButtonTest.php | 5 +- .../OperationRunNotificationTest.php | 6 +- tests/Feature/OperationRunServiceTest.php | 5 +- .../OpsUx/CanonicalViewRunLinksTest.php | 31 +++ .../OpsUx/NoQueuedDbNotificationsTest.php | 36 +++ .../OpsUx/NotificationViewRunLinkTest.php | 42 +++ .../OpsUx/OperationCatalogCoverageTest.php | 61 +++++ .../OpsUx/OperationSummaryKeysSpecTest.php | 18 ++ tests/Feature/OpsUx/OpsUxSmokeTest.php | 5 + .../OpsUx/ProgressWidgetFiltersTest.php | 47 ++++ .../OpsUx/ProgressWidgetOverflowTest.php | 25 ++ .../ProgressWidgetPollerRegistrationTest.php | 16 ++ tests/Feature/OpsUx/QueuedToastCopyTest.php | 22 ++ .../RestoreExecutionOperationRunSyncTest.php | 69 +++++ .../RunDetailPollingStopsOnTerminalTest.php | 27 ++ .../OpsUx/RunEnqueuedBrowserEventTest.php | 50 ++++ .../OpsUx/SummaryCountsWhitelistTest.php | 51 ++++ ...TerminalNotificationFailureMessageTest.php | 53 ++++ .../TerminalNotificationIdempotencyTest.php | 50 ++++ .../OpsUx/UnknownOperationTypeLabelTest.php | 12 + tests/Support/OpsUxTestSupport.php | 22 ++ 60 files changed, 2490 insertions(+), 367 deletions(-) create mode 100644 app/Support/OperationCatalog.php create mode 100644 app/Support/OpsUx/OperationRunUrl.php create mode 100644 app/Support/OpsUx/OperationStatusNormalizer.php create mode 100644 app/Support/OpsUx/OperationSummaryKeys.php create mode 100644 app/Support/OpsUx/OperationUxPresenter.php create mode 100644 app/Support/OpsUx/OpsUxBrowserEvents.php create mode 100644 app/Support/OpsUx/RunDetailPolling.php create mode 100644 app/Support/OpsUx/RunDurationInsights.php create mode 100644 app/Support/OpsUx/SummaryCountsNormalizer.php create mode 100644 specs/055-ops-ux-rollout/checklists/requirements.md create mode 100644 specs/055-ops-ux-rollout/contracts/ux-contracts.md create mode 100644 specs/055-ops-ux-rollout/data-model.md create mode 100644 specs/055-ops-ux-rollout/plan.md create mode 100644 specs/055-ops-ux-rollout/quickstart.md create mode 100644 specs/055-ops-ux-rollout/research.md create mode 100644 specs/055-ops-ux-rollout/spec.md create mode 100644 specs/055-ops-ux-rollout/tasks.md create mode 100644 tests/Feature/OpsUx/CanonicalViewRunLinksTest.php create mode 100644 tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php create mode 100644 tests/Feature/OpsUx/NotificationViewRunLinkTest.php create mode 100644 tests/Feature/OpsUx/OperationCatalogCoverageTest.php create mode 100644 tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php create mode 100644 tests/Feature/OpsUx/OpsUxSmokeTest.php create mode 100644 tests/Feature/OpsUx/ProgressWidgetFiltersTest.php create mode 100644 tests/Feature/OpsUx/ProgressWidgetOverflowTest.php create mode 100644 tests/Feature/OpsUx/ProgressWidgetPollerRegistrationTest.php create mode 100644 tests/Feature/OpsUx/QueuedToastCopyTest.php create mode 100644 tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php create mode 100644 tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php create mode 100644 tests/Feature/OpsUx/RunEnqueuedBrowserEventTest.php create mode 100644 tests/Feature/OpsUx/SummaryCountsWhitelistTest.php create mode 100644 tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php create mode 100644 tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php create mode 100644 tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php create mode 100644 tests/Support/OpsUxTestSupport.php diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 5aee73d..941abc2 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -15,6 +15,8 @@ use App\Services\Drift\DriftRunSelector; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\RunIdempotency; use BackedEnum; use Filament\Actions\Action; @@ -270,16 +272,13 @@ public function mount(): void ); }); - Notification::make() - ->title('Drift generation queued') - ->body('Drift generation has been queued. Monitor progress in Monitoring → Operations.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); } diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index 132484e..caecbf5 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -13,6 +13,8 @@ use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use BackedEnum; use Filament\Actions\Action; use Filament\Actions\Action as HintAction; @@ -83,14 +85,6 @@ protected function getHeaderActions(): array }) ->all(); }) - ->default([]) - ->dehydrated() - ->required() - ->rules([ - 'array', - 'min:1', - new \App\Rules\SupportedPolicyTypesRule, - ]) ->columnSpanFull(), Toggle::make('include_foundations') ->label('Include foundation types') @@ -118,7 +112,7 @@ protected function getHeaderActions(): array return $user->canSyncTenant(Tenant::current()); }) - ->action(function (array $data, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + ->action(function (array $data, self $livewire, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { $tenant = Tenant::current(); $user = auth()->user(); @@ -152,7 +146,6 @@ protected function getHeaderActions(): array } $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); - // --- Phase 3: Canonical Operation Run Start --- /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( @@ -162,9 +155,7 @@ protected function getHeaderActions(): array initiator: $user ); - // If run is already active (was recently created or re-used), and we want to enforce re-use: - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { - // Just notify and exit (Idempotency) + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { Notification::make() ->title('Inventory sync already active') ->body('This operation is already queued or running.') @@ -176,9 +167,10 @@ protected function getHeaderActions(): array ]) ->send(); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + return; } - // ---------------------------------------------- // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) $existing = InventorySyncRun::query() @@ -236,20 +228,6 @@ protected function getHeaderActions(): array resourceId: (string) $run->id, ); - Notification::make() - ->title('Inventory sync started') - ->body('Sync dispatched. Check the progress widget or Monitoring.') - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->success() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->sendToDatabase($user) - ->send(); - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $bulkRun, $opRun): void { RunInventorySyncJob::dispatch( tenantId: (int) $tenant->getKey(), @@ -259,6 +237,16 @@ protected function getHeaderActions(): array operationRun: $opRun ); }); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); }), ]; } diff --git a/app/Filament/Pages/Monitoring/Operations.php b/app/Filament/Pages/Monitoring/Operations.php index 8c7a1ec..394d983 100644 --- a/app/Filament/Pages/Monitoring/Operations.php +++ b/app/Filament/Pages/Monitoring/Operations.php @@ -3,6 +3,7 @@ namespace App\Filament\Pages\Monitoring; use App\Models\OperationRun; +use App\Support\OperationCatalog; use BackedEnum; use Filament\Facades\Filament; use Filament\Forms\Components\DatePicker; @@ -44,6 +45,7 @@ public function table(Table $table): Table ) ->columns([ TextColumn::make('type') + ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->searchable() ->sortable(), diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 2a97863..9f1e051 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -17,6 +17,8 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\TenantRole; use BackedEnum; use Carbon\CarbonImmutable; @@ -38,6 +40,7 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; @@ -288,7 +291,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-play') ->color('success') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) - ->action(function (BackupSchedule $record): void { + ->action(function (BackupSchedule $record, HasTable $livewire): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); $tenant = Tenant::current(); @@ -413,24 +416,21 @@ public static function table(Table $table): Table Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun)); }); - $notification = Notification::make() - ->title('Run dispatched') - ->body('The backup run has been queued.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $operationRun->type) ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($operationRun, $tenant)), - ]); - - $notification->send(); + ]) + ->send(); }), Action::make('retry') ->label('Retry') ->icon('heroicon-o-arrow-path') ->color('warning') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) - ->action(function (BackupSchedule $record): void { + ->action(function (BackupSchedule $record, HasTable $livewire): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); $tenant = Tenant::current(); @@ -555,17 +555,14 @@ public static function table(Table $table): Table Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun)); }); - $notification = Notification::make() - ->title('Retry dispatched') - ->body('A new backup run has been queued.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $operationRun->type) ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($operationRun, $tenant)), - ]); - - $notification->send(); + ]) + ->send(); }), EditAction::make() ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), @@ -580,7 +577,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-play') ->color('success') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) - ->action(function (Collection $records): void { + ->action(function (Collection $records, HasTable $livewire): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); if ($records->isEmpty()) { @@ -710,13 +707,17 @@ public static function table(Table $table): Table } $notification->send(); + + if (count($createdRunIds) > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + } }), BulkAction::make('bulk_retry') ->label('Retry') ->icon('heroicon-o-arrow-path') ->color('warning') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) - ->action(function (Collection $records): void { + ->action(function (Collection $records, HasTable $livewire): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); if ($records->isEmpty()) { @@ -846,6 +847,10 @@ public static function table(Table $table): Table } $notification->send(); + + if (count($createdRunIds) > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + } }), DeleteBulkAction::make('bulk_delete') ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 22796c4..b60236f 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -12,6 +12,7 @@ use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; +use App\Support\OpsUx\OperationUxPresenter; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -201,14 +202,12 @@ public static function table(Table $table): Table $run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count); if ($count >= 10) { - Notification::make() - ->title('Bulk archive started') - ->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('backup_set.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) ->send(); BulkBackupSetDeleteJob::dispatch($run->id); @@ -243,14 +242,12 @@ public static function table(Table $table): Table $run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count); if ($count >= 10) { - Notification::make() - ->title('Bulk restore started') - ->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('backup_set.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) ->send(); BulkBackupSetRestoreJob::dispatch($run->id); @@ -300,14 +297,12 @@ public static function table(Table $table): Table $run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count); if ($count >= 10) { - Notification::make() - ->title('Bulk force delete started') - ->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('backup_set.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) ->send(); BulkBackupSetForceDeleteJob::dispatch($run->id); diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 79f0b21..67c7e3e 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -9,6 +9,8 @@ use App\Models\User; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -208,16 +210,13 @@ public function table(Table $table): Table ); }); - Notification::make() - ->title('Removal queued') - ->body('A background job has been queued. You can monitor progress in the run details or progress widget.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); $this->resetTable(); @@ -305,16 +304,13 @@ public function table(Table $table): Table ); }); - Notification::make() - ->title('Removal queued') - ->body('A background job has been queued. You can monitor progress in the run details or progress widget.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); $this->resetTable(); diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 2229d06..355f0f5 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -5,8 +5,11 @@ use App\Filament\Resources\OperationRunResource\Pages; use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OpsUx\RunDetailPolling; +use App\Support\OpsUx\RunDurationInsights; use BackedEnum; use Filament\Actions; use Filament\Forms\Components\DatePicker; @@ -45,7 +48,9 @@ public static function infolist(Schema $schema): Schema ->schema([ Section::make('Run') ->schema([ - TextEntry::make('type')->badge(), + TextEntry::make('type') + ->badge() + ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)), TextEntry::make('status') ->badge() ->color(fn (OperationRun $record): string => static::statusColor($record->status)), @@ -53,11 +58,36 @@ public static function infolist(Schema $schema): Schema ->badge() ->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)), TextEntry::make('initiator_name')->label('Initiator'), + TextEntry::make('elapsed') + ->label('Elapsed') + ->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)), + TextEntry::make('expected_duration') + ->label('Expected') + ->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'), + TextEntry::make('stuck_guidance') + ->label('') + ->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record)) + ->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null), TextEntry::make('created_at')->dateTime(), TextEntry::make('started_at')->dateTime()->placeholder('—'), TextEntry::make('completed_at')->dateTime()->placeholder('—'), TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(), ]) + ->extraAttributes([ + 'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)', + 'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)', + ]) + ->poll(function (OperationRun $record, $livewire): ?string { + if (($livewire->opsUxIsTabHidden ?? false) === true) { + return null; + } + + if (filled($livewire->mountedActions ?? null)) { + return null; + } + + return RunDetailPolling::interval($record); + }) ->columns(2) ->columnSpanFull(), @@ -110,6 +140,7 @@ public static function table(Table $table): Table ->color(fn (OperationRun $record): string => static::statusColor($record->status)), Tables\Columns\TextColumn::make('type') ->label('Operation') + ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->searchable() ->sortable(), Tables\Columns\TextColumn::make('initiator_name') @@ -225,8 +256,8 @@ public static function getPages(): array private static function statusColor(?string $status): string { return match ($status) { - 'queued' => 'gray', - 'running' => 'info', + 'queued' => 'secondary', + 'running' => 'warning', 'completed' => 'success', default => 'gray', }; diff --git a/app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php b/app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php index b8a5f31..f03e4cc 100644 --- a/app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php +++ b/app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php @@ -14,6 +14,8 @@ class ViewOperationRun extends ViewRecord { protected static string $resource = OperationRunResource::class; + public bool $opsUxIsTabHidden = false; + protected function getHeaderActions(): array { $tenant = Tenant::current(); diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 50e0ce6..b55c53d 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -15,6 +15,8 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -347,7 +349,7 @@ public static function table(Table $table): Table ->color('danger') ->requiresConfirmation() ->visible(fn (Policy $record) => $record->ignored_at === null) - ->action(function (Policy $record) { + ->action(function (Policy $record, HasTable $livewire) { $record->ignore(); Notification::make() @@ -433,17 +435,13 @@ public static function table(Table $table): Table policyIds: [(int) $record->getKey()], operationRun: $opRun ); - - Notification::make() - ->title('Policy sync queued') - ->body('The sync has been queued. You can monitor progress in Monitoring → Operations.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); }), Actions\Action::make('export') @@ -533,7 +531,7 @@ public static function table(Table $table): Table return ! in_array($value, [null, 'ignored'], true); }) - ->action(function (Collection $records) { + ->action(function (Collection $records, HasTable $livewire) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); @@ -636,17 +634,13 @@ public static function table(Table $table): Table policyIds: $ids, operationRun: $opRun ); - - Notification::make() - ->title('Policy sync queued') - ->body("The sync has been queued for {$count} policies. You can monitor progress in Monitoring → Operations.") - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); }) ->deselectRecordsAfterCompletion(), diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 3b4250a..c83bd8c 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -8,6 +8,8 @@ use App\Models\User; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; @@ -35,7 +37,7 @@ protected function getHeaderActions(): array return $user->canSyncTenant($tenant); }) - ->action(function () { + ->action(function (self $livewire): void { $tenant = Tenant::current(); $user = auth()->user(); @@ -88,17 +90,13 @@ protected function getHeaderActions(): array operationRun: $opRun ); }); - - Notification::make() - ->title('Policy sync queued') - ->body('The sync has been queued. You can monitor progress in Monitoring → Operations.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); }), ]; diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index cc44587..f1a7f0b 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -6,6 +6,7 @@ use App\Filament\Resources\PolicyResource; use App\Jobs\CapturePolicySnapshotJob; use App\Services\BulkOperationService; +use App\Support\OpsUx\OperationUxPresenter; use App\Support\RunIdempotency; use Filament\Actions\Action; use Filament\Forms; @@ -102,15 +103,12 @@ protected function getActions(): array createdBy: $user->email ? Str::limit($user->email, 255, '') : null ); - Notification::make() - ->title('Snapshot queued') - ->body('A background job has been queued. You can monitor progress in the run details.') + OperationUxPresenter::queuedToast('policy.capture_snapshot') ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), ]) - ->success() ->send(); $this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)); diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 9d6259a..f89b89a 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -14,6 +14,7 @@ use App\Services\Intune\AuditLogger; use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; +use App\Support\OpsUx\OperationUxPresenter; use BackedEnum; use Carbon\CarbonImmutable; use Filament\Actions; @@ -409,14 +410,12 @@ public static function table(Table $table): Table $run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count); if ($count >= 20) { - Notification::make() - ->title('Bulk prune started') - ->body("Pruning {$count} policy versions in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('policy_version.prune') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) ->send(); BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays); @@ -451,14 +450,12 @@ public static function table(Table $table): Table $run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count); if ($count >= 20) { - Notification::make() - ->title('Bulk restore started') - ->body("Restoring {$count} policy versions in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('policy_version.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) ->send(); BulkPolicyVersionRestoreJob::dispatch($run->id); @@ -502,14 +499,12 @@ public static function table(Table $table): Table $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count); if ($count >= 20) { - Notification::make() - ->title('Bulk force delete started') - ->body("Force deleting {$count} policy versions in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('policy_version.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) ->send(); BulkPolicyVersionForceDeleteJob::dispatch($run->id); diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index e9b2b23..2d3d856 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -19,6 +19,8 @@ use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\RestoreRunStatus; use App\Support\RunIdempotency; use BackedEnum; @@ -743,7 +745,8 @@ public static function table(Table $table): Table ->action(function ( RestoreRun $record, RestoreService $restoreService, - \App\Services\Intune\AuditLogger $auditLogger + \App\Services\Intune\AuditLogger $auditLogger, + HasTable $livewire ) { $tenant = $record->tenant; $backupSet = $record->backupSet; @@ -860,9 +863,8 @@ public static function table(Table $table): Table actorName: $actorName, ); - Notification::make() - ->title('Restore run queued') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast('restore.execute') ->send(); return; @@ -902,9 +904,8 @@ public static function table(Table $table): Table ] ); - Notification::make() - ->title('Restore run started') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast('restore.execute') ->send(); }), Actions\Action::make('restore') diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index a141fa6..ad6e67c 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -18,6 +18,8 @@ use App\Services\Intune\TenantPermissionService; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\TenantRole; use BackedEnum; use Filament\Actions; @@ -197,7 +199,7 @@ public static function table(Table $table): Table return $user->canSyncTenant($record); }) - ->action(function (Tenant $record, AuditLogger $auditLogger): void { + ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { // Phase 3: Canonical Operation Run Start /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -208,7 +210,7 @@ public static function table(Table $table): Table initiator: auth()->user() ); - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { Notification::make() ->title('Policy sync already active') ->body('This operation is already queued or running.') @@ -234,18 +236,13 @@ public static function table(Table $table): Table context: ['metadata' => ['tenant_id' => $record->tenant_id]], ); - Notification::make() - ->title('Sync started') - ->body("Sync dispatched for {$record->name}.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View Run') ->url(OperationRunLinks::view($opRun, $record)), ]) - ->sendToDatabase(auth()->user()) ->send(); }), Actions\Action::make('openTenant') diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php index 2216120..47f1b0a 100644 --- a/app/Jobs/ExecuteRestoreRunJob.php +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Listeners\SyncRestoreRunToOperationRun; use App\Models\RestoreRun; use App\Models\User; use App\Notifications\RunStatusChangedNotification; @@ -40,6 +41,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger, } $this->notifyStatus($restoreRun, 'queued'); + app(SyncRestoreRunToOperationRun::class)->handle($restoreRun); $tenant = $restoreRun->tenant; $backupSet = $restoreRun->backupSet; @@ -51,6 +53,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger, 'completed_at' => CarbonImmutable::now(), ]); + app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); + $this->notifyStatus($restoreRun->refresh(), 'failed'); if ($tenant) { @@ -81,6 +85,10 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger, 'failure_reason' => null, ]); + // Keep the canonical Monitoring/Operations adapter row in sync even if downstream + // code performs restore-run updates without firing model events. + app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); + $this->notifyStatus($restoreRun->refresh(), 'running'); $auditLogger->log( @@ -108,6 +116,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger, actorName: $this->actorName, ); + app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); + $this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status); } catch (Throwable $throwable) { $restoreRun->refresh(); @@ -122,6 +132,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger, ]); } + app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); + $this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status); if ($tenant) { diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index 3fb2192..492cbe2 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -11,6 +11,8 @@ use App\Services\BulkOperationService; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\RunIdempotency; use Filament\Actions\BulkAction; use Filament\Notifications\Notification; @@ -179,6 +181,7 @@ public function table(Table $table): Table ->label('Add selected') ->icon('heroicon-m-plus') ->authorize(function (): bool { + $this->dispatch(OpsUxBrowserEvents::RunEnqueued); $user = auth()->user(); if (! $user instanceof User) { @@ -391,20 +394,12 @@ public function table(Table $table): Table ); }); - $notificationTitle = $this->include_foundations - ? 'Backup items queued' - : 'Policies queued'; - - Notification::make() - ->title($notificationTitle) - ->body('A background job has been queued. You can monitor progress in the run details or progress widget.') + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->success() - ->sendToDatabase($user) ->send(); $this->resetTable(); diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php index a7ef31d..efd334d 100644 --- a/app/Livewire/BulkOperationProgress.php +++ b/app/Livewire/BulkOperationProgress.php @@ -2,26 +2,48 @@ namespace App\Livewire; -use App\Models\BackupScheduleRun; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Tenant; -use Illuminate\Support\Arr; +use App\Support\OpsUx\OpsUxBrowserEvents; +use Filament\Facades\Filament; +use Illuminate\Support\Collection; use Livewire\Attributes\Computed; +use Livewire\Attributes\On; use Livewire\Component; class BulkOperationProgress extends Component { - public $runs; + /** + * @var Collection + */ + public Collection $runs; - public int $pollSeconds = 3; + public int $overflowCount = 0; - public int $recentFinishedSeconds = 12; + public bool $disabled = false; - public function mount() + public bool $hasActiveRuns = false; + + public ?int $tenantId = null; + + public function mount(): void { - $this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); - $this->recentFinishedSeconds = max(3, min(60, (int) config('tenantpilot.bulk_operations.recent_finished_seconds', 12))); - $this->loadRuns(); + $this->runs = collect(); + + $tenant = Filament::getTenant(); + $this->tenantId = $tenant instanceof Tenant ? (int) $tenant->id : null; + + $this->refreshRuns(); + } + + #[On(OpsUxBrowserEvents::RunEnqueued)] + public function onRunEnqueued(?int $tenantId = null): void + { + if ($tenantId !== null) { + $this->tenantId = $tenantId; + } + + $this->refreshRuns(); } #[Computed] @@ -30,116 +52,52 @@ public function activeRuns() return $this->runs; } - public function loadRuns() + public function refreshRuns(): void { - try { - $tenant = Tenant::current(); - } catch (\RuntimeException $e) { + $tenantId = $this->tenantId; + + // Best-effort: if we're mounted on a tenant page, capture it once. + if ($tenantId === null) { + $tenant = Filament::getTenant(); + $tenantId = $tenant instanceof Tenant ? (int) $tenant->id : null; + $this->tenantId = $tenantId; + } + + if ($tenantId === null) { + $this->disabled = true; $this->runs = collect(); + $this->overflowCount = 0; + $this->hasActiveRuns = false; return; } - $recentThreshold = now()->subSeconds($this->recentFinishedSeconds); + if (! auth()->user()?->can('viewAny', OperationRun::class)) { + $this->disabled = true; + $this->runs = collect(); + $this->overflowCount = 0; + $this->hasActiveRuns = false; - $this->runs = BulkOperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('user_id', auth()->id()) - ->where(function ($query) use ($recentThreshold): void { - $query->whereIn('status', ['pending', 'running']) - ->orWhere(function ($query) use ($recentThreshold): void { - $query->whereIn('status', ['completed', 'completed_with_errors', 'failed', 'aborted']) - ->where('updated_at', '>=', $recentThreshold); - }); - }) - ->orderByDesc('created_at') - ->get(); - - $this->reconcileBackupScheduleRuns($tenant->id); - } - - private function reconcileBackupScheduleRuns(int $tenantId): void - { - $userId = auth()->id(); - - if (! $userId) { return; } - $staleThreshold = now()->subSeconds(60); + $this->disabled = false; - foreach ($this->runs as $bulkRun) { - if ($bulkRun->resource !== 'backup_schedule') { - continue; - } + $query = OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->orderByDesc('created_at'); - if (! in_array($bulkRun->status, ['pending', 'running'], true)) { - continue; - } - - if (! $bulkRun->created_at || $bulkRun->created_at->gt($staleThreshold)) { - continue; - } - - $scheduleId = (int) Arr::first($bulkRun->item_ids ?? []); - - if ($scheduleId <= 0) { - continue; - } - - $scheduleRun = BackupScheduleRun::query() - ->where('tenant_id', $tenantId) - ->where('user_id', $userId) - ->where('backup_schedule_id', $scheduleId) - ->where('created_at', '>=', $bulkRun->created_at) - ->orderByDesc('id') - ->first(); - - if (! $scheduleRun) { - continue; - } - - if ($scheduleRun->finished_at) { - $processed = 1; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $status = 'completed'; - - switch ($scheduleRun->status) { - case BackupScheduleRun::STATUS_SUCCESS: - $succeeded = 1; - break; - - case BackupScheduleRun::STATUS_SKIPPED: - $skipped = 1; - break; - - default: - $failed = 1; - $status = 'completed_with_errors'; - break; - } - - $bulkRun->forceFill([ - 'status' => $status, - 'processed_items' => $processed, - 'succeeded' => $succeeded, - 'failed' => $failed, - 'skipped' => $skipped, - ])->save(); - - continue; - } - - if ($scheduleRun->started_at && $bulkRun->status === 'pending') { - $bulkRun->forceFill(['status' => 'running'])->save(); - } - } + $activeCount = (clone $query)->count(); + $this->runs = (clone $query)->limit(6)->get(); + $this->overflowCount = max(0, $activeCount - 5); + $this->hasActiveRuns = $this->runs->isNotEmpty(); } public function render(): \Illuminate\Contracts\View\View { - return view('livewire.bulk-operation-progress'); + return view('livewire.bulk-operation-progress', [ + 'tenant' => Filament::getTenant(), + ]); } } diff --git a/app/Notifications/OperationRunCompleted.php b/app/Notifications/OperationRunCompleted.php index c249a9a..807abc9 100644 --- a/app/Notifications/OperationRunCompleted.php +++ b/app/Notifications/OperationRunCompleted.php @@ -4,8 +4,7 @@ use App\Models\OperationRun; use App\Models\Tenant; -use App\Support\OperationRunLinks; -use Filament\Notifications\Notification as FilamentNotification; +use App\Support\OpsUx\OperationUxPresenter; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Notification; @@ -26,21 +25,9 @@ public function toDatabase(object $notifiable): array { $tenant = $this->run->tenant; - $status = match ((string) $this->run->outcome) { - 'succeeded' => 'success', - 'partially_succeeded' => 'warning', - default => 'danger', - }; - - return FilamentNotification::make() - ->title('Operation completed') - ->body("{$this->run->type} ({$this->run->outcome})") - ->status($status) - ->actions([ - \Filament\Actions\Action::make('view') - ->label('View run') - ->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null), - ]) - ->getDatabaseMessage(); + return OperationUxPresenter::terminalDatabaseNotification( + run: $this->run, + tenant: $tenant instanceof Tenant ? $tenant : null, + )->getDatabaseMessage(); } } diff --git a/app/Notifications/OperationRunQueued.php b/app/Notifications/OperationRunQueued.php index 5e78c84..074e946 100644 --- a/app/Notifications/OperationRunQueued.php +++ b/app/Notifications/OperationRunQueued.php @@ -4,6 +4,7 @@ use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use Filament\Notifications\Notification as FilamentNotification; use Illuminate\Bus\Queueable; @@ -32,10 +33,12 @@ public function toDatabase(object $notifiable): array { $tenant = $this->run->tenant; + $operationLabel = OperationCatalog::label((string) $this->run->type); + return FilamentNotification::make() - ->title('Operation queued') - ->body($this->run->type) - ->info() + ->title("{$operationLabel} queued") + ->body('Queued. Monitor progress in Monitoring → Operations.') + ->warning() ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') diff --git a/app/Services/OperationRunService.php b/app/Services/OperationRunService.php index 7c68c1b..db85eb8 100644 --- a/app/Services/OperationRunService.php +++ b/app/Services/OperationRunService.php @@ -9,6 +9,7 @@ use App\Notifications\OperationRunQueued as OperationRunQueuedNotification; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OpsUx\SummaryCountsNormalizer; use Illuminate\Database\QueryException; use InvalidArgumentException; use Throwable; @@ -107,7 +108,7 @@ public function updateRun( } if (! empty($summaryCounts)) { - $updateData['summary_counts'] = $summaryCounts; + $updateData['summary_counts'] = $this->sanitizeSummaryCounts($summaryCounts); } if (! empty($failures)) { @@ -277,6 +278,15 @@ protected function sanitizeMessage(string $message): string // Redact long opaque blobs that look token-like. $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message; - return substr($message, 0, 500); + return substr($message, 0, 120); + } + + /** + * @param array $summaryCounts + * @return array + */ + protected function sanitizeSummaryCounts(array $summaryCounts): array + { + return SummaryCountsNormalizer::normalize($summaryCounts); } } diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php new file mode 100644 index 0000000..0b313ce --- /dev/null +++ b/app/Support/OperationCatalog.php @@ -0,0 +1,64 @@ + + */ + public static function labels(): array + { + return [ + 'policy.sync' => 'Policy sync', + 'policy.sync_one' => 'Policy sync', + 'policy.capture_snapshot' => 'Policy snapshot', + 'inventory.sync' => 'Inventory sync', + 'directory_groups.sync' => 'Directory groups sync', + 'drift.generate' => 'Drift generation', + 'backup_set.add_policies' => 'Backup set update', + 'backup_set.remove_policies' => 'Backup set update', + 'backup_set.delete' => 'Archive backup sets', + 'backup_set.restore' => 'Restore backup sets', + 'backup_set.force_delete' => 'Delete backup sets', + 'backup_schedule.run_now' => 'Backup schedule run', + 'backup_schedule.retry' => 'Backup schedule retry', + 'restore.execute' => 'Restore execution', + 'policy_version.prune' => 'Prune policy versions', + 'policy_version.restore' => 'Restore policy versions', + 'policy_version.force_delete' => 'Delete policy versions', + ]; + } + + public static function label(string $operationType): string + { + $operationType = trim($operationType); + + if ($operationType === '') { + return 'Operation'; + } + + return self::labels()[$operationType] ?? 'Unknown operation'; + } + + public static function expectedDurationSeconds(string $operationType): ?int + { + return match (trim($operationType)) { + 'policy.sync', 'policy.sync_one' => 90, + 'inventory.sync' => 180, + 'directory_groups.sync' => 120, + 'drift.generate' => 240, + default => null, + }; + } + + /** + * @return array + */ + public static function allowedSummaryKeys(): array + { + return OperationSummaryKeys::all(); + } +} diff --git a/app/Support/OpsUx/OperationRunUrl.php b/app/Support/OpsUx/OperationRunUrl.php new file mode 100644 index 0000000..256a575 --- /dev/null +++ b/app/Support/OpsUx/OperationRunUrl.php @@ -0,0 +1,22 @@ + 'partial', + 'succeeded' => 'succeeded', + 'failed' => 'failed', + default => 'failed', + }; + } +} diff --git a/app/Support/OpsUx/OperationSummaryKeys.php b/app/Support/OpsUx/OperationSummaryKeys.php new file mode 100644 index 0000000..c2f925d --- /dev/null +++ b/app/Support/OpsUx/OperationSummaryKeys.php @@ -0,0 +1,26 @@ +title("{$operationLabel} queued") + ->body('Running in the background.') + ->warning() + ->duration(self::QUEUED_TOAST_DURATION_MS); + } + + /** + * Terminal DB notification payload. + * + * Note: We intentionally return the built Filament notification builder to + * keep DB formatting consistent with existing Notification classes. + */ + public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification + { + $operationLabel = OperationCatalog::label((string) $run->type); + + $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome); + + $titleSuffix = match ($uxStatus) { + 'succeeded' => 'completed', + 'partial' => 'completed with warnings', + default => 'failed', + }; + + $body = match ($uxStatus) { + 'succeeded' => 'Completed successfully.', + 'partial' => 'Completed with warnings.', + default => 'Failed.', + }; + + if ($uxStatus === 'failed') { + $failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? ''); + + $failureMessage = self::sanitizeFailureMessage($failureMessage); + + if ($failureMessage !== null) { + $body = $body.' '.$failureMessage; + } + } + + $summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []); + if ($summary !== null) { + $body = $body."\n".$summary; + } + + $status = match ($uxStatus) { + 'succeeded' => 'success', + 'partial' => 'warning', + default => 'danger', + }; + + $notification = FilamentNotification::make() + ->title("{$operationLabel} {$titleSuffix}") + ->body($body) + ->status($status); + + if ($tenant instanceof Tenant) { + $notification->actions([ + \Filament\Actions\Action::make('view') + ->label('View run') + ->url(OperationRunUrl::view($run, $tenant)), + ]); + } + + return $notification; + } + + private static function sanitizeFailureMessage(string $failureMessage): ?string + { + $failureMessage = trim($failureMessage); + + if ($failureMessage === '') { + return null; + } + + $failureMessage = Str::of($failureMessage) + ->replace(["\r", "\n"], ' ') + ->squish() + ->toString(); + + $failureMessage = Str::limit($failureMessage, self::FAILURE_MESSAGE_MAX_CHARS, '…'); + + return $failureMessage !== '' ? $failureMessage : null; + } +} diff --git a/app/Support/OpsUx/OpsUxBrowserEvents.php b/app/Support/OpsUx/OpsUxBrowserEvents.php new file mode 100644 index 0000000..df63746 --- /dev/null +++ b/app/Support/OpsUx/OpsUxBrowserEvents.php @@ -0,0 +1,31 @@ +getKey(); + + // In Livewire v3, dispatch() emits a DOM event that bubbles. + // Our progress widget is mounted outside the initiating component's DOM tree, + // so we target it explicitly to ensure it receives the event immediately. + $livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(BulkOperationProgress::class); + } +} diff --git a/app/Support/OpsUx/RunDetailPolling.php b/app/Support/OpsUx/RunDetailPolling.php new file mode 100644 index 0000000..0e5de7a --- /dev/null +++ b/app/Support/OpsUx/RunDetailPolling.php @@ -0,0 +1,35 @@ +status, $run->outcome); + + if (! in_array($uxStatus, ['queued', 'running'], true)) { + return null; + } + + $ageSeconds = now()->diffInSeconds($run->created_at ?? now()); + + if ($ageSeconds < 10) { + return '1s'; + } + + if ($ageSeconds < 60) { + return '5s'; + } + + return '10s'; + } +} diff --git a/app/Support/OpsUx/RunDurationInsights.php b/app/Support/OpsUx/RunDurationInsights.php new file mode 100644 index 0000000..ad2ce83 --- /dev/null +++ b/app/Support/OpsUx/RunDurationInsights.php @@ -0,0 +1,145 @@ +started_at ?? $run->created_at; + + if (! $start) { + return null; + } + + $end = $run->completed_at ?? now(); + + $seconds = $end->diffInSeconds($start); + + if (is_int($seconds)) { + return $seconds; + } + + return (int) round((float) $seconds); + } + + public static function elapsedHuman(OperationRun $run): string + { + $start = $run->started_at ?? $run->created_at; + + if (! $start) { + return '—'; + } + + $end = $run->completed_at ?? now(); + + return $end->diffForHumans($start, true); + } + + public static function expectedSeconds(OperationRun $run): ?int + { + $catalog = OperationCatalog::expectedDurationSeconds((string) $run->type); + + if (is_int($catalog)) { + return $catalog; + } + + $tenantId = (int) ($run->tenant_id ?? 0); + $type = (string) ($run->type ?? ''); + + if ($tenantId <= 0 || $type === '') { + return null; + } + + $cacheKey = "opsux:expected-duration:tenant:{$tenantId}:type:".$type; + + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($tenantId, $type): ?int { + $durations = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', $type) + ->whereNotNull('started_at') + ->whereNotNull('completed_at') + ->where('created_at', '>=', now()->subDays(30)) + ->latest('id') + ->limit(200) + ->get(['started_at', 'completed_at']) + ->map(function (OperationRun $run): ?int { + if (! $run->started_at || ! $run->completed_at) { + return null; + } + + $seconds = $run->completed_at->diffInSeconds($run->started_at); + + if (is_int($seconds)) { + return $seconds; + } + + return (int) round((float) $seconds); + }) + ->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0) + ->sort() + ->values(); + + if ($durations->isEmpty()) { + return null; + } + + $count = $durations->count(); + $middle = intdiv($count - 1, 2); + + $median = (int) $durations[$middle]; + + return $median > 0 ? $median : null; + }); + } + + public static function expectedHuman(OperationRun $run): ?string + { + $seconds = self::expectedSeconds($run); + + if (! is_int($seconds) || $seconds <= 0) { + return null; + } + + if ($seconds < 60) { + return 'Typically under 1 minute'; + } + + $minutes = (int) round($seconds / 60); + + return "Typically ~{$minutes} min"; + } + + public static function stuckGuidance(OperationRun $run): ?string + { + $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome); + + if (! in_array($uxStatus, ['queued', 'running'], true)) { + return null; + } + + $elapsed = self::elapsedSeconds($run); + + if (! is_int($elapsed) || $elapsed <= 0) { + return null; + } + + $expected = self::expectedSeconds($run); + + $isLikelyStuck = is_int($expected) + ? ($elapsed > max(600, $expected * 2)) + : ($elapsed > 900); + + if (! $isLikelyStuck) { + return null; + } + + return 'Taking longer than expected. If this does not complete soon, verify the queue worker is running and check logs for errors.'; + } +} diff --git a/app/Support/OpsUx/SummaryCountsNormalizer.php b/app/Support/OpsUx/SummaryCountsNormalizer.php new file mode 100644 index 0000000..1f7a6da --- /dev/null +++ b/app/Support/OpsUx/SummaryCountsNormalizer.php @@ -0,0 +1,69 @@ + $summaryCounts + * @return array + */ + public static function normalize(array $summaryCounts): array + { + $allowedKeys = array_flip(OperationSummaryKeys::all()); + + $sanitized = []; + + foreach ($summaryCounts as $key => $value) { + $key = trim((string) $key); + + if ($key === '' || ! isset($allowedKeys[$key])) { + continue; + } + + if (is_int($value)) { + $sanitized[$key] = $value; + + continue; + } + + if (is_float($value) && is_finite($value)) { + $sanitized[$key] = (int) round($value); + + continue; + } + + if (is_numeric($value)) { + $sanitized[$key] = (int) $value; + } + } + + return $sanitized; + } + + /** + * @param array $summaryCounts + */ + public static function renderSummaryLine(array $summaryCounts): ?string + { + $normalized = self::normalize($summaryCounts); + + if ($normalized === []) { + return null; + } + + $parts = []; + + foreach ($normalized as $key => $value) { + $parts[] = $key.': '.$value; + } + + if ($parts === []) { + return null; + } + + return 'Summary: '.implode(', ', $parts); + } +} diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index f74e5d4..2f8fc4b 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -1,85 +1,189 @@ @php($runs = $runs ?? collect()) -@php($interval = $runs->isEmpty() ? max((int) $pollSeconds, 10) : (int) $pollSeconds) +@php($overflowCount = (int) ($overflowCount ?? 0)) +@php($tenant = $tenant ?? null) -
- +{{-- Widget must always be mounted, even when empty, so it can receive Livewire events --}} +
isNotEmpty()) wire:poll.5s="refreshRuns" @endif +> @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) + @foreach ($runs->take(5) as $run)
- -
-
+ wire:key="run-{{ $run->id }}"> +
+

- {{ ucfirst($run->action) }} {{ ucfirst(str_replace('_', ' ', $run->resource)) }} + {{ \App\Support\OperationCatalog::label((string) $run->type) }}

-

- @if($run->status === 'pending') - @php($isStalePending = $run->created_at->lt(now()->subSeconds(30))) - - - - - - {{ $isStalePending ? 'Queued…' : 'Starting...' }} - - @elseif($run->status === 'running') - - - - - - Processing... - - @elseif(in_array($run->status, ['completed', 'completed_with_errors'], true)) - Done - @elseif(in_array($run->status, ['failed', 'aborted'], true)) - Failed +

+ @if($run->status === 'queued') + Queued • {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }} + @else + Running • {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }} @endif

-
- - {{ $run->processed_items }} / {{ $effectiveTotal }} - -
- {{ $percent }}% -
-
-
-
-
-
- -
-
- @if ($run->succeeded > 0) - - ✓ {{ $run->succeeded }} succeeded - - @endif - @if ($run->failed > 0) - - ✗ {{ $run->failed }} failed - - @endif - @if ($run->skipped > 0) - - ⊘ {{ $run->skipped }} skipped - - @endif -
- - {{ $run->created_at->diffForHumans(null, true, true) }} - + @if ($tenant) + + View run + + @endif
@endforeach + + @if($overflowCount > 0 && $tenant) + + +{{ $overflowCount }} more + + @endif
@endif
+ + diff --git a/specs/055-ops-ux-rollout/checklists/requirements.md b/specs/055-ops-ux-rollout/checklists/requirements.md new file mode 100644 index 0000000..9b05424 --- /dev/null +++ b/specs/055-ops-ux-rollout/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Ops-UX Constitution Rollout (v1.3.0 Alignment) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-18 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass; spec is ready for `/speckit.plan`. diff --git a/specs/055-ops-ux-rollout/contracts/ux-contracts.md b/specs/055-ops-ux-rollout/contracts/ux-contracts.md new file mode 100644 index 0000000..79850b8 --- /dev/null +++ b/specs/055-ops-ux-rollout/contracts/ux-contracts.md @@ -0,0 +1,73 @@ +# UX Contracts (non-API) — Ops-UX Constitution Rollout (055) + +This feature does not introduce new public HTTP APIs. Instead it defines **internal UX contracts**: shared builders/presenters that all operation-related UI must use. + +## Surfaces + +### A) Toast (queued only) + +**Trigger**: user clicks “Start operation” / submits a form that enqueues a job. + +**Contract** + +- Toast must appear immediately. +- Must **not** write a DB notification for `queued`. +- Copy (canonical) (OPS-UX Constitution v1.3.0): + - Title: `{OperationLabel} queued` + - Body: `Running in the background.` + +Optional alternative body (not additive): + +- `You can monitor progress in Operations.` + +Forbidden in toast: + +- counts/metrics +- percentages/progress +- terminal outcomes (completed/failed/partial) +- feature-specific copy + +Notes: + +- Toast is queued-only. +- Terminal outcomes are delivered via DB notification (initiator-only). +- Live awareness is via Progress Widget + Monitoring → Operations. + +### B) Progress widget (queued/running only) + +**Audience**: tenant-wide for users with Monitoring access. + +**Contract** + +- Only shows runs with status `queued` or `running`. +- At most 5 items. +- If more than 5, show `+N more` link to canonical Operations index. +- Each row must include a canonical `View run` action linking to Run Detail. +- Must use centralized label + status copy. +- Polling cadence must be “calm”: start fast when there are actives, then back off. + +### C) Terminal DB notification + +**Trigger**: transition to a terminal state. + +**Audience**: initiator-only. + +**Contract** + +- Exactly one notification per run per initiator. +- Must not write `queued` notifications. +- Title/body/status must be derived via centralized presenter. +- Must include canonical `View run` link. +- Optional summary uses `summary_counts` with whitelist rules. + +## Strings + +All copy that appears in these surfaces must come from a shared source (presenter or resource strings), not ad-hoc in Blade/Livewire. + +## Metrics (summary_counts) + +Allowed keys: + +- See spec.md (FR-012) “Canonical allowed summary keys (single source of truth)”. + +Any other keys must not render. diff --git a/specs/055-ops-ux-rollout/data-model.md b/specs/055-ops-ux-rollout/data-model.md new file mode 100644 index 0000000..f62f3a6 --- /dev/null +++ b/specs/055-ops-ux-rollout/data-model.md @@ -0,0 +1,104 @@ +# Phase 1 Data Model: Ops-UX Constitution Rollout (v1.3.0 Alignment) (055) + +**Date**: 2026-01-18 + +This feature is a migration: it standardizes how existing `operation_runs` records are presented via three UX surfaces. + +## Entities + +### 1) OperationRun (existing) + +**Source**: `operation_runs` table + +**Fields (relevant to this feature)** + +- `id` (int) +- `tenant_id` (int) +- `user_id` (int|null) — initiator user +- `initiator_name` (string) +- `type` (string) — operation type identifier +- `status` (string) — active: `queued|running`, terminal: `completed` +- `outcome` (string) — `pending|succeeded|partially_succeeded|failed|cancelled` (existing vocabulary) +- `started_at` (timestamp|null) +- `completed_at` (timestamp|null) +- `summary_counts` (jsonb) — canonical “metrics” source for this rollout +- `failure_summary` (jsonb) — array of failures with sanitized messages +- `context` (jsonb) — structured context used for related links + +### Status / Outcome (UX-canonical) + +The Ops-UX surfaces (toast/widget/db notifications) use the canonical statuses: + +- active: `queued` | `running` +- terminal: `succeeded` | `partial` | `failed` + +### Legacy / compatibility mapping (if older records exist) + +Some existing records may use legacy fields/values (for example `status=completed` with an `outcome`). +These MUST be normalized for UX rendering as follows: + +Normalization rules: + +- `status=completed` AND `outcome=succeeded` -> `terminal=succeeded` +- `status=completed` AND `outcome=partially_succeeded` -> `terminal=partial` +- `status=failed` OR `outcome=failed` -> `terminal=failed` + +Notes: + +- The Monitoring UI MUST remain usable if legacy values exist. +- Normalization is a presentation concern for Ops-UX; storage may remain unchanged during rollout. + +### 2) OperationCatalog (new/standardized) + +**Purpose**: single source of truth for operation labels. + +**Fields** + +- `operation_type` (string) → `label` (string) + +**Rules** + +- Any code-produced operation type must be registered (CI guard). +- Unknown types from historical data render as `Unknown operation`. + +### 3) OperationRunUrl (new helper) + +**Purpose**: canonical URL generator for “View run”. + +**Rule**: all “View run” links must route to Monitoring → Operations → Run Detail. + +### 4) OperationUxPresenter (new presenter/builder) + +**Purpose**: centralized presentation for all three surfaces. + +**Responsibilities** + +- Toast copy for queued intent +- Progress widget row presentation (label, status text, optional progress) +- Terminal DB notification title/body/status and optional summary rendering + +## Validation rules + +### Summary counts (`operation_runs.summary_counts`) + +- Must be a flat object/dictionary. +- Allowed keys only: + - `total, processed, succeeded, failed, skipped, created, updated, deleted, items, tenants` +- Values must be numeric and normalized to integers. +- Invalid keys/values are ignored; if nothing valid remains, no summary is rendered. + +### Failure messages + +- Short, sanitized, no secrets/tokens, no payload dumps. +- Used only for terminal failure notification body as optional suffix. + +## Relationships + +- `OperationRun` belongs to `Tenant` +- `OperationRun` belongs to `User` (initiator) (nullable) + +## Derived fields for presentation + +- `OperationLabel` = `OperationCatalog::label(type)` (or `Unknown operation`) +- `UxStatus` = `Queued|Running|Completed|Partial|Failed` (derived) +- `ProgressPercent` (optional) = `processed / total` only when both exist and are valid diff --git a/specs/055-ops-ux-rollout/plan.md b/specs/055-ops-ux-rollout/plan.md new file mode 100644 index 0000000..2a803f2 --- /dev/null +++ b/specs/055-ops-ux-rollout/plan.md @@ -0,0 +1,110 @@ +# Implementation Plan: Ops-UX Constitution Rollout (v1.3.0 Alignment) (055) + +**Branch**: `055-ops-ux-rollout` | **Date**: 2026-01-18 | **Spec**: `specs/055-ops-ux-rollout/spec.md` +**Input**: Feature specification from `specs/055-ops-ux-rollout/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Standardize all operation feedback across the app to the Operations UX Constitution (v1.3.0): + +- Three surfaces only (queued toast, progress widget for active runs, terminal DB notification for initiator). +- Centralized operation labels via an OperationCatalog. +- Canonical “View run” navigation to Monitoring → Operations → Run Detail. +- Safe, structured summaries sourced only from `operation_runs.summary_counts` (JSONB), using a single whitelist and numeric-only values. + +Primary deliverables are shared presenters/normalizers + regression guards (Pest) that prevent drift. + +## Technical Context + +**Language/Version**: PHP 8.4.15 (Laravel 12) +**Primary Dependencies**: Filament v4, Livewire v3 +**Storage**: PostgreSQL (JSONB for `operation_runs.summary_counts`) +**Testing**: Pest v4 (Laravel test runner), PHPUnit 12 (via Pest) +**Target Platform**: Docker/Sail for local dev, container-based deploy (Dokploy) +**Project Type**: Laravel web application (monolith) +**Performance Goals**: Not performance-driven; UX consistency and guardrails are primary +**Constraints**: +- Calm polling rules (no modal polling; pause when tab hidden; stop on terminal) +- No new Graph calls introduced by this feature +- No schema refactor required for rollout; normalize for presentation +**Scale/Scope**: Repo-wide UX migration impacting multiple producers and Monitoring UI + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: N/A (this feature is a UX standardization on existing run records) +- Read/write separation: No new writes; changes are UX/presentation + safe sanitization + tests +- Graph contract path: No Graph calls introduced +- Deterministic capabilities: N/A +- Tenant isolation: All queried runs are tenant-scoped; notifications are initiator-only +- Operations UX (unified system): This feature enforces the three-surface model and canonical navigation +- Automation: N/A (no new scheduling/locks added) +- Data minimization: Summary and failure messages are sanitized; summaries are numeric-only + +**Gate status**: PASS (no violations required) + +**Post-design re-check**: PASS (design artifacts align with Ops-UX three-surface model, DB source-of-truth, canonical navigation, and safe summaries) + +## Project Structure + +### Documentation (this feature) + +```text +specs/055-ops-ux-rollout/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── ux-contracts.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +├── Livewire/ +├── Models/ +├── Notifications/ +├── Providers/ +├── Services/ +└── Support/ + +resources/ +└── views/ + +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel web application (monolith) with Filament admin UI in `app/Filament/` and Pest tests in `tests/`. + +## Phase Outputs + +### Phase 0 — Research + +- `specs/055-ops-ux-rollout/research.md` + +### Phase 1 — Design & Contracts + +- `specs/055-ops-ux-rollout/data-model.md` +- `specs/055-ops-ux-rollout/contracts/ux-contracts.md` +- `specs/055-ops-ux-rollout/quickstart.md` + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/055-ops-ux-rollout/quickstart.md b/specs/055-ops-ux-rollout/quickstart.md new file mode 100644 index 0000000..24481e8 --- /dev/null +++ b/specs/055-ops-ux-rollout/quickstart.md @@ -0,0 +1,48 @@ +# Quickstart — Ops-UX Constitution Rollout (v1.3.0 Alignment) (055) + +This feature is a repo-wide migration. It standardizes operation feedback across: + +- **Toast** (queued only) +- **Progress widget** (queued/running only) +- **DB notification** (terminal only) + +## Dev setup + +Use Sail-first: + +- `./vendor/bin/sail up -d` +- `./vendor/bin/sail artisan migrate` + +## Key places in the codebase + +- Operation runs table: `operation_runs` +- Monitoring UI: Filament resources/pages for operation runs +- Existing widget: Livewire `BulkOperationProgress` injected via Filament render hook +- Canonical run links helper: `App\Support\OperationRunLinks` + +## What to change (high level) + +1) Centralize operation label + UX copy +- Create/update catalog/presenter so widget + notification + toast share strings. + +2) Enforce queued vs terminal feedback split +- Toast for `queued` +- Widget for `queued|running` +- DB notification for terminal states only + +3) Enforce canonical “View run” link +- All surfaces link to Monitoring → Operations → Run Detail. + +4) Metrics normalization +- Use `summary_counts` only. +- Apply whitelist + integer normalization. + +## How to validate + +Run targeted tests: + +- `./vendor/bin/sail artisan test --group=ops-ux` + +Then run Pint: + +- `./vendor/bin/pint --dirty` diff --git a/specs/055-ops-ux-rollout/research.md b/specs/055-ops-ux-rollout/research.md new file mode 100644 index 0000000..8e0efce --- /dev/null +++ b/specs/055-ops-ux-rollout/research.md @@ -0,0 +1,72 @@ +# Phase 0 Research: Ops-UX Constitution Rollout (v1.3.0 Alignment) (055) + +**Date**: 2026-01-18 + +## Findings + +### Current operation-run storage and UX primitives + +- The repo has a tenant-scoped `operation_runs` table and an `OperationRun` model. +- Structured “metrics” for this rollout are stored as `operation_runs.summary_counts` (JSONB). +- Canonical Monitoring entrypoint exists via the Filament resource `OperationRunResource`. +- Canonical route building already exists in `App\Support\OperationRunLinks`. + +### Existing progress widget patterns + +- A bottom-right Livewire widget exists today for bulk operations: `App\Livewire\BulkOperationProgress`. +- It is injected globally via a Filament render hook in `App\Providers\Filament\AdminPanelProvider`. +- Current behavior is not constitution-compliant: + - It shows terminal states and renders terminal wording. + - It renders percentages and per-run counts unconditionally. + - It is user-scoped today (filters by `user_id = auth()->id()`). + +### Notifications + +- Filament database notifications are already used and are persistent by default. +- Current code has OperationRun DB notifications (queued + completed), but this rollout requires: + - no queued DB notifications + - exactly one terminal notification per run + - initiator-only audience + +## Decisions + +### Decision 1: Tenant-wide progress widget scope +- **Chosen**: The progress widget shows all active runs for the current tenant (not only runs started by the current user), for users with access to Monitoring → Operations. +- **Rationale**: Aligns with the constitution’s tenant-scoped operations model and avoids “why don’t I see what’s running?” support loops. +- **Alternatives considered**: + - User-only widget: rejected (hides tenant work and defeats the monitoring intent). + +### Decision 2: Canonical metrics source +- **Chosen**: Treat `operation_runs.summary_counts` as the canonical “metrics” field for this rollout. +- **Rationale**: Matches existing schema; avoids adding a new `metrics` column during a UX migration. +- **Alternatives considered**: + - New `operation_runs.metrics` column: rejected (scope increase + migration risk). + +### Decision 3: Unknown operation types in UI +- **Chosen**: Soft-fail at runtime with label `Unknown operation`, and fail-fast in CI for code-produced operation types. +- **Rationale**: Keeps Monitoring usable for legacy/dirty data while enforcing discipline for new work. +- **Alternatives considered**: + - Throw exception at runtime: rejected (breaks Monitoring for historical data). + - Render raw type string: rejected (leaks internal naming and encourages drift). + +### Decision 4: DB notification audience +- **Chosen**: Initiator-only for terminal DB notifications. +- **Rationale**: Prevents tenant-wide notification spam; Monitoring remains the tenant-wide audit surface. +- **Alternatives considered**: + - Tenant-wide fan-out: rejected (noisy; not necessary for monitoring). + +### Decision 5: No queued DB notifications +- **Chosen**: Ban queued DB notifications repo-wide for OperationRuns. +- **Rationale**: Simplifies dedupe; queued intent belongs to the toast surface. +- **Alternatives considered**: + - Allow queued DB notifications with dedupe: rejected (still noisy; adds edge cases). + +### Decision 6: Migration approach for progress widget +- **Chosen**: Reuse the existing “global Livewire progress widget via render hook” pattern, but migrate it to query `OperationRun` and apply constitution rules. +- **Rationale**: Low-risk way to ship a single widget surface across the app. +- **Alternatives considered**: + - Per-feature widgets/pages: rejected (violates the “three surfaces only” rule). + +## Open Questions + +None (all clarifications captured in the feature spec). diff --git a/specs/055-ops-ux-rollout/spec.md b/specs/055-ops-ux-rollout/spec.md new file mode 100644 index 0000000..f5bd2c7 --- /dev/null +++ b/specs/055-ops-ux-rollout/spec.md @@ -0,0 +1,213 @@ +# Feature Specification: Ops-UX Constitution Rollout (v1.3.0 Alignment) + +**Feature Branch**: `055-ops-ux-rollout` +**Created**: 2026-01-18 +**Status**: Draft +**Input**: Repo-wide migration to align all existing operation feedback with the Operations UX Constitution (v1.3.0). + +## Clarifications + +### Session 2026-01-18 + +- Q: For the Progress Widget, what should be the visibility scope? → A: All active runs for the current tenant (visible to users who can access Monitoring → Operations). +- Q: For R7 metrics/summary contract, which field is the canonical source across the app? → A: Treat `operation_runs.summary_counts` as the canonical “metrics” source for this rollout. +- Q: If an existing run record contains an unknown `operation_type`, what should the UI do at runtime? → A: Soft fail: show `Unknown operation` (tests/CI still fail fast for code-produced operation types). +- Q: Who should receive the terminal DB notification for a run? → A: Only the initiator. +- Q: For this rollout, do we ban queued DB notifications entirely in favor of queued toast + terminal DB notification? → A: Yes, ban queued DB notifications. + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Consistent “I started it” feedback (Priority: P1) + +As a tenant admin who triggers a long-running operation, I want immediate confirmation that my action was accepted and a single, consistent way to follow progress, so I don’t retry actions or lose track. + +**Why this priority**: Prevents duplicate operations and reduces confusion/support load. + +**Independent Test**: Starting any operation produces a queued-only intent feedback with a canonical “View run” destination. + +**Acceptance Scenarios**: + +1. **Given** an operation is started and the run is created or reused in `queued`, **When** feedback is shown, **Then** it is a queued-only toast with title `{OperationLabel} queued` and body `Running in the background.` +2. **Given** an operation completes quickly (<2 seconds), **When** feedback is shown, **Then** the queued toast may be suppressed but the terminal DB notification still appears. + +--- + +### User Story 2 - Live awareness of active operations (Priority: P2) + +As a tenant admin, I want a single progress widget that shows only active operations (queued/running) with strict, predictable wording, so I can understand what’s happening without noise. + +**Why this priority**: Creates a unified “what’s running?” view and eliminates feature-specific progress UIs. + +**Independent Test**: The progress widget lists only active runs, with strict “Queued/Running” text and canonical “View run” links. + +**Acceptance Scenarios**: + +1. **Given** there are active operations in `queued` or `running`, **When** the widget is visible, **Then** it shows at most 5 runs and each row includes a canonical “View run”. +2. **Given** an operation is terminal (`succeeded|partial|failed`), **When** the widget queries its data, **Then** that run is never included. + +--- + +### User Story 3 - Audit + outcome without spam (Priority: P3) + +As a tenant admin, I want exactly one persistent notification when an operation finishes (success/partial/failure), with a consistent title/body and safe summary, so I can audit outcomes and troubleshoot. + +**Why this priority**: Delivers reliable outcomes and reduces notification noise. + +**Independent Test**: Terminal runs always create exactly one DB notification with canonical copy and safe summary rules. + +**Acceptance Scenarios**: + +1. **Given** an operation run transitions into a terminal outcome, **When** notifications are emitted, **Then** exactly one terminal DB notification exists for that run. +2. **Given** valid numeric metrics exist for an operation, **When** the notification body includes a summary, **Then** the summary renders only whitelisted numeric keys and never renders free-text. + +--- + +### User Story 4 - Regression-safe by default (Priority: P4) + +As a maintainer, I want automated guards that fail fast when the app deviates from the constitution (labels, links, surfaces, summary rules), so drift is prevented across future features. + +**Why this priority**: This is a migration; without guards, the codebase will regress quickly. + +**Independent Test**: A test suite enforces invariants (catalog coverage, canonical “View run”, terminal notification idempotency, widget filtering, summary whitelist). + +**Acceptance Scenarios**: + +1. **Given** a new/unknown `operation_type` is introduced, **When** tests run, **Then** the build fails until the OperationCatalog is updated. +2. **Given** any “View run” link is generated, **When** it is resolved, **Then** it matches the canonical Monitoring → Operations → Run Detail destination. + +--- + +### Edge Cases + +- Unknown `operation_type` appears in an existing run record. +- Multiple operations start simultaneously (more than 5 active runs). +- Runs with missing/invalid metrics (nested objects, strings, non-whitelisted keys, negative values). +- Runs that transition `queued → terminal` quickly (<2 seconds). +- UI is backgrounded/hidden while operations are active. +- A modal dialog is open while an operation is active. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Operations UX alignment (required when applicable):** If this feature creates/reuses `OperationRun` records or affects +operations feedback (toasts, progress widget, DB notifications, Monitoring → Operations, run detail), the spec MUST +explicitly confirm: +- Three surfaces only (toast + progress widget + DB notification) — no feature-specific patterns +- DB is source of truth: UI renders from `operation_runs` + structured fields (`metrics`, `reason_code`, `message`) +- Labels come from a central OperationCatalog (no embedded labels/strings in feature code) +- “View run” links always target the canonical route (Monitoring → Operations → Run Detail) +- Dedupe/noise control (max 1 queued toast; exactly 1 terminal DB notification; no “running” notifications) +- Calm polling constraints (no polling while modals are open; pause when tab hidden; stop on terminal) +- Test invariants for notifications, summary whitelist, and canonical navigation + +### Assumptions + +- The application already records tenant-scoped operations as OperationRuns. +- “Monitoring → Operations → Run Detail” is the canonical destination for viewing a run. +- Operation feedback is intended for tenant admins with access to Monitoring/Operations. +- The progress widget is tenant-wide (within the current tenant) and respects the same access constraints as Monitoring/Operations. +- The constitution’s “metrics” terminology maps to `operation_runs.summary_counts` for this rollout (no schema rename required). +- Persistent notifications are user-scoped to the run initiator; tenant-wide audit remains the Monitoring → Operations hub. +- Queued feedback is provided via toast only; persistent DB notifications are terminal-only. +- The constitution (v1.3.0) is the authoritative definition for copy/behavior; feature-specific variants are not allowed. + +### Dependencies + +- A single shared OperationCatalog exists and can be treated as the source of truth for operation labels. +- A canonical “View run” helper can be used by all operation feedback surfaces. +- Existing operation producers can be migrated without changing the operation status model. + +### Functional Requirements + +**FR-001 (Three surfaces only)**: The system MUST express operation feedback via exactly three surfaces: toast (intent), progress widget (active awareness), and persistent notification (audit + terminal outcome). + +**FR-002 (OperationCatalog label source of truth)**: The system MUST provide a central OperationCatalog mapping `operation_type → label`, and all operation labels shown in UI MUST be resolved from it. + +**FR-003 (Fail-fast catalog coverage)**: The system MUST fail fast (via automated checks) if any code-produced `operation_type` used in the application is not present in the OperationCatalog. + +**FR-003b (Runtime behavior for unknown types)**: If an existing run record contains an unknown `operation_type`, the UI MUST render the label `Unknown operation` (and MUST NOT render the raw type string). + +**FR-004 (Canonical “View run” everywhere)**: The system MUST generate “View run” links exclusively via one canonical helper, and that destination MUST always be Monitoring → Operations → Run Detail. + +**FR-005 (Centralized presentation)**: The system MUST centralize the user-facing copy for operation toasts, widget status text, and persistent notifications. Feature code MUST NOT define operation feedback strings. + +**FR-006 (Toast queued-only)**: The system MUST show a toast only when a run is created or reused in `queued`, MUST NOT show toasts for `running` or any terminal outcome, MUST auto-dismiss within 3–5 seconds, and MUST use: +- Title: `{OperationLabel} queued` +- Body: `Running in the background.` + +**FR-007 (Progress widget queued/running only)**: The progress widget MUST display only active runs (`queued`, `running`) for the current tenant (not just the initiating user) and MUST never display terminal runs. Status text MUST be exactly `Queued` or `Running`. + +**FR-008 (Progress calculation)**: The widget MUST show a deterministic progress percentage only when numeric `total` and `processed` counts are present and valid in `summary_counts`. Otherwise it MUST show indeterminate progress. Deterministic progress MUST be clamped to 0–100%. + +**FR-009 (Widget run limit + overflow)**: The widget MUST show at most 5 active runs. If more exist, it MUST show a single overflow row `+N more operations running` linking to the operations index. + +**FR-010 (Terminal persistent notifications only)**: Each run MUST produce exactly one persistent notification when it becomes terminal (`succeeded|partial|failed`). + +**FR-010b (Notification audience)**: Terminal persistent notifications MUST be delivered only to the run initiator (no tenant-wide notification fan-out). + +**FR-010c (No queued DB notifications)**: The system MUST NOT emit queued DB notifications as part of this rollout. Queued feedback MUST be provided via the queued-only toast surface. + +**FR-010d (Status normalization for Ops-UX (compatibility))**: Ops-UX surfaces MUST render terminal outcomes using the canonical statuses: `succeeded | partial | failed`. + +If a run record contains legacy values (e.g. `status=completed` with `outcome=partially_succeeded`), the UI MUST normalize as follows: + +- completed + outcome=succeeded -> succeeded +- completed + outcome=partially_succeeded -> partial +- failed (or outcome=failed) -> failed + +This is a presentation/normalization rule for the rollout; it does not mandate a schema refactor. + +**FR-011 (Notification copy templates)**: Persistent notifications MUST use canonical titles and bodies: +- succeeded: `{OperationLabel} completed` / `Completed successfully.` +- partial: `{OperationLabel} completed with warnings` / `Completed with warnings.` +- failed: `{OperationLabel} failed` / `Failed.` + optional sanitized message + +**FR-012 (Metrics/summaries are structured and safe)**: Operation summary counts (`operation_runs.summary_counts`) MUST be flat, numeric-only, and limited to whitelisted keys. Summary rendering MUST use only normalized/validated `summary_counts` and MUST NOT render free-text. + +### Canonical allowed summary keys (single source of truth) + +The following keys are the ONLY allowed summary keys for Ops-UX rendering: +`total, processed, succeeded, failed, skipped, created, updated, deleted, items, tenants` + +All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places). + +**FR-013 (Calm polling policy)**: Polling is allowed only for the progress widget (when visible) and run detail (only while active). Polling MUST pause when a modal is open, pause when the tab is hidden, follow the backoff schedule (1s for first 10s, then 5s, then 10s after 60s), and stop immediately on terminal. + +**FR-014 (Migration scope)**: All existing operation feedback across the application MUST be migrated to these shared rules without introducing new operation types or changing the run status model. + +### Key Entities *(include if feature involves data)* + +- **OperationRun**: A tenant-scoped record of an operation’s type, status, timestamps, outcome, and structured metrics. +- **OperationCatalog**: A central registry of valid `operation_type` values and their user-facing labels. +- **Operation Feedback Surfaces**: + - **Toast**: short-lived intent confirmation for queued runs. + - **Progress Widget**: live awareness of active runs. + - **Persistent Notification**: audit + terminal outcome notification with canonical “View run”. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of tenant-scoped operations use exactly the three approved surfaces (toast, widget, persistent notification), with no feature-specific alternatives. +- **SC-002**: 100% of “View run” links resolve to Monitoring → Operations → Run Detail. +- **SC-003**: For terminal runs, 100% produce exactly one persistent notification (no duplicates) and 0% produce “running” notifications. +- **SC-004**: For active runs, the progress widget returns 0 terminal runs and shows strict status text (`Queued`/`Running`) 100% of the time. +- **SC-005**: Summary rendering shows only whitelisted numeric keys; invalid metrics render no summary in 100% of tested cases. +- **SC-006**: Automated guards fail fast when any new `operation_type` is not registered in OperationCatalog. diff --git a/specs/055-ops-ux-rollout/tasks.md b/specs/055-ops-ux-rollout/tasks.md new file mode 100644 index 0000000..ec3d024 --- /dev/null +++ b/specs/055-ops-ux-rollout/tasks.md @@ -0,0 +1,208 @@ +--- + +description: "Task list for Ops-UX Constitution Rollout (v1.3.0 Alignment) (055)" +--- + +# Tasks: Ops-UX Constitution Rollout (v1.3.0 Alignment) (055) + +**Input**: Design documents from `specs/055-ops-ux-rollout/` + +**Tests**: REQUIRED (Pest) — runtime behavior + UX contract enforcement. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Create a clean workspace for implementing and testing this migration. + +- [x] T001 Create Ops UX test folder structure in `tests/Feature/OpsUx/` +- [x] T002 [P] Add a dedicated test file stub in `tests/Feature/OpsUx/OpsUxSmokeTest.php` +- [x] T003 [P] Add a shared test helper (factory/state helpers) in `tests/Support/OpsUxTestSupport.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives (catalog/links/presenter/normalization) that every user story depends on. + +- [x] T004 Update runtime unknown-type label behavior in `app/Support/OperationCatalog.php` (render `Unknown operation`, never raw type) +- [x] T005 [P] Add shared presenter for toast/widget/notification copy in `app/Support/OpsUx/OperationUxPresenter.php` +- [x] T059 Implement a single status normalization function for Ops-UX rendering + - Map legacy completed/outcome values to canonical terminal statuses (`succeeded|partial|failed`) + - Ensure widget and notifications consume normalized status +- [x] T006 [P] Add summary_counts normalizer + whitelist enforcement in `app/Support/OpsUx/SummaryCountsNormalizer.php` +- [x] T060 Consolidate allowed summary keys into one code constant/source (no duplicated lists) + - Example shape: `OperationSummaryKeys::all()` (or similar) + - Normalizer MUST reference that source + - Catalog (if it references keys) MUST reference the same source + - Add one guard test asserting the list matches spec.md canonical list +- [x] T007 [P] Add canonical “View run” URL helper wrapper in `app/Support/OpsUx/OperationRunUrl.php` (delegates to `app/Support/OperationRunLinks.php`) +- [x] T008 Update summary key whitelist consumption in `app/Support/OperationCatalog.php` to reference the consolidated source (see T060) +- [x] T009 Update summary sanitization to use shared normalizer in `app/Services/OperationRunService.php` +- [x] T010 Update OperationRun completed notification to use shared presenter + normalizer in `app/Notifications/OperationRunCompleted.php` +- [x] T011 Disable queued DB notification emission by default in `app/Services/OperationRunService.php` (align with FR-010c) +- [x] T012 Deprecate/stop using queued DB notification class in `app/Notifications/OperationRunQueued.php` (keep class but ensure no producers call it) +- [x] T013 Ensure all “View run” actions inside operation notifications use canonical URL helper in `app/Notifications/OperationRunCompleted.php` + +**Checkpoint**: Shared contracts available; user story work can proceed. + +--- + +## Phase 3: User Story 1 — Consistent “I started it” feedback (Priority: P1) 🎯 MVP + +**Goal**: Starting any operation shows queued-only intent feedback with canonical “View run”. + +**Independent Test**: Trigger an operation start from a Filament action; observe a queued-only toast (`{OperationLabel} queued` / `Running in the background.`) and verify no queued DB notification is created. + +### Tests for User Story 1 + +- [x] T014 [P] [US1] Add test ensuring no queued DB notifications are emitted in `tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php` +- [x] T015 [P] [US1] Add test for canonical queued toast copy builder in `tests/Feature/OpsUx/QueuedToastCopyTest.php` +- [x] T057 [US1] Enforce toast auto-dismiss duration (3–5 seconds) for queued intent feedback (set duration explicitly, e.g. 4000ms) +- [x] T058 [P] [US1] (Optional guard) Centralize toast duration in `OperationUxPresenter` and add a small unit test to keep it within 3000–5000ms + +### Implementation for User Story 1 + +- [x] T016 [P] [US1] Migrate queued toast copy for policy operations in `app/Filament/Resources/PolicyResource.php` (use `OperationUxPresenter`) +- [x] T017 [P] [US1] Migrate queued toast copy for policy version operations in `app/Filament/Resources/PolicyVersionResource.php` (use `OperationUxPresenter`) +- [x] T018 [P] [US1] Migrate queued toast copy for restore run operations in `app/Filament/Resources/RestoreRunResource.php` (use `OperationUxPresenter`) +- [x] T019 [P] [US1] Migrate queued toast copy for backup schedule operations in `app/Filament/Resources/BackupScheduleResource.php` (use `OperationUxPresenter`) +- [x] T020 [P] [US1] Migrate queued toast copy for backup set operations in `app/Filament/Resources/BackupSetResource.php` (use `OperationUxPresenter`) +- [x] T021 [P] [US1] Migrate queued toast copy for tenant sync operations in `app/Filament/Resources/TenantResource.php` (use `OperationUxPresenter`) +- [x] T022 [P] [US1] Migrate queued toast copy for policy view page operations in `app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php` (use `OperationUxPresenter`) +- [x] T023 [P] [US1] Migrate queued toast copy for inventory landing operations in `app/Filament/Pages/InventoryLanding.php` (use `OperationUxPresenter`) +- [x] T024 [P] [US1] Migrate queued toast copy for drift landing operations in `app/Filament/Pages/DriftLanding.php` (use `OperationUxPresenter`) +- [x] T025 [P] [US1] Migrate queued toast copy for backup-set policy picker operations in `app/Livewire/BackupSetPolicyPickerTable.php` (use `OperationUxPresenter`) + +**Checkpoint**: A user can start operations and get consistent queued intent feedback. + +--- + +## Phase 4: User Story 2 — Live awareness of active operations (Priority: P2) + +**Goal**: A single global widget shows only tenant-scoped queued/running runs with strict copy and canonical links. + +**Independent Test**: Create active runs in the DB; the widget shows max 5 rows, each row has `Queued`/`Running` only, and terminal runs never render. + +### Tests for User Story 2 + +- [x] T026 [P] [US2] Add widget filtering test (never show terminal) in `tests/Feature/OpsUx/ProgressWidgetFiltersTest.php` +- [x] T027 [P] [US2] Add widget max-5 + overflow link test in `tests/Feature/OpsUx/ProgressWidgetOverflowTest.php` +- [x] T062 [US2] Add restore execution → OperationRun sync regression test in `tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php` + +### Implementation for User Story 2 + +- [x] T028 [US2] Migrate widget query from BulkOperationRun to OperationRun in `app/Livewire/BulkOperationProgress.php` +- [x] T029 [US2] Enforce tenant-wide scope + Monitoring access guard in `app/Livewire/BulkOperationProgress.php` +- [x] T030 [US2] Update widget UI strings + strict status text in `resources/views/livewire/bulk-operation-progress.blade.php` +- [x] T031 [US2] Implement max-5 + overflow link behavior in `resources/views/livewire/bulk-operation-progress.blade.php` +- [x] T032 [US2] Use canonical “View run” URLs in widget rows in `resources/views/livewire/bulk-operation-progress.blade.php` (via `OperationRunUrl` / `OperationRunLinks`) +- [x] T033 [US2] No % in widget; widget may show elapsed time only +- [x] T034 [US2] Implement calm polling schedule + pause rules in `resources/views/livewire/bulk-operation-progress.blade.php` +- [x] T063 [US2] Dispatch `ops-ux:run-enqueued` browser event after successful enqueue so the widget refreshes immediately + - Producers: `app/Filament/Pages/InventoryLanding.php`, `app/Filament/Pages/DriftLanding.php`, `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`, `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/TenantResource.php`, `app/Livewire/BackupSetPolicyPickerTable.php`, `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` +- [x] T035 [US2] Confirm widget injection remains global and consistent in `app/Providers/Filament/AdminPanelProvider.php` + +### Run detail polling (missing coverage for FR-013) + +- [x] T053 [US2] Add run-detail polling controller/hook that applies calm polling while status is active (`queued|running`) (only poll when run detail is visible; stop immediately on terminal; backoff 1s (first 10s) → 5s → 10s (after 60s)) +- [x] T054 [US2] Pause run-detail polling when a modal is open (global modal flag) and resume when closed (no network update spam while confirm dialogs/modals are open) +- [x] T055 [US2] Pause run-detail polling when browser tab is hidden (Page Visibility API) and resume when visible (no polling when `document.hidden = true`) +- [x] T056 [P] [US2] Add a small guard test/component test that run-detail polling is disabled once the run becomes terminal +- [x] T061 [US2] Surface elapsed time + expected duration + stuck guidance in run detail + +**Checkpoint**: Widget is constitution-compliant and becomes the single active-ops surface. + +--- + +## Phase 5: User Story 3 — Audit + outcome without spam (Priority: P3) + +**Goal**: Exactly one terminal DB notification per run (initiator-only) with canonical copy, canonical link, safe summary. + +**Independent Test**: Transition a run to terminal multiple times (or call completion twice); verify only one DB notification exists and it contains only whitelisted numeric summary keys. + +### Tests for User Story 3 + +- [x] T036 [P] [US3] Add terminal notification idempotency test in `tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php` +- [x] T037 [P] [US3] Add summary whitelist + numeric-only test in `tests/Feature/OpsUx/SummaryCountsWhitelistTest.php` +- [x] T038 [P] [US3] Add canonical “View run” action test for notifications in `tests/Feature/OpsUx/NotificationViewRunLinkTest.php` + +### Implementation for User Story 3 + +- [x] T039 [US3] Refactor terminal notification copy/title/body to use presenter in `app/Notifications/OperationRunCompleted.php` +- [x] T040 [US3] Ensure initiator-only delivery is enforced in `app/Services/OperationRunService.php` +- [x] T041 [US3] Ensure terminal notification is emitted exactly once per run in `app/Services/OperationRunService.php` +- [x] T042 [US3] Ensure notification summary renders only normalized `summary_counts` in `app/Notifications/OperationRunCompleted.php` +- [x] T043 [US3] Ensure failure message suffix is sanitized + short in `app/Notifications/OperationRunCompleted.php` + +**Checkpoint**: Terminal outcomes are auditable without spam. + +--- + +## Phase 6: User Story 4 — Regression-safe by default (Priority: P4) + +**Goal**: Guards prevent drift (catalog coverage, canonical links, surface rules, summary rules). + +**Independent Test**: Introduce a fake operation type in code and confirm tests fail; confirm “View run” always resolves to the canonical Monitoring run-detail destination. + +### Tests for User Story 4 + +- [x] T044 [P] [US4] Add catalog coverage guard test in `tests/Feature/OpsUx/OperationCatalogCoverageTest.php` +- [x] T045 [P] [US4] Add canonical “View run” helper usage guard test in `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` +- [x] T046 [P] [US4] Add unknown-type runtime label test in `tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php` + +### Implementation for User Story 4 + +- [x] T047 [US4] Ensure OperationRunResource type label rendering never shows raw type in `app/Filament/Resources/OperationRunResource.php` +- [x] T048 [US4] Ensure Monitoring Operations page type labels never show raw type in `app/Filament/Pages/Monitoring/Operations.php` +- [x] T049 [US4] Ensure any remaining “View run” links use canonical helper in `app/Support/OperationRunLinks.php` + +**Checkpoint**: Drift prevention is enforced in CI. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [x] T050 [P] Run Pint autofix for touched files via `app/` and `tests/` (validate against `composer.json` scripts) +- [x] T051 Run targeted test suite for Ops UX via `tests/Feature/OpsUx/` (document exact filter in `specs/055-ops-ux-rollout/quickstart.md`) +- [x] T052 [P] Remove or update any stale queued-notification references in `app/Services/OperationRunService.php` + +--- + +## Dependencies & Execution Order + +### User Story Dependencies + +- **US1 (P1)** depends on Phase 2 (Foundational) tasks T004–T013. +- **US2 (P2)** depends on Phase 2 (Foundational) tasks T004–T013. +- **US3 (P3)** depends on Phase 2 (Foundational) tasks T004–T013. +- **US4 (P4)** depends on completion of US1–US3 (guards should reflect final behavior). + +### Recommended completion order + +1. Phase 2 (Foundational) +2. US1 (queued toast + no queued DB notifications) +3. US3 (terminal notification contract) +4. US2 (widget) +5. US4 (guards) + +## Parallel Opportunities + +- Within Phase 2: T005–T007 can be done in parallel. +- US1 migration tasks T016–T025 are parallelizable (different files). +- US4 tests T044–T046 can be written in parallel. + +## Parallel Example: User Story 1 + +- Task: T016 (PolicyResource) + T017 (PolicyVersionResource) + T018 (RestoreRunResource) can run in parallel. +- Task: T019 (BackupScheduleResource) + T020 (BackupSetResource) can run in parallel. + +## Implementation Strategy + +### MVP scope + +Ship US1 + the minimum foundational primitives (Phase 2) to guarantee: + +- queued-only toast copy is consistent +- queued DB notifications are banned +- canonical “View run” destination is available + +Then layer US3 (terminal notification) before US2 (widget) to ensure audit outcomes are reliable early. diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 6e7b808..02876a6 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -88,7 +88,7 @@ 'notifiable_type' => User::class, 'type' => OperationRunQueued::class, 'data->format' => 'filament', - 'data->title' => 'Operation queued', + 'data->title' => 'Backup schedule run queued', ]); $notification = $user->notifications()->latest('id')->first(); @@ -158,7 +158,7 @@ 'notifiable_type' => User::class, 'type' => OperationRunQueued::class, 'data->format' => 'filament', - 'data->title' => 'Operation queued', + 'data->title' => 'Backup schedule retry queued', ]); $notification = $user->notifications()->latest('id')->first(); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 6e11ec1..c5d8385 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -2,10 +2,12 @@ use App\Filament\Pages\InventoryLanding; use App\Jobs\RunInventorySyncJob; +use App\Livewire\BulkOperationProgress; use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Services\Inventory\InventorySyncService; +use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -23,7 +25,8 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; Livewire::test(InventoryLanding::class) - ->callAction('run_inventory_sync', data: ['policy_types' => $allTypes]); + ->callAction('run_inventory_sync', data: ['policy_types' => $allTypes]) + ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()); Queue::assertPushed(RunInventorySyncJob::class); diff --git a/tests/Feature/Notifications/OperationRunNotificationTest.php b/tests/Feature/Notifications/OperationRunNotificationTest.php index 5371c39..e9b38c8 100644 --- a/tests/Feature/Notifications/OperationRunNotificationTest.php +++ b/tests/Feature/Notifications/OperationRunNotificationTest.php @@ -31,7 +31,7 @@ 'notifiable_type' => $user->getMorphClass(), 'type' => OperationRunQueued::class, 'data->format' => 'filament', - 'data->title' => 'Operation queued', + 'data->title' => 'Policy sync queued', ]); $notification = $user->notifications()->latest('id')->first(); @@ -86,7 +86,7 @@ $run, status: 'completed', outcome: 'succeeded', - summaryCounts: ['observed' => 1], + summaryCounts: ['total' => 1], failures: [], ); @@ -95,7 +95,7 @@ 'notifiable_type' => $user->getMorphClass(), 'type' => OperationRunCompleted::class, 'data->format' => 'filament', - 'data->title' => 'Operation completed', + 'data->title' => 'Inventory sync completed', ]); $notification = $user->notifications()->latest('id')->first(); diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php index 998b1eb..13fc55b 100644 --- a/tests/Feature/OperationRunServiceTest.php +++ b/tests/Feature/OperationRunServiceTest.php @@ -138,14 +138,13 @@ $fresh = $run->fresh(); expect($fresh?->status)->toBe('running'); expect($fresh?->started_at)->not->toBeNull(); - - $service->updateRun($run, 'completed', 'succeeded', ['success' => 1]); + $service->updateRun($run, 'completed', 'succeeded', ['succeeded' => 1]); $fresh = $run->fresh(); expect($fresh?->status)->toBe('completed'); expect($fresh?->outcome)->toBe('succeeded'); expect($fresh?->completed_at)->not->toBeNull(); - expect($fresh?->summary_counts)->toBe(['success' => 1]); + expect($fresh?->summary_counts)->toBe(['succeeded' => 1]); }); it('sanitizes failure messages and redacts obvious secrets', function () { diff --git a/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php b/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php new file mode 100644 index 0000000..dbf2372 --- /dev/null +++ b/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php @@ -0,0 +1,31 @@ +getRealPath(); + if (! is_string($path)) { + continue; + } + + // OperationRunLinks is the canonical wrapper. + if (str_ends_with($path, '/Support/OperationRunLinks.php')) { + continue; + } + + $contents = File::get($path); + + if (preg_match("/\\bOperationRunResource::getUrl\(\\s*'view'/", $contents) === 1) { + $violations[] = $path; + } + } + + expect($violations)->toBeEmpty(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php b/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php new file mode 100644 index 0000000..d387753 --- /dev/null +++ b/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php @@ -0,0 +1,36 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $run = $service->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: ['scope' => 'all'], + initiator: $user, + ); + + $service->dispatchOrFail($run, function (): void { + // no-op (dispatch succeeded) + }); + + expect($user->notifications()->count())->toBe(1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunQueued::class, + ]); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php new file mode 100644 index 0000000..bed43c2 --- /dev/null +++ b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php @@ -0,0 +1,42 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'all'], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'succeeded', + summaryCounts: ['total' => 1], + failures: [], + ); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($run, $tenant)); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/OperationCatalogCoverageTest.php b/tests/Feature/OpsUx/OperationCatalogCoverageTest.php new file mode 100644 index 0000000..b6c1365 --- /dev/null +++ b/tests/Feature/OpsUx/OperationCatalogCoverageTest.php @@ -0,0 +1,61 @@ +getRealPath(); + if (! is_string($path)) { + continue; + } + + // Skip the catalog itself to avoid tautology. + if (str_ends_with($path, '/Support/OperationCatalog.php')) { + continue; + } + + $contents = File::get($path); + + // Capture common patterns where operation type strings are produced in code. + // Example: ensureRun(type: 'inventory.sync', ...) + if (preg_match_all("/(?:\btype\s*:\s*|\btype\b\s*=>\s*)'([a-z0-9_]+\.[a-z0-9_]+)'/i", $contents, $matches)) { + foreach ($matches[1] as $type) { + $discoveredTypes[] = $type; + } + } + + // Example: if ($run->type === 'inventory.sync') + if (preg_match_all("/\btype\s*(?:===|!==|==|!=)\s*'([a-z0-9_]+\.[a-z0-9_]+)'/i", $contents, $matches)) { + foreach ($matches[1] as $type) { + $discoveredTypes[] = $type; + } + } + + // Example: in_array($run->type, ['a.b', 'c.d'], true) + if (preg_match_all("/\bin_array\([^\)]*\[([^\]]+)\]/i", $contents, $matches)) { + foreach ($matches[1] as $list) { + if (preg_match_all("/'([a-z0-9_]+\.[a-z0-9_]+)'/i", $list, $inner)) { + foreach ($inner[1] as $type) { + $discoveredTypes[] = $type; + } + } + } + } + } + + $discoveredTypes = array_values(array_unique($discoveredTypes)); + + $unknownTypes = array_values(array_diff($discoveredTypes, $knownTypes)); + + expect($unknownTypes) + ->toBeEmpty(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php b/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php new file mode 100644 index 0000000..46d3337 --- /dev/null +++ b/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php @@ -0,0 +1,18 @@ +toBeTruthy(); + + $specList = array_map('trim', explode(',', $m[1])); + + $codeList = OperationSummaryKeys::all(); + + expect($codeList)->toEqual($specList); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/OpsUxSmokeTest.php b/tests/Feature/OpsUx/OpsUxSmokeTest.php new file mode 100644 index 0000000..87b453d --- /dev/null +++ b/tests/Feature/OpsUx/OpsUxSmokeTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php b/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php new file mode 100644 index 0000000..9c08592 --- /dev/null +++ b/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php @@ -0,0 +1,47 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $otherUser = \App\Models\User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'owner'); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $otherUser->id, + 'status' => 'running', + 'outcome' => 'pending', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'completed', + 'outcome' => 'succeeded', + ]); + + $component = Livewire::actingAs($user) + ->test(BulkOperationProgress::class) + ->call('refreshRuns'); + + $runs = $component->get('runs'); + expect($runs)->toBeInstanceOf(Collection::class); + expect($runs)->toHaveCount(2); + expect($runs->pluck('status')->unique()->values()->all())->toEqualCanonicalizing(['queued', 'running']); + expect($runs->pluck('user_id')->all())->toContain($otherUser->id); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php b/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php new file mode 100644 index 0000000..c0ff31d --- /dev/null +++ b/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php @@ -0,0 +1,25 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + OperationRun::factory()->count(7)->create([ + 'tenant_id' => $tenant->id, + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $component = Livewire::actingAs($user) + ->test(BulkOperationProgress::class) + ->call('refreshRuns'); + + expect($component->get('runs'))->toHaveCount(6); + expect($component->get('overflowCount'))->toBe(2); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/ProgressWidgetPollerRegistrationTest.php b/tests/Feature/OpsUx/ProgressWidgetPollerRegistrationTest.php new file mode 100644 index 0000000..135329a --- /dev/null +++ b/tests/Feature/OpsUx/ProgressWidgetPollerRegistrationTest.php @@ -0,0 +1,16 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + Livewire::test(BulkOperationProgress::class) + ->assertSee('opsUxProgressWidgetPoller()') + ->assertSee('window.opsUxProgressWidgetPoller'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/QueuedToastCopyTest.php b/tests/Feature/OpsUx/QueuedToastCopyTest.php new file mode 100644 index 0000000..2247e6c --- /dev/null +++ b/tests/Feature/OpsUx/QueuedToastCopyTest.php @@ -0,0 +1,22 @@ +getTitle())->toBe('Policy sync queued'); + expect($toast->getBody())->toBe('Running in the background.'); +})->group('ops-ux'); + +it('enforces queued toast duration within 3–5 seconds', function (): void { + $toast = OperationUxPresenter::queuedToast('policy.sync'); + + $duration = $toast->getDuration(); + + expect($duration)->toBeInt(); + expect($duration)->toBeGreaterThanOrEqual(3000); + expect($duration)->toBeLessThanOrEqual(5000); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php new file mode 100644 index 0000000..b977819 --- /dev/null +++ b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php @@ -0,0 +1,69 @@ +actingAs($user); + + $backupSet = \App\Models\BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'queued', + 'started_at' => null, + 'completed_at' => null, + ]); + + // Observer should create the adapter OperationRun row on create. + $operationRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'restore.execute') + ->where('context->restore_run_id', $restoreRun->id) + ->first(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun?->status)->toBe('queued'); + + $this->mock(BulkOperationService::class, function ($mock): void { + $mock->shouldReceive('sanitizeFailureReason')->andReturnUsing(fn (string $message): string => $message); + }); + + // Simulate downstream code updating RestoreRun status via query builder (no model events). + $this->mock(RestoreService::class, function ($mock) use ($restoreRun): void { + $mock->shouldReceive('executeForRun') + ->once() + ->andReturnUsing(function () use ($restoreRun): RestoreRun { + RestoreRun::query()->whereKey($restoreRun->id)->update([ + 'status' => 'completed', + 'completed_at' => now(), + ]); + + return RestoreRun::query()->findOrFail($restoreRun->id); + }); + }); + + $job = new ExecuteRestoreRunJob($restoreRun->id); + $job->handle( + app(RestoreService::class), + app(AuditLogger::class), + app(BulkOperationService::class), + ); + + $operationRun = $operationRun?->fresh(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun?->status)->toBe('completed'); + expect($operationRun?->outcome)->toBe('succeeded'); + expect($operationRun?->completed_at)->not->toBeNull(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php b/tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php new file mode 100644 index 0000000..da7f2f1 --- /dev/null +++ b/tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php @@ -0,0 +1,27 @@ +create([ + 'status' => 'completed', + 'outcome' => 'succeeded', + ]); + + expect(RunDetailPolling::interval($run))->toBeNull(); +})->group('ops-ux'); + +it('enables run-detail polling while the run is queued or running', function (string $status): void { + $run = OperationRun::factory()->create([ + 'status' => $status, + 'outcome' => 'pending', + ]); + + expect(RunDetailPolling::interval($run))->not->toBeNull(); +})->with([ + 'queued' => 'queued', + 'running' => 'running', +])->group('ops-ux'); diff --git a/tests/Feature/OpsUx/RunEnqueuedBrowserEventTest.php b/tests/Feature/OpsUx/RunEnqueuedBrowserEventTest.php new file mode 100644 index 0000000..77cb6b8 --- /dev/null +++ b/tests/Feature/OpsUx/RunEnqueuedBrowserEventTest.php @@ -0,0 +1,50 @@ + */ + public array $params = [], + public ?string $to = null, + ) {} + + public function to(string $component): self + { + $this->to = $component; + + return $this; + } +} + +it('dispatches the run-enqueued browser event when supported', function () { + $fakeLivewire = new class + { + /** @var array */ + public array $dispatched = []; + + public ?FakeDispatchedEvent $lastEvent = null; + + public function dispatch(string $event, ...$params): FakeDispatchedEvent + { + $this->dispatched[] = $event; + + return $this->lastEvent = new FakeDispatchedEvent($event, $params); + } + }; + + OpsUxBrowserEvents::dispatchRunEnqueued($fakeLivewire); + + expect($fakeLivewire->dispatched)->toBe([OpsUxBrowserEvents::RunEnqueued]); + expect($fakeLivewire->lastEvent?->to)->not->toBeNull(); + expect($fakeLivewire->lastEvent?->params)->toHaveKey('tenantId'); +})->group('ops-ux'); + +it('does nothing when dispatch is unsupported', function () { + OpsUxBrowserEvents::dispatchRunEnqueued(null); + OpsUxBrowserEvents::dispatchRunEnqueued(new stdClass); + + expect(true)->toBeTrue(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php b/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php new file mode 100644 index 0000000..de070b3 --- /dev/null +++ b/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php @@ -0,0 +1,51 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'all'], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'succeeded', + summaryCounts: [ + 'total' => '10', + 'processed' => 5.2, + 'failed' => 'nope', + 'secrets' => 123, + ], + failures: [], + ); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + + $body = (string) ($notification->data['body'] ?? ''); + + expect($body)->toContain('Summary:'); + expect($body)->toContain('total: 10'); + expect($body)->toContain('processed: 5'); + expect($body)->not->toContain('secrets'); + expect($body)->not->toContain('failed:'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php b/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php new file mode 100644 index 0000000..9089a47 --- /dev/null +++ b/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php @@ -0,0 +1,53 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + $longMessage = "This is a very long failure message that should not be allowed to flood the notification UI.\n\n". + str_repeat('x', 400); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['scope' => 'all'], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'failed', + summaryCounts: ['total' => 1], + failures: [[ + 'code' => 'example.failure', + 'message' => $longMessage, + ]], + ); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + + $body = (string) ($notification->data['body'] ?? ''); + + expect($body)->toContain('Failed.'); + expect($body)->toContain('This is a very long failure message'); + + // Ensure message is not full-length / multiline. + expect($body)->not->toContain(str_repeat('x', 200)); + expect($body)->not->toContain("\n\nThis is a very long failure message"); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php b/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php new file mode 100644 index 0000000..6f80a5f --- /dev/null +++ b/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php @@ -0,0 +1,50 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['scope' => 'all'], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + expect($user->notifications()->count())->toBe(0); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'succeeded', + summaryCounts: ['total' => 1], + failures: [], + ); + + expect($user->notifications()->count())->toBe(1); + + // Even if some downstream code re-applies a terminal update, we never spam. + $service->updateRun( + $run, + status: 'completed', + outcome: 'failed', + summaryCounts: ['total' => 2], + failures: [['code' => 'repeat.terminal', 'message' => 'should not notify again']], + ); + + expect($user->notifications()->count())->toBe(1); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php b/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php new file mode 100644 index 0000000..ec65048 --- /dev/null +++ b/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php @@ -0,0 +1,12 @@ +toBe('Unknown operation'); + expect($label)->not->toContain('some.new_operation'); +})->group('ops-ux'); diff --git a/tests/Support/OpsUxTestSupport.php b/tests/Support/OpsUxTestSupport.php new file mode 100644 index 0000000..b293b2f --- /dev/null +++ b/tests/Support/OpsUxTestSupport.php @@ -0,0 +1,22 @@ + $overrides + * @return array + */ + public static function summaryCounts(array $overrides = []): array + { + return array_merge([ + 'total' => 10, + 'processed' => 5, + 'succeeded' => 5, + 'failed' => 0, + ], $overrides); + } +}