*/ public array $selectedPolicyTypes = []; /** * @var list */ public array $selectedStates = []; /** * @var list */ public array $selectedSeverities = []; public string $tenantSort = 'tenant_name'; public string $subjectSort = 'deviation_breadth'; public ?string $focusedSubjectKey = null; /** * @var array */ public array $matrix = []; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.') ->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.') ->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.'); } public function mount(int|string $record): void { $this->record = $this->resolveRecord($record); $this->hydrateFiltersFromRequest(); $this->refreshMatrix(); $this->form->fill($this->filterFormState()); } public function form(Schema $schema): Schema { return $schema ->schema([ Grid::make([ 'default' => 1, 'xl' => 2, ]) ->schema([ Grid::make([ 'default' => 1, 'lg' => 5, ]) ->schema([ CheckboxList::make('selectedPolicyTypes') ->label('Policy types') ->options(fn (): array => $this->matrixOptions('policyTypeOptions')) ->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === [] ? 'Policy type filters appear after a usable reference snapshot is available.' : null) ->extraFieldWrapperAttributes([ 'data-testid' => 'matrix-policy-type-filter', ]) ->columns(1) ->columnSpan([ 'lg' => 2, ]) ->live(), CheckboxList::make('selectedStates') ->label('Technical states') ->options(fn (): array => $this->matrixOptions('stateOptions')) ->columnSpan([ 'lg' => 2, ]) ->columns(1) ->live(), CheckboxList::make('selectedSeverities') ->label('Severity') ->options(fn (): array => $this->matrixOptions('severityOptions')) ->columns(1) ->live(), ]) ->columnSpan([ 'xl' => 1, ]), Grid::make([ 'default' => 1, 'md' => 2, 'xl' => 1, ]) ->schema([ Select::make('tenantSort') ->label('Tenant sort') ->options(fn (): array => $this->matrixOptions('tenantSortOptions')) ->default('tenant_name') ->native(false) ->live() ->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort']) ->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']), Select::make('subjectSort') ->label('Subject sort') ->options(fn (): array => $this->matrixOptions('subjectSortOptions')) ->default('deviation_breadth') ->native(false) ->live() ->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort']) ->extraInputAttributes(['data-testid' => 'matrix-subject-sort']), ]) ->columnSpan([ 'xl' => 1, ]), ]), ]); } protected function authorizeAccess(): void { $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User || ! $workspace instanceof Workspace) { abort(404); } /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $workspace)) { abort(404); } if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { abort(403); } } public function getTitle(): string { /** @var BaselineProfile $profile */ $profile = $this->getRecord(); return 'Compare matrix: '.$profile->name; } /** * @return array */ protected function getHeaderActions(): array { $profile = $this->getRecord(); $compareAssignedTenantsAction = Action::make('compareAssignedTenants') ->label('Compare assigned tenants') ->icon('heroicon-o-play') ->requiresConfirmation() ->modalHeading('Compare assigned tenants') ->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.') ->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null) ->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason()) ->action(fn (): mixed => $this->compareAssignedTenants()); $compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction( $compareAssignedTenantsAction, fn (): ?Workspace => $this->workspace(), ) ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) ->preserveDisabled() ->tooltip('You need workspace baseline manage access to compare the visible assigned set.') ->apply(); return [ Action::make('backToBaselineProfile') ->label('Back to baseline profile') ->color('gray') ->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')), $compareAssignedTenantsAction, ]; } public function refreshMatrix(): void { $user = auth()->user(); abort_unless($user instanceof User, 403); /** @var BaselineProfile $profile */ $profile = $this->getRecord(); $this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [ 'policyTypes' => $this->selectedPolicyTypes, 'states' => $this->selectedStates, 'severities' => $this->selectedSeverities, 'tenantSort' => $this->tenantSort, 'subjectSort' => $this->subjectSort, 'focusedSubjectKey' => $this->focusedSubjectKey, ]); } public function pollMatrix(): void { $this->refreshMatrix(); } public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string { $tenant = $this->tenant($tenantId); if (! $tenant instanceof Tenant) { return null; } return BaselineCompareLanding::getUrl( parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(), panel: 'tenant', tenant: $tenant, ); } public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string { $tenant = $this->tenant($tenantId); if (! $tenant instanceof Tenant) { return null; } return FindingResource::getUrl( 'view', [ 'record' => $findingId, ...$this->navigationContext($tenant, $subjectKey)->toQuery(), ], tenant: $tenant, ); } public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string { return OperationRunLinks::tenantlessView( $runId, $this->navigationContext( $tenantId !== null ? $this->tenant($tenantId) : null, $subjectKey, ), ); } public function clearSubjectFocusUrl(): string { return static::getUrl($this->routeParameters([ 'subject_key' => null, ]), panel: 'admin'); } public function filterUrl(array $overrides = []): string { return static::getUrl($this->routeParameters($overrides), panel: 'admin'); } public function updatedSelectedPolicyTypes(): void { $this->refreshMatrix(); } public function updatedSelectedStates(): void { $this->refreshMatrix(); } public function updatedSelectedSeverities(): void { $this->refreshMatrix(); } public function updatedTenantSort(): void { $this->refreshMatrix(); } public function updatedSubjectSort(): void { $this->refreshMatrix(); } public function updatedFocusedSubjectKey(): void { $this->refreshMatrix(); } public function activeFilterCount(): int { return count($this->selectedPolicyTypes) + count($this->selectedStates) + count($this->selectedSeverities) + ($this->focusedSubjectKey !== null ? 1 : 0); } /** * @return array */ public function activeFilterSummary(): array { $summary = []; if ($this->selectedPolicyTypes !== []) { $summary['Policy types'] = count($this->selectedPolicyTypes); } if ($this->selectedStates !== []) { $summary['Technical states'] = count($this->selectedStates); } if ($this->selectedSeverities !== []) { $summary['Severity'] = count($this->selectedSeverities); } if ($this->focusedSubjectKey !== null) { $summary['Focused subject'] = $this->focusedSubjectKey; } return $summary; } /** * @return array */ protected function getViewData(): array { return array_merge($this->matrix, [ 'profile' => $this->getRecord(), 'currentFilters' => [ 'policy_type' => $this->selectedPolicyTypes, 'state' => $this->selectedStates, 'severity' => $this->selectedSeverities, 'tenant_sort' => $this->tenantSort, 'subject_sort' => $this->subjectSort, 'subject_key' => $this->focusedSubjectKey, ], ]); } private function hydrateFiltersFromRequest(): void { $this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', [])); $this->selectedStates = $this->normalizeQueryList(request()->query('state', [])); $this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', [])); $this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name'; $this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth'; $subjectKey = request()->query('subject_key'); $this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null; } /** * @return array */ private function filterFormState(): array { return [ 'selectedPolicyTypes' => $this->selectedPolicyTypes, 'selectedStates' => $this->selectedStates, 'selectedSeverities' => $this->selectedSeverities, 'tenantSort' => $this->tenantSort, 'subjectSort' => $this->subjectSort, ]; } /** * @return array */ private function matrixOptions(string $key): array { $options = $this->matrix[$key] ?? null; return is_array($options) ? $options : []; } /** * @return list */ private function normalizeQueryList(mixed $value): array { $values = is_array($value) ? $value : [$value]; return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string { if (! is_string($item)) { return null; } $normalized = trim($item); return $normalized !== '' ? $normalized : null; }, $values)))); } private function compareAssignedTenantsDisabledReason(): ?string { $reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : []; if (($reference['referenceState'] ?? null) !== 'ready') { return 'Capture a complete baseline snapshot before comparing assigned tenants.'; } if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) { return 'No visible assigned tenants are available for compare.'; } return null; } private function compareAssignedTenants(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } /** @var BaselineProfile $profile */ $profile = $this->getRecord(); $result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user); $summary = sprintf( '%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.', (int) $result['queuedCount'], (int) $result['alreadyQueuedCount'], (int) $result['blockedCount'], (int) $result['visibleAssignedTenantCount'], (int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's', ); if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($this); $toast = (int) $result['queuedCount'] > 0 ? OperationUxPresenter::queuedToast('baseline_compare') : OperationUxPresenter::alreadyQueuedToast('baseline_compare'); $toast ->body($summary.' Open Operations for progress and next steps.') ->actions([ Action::make('open_operations') ->label('Open operations') ->url(OperationRunLinks::index( context: $this->navigationContext(), allTenants: true, )), ]) ->send(); } else { Notification::make() ->title('No baseline compares were started') ->body($summary) ->warning() ->send(); } $this->refreshMatrix(); } /** * @param array $overrides * @return array */ private function routeParameters(array $overrides = []): array { return array_filter([ 'record' => $this->getRecord(), 'policy_type' => $this->selectedPolicyTypes, 'state' => $this->selectedStates, 'severity' => $this->selectedSeverities, 'tenant_sort' => $this->tenantSort, 'subject_sort' => $this->subjectSort, 'subject_key' => $this->focusedSubjectKey, ...$overrides, ], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); } private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext { /** @var BaselineProfile $profile */ $profile = $this->getRecord(); $subjectKey ??= $this->focusedSubjectKey; return CanonicalNavigationContext::forBaselineCompareMatrix( profile: $profile, filters: $this->routeParameters(), tenant: $tenant, subjectKey: $subjectKey, ); } private function tenant(int $tenantId): ?Tenant { return Tenant::query() ->whereKey($tenantId) ->where('workspace_id', (int) $this->getRecord()->workspace_id) ->first(); } private function workspace(): ?Workspace { return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(); } }