Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m12s
Added BaselineSubjectResolution page and supporting logic to visualize missing identities, ambiguous matches, and skipped coverages per Spec 384.
1590 lines
63 KiB
PHP
1590 lines
63 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Filament\Resources\BaselineProfileResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Baselines\BaselineCompareService;
|
|
use App\Services\Baselines\BaselineSubjectResolutionQuery;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Baselines\BaselineCaptureMode;
|
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
|
use App\Support\Baselines\BaselineCompareStats;
|
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\Navigation\NavigationScope;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ManagedEnvironmentLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Livewire\Attributes\Locked;
|
|
use UnitEnum;
|
|
|
|
class BaselineCompareLanding extends Page
|
|
{
|
|
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
|
'surfaceKey' => 'baseline_compare_landing',
|
|
'surfaceType' => 'launch_context_support',
|
|
'stateFields' => [
|
|
[
|
|
'stateKey' => 'baseline_profile_id',
|
|
'stateClass' => 'contextual_prefilter',
|
|
'carrier' => 'query_param',
|
|
'queryRole' => 'scoped_deeplink',
|
|
'shareable' => true,
|
|
'restorableOnRefresh' => true,
|
|
'tenantSensitive' => false,
|
|
'invalidFallback' => 'discard_and_continue',
|
|
],
|
|
[
|
|
'stateKey' => 'subject_key',
|
|
'stateClass' => 'contextual_prefilter',
|
|
'carrier' => 'query_param',
|
|
'queryRole' => 'scoped_deeplink',
|
|
'shareable' => true,
|
|
'restorableOnRefresh' => true,
|
|
'tenantSensitive' => false,
|
|
'invalidFallback' => 'discard_and_continue',
|
|
],
|
|
[
|
|
'stateKey' => 'nav',
|
|
'stateClass' => 'contextual_prefilter',
|
|
'carrier' => 'query_param',
|
|
'queryRole' => 'durable_restorable',
|
|
'shareable' => true,
|
|
'restorableOnRefresh' => true,
|
|
'tenantSensitive' => false,
|
|
'invalidFallback' => 'discard_and_continue',
|
|
],
|
|
],
|
|
'hydrationRule' => [
|
|
'precedenceOrder' => ['query', 'default'],
|
|
'appliesOnInitialMountOnly' => true,
|
|
'activeStateBecomesAuthoritativeAfterMount' => true,
|
|
'clearsOnTenantSwitch' => ['baseline_profile_id', 'subject_key', 'nav'],
|
|
'invalidRequestedStateFallback' => 'discard_and_continue',
|
|
],
|
|
'inspectContract' => [
|
|
'primaryModel' => 'none',
|
|
'selectedStateKey' => null,
|
|
'openedBy' => ['launch_context'],
|
|
'presentation' => 'none',
|
|
'shareable' => true,
|
|
'invalidSelectionFallback' => 'discard_and_continue',
|
|
],
|
|
'shareableStateKeys' => ['baseline_profile_id', 'subject_key', 'nav'],
|
|
'localOnlyStateKeys' => [],
|
|
];
|
|
|
|
private const LEGACY_SCOPE_QUERY_KEYS = [
|
|
'environment_id',
|
|
'tenant',
|
|
'tenant_id',
|
|
'managed_environment_id',
|
|
'environment',
|
|
'tenant_scope',
|
|
'tableFilters',
|
|
];
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $navigationLabel = 'Baseline Compare';
|
|
|
|
protected static ?int $navigationSort = 10;
|
|
|
|
protected static ?string $title = 'Baseline Compare';
|
|
|
|
protected static ?string $slug = 'workspaces/{workspace}/environments/{environment}/baseline-compare';
|
|
|
|
protected string $view = 'filament.pages.baseline-compare-landing';
|
|
|
|
public static function shouldRegisterNavigation(): bool
|
|
{
|
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
|
&& parent::shouldRegisterNavigation();
|
|
}
|
|
|
|
public ?string $state = null;
|
|
|
|
public ?string $message = null;
|
|
|
|
public ?string $reasonCode = null;
|
|
|
|
public ?string $reasonMessage = null;
|
|
|
|
public ?string $profileName = null;
|
|
|
|
public ?int $profileId = null;
|
|
|
|
public ?int $snapshotId = null;
|
|
|
|
public ?int $duplicateNamePoliciesCount = null;
|
|
|
|
public ?int $duplicateNameSubjectsCount = null;
|
|
|
|
public ?int $operationRunId = null;
|
|
|
|
public ?int $findingsCount = null;
|
|
|
|
/** @var array<string, int>|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<string>|null */
|
|
public ?array $uncoveredTypes = null;
|
|
|
|
public ?string $fidelity = null;
|
|
|
|
public ?int $evidenceGapsCount = null;
|
|
|
|
/** @var array<string, int>|null */
|
|
public ?array $evidenceGapsTopReasons = null;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $evidenceGapSummary = null;
|
|
|
|
/** @var list<array<string, mixed>>|null */
|
|
public ?array $evidenceGapBuckets = null;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $baselineCompareDiagnostics = null;
|
|
|
|
/** @var array<string, int>|null */
|
|
public ?array $rbacRoleDefinitionSummary = null;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $operatorExplanation = null;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $summaryAssessment = null;
|
|
|
|
public ?int $subjectResolutionActionCount = null;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $navigationContextPayload = null;
|
|
|
|
public ?int $matrixBaselineProfileId = null;
|
|
|
|
public ?string $matrixSubjectKey = null;
|
|
|
|
#[Locked]
|
|
public ?int $scopedEnvironmentId = null;
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
|
|
{
|
|
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
|
|
|
if ($panelId !== 'admin') {
|
|
return parent::getUrl($parameters, $isAbsolute, $panelId, $tenant);
|
|
}
|
|
|
|
$environment = static::resolveAdminUrlEnvironment($parameters, $tenant);
|
|
|
|
if (! $environment instanceof ManagedEnvironment) {
|
|
return url('/admin');
|
|
}
|
|
|
|
$workspace = static::resolveAdminUrlWorkspace($environment, $parameters);
|
|
|
|
if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) {
|
|
return url('/admin');
|
|
}
|
|
|
|
$parameters = static::withoutLegacyScopeQuery($parameters);
|
|
$parameters['environment'] = $environment;
|
|
$parameters['workspace'] = $workspace instanceof Workspace
|
|
? static::workspaceRouteKey($workspace)
|
|
: $workspace;
|
|
|
|
return parent::getUrl($parameters, $isAbsolute, $panelId, null);
|
|
}
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
return static::hasEnvironmentAccess(static::resolveRouteOwnedEnvironment());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function monitoringPageStateContract(): array
|
|
{
|
|
return self::MONITORING_PAGE_STATE_CONTRACT;
|
|
}
|
|
|
|
public function mount(ManagedEnvironment|string|null $environment = null): void
|
|
{
|
|
$tenant = static::resolveRouteOwnedEnvironment($environment);
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! static::hasEnvironmentAccess($tenant)) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->scopedEnvironmentId = (int) $tenant->getKey();
|
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
|
$baselineProfileId = request()->query('baseline_profile_id');
|
|
$subjectKey = request()->query('subject_key');
|
|
|
|
$this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null;
|
|
$this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
|
$this->refreshStatsForEnvironment($tenant);
|
|
}
|
|
|
|
public function refreshStats(): void
|
|
{
|
|
$this->refreshStatsForEnvironment($this->currentEnvironment());
|
|
}
|
|
|
|
private function refreshStatsForEnvironment(?ManagedEnvironment $tenant): void
|
|
{
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
$aggregate = $tenant instanceof ManagedEnvironment
|
|
? $this->governanceAggregate($tenant, $stats)
|
|
: null;
|
|
|
|
$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->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
|
$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->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
|
? $stats->evidenceGapDetails['summary']
|
|
: null;
|
|
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
|
? $stats->evidenceGapDetails['buckets']
|
|
: null;
|
|
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
|
? $stats->baselineCompareDiagnostics
|
|
: null;
|
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
|
$this->summaryAssessment = $aggregate?->summaryAssessment->toArray();
|
|
|
|
$subjectResolutionSummary = $tenant instanceof ManagedEnvironment
|
|
? app(BaselineSubjectResolutionQuery::class)->summary($tenant, $stats->operationRunId)
|
|
: null;
|
|
$this->subjectResolutionActionCount = is_array($subjectResolutionSummary)
|
|
? (int) ($subjectResolutionSummary['actionable_count'] ?? 0)
|
|
: 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<string, mixed>
|
|
*/
|
|
protected function getViewData(): array
|
|
{
|
|
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
|
$reasonPresenter = app(ReasonPresenter::class);
|
|
$reasonSemantics = $reasonPresenter->semantics(
|
|
$reasonPresenter->forArtifactTruth($this->reasonCode, 'baseline_compare_landing'),
|
|
);
|
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
|
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
|
? (int) $evidenceGapSummary['count']
|
|
: (int) ($this->evidenceGapsCount ?? 0);
|
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
|
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
|
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
|
? (string) $evidenceGapSummary['detail_state']
|
|
: 'no_gaps';
|
|
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
|
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
|
|
|
$evidenceGapsSummary = null;
|
|
$evidenceGapsTooltip = null;
|
|
|
|
if ($hasEvidenceGaps) {
|
|
$parts = array_map(
|
|
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
|
BaselineCompareEvidenceGapDetails::topReasons(
|
|
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
|
5,
|
|
),
|
|
);
|
|
|
|
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 [
|
|
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
|
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
|
'matrixSubjectKey' => $this->matrixSubjectKey,
|
|
'openCompareMatrixUrl' => $this->openCompareMatrixUrl(),
|
|
'decisionCard' => $this->decisionCard($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
|
|
'decisionSummaryItems' => $this->decisionSummaryItems($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
|
|
'proofPanelItems' => $this->proofPanelItems($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
|
|
'compareReadinessFlow' => $this->compareReadinessFlow($hasWarnings, $hasCoverageWarnings),
|
|
'availableCompareInputs' => $this->availableCompareInputs($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
|
|
'assignmentUnlocks' => $this->assignmentUnlocks(),
|
|
'diagnosticsDisclosure' => $this->diagnosticsDisclosure($hasEvidenceGapDiagnostics),
|
|
'hasCoverageWarnings' => $hasCoverageWarnings,
|
|
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
|
'hasWarnings' => $hasWarnings,
|
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
|
'evidenceGapDetailState' => $evidenceGapDetailState,
|
|
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
|
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
|
'findingsColorClass' => $findingsColorClass,
|
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
|
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
|
'reasonSemantics' => $reasonSemantics,
|
|
'subjectResolutionActionCount' => (int) ($this->subjectResolutionActionCount ?? 0),
|
|
'subjectResolutionUrl' => $this->subjectResolutionUrl(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function decisionCard(bool $hasWarnings, bool $hasCoverageWarnings, bool $hasEvidenceGaps): array
|
|
{
|
|
$state = (string) ($this->state ?? 'unknown');
|
|
$findingsCount = (int) ($this->findingsCount ?? 0);
|
|
$hasHighSeverity = ($this->severityCounts['high'] ?? 0) > 0;
|
|
$primaryAction = $this->primaryDecisionAction($state, $findingsCount);
|
|
|
|
return [
|
|
'question' => 'Which baseline drift requires action?',
|
|
'statusLabel' => 'Status',
|
|
'status' => $this->decisionStatus($state, $findingsCount, $hasWarnings),
|
|
'tone' => match (true) {
|
|
$state === 'failed' || $hasHighSeverity => 'danger',
|
|
in_array($state, ['no_assignment', 'no_snapshot', 'invalid_scope'], true) || $hasWarnings || $findingsCount > 0 => 'warning',
|
|
$state === 'comparing' => 'info',
|
|
$state === 'ready' => 'success',
|
|
default => 'gray',
|
|
},
|
|
'reasonLabel' => 'Reason',
|
|
'reason' => $this->decisionReason($state, $findingsCount),
|
|
'impactLabel' => 'Impact',
|
|
'impact' => $this->decisionImpact($state, $findingsCount, $hasCoverageWarnings, $hasEvidenceGaps),
|
|
'evidenceLabel' => 'Evidence path',
|
|
'evidence' => $this->evidencePathSummary($hasCoverageWarnings, $hasEvidenceGaps),
|
|
'nextActionLabel' => 'Next action',
|
|
...$primaryAction,
|
|
];
|
|
}
|
|
|
|
private function decisionStatus(string $state, int $findingsCount, bool $hasWarnings): string
|
|
{
|
|
return match ($state) {
|
|
'no_assignment' => 'Baseline not assigned',
|
|
'no_snapshot' => 'Baseline snapshot required',
|
|
'invalid_scope' => 'Baseline scope requires review',
|
|
'comparing' => 'Compare in progress',
|
|
'failed' => 'Compare failed',
|
|
'idle' => 'Compare run required',
|
|
'ready' => $findingsCount > 0
|
|
? 'Drift findings available'
|
|
: ($hasWarnings ? 'Decision output needs review' : 'No drift detected'),
|
|
default => 'Compare unavailable',
|
|
};
|
|
}
|
|
|
|
private function decisionReason(string $state, int $findingsCount): string
|
|
{
|
|
$reason = trim((string) ($this->reasonMessage ?? ''));
|
|
$message = trim((string) ($this->message ?? ''));
|
|
|
|
return match ($state) {
|
|
'no_assignment' => 'This environment does not have an assigned baseline.',
|
|
'no_snapshot' => 'A baseline is assigned, but no usable baseline snapshot is available.'
|
|
.($reason !== '' ? ' '.$reason : ($message !== '' ? ' '.$message : '')),
|
|
'invalid_scope' => 'A baseline is assigned, but its scope cannot be used safely for compare.'
|
|
.($reason !== '' ? ' '.$reason : ($message !== '' ? ' '.$message : '')),
|
|
'failed' => $this->failureReason ?: ($reason !== '' ? $reason : 'The compare operation ended with errors.'),
|
|
'comparing' => 'Baseline comparison is currently running.',
|
|
'idle' => 'Required inputs exist, but no compare run has been created for the current state.',
|
|
'ready' => $findingsCount > 0
|
|
? 'Baseline comparison found governance-relevant differences. Drift requires review before a decision is recorded.'
|
|
: 'Current environment state matches the assigned baseline within available compare coverage.',
|
|
default => 'Compare state is derived from the latest baseline assignment, snapshot, and operation proof.',
|
|
};
|
|
}
|
|
|
|
private function decisionImpact(string $state, int $findingsCount, bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
|
|
{
|
|
if ($state === 'no_assignment') {
|
|
return 'Baseline drift cannot be used for governance decisions until a baseline assignment exists.';
|
|
}
|
|
|
|
if ($state === 'no_snapshot') {
|
|
return 'Compare cannot run until baseline snapshot input exists.';
|
|
}
|
|
|
|
if ($state === 'invalid_scope') {
|
|
return 'Compare cannot run safely until the assigned baseline scope is reviewed.';
|
|
}
|
|
|
|
if ($state === 'failed') {
|
|
return 'Drift findings cannot be trusted until the failure is resolved. Review operation proof before retrying.';
|
|
}
|
|
|
|
if ($state === 'comparing') {
|
|
return 'Drift findings are not final yet. Wait for operation proof before acting on drift or evidence state.';
|
|
}
|
|
|
|
if ($state === 'idle') {
|
|
return 'Drift findings are not available yet. Run compare after the required inputs are confirmed.';
|
|
}
|
|
|
|
if ($findingsCount > 0) {
|
|
return 'Review findings and decide the next governance action before presenting this environment as aligned to baseline.';
|
|
}
|
|
|
|
if ($hasCoverageWarnings || $hasEvidenceGaps) {
|
|
return 'Zero findings must not be treated as final while coverage or evidence gaps remain.';
|
|
}
|
|
|
|
return 'No governance action is required from this compare result within available compare coverage.';
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string,actionName?:string}
|
|
*/
|
|
private function primaryDecisionAction(string $state, int $findingsCount): array
|
|
{
|
|
if ($state === 'no_assignment') {
|
|
$canOpenBaselines = BaselineProfileResource::canViewAny();
|
|
|
|
return [
|
|
'actionLabel' => $canOpenBaselines ? 'Open baseline profiles' : 'Baseline assignment unavailable',
|
|
'actionUrl' => $canOpenBaselines ? BaselineProfileResource::getUrl('index', panel: 'admin') : null,
|
|
'actionDisabled' => ! $canOpenBaselines,
|
|
'helperText' => $canOpenBaselines
|
|
? 'Open baseline profiles to assign a baseline to this environment.'
|
|
: 'No authorized baseline assignment path is available from this page.',
|
|
];
|
|
}
|
|
|
|
if (in_array($state, ['no_snapshot', 'invalid_scope'], true)) {
|
|
$profileUrl = $this->baselineProfileUrl();
|
|
|
|
return [
|
|
'actionLabel' => $profileUrl !== null ? 'Open baseline profile' : 'Baseline profile unavailable',
|
|
'actionUrl' => $profileUrl,
|
|
'actionDisabled' => $profileUrl === null,
|
|
'helperText' => $profileUrl !== null
|
|
? 'Open the assigned baseline profile to review capture and snapshot state.'
|
|
: 'No authorized baseline profile path is available from this page.',
|
|
];
|
|
}
|
|
|
|
if ($state === 'idle') {
|
|
$canRunCompare = $this->canRunCompareAction();
|
|
|
|
return [
|
|
'actionLabel' => $canRunCompare ? 'Compare now' : 'Compare unavailable',
|
|
'actionUrl' => null,
|
|
'actionDisabled' => ! $canRunCompare,
|
|
'actionName' => 'compareNow',
|
|
'helperText' => $canRunCompare
|
|
? 'Use the confirmed Compare now action to generate drift findings.'
|
|
: 'You are not authorized to start baseline compare from this environment.',
|
|
];
|
|
}
|
|
|
|
if ($state === 'comparing' && $this->getRunUrl() !== null) {
|
|
return [
|
|
'actionLabel' => 'View operation progress',
|
|
'actionUrl' => $this->getRunUrl(),
|
|
'actionDisabled' => false,
|
|
'helperText' => null,
|
|
];
|
|
}
|
|
|
|
if ($state === 'failed' && $this->getRunUrl() !== null) {
|
|
return [
|
|
'actionLabel' => 'Review compare failure',
|
|
'actionUrl' => $this->getRunUrl(),
|
|
'actionDisabled' => false,
|
|
'helperText' => null,
|
|
];
|
|
}
|
|
|
|
if ($state === 'ready' && $findingsCount > 0 && $this->getFindingsUrl() !== null) {
|
|
return [
|
|
'actionLabel' => 'Review drift findings',
|
|
'actionUrl' => $this->getFindingsUrl(),
|
|
'actionDisabled' => false,
|
|
'helperText' => null,
|
|
];
|
|
}
|
|
|
|
if ($state === 'ready' && $this->getRunUrl() !== null) {
|
|
return [
|
|
'actionLabel' => 'Review evidence',
|
|
'actionUrl' => $this->getRunUrl(),
|
|
'actionDisabled' => false,
|
|
'helperText' => null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'actionLabel' => 'Review compare state',
|
|
'actionUrl' => $this->getRunUrl(),
|
|
'actionDisabled' => $this->getRunUrl() === null,
|
|
'helperText' => $this->getRunUrl() === null ? 'No operation proof link is currently available.' : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, string>>
|
|
*/
|
|
private function decisionSummaryItems(bool $hasWarnings, bool $hasCoverageWarnings, bool $hasEvidenceGaps): array
|
|
{
|
|
if ($this->state === 'no_assignment') {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
[
|
|
'label' => 'Assigned baseline',
|
|
'value' => $this->profileName ?? 'Baseline not assigned',
|
|
'description' => $this->snapshotId !== null ? 'Snapshot #'.$this->snapshotId : 'No consumable snapshot proof is linked.',
|
|
],
|
|
[
|
|
'label' => 'Compare trust',
|
|
'value' => $this->compareTrustLabel($hasWarnings),
|
|
'description' => $this->coverageDescription($hasCoverageWarnings, $hasEvidenceGaps),
|
|
],
|
|
[
|
|
'label' => 'Drift impact',
|
|
'value' => $this->driftImpactLabel(),
|
|
'description' => 'Derived from latest findings, severity counts, and compare result state.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function proofPanelItems(bool $hasWarnings, bool $hasCoverageWarnings, bool $hasEvidenceGaps): array
|
|
{
|
|
if ($this->state === 'no_assignment') {
|
|
return [];
|
|
}
|
|
|
|
$environmentSnapshotState = $this->environmentSnapshotState($hasCoverageWarnings);
|
|
$operationProofAvailable = $this->operationRunId !== null && $this->getRunUrl() !== null;
|
|
$baselineSnapshotState = $this->snapshotId !== null ? 'Available' : ($this->state === 'no_snapshot' ? 'Missing' : 'Unavailable');
|
|
$driftFindingsAvailable = $this->state === 'ready';
|
|
|
|
return [
|
|
[
|
|
'key' => 'assigned_baseline',
|
|
'label' => 'Assigned baseline',
|
|
'value' => $this->profileName ?? 'Baseline not assigned',
|
|
'tone' => $this->profileName !== null ? 'success' : 'warning',
|
|
'description' => $this->state === 'invalid_scope'
|
|
? 'Assignment exists, but the baseline scope requires review.'
|
|
: 'Environment-owned baseline assignment state.',
|
|
'actionLabel' => $this->openCompareMatrixUrl() !== null ? 'Open compare matrix' : null,
|
|
'actionUrl' => $this->openCompareMatrixUrl(),
|
|
],
|
|
[
|
|
'key' => 'baseline_snapshot',
|
|
'label' => 'Baseline snapshot',
|
|
'value' => $baselineSnapshotState,
|
|
'tone' => $this->flowTone($baselineSnapshotState),
|
|
'description' => $this->snapshotId !== null ? 'Snapshot #'.$this->snapshotId.' is the baseline compare input.' : 'No usable baseline snapshot input is linked.',
|
|
'actionLabel' => $this->baselineProfileUrl() !== null ? 'Open baseline profile' : null,
|
|
'actionUrl' => $this->baselineProfileUrl(),
|
|
],
|
|
[
|
|
'key' => 'environment_snapshot',
|
|
'label' => 'Environment snapshot',
|
|
'value' => $environmentSnapshotState,
|
|
'tone' => $this->flowTone($environmentSnapshotState),
|
|
'description' => $this->environmentSnapshotDescription($environmentSnapshotState),
|
|
'actionLabel' => null,
|
|
'actionUrl' => null,
|
|
],
|
|
[
|
|
'key' => 'operation_run_proof',
|
|
'label' => 'OperationRun proof',
|
|
'value' => $operationProofAvailable ? 'Available' : 'Unavailable',
|
|
'tone' => $operationProofAvailable ? 'success' : 'gray',
|
|
'description' => $operationProofAvailable ? 'Compare proof is linked to an OperationRun.' : 'No compare OperationRun proof is available yet.',
|
|
'actionLabel' => $operationProofAvailable ? 'Open operation proof' : null,
|
|
'actionUrl' => $operationProofAvailable ? $this->getRunUrl() : null,
|
|
],
|
|
[
|
|
'key' => 'drift_findings',
|
|
'label' => 'Drift findings',
|
|
'value' => $driftFindingsAvailable ? $this->driftImpactLabel() : 'Unavailable',
|
|
'tone' => $driftFindingsAvailable && ((int) ($this->findingsCount ?? 0)) > 0 ? 'warning' : ($driftFindingsAvailable ? 'success' : 'gray'),
|
|
'description' => $this->driftFindingsDescription(),
|
|
'actionLabel' => $this->getFindingsUrl() !== null && ((int) ($this->findingsCount ?? 0)) > 0 ? 'Review findings' : null,
|
|
'actionUrl' => ((int) ($this->findingsCount ?? 0)) > 0 ? $this->getFindingsUrl() : null,
|
|
],
|
|
[
|
|
'key' => 'evidence_path',
|
|
'label' => 'Evidence path',
|
|
'value' => $this->evidencePathSummary($hasCoverageWarnings, $hasEvidenceGaps),
|
|
'tone' => ($hasCoverageWarnings || $hasEvidenceGaps) ? 'warning' : 'gray',
|
|
'description' => $this->evidenceInputDescription($hasCoverageWarnings, $hasEvidenceGaps),
|
|
'actionLabel' => null,
|
|
'actionUrl' => null,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function compareReadinessFlow(bool $hasWarnings, bool $hasCoverageWarnings): array
|
|
{
|
|
$state = (string) ($this->state ?? 'unknown');
|
|
$environmentSnapshotState = $this->environmentSnapshotState($hasCoverageWarnings);
|
|
$baselineAssignedState = match ($state) {
|
|
'no_assignment' => 'Missing',
|
|
'invalid_scope' => 'Needs review',
|
|
'no_tenant' => 'Unavailable',
|
|
default => 'Complete',
|
|
};
|
|
$baselineSnapshotState = match ($state) {
|
|
'no_assignment' => 'Unavailable',
|
|
'no_snapshot' => 'Missing',
|
|
'invalid_scope' => 'Unavailable',
|
|
default => $this->snapshotId !== null ? 'Available' : 'Unavailable',
|
|
};
|
|
$compareRunState = match ($state) {
|
|
'idle' => 'Required',
|
|
'comparing' => 'In progress',
|
|
'failed' => 'Failed',
|
|
'ready' => 'Available',
|
|
default => 'Unavailable',
|
|
};
|
|
$decisionOutputState = match ($state) {
|
|
'ready' => $hasWarnings ? 'Needs review' : 'Available',
|
|
'idle' => 'Required',
|
|
default => 'Unavailable',
|
|
};
|
|
|
|
return [
|
|
[
|
|
'label' => 'Baseline assigned',
|
|
'state' => $baselineAssignedState,
|
|
'tone' => $this->flowTone($baselineAssignedState),
|
|
'description' => match ($baselineAssignedState) {
|
|
'Complete' => 'Baseline assignment exists.',
|
|
'Needs review' => 'Assignment scope needs review.',
|
|
'Missing' => 'No baseline is assigned.',
|
|
default => 'Assignment unavailable.',
|
|
},
|
|
'currentBlocker' => in_array($state, ['no_assignment', 'invalid_scope'], true),
|
|
],
|
|
[
|
|
'label' => 'Baseline snapshot',
|
|
'state' => $baselineSnapshotState,
|
|
'tone' => $this->flowTone($baselineSnapshotState),
|
|
'description' => match ($baselineSnapshotState) {
|
|
'Available' => 'Snapshot #'.$this->snapshotId.' is available.',
|
|
'Missing' => 'No usable snapshot.',
|
|
'Needs review' => 'Snapshot needs review.',
|
|
default => 'No snapshot linked.',
|
|
},
|
|
'currentBlocker' => $state === 'no_snapshot',
|
|
],
|
|
[
|
|
'label' => 'Environment snapshot',
|
|
'state' => $environmentSnapshotState,
|
|
'tone' => $this->flowTone($environmentSnapshotState),
|
|
'description' => $this->environmentSnapshotDescription($environmentSnapshotState),
|
|
'currentBlocker' => false,
|
|
],
|
|
[
|
|
'label' => 'Compare run',
|
|
'state' => $compareRunState,
|
|
'tone' => $this->flowTone($compareRunState),
|
|
'description' => match ($compareRunState) {
|
|
'Available' => 'Completed run available.',
|
|
'Required' => 'Run compare.',
|
|
'In progress' => 'Queued or running.',
|
|
'Failed' => 'Latest run failed.',
|
|
default => 'Blocked by missing inputs.',
|
|
},
|
|
'currentBlocker' => in_array($state, ['idle', 'comparing', 'failed'], true),
|
|
],
|
|
[
|
|
'label' => 'Decision output',
|
|
'state' => $decisionOutputState,
|
|
'tone' => $this->flowTone($decisionOutputState),
|
|
'description' => match ($decisionOutputState) {
|
|
'Available' => 'Decision output available.',
|
|
'Needs review' => 'Evidence or coverage needs review.',
|
|
'Required' => 'Run compare first.',
|
|
default => 'No decision output.',
|
|
},
|
|
'currentBlocker' => $state === 'ready' && $hasWarnings,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, string>>
|
|
*/
|
|
private function availableCompareInputs(bool $hasWarnings, bool $hasCoverageWarnings, bool $hasEvidenceGaps): array
|
|
{
|
|
if ($this->state !== 'no_assignment') {
|
|
$environmentSnapshotState = $this->environmentSnapshotState($hasCoverageWarnings);
|
|
$baselineSnapshotState = $this->state === 'no_snapshot'
|
|
? 'Missing'
|
|
: ($this->snapshotId !== null ? 'Available' : 'Unavailable');
|
|
$operationProofState = $this->operationRunId !== null && $this->getRunUrl() !== null ? 'Available' : 'Unavailable';
|
|
$driftFindingsState = $this->state === 'ready' ? 'Available' : 'Unavailable';
|
|
$evidencePathState = $this->evidenceInputState($hasWarnings);
|
|
|
|
return [
|
|
[
|
|
'label' => 'Assigned baseline',
|
|
'state' => $this->state === 'invalid_scope' ? 'Needs review' : 'Available',
|
|
'tone' => $this->state === 'invalid_scope' ? 'warning' : 'success',
|
|
'description' => $this->profileName !== null
|
|
? 'Assigned baseline: '.$this->profileName.'.'
|
|
: 'Baseline assignment exists but requires review.',
|
|
],
|
|
[
|
|
'label' => 'Baseline snapshot',
|
|
'state' => $baselineSnapshotState,
|
|
'tone' => $this->flowTone($baselineSnapshotState),
|
|
'description' => $this->snapshotId !== null
|
|
? 'Snapshot #'.$this->snapshotId.' is the compare input.'
|
|
: 'No usable baseline snapshot input is linked.',
|
|
],
|
|
[
|
|
'label' => 'Environment snapshot',
|
|
'state' => $environmentSnapshotState,
|
|
'tone' => $this->flowTone($environmentSnapshotState),
|
|
'description' => $this->environmentSnapshotDescription($environmentSnapshotState),
|
|
],
|
|
[
|
|
'label' => 'OperationRun proof',
|
|
'state' => $operationProofState,
|
|
'tone' => $this->flowTone($operationProofState),
|
|
'description' => $operationProofState === 'Available'
|
|
? 'A compare OperationRun proof link is available.'
|
|
: 'No compare OperationRun proof is available yet.',
|
|
],
|
|
[
|
|
'label' => 'Drift findings',
|
|
'state' => $driftFindingsState,
|
|
'tone' => $driftFindingsState === 'Available' && ((int) ($this->findingsCount ?? 0)) > 0 ? 'warning' : $this->flowTone($driftFindingsState),
|
|
'description' => $this->driftFindingsDescription(),
|
|
],
|
|
[
|
|
'label' => 'Evidence path',
|
|
'state' => $evidencePathState,
|
|
'tone' => $this->flowTone($evidencePathState),
|
|
'description' => $this->evidenceInputDescription($hasCoverageWarnings, $hasEvidenceGaps),
|
|
],
|
|
];
|
|
}
|
|
|
|
$environmentSnapshotState = $this->hasEnvironmentSnapshot() ? 'Available' : 'Unavailable';
|
|
$operationProofState = $this->operationRunId !== null && $this->getRunUrl() !== null ? 'Available' : 'Unavailable';
|
|
|
|
return [
|
|
[
|
|
'label' => 'Environment snapshot',
|
|
'state' => $environmentSnapshotState,
|
|
'tone' => $environmentSnapshotState === 'Available' ? 'success' : 'gray',
|
|
'description' => $environmentSnapshotState === 'Available'
|
|
? 'Current environment evidence is present.'
|
|
: 'No repo-backed environment snapshot is available yet.',
|
|
],
|
|
[
|
|
'label' => 'Operation proof',
|
|
'state' => $operationProofState,
|
|
'tone' => $operationProofState === 'Available' ? 'success' : 'gray',
|
|
'description' => $operationProofState === 'Available'
|
|
? 'A compare operation proof link is available.'
|
|
: 'No compare operation proof is available yet.',
|
|
],
|
|
[
|
|
'label' => 'Baseline snapshot',
|
|
'state' => 'Unavailable',
|
|
'tone' => 'gray',
|
|
'description' => 'Unavailable because no baseline assigned.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function assignmentUnlocks(): array
|
|
{
|
|
if ($this->state !== 'no_assignment') {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'Actionable drift categories',
|
|
'Evidence-backed compare',
|
|
'Governance decision path',
|
|
];
|
|
}
|
|
|
|
private function hasEnvironmentSnapshot(): bool
|
|
{
|
|
$environment = $this->currentEnvironment();
|
|
|
|
if (! $environment instanceof ManagedEnvironment) {
|
|
return false;
|
|
}
|
|
|
|
$environmentId = (int) $environment->getKey();
|
|
|
|
if (InventoryItem::query()->where('managed_environment_id', $environmentId)->exists()) {
|
|
return true;
|
|
}
|
|
|
|
return OperationRun::query()
|
|
->where('managed_environment_id', $environmentId)
|
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->exists();
|
|
}
|
|
|
|
private function compareTrustLabel(bool $hasWarnings): string
|
|
{
|
|
if ($this->state === 'ready' && ! $hasWarnings) {
|
|
return 'Usable latest compare';
|
|
}
|
|
|
|
if ($this->state === 'ready') {
|
|
return 'Usable with warnings';
|
|
}
|
|
|
|
return match ($this->state) {
|
|
'idle' => 'Not generated yet',
|
|
'comparing' => 'Generating',
|
|
'failed' => 'Failed',
|
|
'no_assignment' => 'Unavailable',
|
|
'no_snapshot' => 'Unavailable',
|
|
'invalid_scope' => 'Needs review',
|
|
default => 'Unavailable',
|
|
};
|
|
}
|
|
|
|
private function coverageDescription(bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
|
|
{
|
|
if ($this->state === 'no_assignment') {
|
|
return 'Compare trust is unavailable until a baseline assignment exists.';
|
|
}
|
|
|
|
if ($hasCoverageWarnings && $hasEvidenceGaps) {
|
|
return 'Coverage warnings and evidence gaps limit the latest compare.';
|
|
}
|
|
|
|
if ($hasCoverageWarnings) {
|
|
return 'Coverage warnings limit the latest compare.';
|
|
}
|
|
|
|
if ($hasEvidenceGaps) {
|
|
return 'Evidence gaps still need review before governance use.';
|
|
}
|
|
|
|
return 'No coverage warning is currently reported for the latest compare.';
|
|
}
|
|
|
|
private function driftImpactLabel(): string
|
|
{
|
|
$findingsCount = (int) ($this->findingsCount ?? 0);
|
|
|
|
if ($findingsCount > 0) {
|
|
return $findingsCount.' '.\Illuminate\Support\Str::plural('finding', $findingsCount).' need review';
|
|
}
|
|
|
|
if ($this->state === 'ready') {
|
|
return 'No confirmed drift';
|
|
}
|
|
|
|
return 'No usable drift result';
|
|
}
|
|
|
|
private function evidencePathSummary(bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
|
|
{
|
|
if ($hasEvidenceGaps) {
|
|
return 'Evidence unavailable - Evidence gaps need review';
|
|
}
|
|
|
|
if ($hasCoverageWarnings) {
|
|
return 'Evidence unavailable - Coverage warning recorded';
|
|
}
|
|
|
|
if ($this->operationRunId !== null) {
|
|
return 'Evidence unavailable - Operation proof available';
|
|
}
|
|
|
|
return 'Evidence unavailable';
|
|
}
|
|
|
|
private function baselineProfileUrl(): ?string
|
|
{
|
|
if (! BaselineProfileResource::canViewAny()) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->profileId !== null) {
|
|
return BaselineProfileResource::getUrl('view', ['record' => $this->profileId], panel: 'admin');
|
|
}
|
|
|
|
return BaselineProfileResource::getUrl('index', panel: 'admin');
|
|
}
|
|
|
|
private function canRunCompareAction(): bool
|
|
{
|
|
if (! in_array($this->state, ['idle', 'ready', 'failed'], true)) {
|
|
return false;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
|
return false;
|
|
}
|
|
|
|
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_SYNC);
|
|
}
|
|
|
|
private function environmentSnapshotState(bool $hasCoverageWarnings): string
|
|
{
|
|
if ($hasCoverageWarnings) {
|
|
return 'Needs review';
|
|
}
|
|
|
|
return $this->hasEnvironmentSnapshot() ? 'Available' : 'Unavailable';
|
|
}
|
|
|
|
private function environmentSnapshotDescription(string $environmentSnapshotState): string
|
|
{
|
|
return match ($environmentSnapshotState) {
|
|
'Available' => 'Environment snapshot evidence is present.',
|
|
'Needs review' => 'Coverage proof has warnings.',
|
|
default => 'No environment snapshot yet.',
|
|
};
|
|
}
|
|
|
|
private function flowTone(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'Available', 'Complete' => 'success',
|
|
'Missing', 'Required', 'Needs review' => 'warning',
|
|
'In progress' => 'info',
|
|
'Failed' => 'danger',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function driftFindingsDescription(): string
|
|
{
|
|
if ($this->state !== 'ready') {
|
|
return 'Run compare after required inputs exist.';
|
|
}
|
|
|
|
$findingsCount = (int) ($this->findingsCount ?? 0);
|
|
|
|
if ($findingsCount > 0) {
|
|
return $findingsCount.' '.\Illuminate\Support\Str::plural('open drift finding', $findingsCount).' available for review.';
|
|
}
|
|
|
|
return 'Zero open drift findings are recorded for the latest compare result.';
|
|
}
|
|
|
|
private function evidenceInputState(bool $hasWarnings): string
|
|
{
|
|
if ($this->operationRunId === null) {
|
|
return 'Unavailable';
|
|
}
|
|
|
|
return $hasWarnings ? 'Needs review' : 'Unavailable';
|
|
}
|
|
|
|
private function evidenceInputDescription(bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
|
|
{
|
|
if ($hasEvidenceGaps) {
|
|
return 'Compare result exists, but evidence output is not available. Evidence gaps need review.';
|
|
}
|
|
|
|
if ($hasCoverageWarnings) {
|
|
return 'Compare result exists, but evidence output is not available. Coverage warnings need review.';
|
|
}
|
|
|
|
if ($this->operationRunId !== null) {
|
|
return 'Compare result exists, but no evidence output is linked yet.';
|
|
}
|
|
|
|
return 'No evidence output is linked yet.';
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function diagnosticsDisclosure(bool $hasEvidenceGapDiagnostics): array
|
|
{
|
|
return [
|
|
'label' => 'Diagnostics - Collapsed',
|
|
'summary' => $hasEvidenceGapDiagnostics
|
|
? 'Support diagnostics exist for this compare and stay closed behind the disclosure until needed.'
|
|
: 'No support diagnostics are available for this compare state.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = [];
|
|
$navigationContext = $this->navigationContext();
|
|
|
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
|
$actions[] = Action::make('backToOrigin')
|
|
->label($navigationContext->backLinkLabel)
|
|
->color('gray')
|
|
->url($navigationContext->backLinkUrl);
|
|
}
|
|
|
|
$actions[] = $this->compareNowAction();
|
|
|
|
return $actions;
|
|
}
|
|
|
|
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 = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
Notification::make()->title('Open an environment to compare baselines')->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(BaselineCompareService::class);
|
|
$result = $service->startCompare($tenant, $user);
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
|
|
|
|
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
|
|
? trim((string) $translation['short_explanation'])
|
|
: match ($reasonCode) {
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before compare can start.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy.',
|
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before compare can start.',
|
|
default => 'Reason: '.$reasonCode,
|
|
};
|
|
|
|
Notification::make()
|
|
->title('Cannot start comparison')
|
|
->body($message)
|
|
->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 : OperationRunType::BaselineCompare->value)
|
|
->actions($run instanceof OperationRun ? [
|
|
Action::make('view_run')
|
|
->label('Open operation')
|
|
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
|
|
] : [])
|
|
->send();
|
|
});
|
|
|
|
return UiEnforcement::forAction($action)
|
|
->requireCapability(Capabilities::TENANT_SYNC)
|
|
->preserveDisabled()
|
|
->apply();
|
|
}
|
|
|
|
public function getFindingsUrl(): ?string
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return FindingResource::getUrl('index', tenant: $tenant);
|
|
}
|
|
|
|
public function getRunUrl(): ?string
|
|
{
|
|
if ($this->operationRunId === null) {
|
|
return null;
|
|
}
|
|
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRunLinks::view($this->operationRunId, $tenant);
|
|
}
|
|
|
|
public function subjectResolutionUrl(): ?string
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant, array_filter([
|
|
'operation_run_id' => $this->operationRunId,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
|
}
|
|
|
|
public function openCompareMatrixUrl(): ?string
|
|
{
|
|
$profile = $this->resolveCompareMatrixProfile();
|
|
|
|
if (! $profile instanceof BaselineProfile) {
|
|
return null;
|
|
}
|
|
|
|
$url = BaselineProfileResource::compareMatrixUrl($profile);
|
|
$query = array_filter([
|
|
'subject_key' => $this->matrixSubjectKey,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
|
|
|
if ($query === []) {
|
|
return $url;
|
|
}
|
|
|
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
|
}
|
|
|
|
private function governanceAggregate(ManagedEnvironment $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
|
{
|
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
|
|
|
/** @var TenantGovernanceAggregate $aggregate */
|
|
$aggregate = $resolver->fromStats($tenant, $stats);
|
|
|
|
return $aggregate;
|
|
}
|
|
|
|
private function navigationContext(): ?CanonicalNavigationContext
|
|
{
|
|
if (! is_array($this->navigationContextPayload)) {
|
|
return CanonicalNavigationContext::fromRequest(request());
|
|
}
|
|
|
|
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
|
}
|
|
|
|
private function resolveCompareMatrixProfile(): ?BaselineProfile
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$candidateIds = array_values(array_filter([
|
|
$this->matrixBaselineProfileId,
|
|
$this->profileId,
|
|
], static fn (mixed $value): bool => is_int($value) && $value > 0));
|
|
|
|
foreach ($candidateIds as $profileId) {
|
|
$profile = BaselineProfile::query()
|
|
->whereKey($profileId)
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->first();
|
|
|
|
if ($profile instanceof BaselineProfile) {
|
|
return $profile;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function currentEnvironment(): ?ManagedEnvironment
|
|
{
|
|
if ($this->scopedEnvironmentId !== null) {
|
|
$tenant = ManagedEnvironment::query()->whereKey($this->scopedEnvironmentId)->first();
|
|
|
|
return $tenant instanceof ManagedEnvironment && static::hasEnvironmentAccess($tenant)
|
|
? $tenant
|
|
: null;
|
|
}
|
|
|
|
$tenant = static::resolveRouteOwnedEnvironment();
|
|
|
|
return $tenant instanceof ManagedEnvironment && static::hasEnvironmentAccess($tenant)
|
|
? $tenant
|
|
: null;
|
|
}
|
|
|
|
protected static function resolveRouteOwnedEnvironment(ManagedEnvironment|string|null $environment = null): ?ManagedEnvironment
|
|
{
|
|
if ($environment instanceof ManagedEnvironment) {
|
|
return $environment;
|
|
}
|
|
|
|
if (is_string($environment) && $environment !== '') {
|
|
return ManagedEnvironment::query()
|
|
->where('slug', $environment)
|
|
->first();
|
|
}
|
|
|
|
$routeEnvironment = request()->route('environment');
|
|
|
|
if ($routeEnvironment instanceof ManagedEnvironment) {
|
|
return $routeEnvironment;
|
|
}
|
|
|
|
if (is_string($routeEnvironment) && $routeEnvironment !== '') {
|
|
return ManagedEnvironment::query()
|
|
->where('slug', $routeEnvironment)
|
|
->first();
|
|
}
|
|
|
|
return static::resolveRefererOwnedEnvironment();
|
|
}
|
|
|
|
private static function hasEnvironmentAccess(?ManagedEnvironment $environment): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $environment instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$routeWorkspace = request()->route('workspace');
|
|
|
|
if ($routeWorkspace instanceof Workspace && (int) $routeWorkspace->getKey() !== (int) $environment->workspace_id) {
|
|
return false;
|
|
}
|
|
|
|
if (is_string($routeWorkspace) && $routeWorkspace !== '') {
|
|
$workspace = $environment->workspace instanceof Workspace
|
|
? $environment->workspace
|
|
: $environment->workspace()->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
if ($routeWorkspace !== static::workspaceRouteKey($workspace) && $routeWorkspace !== (string) $workspace->getKey()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId !== null && (int) $workspaceId !== (int) $environment->workspace_id) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $environment)
|
|
&& $resolver->can($user, $environment, Capabilities::TENANT_VIEW);
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
private static function resolveAdminUrlEnvironment(array $parameters, ?Model $tenant = null): ?ManagedEnvironment
|
|
{
|
|
$parameterEnvironment = $parameters['tenant'] ?? $parameters['environment'] ?? null;
|
|
|
|
if ($parameterEnvironment instanceof ManagedEnvironment) {
|
|
return $parameterEnvironment;
|
|
}
|
|
|
|
if ($tenant instanceof ManagedEnvironment) {
|
|
return $tenant;
|
|
}
|
|
|
|
$routeEnvironment = static::resolveRouteOwnedEnvironment();
|
|
|
|
if ($routeEnvironment instanceof ManagedEnvironment) {
|
|
return $routeEnvironment;
|
|
}
|
|
|
|
$filamentTenant = Filament::getTenant();
|
|
|
|
return $filamentTenant instanceof ManagedEnvironment ? $filamentTenant : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
private static function resolveAdminUrlWorkspace(ManagedEnvironment $environment, array $parameters): Workspace|string|int|null
|
|
{
|
|
$workspace = $parameters['workspace'] ?? null;
|
|
|
|
if ($workspace instanceof Workspace || is_string($workspace) || is_int($workspace)) {
|
|
return $workspace;
|
|
}
|
|
|
|
$environmentWorkspace = $environment->workspace;
|
|
|
|
if ($environmentWorkspace instanceof Workspace) {
|
|
return $environmentWorkspace;
|
|
}
|
|
|
|
return $environment->workspace()->first();
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
* @return array<mixed>
|
|
*/
|
|
private static function withoutLegacyScopeQuery(array $parameters): array
|
|
{
|
|
foreach (self::LEGACY_SCOPE_QUERY_KEYS as $key) {
|
|
unset($parameters[$key]);
|
|
}
|
|
|
|
return $parameters;
|
|
}
|
|
|
|
private static function workspaceRouteKey(Workspace $workspace): string
|
|
{
|
|
$slug = $workspace->getAttribute('slug');
|
|
|
|
return is_string($slug) && $slug !== ''
|
|
? $slug
|
|
: (string) $workspace->getKey();
|
|
}
|
|
|
|
private static function resolveRefererOwnedEnvironment(): ?ManagedEnvironment
|
|
{
|
|
$referer = request()->headers->get('referer');
|
|
|
|
if (! is_string($referer) || $referer === '') {
|
|
return null;
|
|
}
|
|
|
|
$path = parse_url($referer, PHP_URL_PATH);
|
|
|
|
if (! is_string($path)) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('#^/admin/workspaces/([^/]+)/environments/([^/]+)/baseline-compare$#', $path, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$workspaceRouteKey = rawurldecode($matches[1]);
|
|
$environmentRouteKey = rawurldecode($matches[2]);
|
|
|
|
$environment = ManagedEnvironment::query()
|
|
->where('slug', $environmentRouteKey)
|
|
->first();
|
|
|
|
if (! $environment instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$workspace = $environment->workspace;
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
$workspace = $environment->workspace()->first();
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return null;
|
|
}
|
|
|
|
if ($workspaceRouteKey !== static::workspaceRouteKey($workspace) && $workspaceRouteKey !== (string) $workspace->getKey()) {
|
|
return null;
|
|
}
|
|
|
|
return $environment;
|
|
}
|
|
}
|