057-filament-v5-upgrade #66

Merged
ahmido merged 23 commits from 057-filament-v5-upgrade into dev 2026-01-20 21:19:28 +00:00
58 changed files with 2388 additions and 384 deletions
Showing only changes of commit 8b9ab52138 - Show all commits

View File

@ -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()
$this->dispatch(OpsUxBrowserEvents::RunEnqueued);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
}

View File

@ -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,13 +155,8 @@ 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)
Notification::make()
->title('Inventory sync already active')
->body('This operation is already queued or running.')
->warning()
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View Run')
@ -176,6 +164,8 @@ protected function getHeaderActions(): array
])
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
return;
}
// ----------------------------------------------
@ -236,20 +226,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 +235,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);
}),
];
}

View File

@ -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,15 +45,16 @@ public function table(Table $table): Table
)
->columns([
TextColumn::make('type')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->colors([
'secondary' => 'queued',
'warning' => 'running',
'success' => 'completed',
'warning' => 'queued',
'info' => 'running',
'secondary' => 'completed',
]),
TextColumn::make('outcome')

View File

@ -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()) {
@ -688,7 +685,7 @@ public static function table(Table $table): Table
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
}, emitQueuedNotification: false);
});
}
$notification = Notification::make()
@ -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()) {
@ -824,7 +825,7 @@ public static function table(Table $table): Table
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
}, emitQueuedNotification: false);
});
}
$notification = Notification::make()
@ -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),

View File

@ -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);

View File

@ -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()
$this->dispatch(OpsUxBrowserEvents::RunEnqueued);
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,6 +304,7 @@ public function table(Table $table): Table
);
});
$this->dispatch(OpsUxBrowserEvents::RunEnqueued);
Notification::make()
->title('Removal queued')
->body('A background job has been queued. You can monitor progress in the run details or progress widget.')

View File

@ -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,9 +256,9 @@ public static function getPages(): array
private static function statusColor(?string $status): string
{
return match ($status) {
'queued' => 'gray',
'queued' => 'warning',
'running' => 'info',
'completed' => 'success',
'completed' => 'secondary',
default => 'gray',
};
}

View File

@ -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();

View File

@ -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()
@ -434,16 +436,13 @@ public static function table(Table $table): Table
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 +532,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();
@ -637,16 +636,13 @@ public static function table(Table $table): Table
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(),

View File

@ -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();
@ -89,16 +91,13 @@ protected function getHeaderActions(): array
);
});
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();
}),
];

View File

@ -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));

View File

@ -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);

View File

@ -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')

View File

@ -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);
@ -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')

View File

@ -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) {

View File

@ -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();

View File

@ -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<int, OperationRun>
*/
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(),
]);
}
}

View File

@ -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,10 @@ 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),
])
return OperationUxPresenter::terminalDatabaseNotification(
run: $this->run,
tenant: $tenant instanceof Tenant ? $tenant : null,
)
->getDatabaseMessage();
}
}

View File

@ -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')

View File

@ -20,6 +20,7 @@
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Blade;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
@ -41,7 +42,7 @@ public function panel(Panel $panel): Panel
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
? view('livewire.bulk-operation-progress-wrapper')->render()
? Blade::render("@livewire('bulk-operation-progress', [], key('ops-ux-progress'))")
: ''
)
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')

View File

@ -6,9 +6,9 @@
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
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 +107,7 @@ public function updateRun(
}
if (! empty($summaryCounts)) {
$updateData['summary_counts'] = $summaryCounts;
$updateData['summary_counts'] = $this->sanitizeSummaryCounts($summaryCounts);
}
if (! empty($failures)) {
@ -142,14 +142,10 @@ public function updateRun(
* If dispatch fails synchronously (misconfiguration, serialization errors, etc.),
* the OperationRun is marked terminal failed so we do not leave a misleading queued run behind.
*/
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
public function dispatchOrFail(OperationRun $run, callable $dispatcher): void
{
try {
$dispatcher();
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
$run->user->notify(new OperationRunQueuedNotification($run));
}
} catch (Throwable $e) {
$this->updateRun(
$run,
@ -277,6 +273,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<string, mixed> $summaryCounts
* @return array<string, int>
*/
protected function sanitizeSummaryCounts(array $summaryCounts): array
{
return SummaryCountsNormalizer::normalize($summaryCounts);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Support;
use App\Support\OpsUx\OperationSummaryKeys;
final class OperationCatalog
{
/**
* @return array<string, string>
*/
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<int, string>
*/
public static function allowedSummaryKeys(): array
{
return OperationSummaryKeys::all();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
final class OperationRunUrl
{
public static function view(OperationRun $run, Tenant $tenant): string
{
return OperationRunLinks::view($run, $tenant);
}
public static function index(Tenant $tenant): string
{
return OperationRunLinks::index($tenant);
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
/**
* Normalize OperationRun legacy storage fields into Ops-UX canonical statuses.
*/
final class OperationStatusNormalizer
{
/**
* Returns one of: queued|running|succeeded|partial|failed.
*/
public static function toUxStatus(?string $status, ?string $outcome): string
{
$status = strtolower(trim((string) $status));
$outcome = strtolower(trim((string) $outcome));
if ($status === 'queued') {
return 'queued';
}
if ($status === 'running') {
return 'running';
}
// Terminal normalization (compatibility)
if ($status === 'failed' || $outcome === 'failed') {
return 'failed';
}
if ($status === 'completed' && $outcome === 'partially_succeeded') {
return 'partial';
}
if ($status === 'completed' && $outcome === 'succeeded') {
return 'succeeded';
}
// Best-effort fallback.
return match ($outcome) {
'partially_succeeded' => 'partial',
'succeeded' => 'succeeded',
'failed' => 'failed',
default => 'failed',
};
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Support\OpsUx;
class OperationSummaryKeys
{
/**
* Return the canonical allowed summary keys used for Ops-UX rendering.
* Keep ordering stable to make guard tests deterministic.
*/
public static function all(): array
{
return [
'total',
'processed',
'succeeded',
'failed',
'skipped',
'created',
'updated',
'deleted',
'items',
'tenants',
];
}
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Support\Str;
final class OperationUxPresenter
{
public const int QUEUED_TOAST_DURATION_MS = 4000;
public const int FAILURE_MESSAGE_MAX_CHARS = 140;
/**
* Queued intent feedback toast (ephemeral, not persisted).
*/
public static function queuedToast(string $operationType): FilamentNotification
{
$operationLabel = OperationCatalog::label($operationType);
return FilamentNotification::make()
->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;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Livewire\BulkOperationProgress;
use Filament\Facades\Filament;
final class OpsUxBrowserEvents
{
public const RunEnqueued = 'ops-ux:run-enqueued';
public static function dispatchRunEnqueued(mixed $livewire): void
{
if (! is_object($livewire)) {
return;
}
if (! method_exists($livewire, 'dispatch')) {
return;
}
$tenantId = Filament::getTenant()?->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);
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
final class RunDetailPolling
{
/**
* Returns a Livewire polling interval string (e.g. "1s") while the run is active.
* Returns null once the run is terminal.
*/
public static function interval(OperationRun $run): ?string
{
$uxStatus = OperationStatusNormalizer::toUxStatus($run->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';
}
}

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use Illuminate\Support\Facades\Cache;
final class RunDurationInsights
{
public static function elapsedSeconds(OperationRun $run): ?int
{
$start = $run->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.';
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
final class SummaryCountsNormalizer
{
/**
* @param array<string, mixed> $summaryCounts
* @return array<string, int>
*/
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<string, mixed> $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);
}
}

View File

@ -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)
<div wire:poll.{{ $interval }}s="loadRuns">
<!-- Bulk Operation Progress Component: {{ $runs->count() }} active runs -->
{{-- Widget must always be mounted, even when empty, so it can receive Livewire events --}}
<div
x-data="opsUxProgressWidgetPoller()"
x-init="init()"
wire:key="ops-ux-progress-widget"
@if($runs->isNotEmpty()) wire:poll.5s="refreshRuns" @endif
>
@if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
@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)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
wire:key="run-{{ $run->id }}">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
wire:key="run-{{ $run->id }}">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ ucfirst($run->action) }} {{ ucfirst(str_replace('_', ' ', $run->resource)) }}
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($run->status === 'pending')
@php($isStalePending = $run->created_at->lt(now()->subSeconds(30)))
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ $isStalePending ? 'Queued…' : 'Starting...' }}
</span>
@elseif($run->status === 'running')
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
@elseif(in_array($run->status, ['completed', 'completed_with_errors'], true))
<span class="text-success-600 dark:text-success-400">Done</span>
@elseif(in_array($run->status, ['failed', 'aborted'], true))
<span class="text-danger-600 dark:text-danger-400">Failed</span>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
@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
</p>
</div>
<div class="text-right">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ $run->processed_items }} / {{ $effectiveTotal }}
</span>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ $percent }}%
</div>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700 overflow-hidden">
<div class="bg-primary-600 dark:bg-primary-500 h-3 rounded-full transition-all duration-300 ease-out"
style="width: {{ $percent }}%"></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs">
<div class="flex items-center gap-3">
@if ($run->succeeded > 0)
<span class="text-success-600 dark:text-success-400">
{{ $run->succeeded }} succeeded
</span>
@endif
@if ($run->failed > 0)
<span class="text-danger-600 dark:text-danger-400">
{{ $run->failed }} failed
</span>
@endif
@if ($run->skipped > 0)
<span class="text-gray-500 dark:text-gray-400">
{{ $run->skipped }} skipped
</span>
@endif
</div>
<span class="text-gray-400 dark:text-gray-500">
{{ $run->created_at->diffForHumans(null, true, true) }}
</span>
@if ($tenant)
<a
href="{{ \App\Support\OpsUx\OperationRunUrl::view($run, $tenant) }}"
class="shrink-0 text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
View run
</a>
@endif
</div>
</div>
@endforeach
@if($overflowCount > 0 && $tenant)
<a
href="{{ \App\Support\OpsUx\OperationRunUrl::index($tenant) }}"
class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center text-xs font-medium text-gray-700 hover:text-gray-900 dark:text-gray-200 dark:hover:text-white shadow"
>
+{{ $overflowCount }} more
</a>
@endif
</div>
@endif
</div>
<script>
window.opsUxProgressWidgetPoller ??= function opsUxProgressWidgetPoller() {
return {
timer: null,
activeSinceMs: null,
fastUntilMs: null,
init() {
this.onVisibilityChange = this.onVisibilityChange.bind(this);
window.addEventListener('visibilitychange', this.onVisibilityChange);
this.onNavigated = this.onNavigated.bind(this);
window.addEventListener('livewire:navigated', this.onNavigated);
// First sync immediately.
this.schedule(0);
},
destroy() {
this.stop();
window.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('livewire:navigated', this.onNavigated);
},
stop() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
},
isModalOpen() {
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
},
isPaused() {
if (document.hidden === true) {
return true;
}
if (this.isModalOpen()) {
return true;
}
if (!this.$el || this.$el.isConnected !== true) {
return true;
}
return false;
},
onVisibilityChange() {
if (!this.isPaused()) {
this.schedule(0);
}
},
onNavigated() {
if (!this.isPaused()) {
this.schedule(0);
}
},
activeAgeSeconds() {
if (this.activeSinceMs === null) {
return 0;
}
return Math.floor((Date.now() - this.activeSinceMs) / 1000);
},
nextIntervalMs() {
// Stop polling entirely if server says this component is disabled.
if (this.$wire?.disabled === true) {
return null;
}
// Discovery polling.
if (this.$wire?.hasActiveRuns !== true) {
this.activeSinceMs = null;
return 30_000;
}
// Active polling backoff.
if (this.activeSinceMs === null) {
this.activeSinceMs = Date.now();
}
const now = Date.now();
if (this.fastUntilMs && now < this.fastUntilMs) {
return 1_000;
}
const age = this.activeAgeSeconds();
if (age < 10) {
return 1_000;
}
if (age < 60) {
return 5_000;
}
return 10_000;
},
async tick() {
if (this.isPaused()) {
// Keep it calm: check again later.
this.schedule(2_000);
return;
}
await this.$wire.refreshRuns();
const next = this.nextIntervalMs();
if (next === null) {
this.stop();
return;
}
this.schedule(next);
},
schedule(delayMs) {
this.stop();
const delay = Math.max(0, Number(delayMs ?? 0));
this.timer = setTimeout(() => {
this.tick();
}, delay);
},
};
};
</script>

View File

@ -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`.

View File

@ -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.

View File

@ -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

View File

@ -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] |

View File

@ -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`

View File

@ -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 constitutions tenant-scoped operations model and avoids “why dont I see whats 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).

View File

@ -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)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### 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 dont 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 whats happening without noise.
**Why this priority**: Creates a unified “whats 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 constitutions “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 35 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 0100%.
**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 operations 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.

View File

@ -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 (35 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 30005000ms
### 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
- [ ] T044 [P] [US4] Add catalog coverage guard test in `tests/Feature/OpsUx/OperationCatalogCoverageTest.php`
- [ ] T045 [P] [US4] Add canonical “View run” helper usage guard test in `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
- [ ] 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`
- [ ] 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 T004T013.
- **US2 (P2)** depends on Phase 2 (Foundational) tasks T004T013.
- **US3 (P3)** depends on Phase 2 (Foundational) tasks T004T013.
- **US4 (P4)** depends on completion of US1US3 (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: T005T007 can be done in parallel.
- US1 migration tasks T016T025 are parallelizable (different files).
- US4 tests T044T046 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.

View File

@ -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();

View File

@ -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);

View File

@ -2,12 +2,11 @@
use App\Models\OperationRun;
use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
it('does not emit a queued database notification after successful dispatch (toast-only)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
@ -26,18 +25,7 @@
// no-op (dispatch succeeded)
});
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => OperationRunQueued::class,
'data->format' => 'filament',
'data->title' => 'Operation queued',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($run, $tenant));
expect($user->notifications()->count())->toBe(0);
});
it('does not emit queued notifications for runs without an initiator', function () {
@ -86,7 +74,7 @@
$run,
status: 'completed',
outcome: 'succeeded',
summaryCounts: ['observed' => 1],
summaryCounts: ['total' => 1],
failures: [],
);
@ -95,7 +83,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();

View File

@ -139,13 +139,13 @@
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 () {

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('does not emit database notifications when dispatching a queued operation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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(0);
})->group('ops-ux');

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
it('uses canonical “View run” URL in terminal notifications', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,18 @@
<?php
use App\Support\OpsUx\OperationSummaryKeys;
it('matches the canonical summary keys declared in spec.md', function () {
$specPath = base_path('specs/055-ops-ux-rollout/spec.md');
$spec = file_get_contents($specPath);
// Extract the backtick-enclosed comma-separated canonical list from spec.md
$matched = preg_match('/The following keys are the ONLY allowed summary keys[\s\S]*?`([^`]*)`/i', $spec, $m);
expect($matched)->toBeTruthy();
$specList = array_map('trim', explode(',', $m[1]));
$codeList = OperationSummaryKeys::all();
expect($codeList)->toEqual($specList);
})->group('ops-ux');

View File

@ -0,0 +1,5 @@
<?php
it('ops-ux test suite boots', function () {
expect(true)->toBeTrue();
})->group('ops-ux');

View File

@ -0,0 +1,47 @@
<?php
use App\Livewire\BulkOperationProgress;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Support\Collection;
use Livewire\Livewire;
test('progress widget shows only queued and running operation runs', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,25 @@
<?php
use App\Livewire\BulkOperationProgress;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Livewire\Livewire;
test('progress widget limits to five active runs and exposes overflow count', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,16 @@
<?php
use App\Livewire\BulkOperationProgress;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('renders the progress widget poller script', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(BulkOperationProgress::class)
->assertSee('opsUxProgressWidgetPoller()')
->assertSee('window.opsUxProgressWidgetPoller');
})->group('ops-ux');

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use App\Support\OpsUx\OperationUxPresenter;
it('builds canonical queued toast copy', function (): void {
$toast = OperationUxPresenter::queuedToast('policy.sync');
expect($toast->getTitle())->toBe('Policy sync queued');
expect($toast->getBody())->toBe('Running in the background.');
})->group('ops-ux');
it('enforces queued toast duration within 35 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');

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
it('syncs restore execution into OperationRun even if restore status updates bypass model events', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\OpsUx\RunDetailPolling;
it('disables run-detail polling once the run is terminal', function (): void {
$run = OperationRun::factory()->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');

View File

@ -0,0 +1,50 @@
<?php
use App\Support\OpsUx\OpsUxBrowserEvents;
class FakeDispatchedEvent
{
public function __construct(
public string $name,
/** @var array<string, mixed> */
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<int, string> */
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');

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('renders only whitelisted numeric summary counts in terminal notifications', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('sanitizes and truncates failure message suffix in terminal notifications', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('emits a terminal database notification only once per run', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class OpsUxTestSupport
{
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function summaryCounts(array $overrides = []): array
{
return array_merge([
'total' => 10,
'processed' => 5,
'succeeded' => 5,
'failed' => 0,
], $overrides);
}
}