|null */ public ?array $severityCounts = null; public ?string $lastComparedAt = null; public ?string $lastComparedIso = null; public ?string $failureReason = null; public ?string $coverageStatus = null; public ?int $uncoveredTypesCount = null; /** @var list|null */ public ?array $uncoveredTypes = null; public ?string $fidelity = null; public ?int $evidenceGapsCount = null; /** @var array|null */ public ?array $evidenceGapsTopReasons = null; /** @var array|null */ public ?array $rbacRoleDefinitionSummary = null; public static function canAccess(): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } $tenant = static::resolveTenantContextForCurrentPanel(); if (! $tenant instanceof Tenant) { return false; } $resolver = app(CapabilityResolver::class); return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public function mount(): void { $this->refreshStats(); } public function refreshStats(): void { $stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel()); $this->state = $stats->state; $this->message = $stats->message; $this->profileName = $stats->profileName; $this->profileId = $stats->profileId; $this->snapshotId = $stats->snapshotId; $this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount; $this->operationRunId = $stats->operationRunId; $this->findingsCount = $stats->findingsCount; $this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null; $this->lastComparedAt = $stats->lastComparedHuman; $this->lastComparedIso = $stats->lastComparedIso; $this->failureReason = $stats->failureReason; $this->reasonCode = $stats->reasonCode; $this->reasonMessage = $stats->reasonMessage; $this->coverageStatus = $stats->coverageStatus; $this->uncoveredTypesCount = $stats->uncoveredTypesCount; $this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null; $this->fidelity = $stats->fidelity; $this->evidenceGapsCount = $stats->evidenceGapsCount; $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; $this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null; } /** * Computed view data exposed to the Blade template. * * Moves presentational logic out of Blade `@php` blocks so the * template only receives ready-to-render values. * * @return array */ protected function getViewData(): array { $hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true); $evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0); $hasEvidenceGaps = $evidenceGapsCountValue > 0; $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; $hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary) && array_sum($this->rbacRoleDefinitionSummary) > 0; $evidenceGapsSummary = null; $evidenceGapsTooltip = null; if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) { $parts = []; foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { continue; } $parts[] = $reason.' ('.((int) $count).')'; } if ($parts !== []) { $evidenceGapsSummary = implode(', ', $parts); $evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]); } } // Derive the colour class for the findings-count stat card. // Only show danger-red when high-severity findings exist; // use warning-orange for low/medium-only, and success-green for zero. $findingsColorClass = $this->resolveFindingsColorClass($hasWarnings); // "Why no findings" explanation when count is zero. $whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null; $whyNoFindingsFallback = ! $hasWarnings ? __('baseline-compare.no_findings_all_clear') : ($hasCoverageWarnings ? __('baseline-compare.no_findings_coverage_warnings') : ($hasEvidenceGaps ? __('baseline-compare.no_findings_evidence_gaps') : __('baseline-compare.no_findings_default'))); $whyNoFindingsColor = $hasWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400'; if ($this->reasonCode === 'no_subjects_in_scope') { $whyNoFindingsColor = 'text-gray-600 dark:text-gray-400'; } return [ 'hasCoverageWarnings' => $hasCoverageWarnings, 'evidenceGapsCountValue' => $evidenceGapsCountValue, 'hasEvidenceGaps' => $hasEvidenceGaps, 'hasWarnings' => $hasWarnings, 'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary, 'evidenceGapsSummary' => $evidenceGapsSummary, 'evidenceGapsTooltip' => $evidenceGapsTooltip, 'findingsColorClass' => $findingsColorClass, 'whyNoFindingsMessage' => $whyNoFindingsMessage, 'whyNoFindingsFallback' => $whyNoFindingsFallback, 'whyNoFindingsColor' => $whyNoFindingsColor, ]; } /** * Resolve the Tailwind colour class for the Total Findings stat. * * - Red (danger) only when high-severity findings exist * - Orange (warning) for medium/low-only findings or when warnings present * - Green (success) when fully clear */ private function resolveFindingsColorClass(bool $hasWarnings): string { $count = (int) ($this->findingsCount ?? 0); if ($count === 0) { return $hasWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400'; } $hasHigh = ($this->severityCounts['high'] ?? 0) > 0; return $hasHigh ? 'text-danger-600 dark:text-danger-400' : 'text-warning-600 dark:text-warning-400'; } 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 { $isFullContent = false; if (is_int($this->profileId) && $this->profileId > 0) { $profile = \App\Models\BaselineProfile::query()->find($this->profileId); $mode = $profile?->capture_mode instanceof BaselineCaptureMode ? $profile->capture_mode : (is_string($profile?->capture_mode) ? BaselineCaptureMode::tryFrom($profile->capture_mode) : null); $isFullContent = $mode === BaselineCaptureMode::FullContent; } $label = $isFullContent ? 'Compare now (full content)' : 'Compare now'; $modalDescription = $isFullContent ? 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.' : 'This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.'; $action = Action::make('compareNow') ->label($label) ->icon('heroicon-o-play') ->requiresConfirmation() ->modalHeading($label) ->modalDescription($modalDescription) ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true)) ->action(function (): void { $user = auth()->user(); if (! $user instanceof User) { Notification::make()->title('Not authenticated')->danger()->send(); return; } $tenant = static::resolveTenantContextForCurrentPanel(); if (! $tenant instanceof Tenant) { Notification::make()->title('Select a tenant to compare baselines')->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(); }); return UiEnforcement::forAction($action) ->requireCapability(Capabilities::TENANT_SYNC) ->preserveDisabled() ->apply(); } public function getFindingsUrl(): ?string { $tenant = static::resolveTenantContextForCurrentPanel(); if (! $tenant instanceof Tenant) { return null; } return FindingResource::getUrl('index', tenant: $tenant); } public function getRunUrl(): ?string { if ($this->operationRunId === null) { return null; } $tenant = static::resolveTenantContextForCurrentPanel(); if (! $tenant instanceof Tenant) { return null; } return OperationRunLinks::view($this->operationRunId, $tenant); } }