|null */ public ?array $severityCounts = null; public ?string $lastComparedAt = null; public static function canAccess(): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { return false; } $resolver = app(CapabilityResolver::class); return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public function mount(): void { $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { $this->state = 'no_tenant'; $this->message = 'No tenant selected.'; return; } $assignment = BaselineTenantAssignment::query() ->where('tenant_id', $tenant->getKey()) ->first(); if (! $assignment instanceof BaselineTenantAssignment) { $this->state = 'no_assignment'; $this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.'; return; } $profile = $assignment->baselineProfile; if ($profile === null) { $this->state = 'no_assignment'; $this->message = 'The assigned baseline profile no longer exists.'; return; } $this->profileName = (string) $profile->name; $this->profileId = (int) $profile->getKey(); $this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; if ($this->snapshotId === null) { $this->state = 'no_snapshot'; $this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.'; return; } $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'baseline_compare') ->latest('id') ->first(); if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { $this->state = 'comparing'; $this->operationRunId = (int) $latestRun->getKey(); $this->message = 'A baseline comparison is currently in progress.'; return; } if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) { $this->lastComparedAt = $latestRun->finished_at->diffForHumans(); } $scopeKey = 'baseline_profile:'.$profile->getKey(); $findingsQuery = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey); $totalFindings = (int) (clone $findingsQuery)->count(); if ($totalFindings > 0) { $this->state = 'ready'; $this->findingsCount = $totalFindings; $this->severityCounts = [ 'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(), 'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(), 'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(), ]; if ($latestRun instanceof OperationRun) { $this->operationRunId = (int) $latestRun->getKey(); } return; } if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') { $this->state = 'ready'; $this->findingsCount = 0; $this->operationRunId = (int) $latestRun->getKey(); $this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.'; return; } $this->state = 'idle'; $this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.'; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).') ->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.') ->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.'); } /** * @return array */ protected function getHeaderActions(): array { return [ $this->compareNowAction(), ]; } private function compareNowAction(): Action { return Action::make('compareNow') ->label('Compare Now') ->icon('heroicon-o-play') ->requiresConfirmation() ->modalHeading('Start baseline comparison') ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') ->visible(fn (): bool => $this->canCompare()) ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true)) ->action(function (): void { $user = auth()->user(); if (! $user instanceof User) { Notification::make()->title('Not authenticated')->danger()->send(); return; } $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { Notification::make()->title('No tenant context')->danger()->send(); return; } $service = app(BaselineCompareService::class); $result = $service->startCompare($tenant, $user); if (! ($result['ok'] ?? false)) { Notification::make() ->title('Cannot start comparison') ->body('Reason: '.($result['reason_code'] ?? 'unknown')) ->danger() ->send(); return; } $run = $result['run'] ?? null; if ($run instanceof OperationRun) { $this->operationRunId = (int) $run->getKey(); } $this->state = 'comparing'; 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(); }); } private function canCompare(): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { return false; } $resolver = app(CapabilityResolver::class); return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); } public function getFindingsUrl(): ?string { $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { return null; } return FindingResource::getUrl('index', tenant: $tenant); } public function getRunUrl(): ?string { if ($this->operationRunId === null) { return null; } $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { return null; } return OperationRunLinks::view($this->operationRunId, $tenant); } }