feat(ops-ux): enterprise start/dedup standard
This commit is contained in:
parent
29112225b6
commit
0de0494426
@ -14,6 +14,8 @@
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
@ -243,10 +245,14 @@ private function compareNowAction(): Action
|
||||
|
||||
$this->state = 'comparing';
|
||||
|
||||
Notification::make()
|
||||
->title('Baseline comparison started')
|
||||
->body('A background job will compute drift against the baseline snapshot.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $tenant)),
|
||||
] : [])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,7 +20,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
@ -240,10 +239,8 @@ public function mount(): void
|
||||
$this->state = 'generating';
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Drift generation already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -33,6 +33,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
@ -1444,6 +1446,8 @@ public function startVerification(): void
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -1501,23 +1505,27 @@ public function startVerification(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||
]);
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
$notification
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
public function refreshVerificationStatus(): void
|
||||
@ -1609,7 +1617,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
|
||||
/** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|array{status: 'scope_busy', run: OperationRun} $result */
|
||||
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
|
||||
$lockedConnection = ProviderConnection::query()
|
||||
->whereKey($connection->getKey())
|
||||
@ -1633,6 +1641,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
$runsService = app(OperationRunService::class);
|
||||
|
||||
$bootstrapRuns = [];
|
||||
$bootstrapCreated = [];
|
||||
|
||||
foreach ($types as $operationType) {
|
||||
$definition = $registry->get($operationType);
|
||||
@ -1671,15 +1680,19 @@ public function startBootstrap(array $operationTypes): void
|
||||
}
|
||||
|
||||
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
||||
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'started',
|
||||
'runs' => $bootstrapRuns,
|
||||
'created' => $bootstrapCreated,
|
||||
];
|
||||
});
|
||||
|
||||
if ($result['status'] === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -1711,10 +1724,27 @@ public function startBootstrap(array $operationTypes): void
|
||||
$this->onboardingSession->save();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Bootstrap started')
|
||||
->success()
|
||||
->send();
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
foreach ($types as $operationType) {
|
||||
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
|
||||
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
|
||||
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
|
||||
|
||||
$toast = $wasCreated
|
||||
? OperationUxPresenter::queuedToast($operationType)
|
||||
: OperationUxPresenter::alreadyQueuedToast($operationType);
|
||||
|
||||
if ($runUrl !== null) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchBootstrapJob(
|
||||
|
||||
@ -765,7 +765,7 @@ public static function table(Table $table): Table
|
||||
Action::make('view_runs')
|
||||
->label('View in Operations')
|
||||
->url(OperationRunLinks::index($tenant)),
|
||||
])->sendToDatabase($user);
|
||||
]);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
@ -862,7 +862,7 @@ public static function table(Table $table): Table
|
||||
Action::make('view_runs')
|
||||
->label('View in Operations')
|
||||
->url(OperationRunLinks::index($tenant)),
|
||||
])->sendToDatabase($user);
|
||||
]);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
@ -105,10 +104,9 @@ public function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -196,10 +194,9 @@ public function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\EditAction;
|
||||
@ -88,10 +91,36 @@ private function captureAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Capture enqueued')
|
||||
->body('Baseline snapshot capture has been started.')
|
||||
->success()
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof \App\Models\OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot start capture')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $sourceTenant));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,9 +10,10 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroups extends ListRecords
|
||||
@ -57,10 +58,8 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||
Notification::make()
|
||||
->title('Group sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
@ -80,16 +79,13 @@ protected function getHeaderActions(): array
|
||||
operationRun: $opRun
|
||||
));
|
||||
|
||||
Notification::make()
|
||||
->title('Group sync started')
|
||||
->body('Sync dispatched.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
})
|
||||
)
|
||||
|
||||
@ -167,10 +167,7 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Inventory sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
|
||||
@ -471,10 +471,8 @@ public static function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -603,7 +601,7 @@ public static function table(Table $table): Table
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records): void {
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
@ -643,19 +641,30 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy delete queued')
|
||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
->url($runUrl),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
@ -730,18 +739,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk restore started')
|
||||
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
@ -803,10 +800,8 @@ public static function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -900,18 +895,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk export started')
|
||||
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPolicies extends ListRecords
|
||||
@ -68,10 +67,8 @@ private function makeSyncAction(): Actions\Action
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -303,20 +303,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version prune queued')
|
||||
->body("Queued prune for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
@ -476,20 +462,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version force delete queued')
|
||||
->body("Queued force delete for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -586,7 +588,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
@ -623,10 +625,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A connection check is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -662,10 +663,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check queued')
|
||||
->body('Health check was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -684,7 +684,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
@ -725,10 +725,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('An inventory sync is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -758,10 +757,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync queued')
|
||||
->body('Inventory sync was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -780,7 +778,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
@ -821,10 +819,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A compliance snapshot is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -854,10 +851,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot queued')
|
||||
->body('Compliance snapshot was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
@ -254,10 +256,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A connection check is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -293,10 +294,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check queued')
|
||||
->body('Health check was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -493,10 +493,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('An inventory sync is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -526,10 +525,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync queued')
|
||||
->body('Inventory sync was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -606,10 +604,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A compliance snapshot is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -639,10 +636,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot queued')
|
||||
->body('Compliance snapshot was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -1535,11 +1535,23 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return $existing;
|
||||
}
|
||||
@ -1561,11 +1573,23 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return $existing;
|
||||
}
|
||||
@ -1904,11 +1928,25 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -1930,11 +1968,25 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -354,10 +354,8 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
@ -472,6 +470,7 @@ public static function table(Table $table): Table
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
\Filament\Tables\Contracts\HasTable $livewire,
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -496,6 +495,8 @@ public static function table(Table $table): Table
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -511,10 +512,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Verification already running')
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -568,9 +568,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Verification started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -753,7 +753,6 @@ public static function table(Table $table): Table
|
||||
->body('No eligible tenants selected.')
|
||||
->icon('heroicon-o-information-circle')
|
||||
->info()
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
return;
|
||||
@ -1606,10 +1605,7 @@ public static function syncRoleDefinitionsAction(): Actions\Action
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Role definitions sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -107,6 +109,8 @@ protected function getHeaderActions(): array
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -122,10 +126,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Verification already running')
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -179,9 +182,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Verification started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -228,10 +231,9 @@ protected function getHeaderActions(): array
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
Notification::make()
|
||||
->title('RBAC health check already running')
|
||||
->body('A check is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -248,9 +250,9 @@ protected function getHeaderActions(): array
|
||||
$opRun,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('RBAC health check started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -8,9 +8,13 @@
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class AdminRolesSummaryWidget extends Widget
|
||||
@ -54,16 +58,56 @@ public function scanNow(): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
ScanEntraAdminRolesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
|
||||
$opRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'entra.admin_roles.scan',
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'trigger' => 'scan',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiator_user_id' => (int) $user->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Entra admin roles scan queued')
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
||||
ScanEntraAdminRolesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body('The scan will run in the background. Results appear once complete.')
|
||||
->success()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
|
||||
@ -4,14 +4,19 @@
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantReviewPackCard extends Widget
|
||||
@ -58,26 +63,53 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRun($tenant)) {
|
||||
Notification::make()
|
||||
->title('Generation already in progress')
|
||||
->body('A review pack is currently being generated for this tenant.')
|
||||
->warning()
|
||||
$activeRun = $service->checkActiveRun($tenant)
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||
->active()
|
||||
->orderByDesc('id')
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if ($activeRun) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $activeRun->type)
|
||||
->body('A review pack is already queued or running for this tenant.')
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView($activeRun)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service->generate($tenant, $user, [
|
||||
$reviewPack = $service->generate($tenant, $user, [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Review pack generation started')
|
||||
->body('The pack will be generated in the background. You will be notified when it is ready.')
|
||||
->success()
|
||||
->send();
|
||||
$runUrl = $reviewPack->operationRun
|
||||
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
||||
: null;
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body('The pack will be generated in the background. You will be notified when it is ready.');
|
||||
|
||||
if ($runUrl !== null) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -68,6 +70,8 @@ public function startVerification(StartVerification $verification): void
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -83,10 +87,9 @@ public function startVerification(StartVerification $verification): void
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Verification already running')
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -140,9 +143,9 @@ public function startVerification(StartVerification $verification): void
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Verification started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -13,9 +13,7 @@
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\SnapshotValidator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
@ -465,48 +463,6 @@ public function handle(
|
||||
failures: $runFailuresForOperationRun,
|
||||
);
|
||||
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Added {$succeeded} policies";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if ($includeFoundations) {
|
||||
$message .= ". Foundations: {$foundationMutations} items";
|
||||
|
||||
if ($foundationFailures > 0) {
|
||||
$message .= " ({$foundationFailures} failed)";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
|
||||
->body($message)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
|
||||
if ($partial) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
$notification
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
} catch (Throwable $throwable) {
|
||||
$this->failRun(
|
||||
operationRunService: $operationRunService,
|
||||
@ -554,31 +510,6 @@ private function failRun(
|
||||
]],
|
||||
);
|
||||
|
||||
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
|
||||
}
|
||||
|
||||
private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
||||
{
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Add Policies Failed')
|
||||
->body($reason);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$notification
|
||||
->danger()
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
|
||||
private function mapGraphFailureReasonCode(?int $status): string
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -20,7 +21,7 @@ class ApplyBackupScheduleRetentionJob implements ShouldQueue
|
||||
|
||||
public function __construct(public int $backupScheduleId) {}
|
||||
|
||||
public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver): void
|
||||
public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver, OperationRunService $operationRunService): void
|
||||
{
|
||||
$schedule = BackupSchedule::query()
|
||||
->with(['tenant.workspace'])
|
||||
@ -132,18 +133,18 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
});
|
||||
}
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'summary_counts' => [
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => (int) $deleteBackupSetIds->count(),
|
||||
'processed' => (int) $deleteBackupSetIds->count(),
|
||||
'succeeded' => $deletedCount,
|
||||
'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount),
|
||||
'updated' => $deletedCount,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
|
||||
@ -10,10 +10,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -116,21 +114,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -165,21 +148,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -229,21 +197,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -278,27 +231,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($succeeded > 0 || $failed > 0) {
|
||||
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Export Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
@ -311,21 +243,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($user) && $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -129,32 +127,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$message = "Restored {$succeeded} policies";
|
||||
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
@ -167,21 +139,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($user) && $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -104,21 +102,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -158,21 +141,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -205,39 +173,5 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
$message = "Force deleted {$succeeded} restore runs";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -103,21 +101,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -156,21 +139,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -202,39 +170,5 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
$message = "Restored {$succeeded} restore runs";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,6 @@
|
||||
use App\Listeners\SyncRestoreRunToOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
@ -60,7 +58,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifyStatus($restoreRun, 'queued');
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun);
|
||||
|
||||
$tenant = $restoreRun->tenant;
|
||||
@ -75,8 +72,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), 'failed');
|
||||
|
||||
if ($tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -134,8 +129,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
// code performs restore-run updates without firing model events.
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), 'running');
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'restore.started',
|
||||
@ -163,7 +156,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
|
||||
} catch (Throwable $throwable) {
|
||||
$restoreRun->refresh();
|
||||
|
||||
@ -179,8 +171,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
|
||||
|
||||
if ($tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -203,45 +193,4 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyStatus(RestoreRun $restoreRun, string $status): void
|
||||
{
|
||||
$email = $this->actorEmail;
|
||||
|
||||
if (! is_string($email) || $email === '') {
|
||||
$email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null;
|
||||
}
|
||||
|
||||
if (! is_string($email) || $email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
$counts = [];
|
||||
|
||||
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
|
||||
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
|
||||
$counts[$key] = (int) $metadata[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'tenant_id' => (int) $restoreRun->tenant_id,
|
||||
'run_type' => 'restore',
|
||||
'run_id' => (int) $restoreRun->getKey(),
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if ($counts !== []) {
|
||||
$payload['counts'] = $counts;
|
||||
}
|
||||
|
||||
$user->notify(new RunStatusChangedNotification($payload));
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,9 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -180,14 +178,6 @@ public function handle(
|
||||
);
|
||||
}
|
||||
|
||||
$this->notifyCompleted(
|
||||
initiator: $initiator,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
removed: $removed,
|
||||
requested: $requestedCount,
|
||||
missing: count($missingIds),
|
||||
outcome: $outcome,
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
if ($tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
@ -212,100 +202,7 @@ public function handle(
|
||||
$opService->failRun($this->operationRun, $throwable);
|
||||
}
|
||||
|
||||
$this->notifyFailed(
|
||||
initiator: $initiator,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()),
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyCompleted(
|
||||
?User $initiator,
|
||||
?Tenant $tenant,
|
||||
int $removed,
|
||||
int $requested,
|
||||
int $missing,
|
||||
?string $outcome,
|
||||
): void {
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->operationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Removed {$removed} policies";
|
||||
|
||||
if ($missing > 0) {
|
||||
$message .= " ({$missing} missing)";
|
||||
}
|
||||
|
||||
if ($requested !== $removed && $missing === 0) {
|
||||
$skipped = max(0, $requested - $removed);
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} not removed)";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
$partial = in_array((string) $outcome, ['partially_succeeded'], true) || $missing > 0;
|
||||
$failed = in_array((string) $outcome, ['failed'], true);
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($failed ? 'Removal failed' : ($partial ? 'Removal completed (partial)' : 'Removal completed'))
|
||||
->body($message);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($failed) {
|
||||
$notification->danger();
|
||||
} elseif ($partial) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
$notification
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
|
||||
private function notifyFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
||||
{
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->operationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Removal failed')
|
||||
->body($reason);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$notification
|
||||
->danger()
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
@ -14,11 +13,8 @@
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -162,13 +158,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'Schedule is archived; run will not execute.',
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_skipped',
|
||||
@ -217,13 +206,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_skipped',
|
||||
@ -245,8 +227,6 @@ public function handle(
|
||||
try {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_started',
|
||||
@ -291,13 +271,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'All configured policy types are unknown.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -421,13 +394,6 @@ public function handle(
|
||||
nowUtc: $nowUtc,
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: $status,
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
|
||||
if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey()));
|
||||
}
|
||||
@ -485,13 +451,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_FAILED,
|
||||
errorMessage: (string) $mapped['error_message'],
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_failed',
|
||||
@ -522,80 +481,6 @@ private function resolveBackupScheduleId(): int
|
||||
return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0;
|
||||
}
|
||||
|
||||
private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void
|
||||
{
|
||||
$userId = $this->operationRun?->user_id;
|
||||
|
||||
if (! $userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup started')
|
||||
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
||||
->info()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
])
|
||||
->sendToDatabase($user);
|
||||
}
|
||||
|
||||
private function notifyScheduleRunFinished(
|
||||
Tenant $tenant,
|
||||
BackupSchedule $schedule,
|
||||
string $status,
|
||||
?string $errorMessage,
|
||||
): void {
|
||||
$userId = $this->operationRun?->user_id;
|
||||
|
||||
if (! $userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$title = match ($status) {
|
||||
self::STATUS_SUCCESS => 'Backup completed',
|
||||
self::STATUS_PARTIAL => 'Backup completed (partial)',
|
||||
self::STATUS_SKIPPED => 'Backup skipped',
|
||||
default => 'Backup failed',
|
||||
};
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($title)
|
||||
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $status));
|
||||
|
||||
if (is_string($errorMessage) && $errorMessage !== '') {
|
||||
$notification->body($notification->getBody()."\n".$errorMessage);
|
||||
}
|
||||
|
||||
match ($status) {
|
||||
self::STATUS_SUCCESS => $notification->success(),
|
||||
self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(),
|
||||
default => $notification->danger(),
|
||||
};
|
||||
|
||||
$notification
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
])
|
||||
->sendToDatabase($user);
|
||||
}
|
||||
|
||||
private function finishSchedule(
|
||||
BackupSchedule $schedule,
|
||||
string $status,
|
||||
|
||||
@ -320,15 +320,12 @@ public function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Add policies already queued')
|
||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
|
||||
return;
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class RunStatusChangedNotification extends Notification
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* tenant_id:int,
|
||||
* run_type:string,
|
||||
* run_id:int,
|
||||
* status:string,
|
||||
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
|
||||
* } $metadata
|
||||
*/
|
||||
public function __construct(public array $metadata) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$status = (string) ($this->metadata['status'] ?? 'queued');
|
||||
$runType = (string) ($this->metadata['run_type'] ?? 'run');
|
||||
$tenantId = (int) ($this->metadata['tenant_id'] ?? 0);
|
||||
$runId = (int) ($this->metadata['run_id'] ?? 0);
|
||||
|
||||
$title = match ($status) {
|
||||
'queued' => 'Run queued',
|
||||
'running' => 'Run started',
|
||||
'completed', 'succeeded' => 'Run completed',
|
||||
'partial', 'partially succeeded', 'completed_with_errors' => 'Run completed (partial)',
|
||||
'failed' => 'Run failed',
|
||||
default => 'Run updated',
|
||||
};
|
||||
|
||||
$body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status);
|
||||
|
||||
$color = match ($status) {
|
||||
'queued', 'running' => 'gray',
|
||||
'completed', 'succeeded' => 'success',
|
||||
'partial', 'partially succeeded', 'completed_with_errors' => 'warning',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
|
||||
$actions = [];
|
||||
|
||||
if ($tenantId > 0 && $runId > 0) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
|
||||
if ($tenant) {
|
||||
$url = $runType === 'restore'
|
||||
? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
|
||||
: OperationRunLinks::view($runId, $tenant);
|
||||
|
||||
if (! $url) {
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => [],
|
||||
'icon' => null,
|
||||
'iconColor' => null,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'metadata' => $this->metadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$actions[] = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($url)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => $actions,
|
||||
'icon' => null,
|
||||
'iconColor' => null,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'metadata' => $this->metadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -30,6 +31,7 @@ public function __construct(
|
||||
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
|
||||
private readonly ProviderConnectionResolver $providerConnections,
|
||||
private readonly ProviderGateway $providerGateway,
|
||||
private readonly OperationRunService $operationRuns,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -100,9 +102,14 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
];
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $operationOutcome,
|
||||
'summary_counts' => [
|
||||
'context' => $updatedContext,
|
||||
]);
|
||||
|
||||
$this->operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $operationOutcome,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)),
|
||||
@ -110,10 +117,8 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
'items' => (int) ($result['items_observed_count'] ?? 0),
|
||||
'updated' => (int) ($result['items_upserted_count'] ?? 0),
|
||||
],
|
||||
'failure_summary' => $failureSummary,
|
||||
'context' => $updatedContext,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
failures: $failureSummary,
|
||||
);
|
||||
|
||||
return $operationRun->refresh();
|
||||
}
|
||||
|
||||
@ -329,7 +329,7 @@ public function enqueueBulkOperation(
|
||||
callable $dispatcher,
|
||||
?User $initiator = null,
|
||||
array $extraContext = [],
|
||||
bool $emitQueuedNotification = true
|
||||
bool $emitQueuedNotification = false
|
||||
): OperationRun {
|
||||
$targetScope = BulkRunContext::normalizeTargetScope($targetScope);
|
||||
|
||||
@ -543,7 +543,7 @@ public function incrementSummaryCounts(OperationRun $run, array $delta): Operati
|
||||
* 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, bool $emitQueuedNotification = false): void
|
||||
{
|
||||
try {
|
||||
$this->invokeDispatcher($dispatcher, $run);
|
||||
|
||||
@ -30,6 +30,20 @@ public static function queuedToast(string $operationType): FilamentNotification
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical dedupe feedback when a matching run is already active.
|
||||
*/
|
||||
public static function alreadyQueuedToast(string $operationType): FilamentNotification
|
||||
{
|
||||
$operationLabel = OperationCatalog::label($operationType);
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} already queued")
|
||||
->body('A matching run is already queued or running.')
|
||||
->info()
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal DB notification payload.
|
||||
*
|
||||
|
||||
@ -19,7 +19,7 @@ ## Summary
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v4, Livewire v3
|
||||
**Primary Dependencies**: Filament v5, Livewire v4
|
||||
**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)
|
||||
|
||||
@ -17,12 +17,6 @@ ## Summary
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
@ -37,7 +31,7 @@ ## Technical Context
|
||||
- No queued/running DB notifications anywhere (including `OperationRunQueued`).
|
||||
- Existing RBAC/capability gates for starting operations remain unchanged.
|
||||
|
||||
**Scale/Scope**: Repo-wide enforcement via guard tests; remediation limited to enumerated violating flows in the spec.
|
||||
**Scale/Scope**: Tenant-runtime remediation for enumerated violating flows + repo-wide enforcement via guard tests.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
@ -69,13 +63,6 @@ ### Documentation (this feature)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Jobs/
|
||||
@ -110,8 +97,7 @@ ## Complexity Tracking
|
||||
|
||||
| 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] |
|
||||
| None | N/A | N/A |
|
||||
|
||||
## Phase 0 — Outline & Research
|
||||
|
||||
|
||||
@ -3,21 +3,22 @@ # Quickstart: Ops-UX Enforcement & Cleanup
|
||||
## Prereqs
|
||||
|
||||
- Sail running: `vendor/bin/sail up -d`
|
||||
- Sail running: `./vendor/bin/sail up -d`
|
||||
|
||||
## Run the focused guard tests
|
||||
|
||||
Once implemented, run the guard tests only:
|
||||
|
||||
- `vendor/bin/sail artisan test --compact --filter=OpsUx`
|
||||
|
||||
If the tests are split by directory as in the spec:
|
||||
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution`
|
||||
- `./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution`
|
||||
|
||||
## Run the focused regression tests
|
||||
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Regression`
|
||||
- `./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Regression`
|
||||
|
||||
## Run the combined Ops-UX pack
|
||||
|
||||
- `./vendor/bin/sail artisan test --compact --group=ops-ux`
|
||||
|
||||
## Format touched files
|
||||
|
||||
- `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
@ -15,7 +15,7 @@ ### Session 2026-02-23
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant (all operation-run-producing flows within a tenant)
|
||||
- **Scope**: tenant runtime remediation + repo-wide enforcement guards (CI/static scan)
|
||||
- **Primary Routes**: No new routes. Affected internal flows: Inventory Sync, Backup Schedule Retention, Backup Schedule Run, Bulk Policy Export, Bulk Restore Run Restore, Bulk Restore Run Force Delete, Bulk Policy Unignore, Entra Group manual sync, Add/Remove Policies to Backup Set, Restore Run execution.
|
||||
- **Data Ownership**: `operation_runs` (tenant-scoped), `notifications` (tenant user-scoped). No schema changes required.
|
||||
- **RBAC**: No new RBAC surfaces. Existing capability gates on triggering operations remain unchanged. Notification delivery is limited to the initiator user. System/scheduled runs with no initiator receive no DB notification.
|
||||
@ -144,7 +144,11 @@ ### Functional Requirements
|
||||
- **FR-010b**: Filament start surfaces that initiate operation-run-producing flows MUST NOT persist queued/running DB notifications (including any `sendToDatabase()` “queued” notifications). Start feedback is toast-only.
|
||||
- **FR-011**: Context-only updates (e.g., updating `context`, `message`, `reason_code` fields without touching `status` or `outcome`) are permitted directly on the model outside `OperationRunService`.
|
||||
- **FR-012**: Three Pest guard tests MUST exist and pass in CI:
|
||||
- Guard A: Detects direct status/outcome transitions outside `OperationRunService`; reports file + snippet. Implementation MUST scan `app/**/*.php` for `->update(` calls whose update array includes a `status` and/or `outcome` key (multi-line block match allowed), excluding `app/Services/OperationRunService.php`. Context-only updates without `status`/`outcome` MUST NOT fail this guard.
|
||||
- Guard A: Detects direct status/outcome transitions outside `OperationRunService`; reports file + snippet. Implementation MUST scan `app/**/*.php` for forbidden transition patterns, excluding `app/Services/OperationRunService.php`, including:
|
||||
- `->update(` calls whose update array includes a `status` and/or `outcome` key (multi-line block match allowed)
|
||||
- direct assignments to `->status` / `->outcome`
|
||||
- query/builder/bulk `update([...])` calls that set `status` and/or `outcome`
|
||||
Context-only updates without `status`/`outcome` MUST NOT fail this guard.
|
||||
- Guard B: Detects DB-notification emissions in operation-flow code; reports file path. Implementation MUST scan `app/**/*.php` and fail when a file contains BOTH (a) an OperationRun signal (`use App\\Models\\OperationRun;` OR `OperationRun` token OR `$this->operationRun` OR `$operationRun`) AND (b) a DB-notification emission (`sendToDatabase(` OR `->notify(`). Allowed exceptions (explicit allowlist): `app/Services/OperationRunService.php`, `app/Notifications/OperationRunCompleted.php`.
|
||||
- Guard C: Detects any reference to `RunStatusChangedNotification` in `app/` or `tests/`.
|
||||
- **FR-012b**: Operation enqueue helpers MUST NOT emit queued DB notifications by default. Any helper param like `emitQueuedNotification` MUST default to `false`, and all current call sites MUST comply.
|
||||
@ -252,17 +256,19 @@ ## Assumptions
|
||||
2. The `failed()` job lifecycle callback or try/finally blocks in affected jobs already exist (or will be added) to ensure terminal transitions even on unhandled exceptions — confirmed during implementation per-file.
|
||||
3. Guard tests are static analysis (filesystem grep-based) Pest tests, not runtime tests. They do not require a running application.
|
||||
4. The allowlist for Guard B (job DB notifications) is intentionally minimal. Any new entry requires justification in a spec comment.
|
||||
5. P2 tasks (T110-040/041) are optional and do not gate release of P0/P1 work.
|
||||
5. P2 tasks (`T026`/`T027`) are optional and do not gate release of P0/P1 work.
|
||||
|
||||
---
|
||||
|
||||
## Rollout / PR Slicing
|
||||
|
||||
| PR | Tasks | Priority |
|
||||
|----|-------|----------|
|
||||
| PR-A | T110-001, T110-002 + regression tests (inventory sync, retention) | P0 |
|
||||
| PR-B | T110-010, T110-011 + restore run tests | P0 |
|
||||
| PR-C | T110-020, T110-021 + backup schedule tests | P0 |
|
||||
| PR-D | T110-022–T110-025 + bulk job tests | P1 |
|
||||
| PR-E | T110-030, T110-031, T110-032 (guards — can land early) | P0 |
|
||||
| PR-F | T110-040, T110-041 (optional polish) | P2 |
|
||||
PR slicing is phase/story-based. `tasks.md` remains the source of truth for exact task IDs and sequencing.
|
||||
|
||||
| PR | Scope Slice | Priority |
|
||||
|----|-------------|----------|
|
||||
| PR-A | Phase 1–3 (Setup + Foundational + US1 “No Silent Completions”) | P0 |
|
||||
| PR-B | Phase 4 (US2 “No Notification Spam” cleanup: jobs + start surfaces) | P0/P1 |
|
||||
| PR-C | Phase 5 (US3 legacy notification removal) | P0 |
|
||||
| PR-D | Phase 6 (US4 constitution guard tests A/B/C) | P0 |
|
||||
| PR-E | Phase 7 (US5 canonical “already queued” toast polish) | P2 |
|
||||
| PR-F | Phase 8 (docs alignment + validation execution) | P1/P2 |
|
||||
|
||||
@ -20,7 +20,7 @@ ## Phase 1: Setup (Shared Test Infrastructure)
|
||||
|
||||
**Purpose**: Create minimal shared helpers for guard tests and keep failure output consistent.
|
||||
|
||||
- [ ] T001 [P] Add source scanning helper in tests/Support/OpsUx/SourceFileScanner.php
|
||||
- [X] T001 [P] Add source scanning helper in tests/Support/OpsUx/SourceFileScanner.php
|
||||
|
||||
---
|
||||
|
||||
@ -28,8 +28,8 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Cross-cutting invariants that must be true before user story work can be considered “done”.
|
||||
|
||||
- [ ] T002 Update queued notification defaults in app/Services/OperationRunService.php (dispatchOrFail + enqueue helpers default emitQueuedNotification=false)
|
||||
- [ ] T003 Confirm no call sites opt into queued DB notifications in app/Services/OperationRunService.php (remove/forbid emitQueuedNotification:true usage)
|
||||
- [X] T002 Update queued notification defaults in app/Services/OperationRunService.php (dispatchOrFail + enqueue helpers default emitQueuedNotification=false)
|
||||
- [X] T003 Confirm repo-wide call sites do not opt into queued DB notifications (remove/forbid `emitQueuedNotification: true` usages in `app/**`)
|
||||
|
||||
**Checkpoint**: No queued/running DB notifications can be emitted by default.
|
||||
|
||||
@ -43,14 +43,14 @@ ## Phase 3: User Story 1 — No Silent Completions (Priority: P1) 🎯 MVP
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [ ] T004 [P] [US1] Add inventory sync terminal notification regression test in tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php
|
||||
- [ ] T005 [P] [US1] Add retention terminal notification regression test in tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php
|
||||
- [X] T004 [P] [US1] Add inventory sync terminal notification regression test in tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php
|
||||
- [X] T005 [P] [US1] Add retention terminal notification regression test in tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] T006 [US1] Refactor terminal transition in app/Services/Inventory/InventorySyncService.php to use OperationRunService::updateRun()
|
||||
- [ ] T007 [US1] Refactor terminal transition in app/Jobs/ApplyBackupScheduleRetentionJob.php to use OperationRunService::updateRun()
|
||||
- [ ] T008 [US1] Refactor OperationRun status/outcome update in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php to use OperationRunService::updateRun() (initiator may be null)
|
||||
- [X] T006 [US1] Refactor terminal transition in app/Services/Inventory/InventorySyncService.php to use OperationRunService::updateRun()
|
||||
- [X] T007 [US1] Refactor terminal transition in app/Jobs/ApplyBackupScheduleRetentionJob.php to use OperationRunService::updateRun()
|
||||
- [X] T008 [US1] Refactor OperationRun status/outcome update in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php to use OperationRunService::updateRun() (initiator may be null)
|
||||
|
||||
**Checkpoint**: US1 regressions pass, with no silent completions.
|
||||
|
||||
@ -64,26 +64,26 @@ ## Phase 4: User Story 2 — No Notification Spam (Priority: P1)
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [ ] T009 [P] [US2] Add backup schedule run notification regression test in tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php
|
||||
- [ ] T010 [P] [US2] Add bulk job “abort/circuit-break” regression test in tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
|
||||
- [X] T009 [P] [US2] Add backup schedule run notification regression test in tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php
|
||||
- [X] T010 [P] [US2] Add bulk job “abort/circuit-break” regression test in tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
|
||||
|
||||
### Implementation (Jobs)
|
||||
|
||||
- [ ] T011 [P] [US2] Remove queued + custom finished DB notifications in app/Jobs/RunBackupScheduleJob.php
|
||||
- [ ] T012 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyExportJob.php
|
||||
- [ ] T013 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunForceDeleteJob.php
|
||||
- [ ] T029 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunRestoreJob.php
|
||||
- [ ] T030 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyUnignoreJob.php
|
||||
- [ ] T014 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
- [ ] T015 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/RemovePoliciesFromBackupSetJob.php
|
||||
- [X] T011 [P] [US2] Remove queued + custom finished DB notifications in app/Jobs/RunBackupScheduleJob.php
|
||||
- [X] T012 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyExportJob.php
|
||||
- [X] T013 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunForceDeleteJob.php
|
||||
- [X] T029 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunRestoreJob.php
|
||||
- [X] T030 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyUnignoreJob.php
|
||||
- [X] T014 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
- [X] T015 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/RemovePoliciesFromBackupSetJob.php
|
||||
|
||||
### Implementation (Start surfaces / Filament)
|
||||
|
||||
- [ ] T016 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyResource.php (remove sendToDatabase for queued ops)
|
||||
- [ ] T017 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/BackupScheduleResource.php (remove sendToDatabase for queued ops)
|
||||
- [ ] T018 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/TenantResource.php (remove sendToDatabase for queued ops)
|
||||
- [ ] T019 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyVersionResource.php (remove sendToDatabase for queued ops)
|
||||
- [ ] T031 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php (remove sendToDatabase for queued ops)
|
||||
- [X] T016 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T017 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/BackupScheduleResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T018 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/TenantResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T019 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyVersionResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T031 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php (remove sendToDatabase for queued ops)
|
||||
|
||||
**Checkpoint**: US2 regressions pass and notifications remain terminal-only.
|
||||
|
||||
@ -97,12 +97,12 @@ ## Phase 5: User Story 3 — Legacy Notification Removed (Priority: P1)
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [ ] T020 [P] [US3] Add restore run terminal notification regression test in tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php
|
||||
- [X] T020 [P] [US3] Add restore run terminal notification regression test in tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] T021 [US3] Remove legacy notification invocation in app/Jobs/ExecuteRestoreRunJob.php
|
||||
- [ ] T022 [US3] Delete legacy notification class app/Notifications/RunStatusChangedNotification.php
|
||||
- [X] T021 [US3] Remove legacy notification invocation in app/Jobs/ExecuteRestoreRunJob.php
|
||||
- [X] T022 [US3] Delete legacy notification class app/Notifications/RunStatusChangedNotification.php
|
||||
|
||||
**Checkpoint**: US3 regression passes; no legacy notification remains.
|
||||
|
||||
@ -116,9 +116,9 @@ ## Phase 6: User Story 4 — Regression Guards Enforce the Constitution (Priorit
|
||||
|
||||
### Guard tests
|
||||
|
||||
- [ ] T023 [P] [US4] Implement Guard A in tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php (scan app/** for ->update([...]) containing status/outcome; exclude app/Services/OperationRunService.php; print snippet)
|
||||
- [ ] T024 [P] [US4] Implement Guard B in tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php (scan app/** for OperationRun signal + DB notify emission; allowlist app/Services/OperationRunService.php and app/Notifications/OperationRunCompleted.php)
|
||||
- [ ] T025 [P] [US4] Implement Guard C in tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php (scan app/** and tests/** for RunStatusChangedNotification)
|
||||
- [X] T023 [P] [US4] Implement Guard A in tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php (scan app/** for forbidden status/outcome transitions: ->update([...]) arrays, direct ->status/->outcome assignments, and query/bulk updates; exclude app/Services/OperationRunService.php; print snippet)
|
||||
- [X] T024 [P] [US4] Implement Guard B in tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php (scan app/** for OperationRun signal + DB notify emission; allowlist app/Services/OperationRunService.php and app/Notifications/OperationRunCompleted.php)
|
||||
- [X] T025 [P] [US4] Implement Guard C in tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php (scan app/** and tests/** for RunStatusChangedNotification)
|
||||
|
||||
**Checkpoint**: Guard tests pass green and provide clear failure output.
|
||||
|
||||
@ -130,16 +130,34 @@ ## Phase 7: User Story 5 — Canonical "Already Queued" Toast (Priority: P2)
|
||||
|
||||
**Independent Test**: Trigger dedup path and confirm toast uses `OperationUxPresenter::alreadyQueuedToast(...)`.
|
||||
|
||||
- [ ] T026 [P] [US5] Add OperationUxPresenter::alreadyQueuedToast(...) helper in app/Support/OpsUx/OperationUxPresenter.php
|
||||
- [ ] T027 [US5] Migrate dedup toast to canonical helper in app/Livewire/BackupSetPolicyPickerTable.php
|
||||
- [X] T026 [P] [US5] Add OperationUxPresenter::alreadyQueuedToast(...) helper in app/Support/OpsUx/OperationUxPresenter.php
|
||||
- [X] T027 [US5] Migrate dedup toast to canonical helper in app/Livewire/BackupSetPolicyPickerTable.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Keep docs and developer workflow aligned with final test locations.
|
||||
**Purpose**: Final documentation alignment plus execution validation (guards, regressions, full suite, formatting).
|
||||
|
||||
- [ ] T028 [P] Update quickstart commands/paths if needed in specs/110-ops-ux-enforcement/quickstart.md
|
||||
- [X] T028 [P] Update quickstart commands/paths if needed in specs/110-ops-ux-enforcement/quickstart.md
|
||||
- [X] T032 Run focused Ops-UX regression pack (including `tests/Feature/OpsUx/Regression/*`) and confirm green (SC-006 / DoD)
|
||||
- [X] T033 Run constitution guard tests (`tests/Feature/OpsUx/Constitution/*`) and verify actionable failure output on synthetic violation + green on clean codebase (SC-005)
|
||||
- [X] T034 Run full test suite (or CI-equivalent command used by this repo) and confirm green (DoD)
|
||||
- [X] T035 [P] Run Pint for touched files via Sail (`./vendor/bin/sail bin pint --dirty`) and confirm clean (DoD)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Follow-up — Repo-wide Start/Dedup Toast Standardization
|
||||
|
||||
**Purpose**: Ensure all remaining Filament start/dedup surfaces use canonical Ops-UX toasts and trigger immediate progress refresh.
|
||||
|
||||
- [X] T036 Standardize tenant verification start/dedup toasts + progress refresh (Tenant list row, tenant view header, verification widget)
|
||||
- [X] T037 Standardize review pack generation widget to canonical queued/already queued toasts + view-run action
|
||||
- [X] T038 Ensure admin roles scan creates/dedupes OperationRun at enqueue time + canonical toasts + progress refresh
|
||||
- [X] T039 Standardize backup set removal dedupe notifications to canonical already queued toast + progress refresh
|
||||
- [X] T040 Standardize restore run idempotency “already queued” to canonical already queued toast (with view-run when available)
|
||||
- [X] T041 Standardize policy bulk delete queued/dedup toast to canonical queued/already queued + progress refresh
|
||||
- [X] T042 Standardize onboarding wizard verification + bootstrap start/dedup toasts + progress refresh
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -4,11 +4,8 @@
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -25,7 +22,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
test('operator can run now and it persists a database notification', function () {
|
||||
test('operator can run now without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -67,19 +64,7 @@
|
||||
&& $job->operationRun->is($operationRun);
|
||||
});
|
||||
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'notifiable_type' => User::class,
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Backup schedule run queued',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($operationRun, $tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('run now is unique per click (no dedupe)', function () {
|
||||
@ -119,10 +104,10 @@
|
||||
expect($runs[0])->not->toBe($runs[1]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 2);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('operator can retry and it persists a database notification', function () {
|
||||
test('operator can retry without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -163,19 +148,7 @@
|
||||
&& $job->operationRun instanceof OperationRun
|
||||
&& $job->operationRun->is($operationRun);
|
||||
});
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'notifiable_type' => User::class,
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Backup schedule run queued',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($operationRun, $tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('retry is unique per click (no dedupe)', function () {
|
||||
@ -215,7 +188,7 @@
|
||||
expect($runs[0])->not->toBe($runs[1]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 2);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('readonly cannot dispatch run now or retry', function () {
|
||||
@ -258,7 +231,7 @@
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('operator can bulk run now and it persists a database notification', function () {
|
||||
test('operator can bulk run now without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -311,20 +284,10 @@
|
||||
->toBe([$user->id]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Runs dispatched',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::index($tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('operator can bulk retry and it persists a database notification', function () {
|
||||
test('operator can bulk retry without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -377,17 +340,7 @@
|
||||
->toBe([$user->id]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Retries dispatched',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::index($tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('operator can bulk retry even if a previous canonical run exists', function () {
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
|
||||
it('remove policies job sends completion notification with view link', function () {
|
||||
it('remove policies job sends canonical terminal notification with view link', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
@ -54,7 +54,7 @@
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => DatabaseNotification::class,
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -125,6 +126,13 @@
|
||||
->count())->toBe(1);
|
||||
|
||||
Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1);
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
$expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle());
|
||||
expect(collect($notifications)->last()['body'] ?? null)->toBe($expectedToast->getBody());
|
||||
});
|
||||
|
||||
test('policy picker table forbids readonly users from starting add policies (403)', function () {
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->callAction('compareNow')
|
||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
@ -222,9 +222,9 @@ function getHeaderAction(Testable $component, string $name): ?Action
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
$component = Livewire::test(ListTenants::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['create']);
|
||||
->assertTableEmptyStateActionsExistInOrder(['add_tenant']);
|
||||
|
||||
$headerCreate = getHeaderAction($component, 'create');
|
||||
$headerCreate = getHeaderAction($component, 'add_tenant');
|
||||
expect($headerCreate)->not->toBeNull();
|
||||
expect($headerCreate?->isVisible())->toBeFalse();
|
||||
});
|
||||
@ -237,7 +237,7 @@ function getHeaderAction(Testable $component, string $name): ?Action
|
||||
$component = Livewire::test(ListTenants::class)
|
||||
->assertCountTableRecords(1);
|
||||
|
||||
$headerCreate = getHeaderAction($component, 'create');
|
||||
$headerCreate = getHeaderAction($component, 'add_tenant');
|
||||
expect($headerCreate)->not->toBeNull();
|
||||
expect($headerCreate?->isVisible())->toBeTrue();
|
||||
});
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
$service->dispatchOrFail($run, function (): void {
|
||||
// no-op (dispatch succeeded)
|
||||
});
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
@ -58,7 +58,7 @@
|
||||
|
||||
$service->dispatchOrFail($run, function (): void {
|
||||
// no-op
|
||||
});
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
it('does not allow direct OperationRun status or outcome transitions outside OperationRunService', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
$excluded = [$root.'/app/Services/OperationRunService.php'];
|
||||
|
||||
$files = SourceFileScanner::phpFiles([$root.'/app'], $excluded);
|
||||
|
||||
$patterns = [
|
||||
'direct update() with status/outcome' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*update\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s',
|
||||
'direct fill()/forceFill() with status/outcome' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*(?:fill|forceFill)\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s',
|
||||
'direct property assignment' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*(?:status|outcome)\s*=(?!=)/',
|
||||
'OperationRun query/bulk update with status/outcome' => '/OperationRun::(?:(?!;).){0,800}?->\s*update\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s',
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
foreach ($patterns as $label => $pattern) {
|
||||
if (! preg_match_all($pattern, $source, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches[0] as [$snippetMatch, $offset]) {
|
||||
if (! is_int($offset)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'label' => $label,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
'match' => trim((string) $snippetMatch),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d [%s]\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['label'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Forbidden direct OperationRun status/outcome transition(s) found outside OperationRunService:\n\n"
|
||||
.implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
it('does not emit database notifications from OperationRun-producing jobs or start surfaces', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
|
||||
$allowlist = [
|
||||
$root.'/app/Services/OperationRunService.php',
|
||||
$root.'/app/Notifications/OperationRunCompleted.php',
|
||||
];
|
||||
|
||||
$files = SourceFileScanner::phpFiles([$root.'/app'], $allowlist);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
if (! str_contains($source, 'sendToDatabase(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasOperationRunSignal = str_contains($source, 'OperationRun')
|
||||
|| str_contains($source, 'operationRun')
|
||||
|| str_contains($source, 'OperationRunLinks')
|
||||
|| str_contains($source, 'OperationUxPresenter');
|
||||
|
||||
if (! $hasOperationRunSignal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! preg_match_all('/sendToDatabase\s*\(/', $source, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches[0] as [, $offset]) {
|
||||
if (! is_int($offset)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Forbidden OperationRun-related database notification emission found (use canonical OperationRunService terminal notification / toast-only start feedback):\n\n"
|
||||
.implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
it('does not reference the removed legacy run status notification', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
$files = SourceFileScanner::phpFiles([$root.'/app', $root.'/tests'], [
|
||||
__FILE__,
|
||||
]);
|
||||
|
||||
$needle = 'RunStatus'.'ChangedNotification';
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
if (! str_contains($source, $needle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
|
||||
while (($position = strpos($source, $needle, $offset)) !== false) {
|
||||
$line = substr_count(substr($source, 0, $position), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
];
|
||||
|
||||
$offset = $position + strlen($needle);
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Legacy notification reference(s) found:\n\n".implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
@ -6,7 +6,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('emits at most one queued database notification per newly created run', function (): void {
|
||||
it('emits at most one queued database notification per newly created run when explicitly enabled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
|
||||
$service->dispatchOrFail($run, function (): void {
|
||||
// no-op (dispatch succeeded)
|
||||
});
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
@ -42,8 +41,11 @@
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not link to legacy bulk run resources in status-change notifications', function (): void {
|
||||
it('does not link to legacy bulk run resources in canonical terminal notifications', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -55,12 +57,16 @@
|
||||
'context' => ['operation' => ['type' => 'policy.delete']],
|
||||
]);
|
||||
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'bulk_operation',
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => 'completed',
|
||||
]));
|
||||
/** @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();
|
||||
|
||||
@ -20,3 +20,10 @@
|
||||
expect($duration)->toBeGreaterThanOrEqual(3000);
|
||||
expect($duration)->toBeLessThanOrEqual(5000);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('builds canonical already-queued toast copy', function (): void {
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
|
||||
expect($toast->getTitle())->toBe('Backup set update already queued');
|
||||
expect($toast->getBody())->toBe('A matching run is already queued or running.');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
|
||||
it('completes backup retention runs without persisting terminal notifications for system runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Retention Regression',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 2,
|
||||
]);
|
||||
|
||||
$sets = collect(range(1, 4))->map(function (int $index) use ($tenant): BackupSet {
|
||||
return BackupSet::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Retention Set '.$index,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
'completed_at' => now()->subMinutes(10 - $index),
|
||||
]);
|
||||
});
|
||||
|
||||
$completedAt = now('UTC')->startOfMinute()->subMinutes(8);
|
||||
|
||||
foreach ($sets as $set) {
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule_run',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', 'ops-ux-retention-regression:'.$schedule->id.':'.$set->id),
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_set_id' => (int) $set->id,
|
||||
],
|
||||
'started_at' => $completedAt,
|
||||
'completed_at' => $completedAt,
|
||||
]);
|
||||
|
||||
$completedAt = $completedAt->addMinute();
|
||||
}
|
||||
|
||||
ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->getKey());
|
||||
|
||||
$retentionRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'backup_schedule_retention')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($retentionRun)->not->toBeNull();
|
||||
expect($retentionRun?->status)->toBe('completed');
|
||||
expect($retentionRun?->outcome)->toBe('succeeded');
|
||||
expect((int) ($retentionRun?->summary_counts['processed'] ?? 0))->toBe(2);
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
it('persists only the canonical terminal notification for initiated backup schedule runs', function (): void {
|
||||
Bus::fake();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'OpsUx Regression Schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule_run',
|
||||
inputs: ['backup_schedule_id' => (int) $schedule->getKey()],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
app()->bind(PolicySyncService::class, fn (): PolicySyncService => new class extends PolicySyncService
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
|
||||
{
|
||||
return ['synced' => [], 'failures' => []];
|
||||
}
|
||||
});
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
app()->bind(BackupService::class, fn (): BackupService => new class($backupSet) extends BackupService
|
||||
{
|
||||
public function __construct(private readonly BackupSet $backupSet) {}
|
||||
|
||||
public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
|
||||
{
|
||||
return $this->backupSet;
|
||||
}
|
||||
});
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new RunBackupScheduleJob(operationRun: $run, backupScheduleId: (int) $schedule->getKey()))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(PolicyTypeResolver::class),
|
||||
app(ScheduleTimeService::class),
|
||||
app(AuditLogger::class),
|
||||
app(RunErrorMapper::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => DatabaseNotification::class,
|
||||
]);
|
||||
|
||||
Bus::assertDispatched(ApplyBackupScheduleRetentionJob::class);
|
||||
})->group('ops-ux');
|
||||
55
tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
Normal file
55
tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
|
||||
it('persists only the canonical terminal notification when bulk export aborts via circuit breaker', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
inputs: ['scope' => 'subset', 'policy_ids' => [999_999_991]],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new BulkPolicyExportJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: [999_999_991],
|
||||
backupName: 'OpsUx Circuit Breaker Regression',
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle($operationRuns);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('failed');
|
||||
expect((int) ($run->summary_counts['failed'] ?? 0))->toBeGreaterThan(0);
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => DatabaseNotification::class,
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
|
||||
it('persists exactly one terminal notification for initiated inventory sync runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$sync = app(InventorySyncService::class);
|
||||
$selectionPayload = $sync->defaultSelectionPayload();
|
||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||
|
||||
$mockSync = \Mockery::mock(InventorySyncService::class);
|
||||
$mockSync
|
||||
->shouldReceive('executeSelection')
|
||||
->once()
|
||||
->andReturn([
|
||||
'status' => 'success',
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => [],
|
||||
'errors_count' => 0,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'skipped_policy_types' => [],
|
||||
'processed_policy_types' => $computed['selection']['policy_types'],
|
||||
'failed_policy_types' => [],
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'inventory_sync',
|
||||
inputs: $computed['selection'],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new RunInventorySyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
|
||||
$job->handle($mockSync, app(AuditLogger::class), $operationRuns);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not persist terminal notifications for system-run inventory syncs without initiator', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$sync = app(InventorySyncService::class);
|
||||
$run = $sync->syncNow($tenant, $sync->defaultSelectionPayload());
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($tenant->users()->firstOrFail()->notifications()->count())->toBe(0);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ExecuteRestoreRunJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('persists exactly one canonical terminal notification for initiated restore execution runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'requested_by' => $user->email,
|
||||
'status' => 'queued',
|
||||
'started_at' => null,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$operationRun = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'restore.execute',
|
||||
inputs: [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$operationRun->forceFill([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
])->save();
|
||||
|
||||
$this->mock(RestoreService::class, function ($mock) use ($restoreRun): void {
|
||||
$mock->shouldReceive('executeForRun')
|
||||
->once()
|
||||
->andReturnUsing(function () use ($restoreRun): RestoreRun {
|
||||
RestoreRun::query()->whereKey($restoreRun->getKey())->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return RestoreRun::query()->findOrFail($restoreRun->getKey());
|
||||
});
|
||||
});
|
||||
|
||||
$job = new ExecuteRestoreRunJob(
|
||||
restoreRunId: (int) $restoreRun->getKey(),
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
|
||||
$job->handle(app(RestoreService::class), app(AuditLogger::class));
|
||||
|
||||
$operationRun->refresh();
|
||||
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('succeeded');
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
@ -303,7 +303,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
});
|
||||
});
|
||||
|
||||
it('sends queued database notification when review pack generation is requested', function (): void {
|
||||
it('does not send queued database notification when review pack generation is requested', function (): void {
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
|
||||
@ -313,7 +313,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
$service = app(ReviewPackService::class);
|
||||
$service->generate($tenant, $user);
|
||||
|
||||
Notification::assertSentTo($user, OperationRunQueued::class);
|
||||
Notification::assertNotSentTo($user, OperationRunQueued::class);
|
||||
});
|
||||
|
||||
// ─── OperationRun Type ──────────────────────────────────────────
|
||||
|
||||
103
tests/Support/OpsUx/SourceFileScanner.php
Normal file
103
tests/Support/OpsUx/SourceFileScanner.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support\OpsUx;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
|
||||
final class SourceFileScanner
|
||||
{
|
||||
/**
|
||||
* @param list<string> $roots
|
||||
* @param list<string> $excludedAbsolutePaths
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function phpFiles(array $roots, array $excludedAbsolutePaths = []): array
|
||||
{
|
||||
$files = [];
|
||||
$excluded = array_fill_keys(array_map(self::normalizePath(...), $excludedAbsolutePaths), true);
|
||||
|
||||
foreach ($roots as $root) {
|
||||
$root = self::normalizePath($root);
|
||||
|
||||
if (! is_dir($root)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
/** @var SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = self::normalizePath($file->getPathname());
|
||||
|
||||
if (pathinfo($path, PATHINFO_EXTENSION) !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($excluded[$path])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return array_values(array_unique($files));
|
||||
}
|
||||
|
||||
public static function projectRoot(): string
|
||||
{
|
||||
return self::normalizePath(dirname(__DIR__, 3));
|
||||
}
|
||||
|
||||
public static function relativePath(string $absolutePath): string
|
||||
{
|
||||
$absolutePath = self::normalizePath($absolutePath);
|
||||
$root = self::projectRoot();
|
||||
|
||||
if (str_starts_with($absolutePath, $root.'/')) {
|
||||
return substr($absolutePath, strlen($root) + 1);
|
||||
}
|
||||
|
||||
return $absolutePath;
|
||||
}
|
||||
|
||||
public static function read(string $path): string
|
||||
{
|
||||
return (string) file_get_contents($path);
|
||||
}
|
||||
|
||||
public static function snippet(string $source, int $line, int $contextLines = 2): string
|
||||
{
|
||||
$allLines = preg_split('/\R/', $source) ?: [];
|
||||
$line = max(1, $line);
|
||||
|
||||
$start = max(1, $line - $contextLines);
|
||||
$end = min(count($allLines), $line + $contextLines);
|
||||
|
||||
$snippet = [];
|
||||
|
||||
for ($index = $start; $index <= $end; $index++) {
|
||||
$prefix = $index === $line ? '>' : ' ';
|
||||
$snippet[] = sprintf('%s%4d | %s', $prefix, $index, $allLines[$index - 1] ?? '');
|
||||
}
|
||||
|
||||
return implode("\n", $snippet);
|
||||
}
|
||||
|
||||
private static function normalizePath(string $path): string
|
||||
{
|
||||
return str_replace('\\', '/', $path);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user