Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces. Key points - Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running). - Dedup paths: canonical “already queued” toast. - Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately. - Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract). Tests & formatting - Full suite: 1738 passed, 8 skipped (8477 assertions). - Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass). Notable change - Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #134
172 lines
5.6 KiB
PHP
172 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
|
|
|
use App\Filament\Resources\BaselineProfileResource;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
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;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ViewRecord;
|
|
|
|
class ViewBaselineProfile extends ViewRecord
|
|
{
|
|
protected static string $resource = BaselineProfileResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
$this->captureAction(),
|
|
EditAction::make()
|
|
->visible(fn (): bool => $this->hasManageCapability()),
|
|
];
|
|
}
|
|
|
|
private function captureAction(): Action
|
|
{
|
|
return Action::make('capture')
|
|
->label('Capture Snapshot')
|
|
->icon('heroicon-o-camera')
|
|
->color('primary')
|
|
->visible(fn (): bool => $this->hasManageCapability())
|
|
->disabled(fn (): bool => ! $this->hasManageCapability())
|
|
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
|
|
->requiresConfirmation()
|
|
->modalHeading('Capture Baseline Snapshot')
|
|
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
|
|
->form([
|
|
Select::make('source_tenant_id')
|
|
->label('Source Tenant')
|
|
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
|
->required()
|
|
->searchable(),
|
|
])
|
|
->action(function (array $data): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
|
Notification::make()
|
|
->title('Permission denied')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getRecord();
|
|
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
|
|
|
if (! $sourceTenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('Source tenant not found')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(BaselineCaptureService::class);
|
|
$result = $service->startCapture($profile, $sourceTenant, $user);
|
|
|
|
if (! $result['ok']) {
|
|
Notification::make()
|
|
->title('Cannot start capture')
|
|
->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getWorkspaceTenantOptions(): array
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
return [];
|
|
}
|
|
|
|
return Tenant::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->orderBy('name')
|
|
->pluck('name', 'id')
|
|
->all();
|
|
}
|
|
|
|
private function hasManageCapability(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
return false;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $workspace)
|
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
}
|
|
}
|