## Summary - add adaptive baseline compare presentation modes with `auto`, `dense`, and `compact` route handling on the existing matrix page - compress support surfaces with staged filters, grouped legends, last-updated and passive refresh cues, compact single-tenant results, and dense multi-tenant scan rendering - extend the matrix builder plus Pest and browser smoke coverage for visible-set-only compact and dense workflows ## Filament / Laravel notes - Livewire v4 compliance preserved; no legacy Livewire v3 patterns introduced - provider registration is unchanged; no `bootstrap/providers.php` changes were needed for this feature - no globally searchable resources were changed by this branch - no destructive actions were added; the existing compare action remains simulation-only and non-destructive - asset strategy is unchanged; no new Filament assets were introduced ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` - `80` tests passed with `673` assertions - integrated browser smoke run on `http://localhost/admin/baseline-profiles/20/compare-matrix` ## Scope - Spec 191 implementation - spec contract updates in `spec.md`, `tasks.md`, and the logical OpenAPI contract Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #224
1006 lines
38 KiB
PHP
1006 lines
38 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineSnapshotItem;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class BaselineCompareMatrixBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
|
private readonly CapabilityResolver $capabilityResolver,
|
|
private readonly BaselineCompareExplanationRegistry $explanationRegistry,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function build(BaselineProfile $profile, User $user, array $filters = []): array
|
|
{
|
|
$normalizedFilters = $this->normalizeFilters($filters);
|
|
|
|
$assignments = BaselineTenantAssignment::query()
|
|
->inWorkspace((int) $profile->workspace_id)
|
|
->forBaselineProfile($profile)
|
|
->with('tenant')
|
|
->get();
|
|
|
|
$visibleTenants = $this->visibleTenants($assignments, $user);
|
|
$referenceResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile);
|
|
$referenceSnapshot = $this->resolvedSnapshot($referenceResolution);
|
|
$referenceReasonCode = is_string($referenceResolution['reason_code'] ?? null)
|
|
? trim((string) $referenceResolution['reason_code'])
|
|
: null;
|
|
|
|
$reference = [
|
|
'workspaceId' => (int) $profile->workspace_id,
|
|
'baselineProfileId' => (int) $profile->getKey(),
|
|
'baselineProfileName' => (string) $profile->name,
|
|
'baselineStatus' => $profile->status instanceof BaselineProfileStatus
|
|
? $profile->status->value
|
|
: (string) $profile->status,
|
|
'referenceSnapshotId' => $referenceSnapshot?->getKey(),
|
|
'referenceSnapshotCapturedAt' => $referenceSnapshot?->captured_at?->toIso8601String(),
|
|
'referenceState' => $referenceSnapshot instanceof BaselineSnapshot ? 'ready' : 'no_snapshot',
|
|
'referenceReasonCode' => $referenceReasonCode,
|
|
'assignedTenantCount' => $assignments->count(),
|
|
'visibleTenantCount' => $visibleTenants->count(),
|
|
];
|
|
|
|
$snapshotItems = $referenceSnapshot instanceof BaselineSnapshot
|
|
? BaselineSnapshotItem::query()
|
|
->where('baseline_snapshot_id', (int) $referenceSnapshot->getKey())
|
|
->orderBy('policy_type')
|
|
->orderBy('subject_key')
|
|
->orderBy('id')
|
|
->get()
|
|
: collect();
|
|
|
|
$policyTypeOptions = $snapshotItems
|
|
->pluck('policy_type')
|
|
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
|
->unique()
|
|
->sort()
|
|
->mapWithKeys(static fn (string $type): array => [
|
|
$type => InventoryPolicyTypeMeta::label($type) ?? $type,
|
|
])
|
|
->all();
|
|
|
|
$bundle = [
|
|
'reference' => $reference,
|
|
'filters' => [
|
|
'policyTypes' => $normalizedFilters['policyTypes'],
|
|
'states' => $normalizedFilters['states'],
|
|
'severities' => $normalizedFilters['severities'],
|
|
'tenantSort' => $normalizedFilters['tenantSort'],
|
|
'subjectSort' => $normalizedFilters['subjectSort'],
|
|
'focusedSubjectKey' => $normalizedFilters['focusedSubjectKey'],
|
|
],
|
|
'policyTypeOptions' => $policyTypeOptions,
|
|
'stateOptions' => BadgeCatalog::options(BadgeDomain::BaselineCompareMatrixState, [
|
|
'match',
|
|
'differ',
|
|
'missing',
|
|
'ambiguous',
|
|
'not_compared',
|
|
'stale_result',
|
|
]),
|
|
'severityOptions' => BadgeCatalog::options(BadgeDomain::FindingSeverity, [
|
|
Finding::SEVERITY_LOW,
|
|
Finding::SEVERITY_MEDIUM,
|
|
Finding::SEVERITY_HIGH,
|
|
Finding::SEVERITY_CRITICAL,
|
|
]),
|
|
'tenantSortOptions' => [
|
|
'tenant_name' => 'Tenant name',
|
|
'deviation_count' => 'Deviation count',
|
|
'freshness_urgency' => 'Freshness urgency',
|
|
],
|
|
'subjectSortOptions' => [
|
|
'deviation_breadth' => 'Deviation breadth',
|
|
'policy_type' => 'Policy type',
|
|
'display_name' => 'Display name',
|
|
],
|
|
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
|
|
'match',
|
|
'differ',
|
|
'missing',
|
|
'ambiguous',
|
|
'not_compared',
|
|
'stale_result',
|
|
]),
|
|
'freshnessLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixFreshness, [
|
|
'fresh',
|
|
'stale',
|
|
'never_compared',
|
|
'unknown',
|
|
]),
|
|
'trustLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixTrust, [
|
|
'trustworthy',
|
|
'limited_confidence',
|
|
'diagnostic_only',
|
|
'unusable',
|
|
]),
|
|
'supportSurfaceState' => [
|
|
'legendMode' => 'grouped',
|
|
'showActiveFilterSummary' => true,
|
|
'showLastUpdated' => true,
|
|
'showAutoRefreshHint' => false,
|
|
'showBlockingRefreshState' => false,
|
|
],
|
|
'lastUpdatedAt' => now()->toIso8601String(),
|
|
'tenantSummaries' => [],
|
|
'subjectSummaries' => [],
|
|
'rows' => [],
|
|
'denseRows' => [],
|
|
'compactResults' => [],
|
|
'emptyState' => $this->emptyState(
|
|
reference: $reference,
|
|
snapshotItemsCount: $snapshotItems->count(),
|
|
visibleTenantsCount: $visibleTenants->count(),
|
|
),
|
|
'hasActiveRuns' => false,
|
|
];
|
|
|
|
if (! $referenceSnapshot instanceof BaselineSnapshot) {
|
|
return $bundle;
|
|
}
|
|
|
|
if ($visibleTenants->isEmpty()) {
|
|
return $bundle;
|
|
}
|
|
|
|
if ($snapshotItems->isEmpty()) {
|
|
return $bundle;
|
|
}
|
|
|
|
$tenantIds = $visibleTenants
|
|
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
|
->values()
|
|
->all();
|
|
|
|
$latestRuns = OperationRun::latestBaselineCompareRunsForProfile(
|
|
profile: $profile,
|
|
tenantIds: $tenantIds,
|
|
workspaceId: (int) $profile->workspace_id,
|
|
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
|
|
|
$completedRuns = OperationRun::latestBaselineCompareRunsForProfile(
|
|
profile: $profile,
|
|
tenantIds: $tenantIds,
|
|
workspaceId: (int) $profile->workspace_id,
|
|
completedOnly: true,
|
|
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
|
|
|
$findingMap = $this->findingMap($profile, $tenantIds, $completedRuns);
|
|
$rows = [];
|
|
|
|
foreach ($snapshotItems as $item) {
|
|
if (! $item instanceof BaselineSnapshotItem) {
|
|
continue;
|
|
}
|
|
|
|
$subjectKey = is_string($item->subject_key) ? trim($item->subject_key) : '';
|
|
|
|
if ($subjectKey === '') {
|
|
continue;
|
|
}
|
|
|
|
$subject = [
|
|
'subjectKey' => $subjectKey,
|
|
'policyType' => (string) $item->policy_type,
|
|
'displayName' => $this->subjectDisplayName($item),
|
|
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
|
|
];
|
|
|
|
$cells = [];
|
|
|
|
foreach ($visibleTenants as $tenant) {
|
|
$tenantId = (int) $tenant->getKey();
|
|
$latestRun = $latestRuns->get($tenantId);
|
|
$completedRun = $completedRuns->get($tenantId);
|
|
$cells[] = $this->cellFor(
|
|
item: $item,
|
|
tenant: $tenant,
|
|
referenceSnapshot: $referenceSnapshot,
|
|
latestRun: $latestRun instanceof OperationRun ? $latestRun : null,
|
|
completedRun: $completedRun instanceof OperationRun ? $completedRun : null,
|
|
finding: $findingMap[$this->cellKey($tenantId, $subjectKey)] ?? null,
|
|
);
|
|
}
|
|
|
|
if (! $this->rowMatchesFilters($subject, $cells, $normalizedFilters)) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'subject' => $this->subjectSummary($subject, $cells),
|
|
'cells' => $cells,
|
|
];
|
|
}
|
|
|
|
$rows = $this->sortRows($rows, $normalizedFilters['subjectSort']);
|
|
$tenantSummaries = $this->sortTenantSummaries(
|
|
tenantSummaries: $this->tenantSummaries($visibleTenants, $latestRuns, $completedRuns, $rows, $referenceSnapshot),
|
|
sort: $normalizedFilters['tenantSort'],
|
|
);
|
|
|
|
foreach ($rows as &$row) {
|
|
$row['cells'] = $this->sortCellsForTenants($row['cells'], $tenantSummaries);
|
|
}
|
|
unset($row);
|
|
|
|
$bundle['tenantSummaries'] = $tenantSummaries;
|
|
$bundle['subjectSummaries'] = array_map(
|
|
static fn (array $row): array => $row['subject'],
|
|
$rows,
|
|
);
|
|
$bundle['rows'] = $rows;
|
|
$bundle['denseRows'] = $rows;
|
|
$bundle['compactResults'] = $this->compactResults($rows, $tenantSummaries);
|
|
$bundle['emptyState'] = $this->emptyState(
|
|
reference: $reference,
|
|
snapshotItemsCount: $snapshotItems->count(),
|
|
visibleTenantsCount: $visibleTenants->count(),
|
|
renderedRowsCount: count($rows),
|
|
);
|
|
$bundle['hasActiveRuns'] = collect($tenantSummaries)
|
|
->contains(static fn (array $summary): bool => in_array((string) ($summary['compareRunStatus'] ?? ''), [
|
|
OperationRunStatus::Queued->value,
|
|
OperationRunStatus::Running->value,
|
|
], true));
|
|
$bundle['supportSurfaceState']['showAutoRefreshHint'] = $bundle['hasActiveRuns'];
|
|
|
|
return $bundle;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array{
|
|
* policyTypes: list<string>,
|
|
* states: list<string>,
|
|
* severities: list<string>,
|
|
* tenantSort: string,
|
|
* subjectSort: string,
|
|
* focusedSubjectKey: ?string
|
|
* }
|
|
*/
|
|
private function normalizeFilters(array $filters): array
|
|
{
|
|
$policyTypes = $this->normalizeStringList($filters['policy_type'] ?? $filters['policyTypes'] ?? []);
|
|
$states = array_values(array_intersect(
|
|
$this->normalizeStringList($filters['state'] ?? $filters['states'] ?? []),
|
|
['match', 'differ', 'missing', 'ambiguous', 'not_compared', 'stale_result'],
|
|
));
|
|
$severities = array_values(array_intersect(
|
|
$this->normalizeStringList($filters['severity'] ?? $filters['severities'] ?? []),
|
|
[Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL],
|
|
));
|
|
$tenantSort = in_array((string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name'), [
|
|
'tenant_name',
|
|
'deviation_count',
|
|
'freshness_urgency',
|
|
], true)
|
|
? (string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name')
|
|
: 'tenant_name';
|
|
$subjectSort = in_array((string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth'), [
|
|
'deviation_breadth',
|
|
'policy_type',
|
|
'display_name',
|
|
], true)
|
|
? (string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth')
|
|
: 'deviation_breadth';
|
|
$focusedSubjectKey = $filters['subject_key'] ?? $filters['focusedSubjectKey'] ?? null;
|
|
$focusedSubjectKey = is_string($focusedSubjectKey) && trim($focusedSubjectKey) !== ''
|
|
? trim($focusedSubjectKey)
|
|
: null;
|
|
|
|
return [
|
|
'policyTypes' => $policyTypes,
|
|
'states' => $states,
|
|
'severities' => $severities,
|
|
'tenantSort' => $tenantSort,
|
|
'subjectSort' => $subjectSort,
|
|
'focusedSubjectKey' => $focusedSubjectKey,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function normalizeStringList(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))));
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BaselineTenantAssignment> $assignments
|
|
* @return Collection<int, Tenant>
|
|
*/
|
|
private function visibleTenants(Collection $assignments, User $user): Collection
|
|
{
|
|
return $assignments
|
|
->map(static fn (BaselineTenantAssignment $assignment): ?Tenant => $assignment->tenant)
|
|
->filter(fn (?Tenant $tenant): bool => $tenant instanceof Tenant
|
|
&& $this->capabilityResolver->isMember($user, $tenant)
|
|
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
|
->sortBy(static fn (Tenant $tenant): string => Str::lower((string) $tenant->name))
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $resolution
|
|
*/
|
|
private function resolvedSnapshot(array $resolution): ?BaselineSnapshot
|
|
{
|
|
$snapshot = $resolution['effective_snapshot'] ?? $resolution['snapshot'] ?? null;
|
|
|
|
return $snapshot instanceof BaselineSnapshot ? $snapshot : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $tenantIds
|
|
* @param Collection<int, OperationRun> $completedRuns
|
|
* @return array<string, Finding>
|
|
*/
|
|
private function findingMap(BaselineProfile $profile, array $tenantIds, Collection $completedRuns): array
|
|
{
|
|
$findings = Finding::query()
|
|
->baselineCompareForProfile($profile)
|
|
->whereIn('tenant_id', $tenantIds)
|
|
->orderByDesc('last_seen_at')
|
|
->orderByDesc('id')
|
|
->get();
|
|
|
|
$map = [];
|
|
|
|
foreach ($findings as $finding) {
|
|
if (! $finding instanceof Finding) {
|
|
continue;
|
|
}
|
|
|
|
$tenantId = (int) $finding->tenant_id;
|
|
$subjectKey = $this->subjectKeyForFinding($finding);
|
|
|
|
if ($subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$completedRun = $completedRuns->get($tenantId);
|
|
|
|
if (
|
|
$completedRun instanceof OperationRun
|
|
&& (int) ($finding->current_operation_run_id ?? 0) !== (int) $completedRun->getKey()
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cellKey = $this->cellKey($tenantId, $subjectKey);
|
|
|
|
if (! array_key_exists($cellKey, $map)) {
|
|
$map[$cellKey] = $finding;
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
private function subjectKeyForFinding(Finding $finding): ?string
|
|
{
|
|
$subjectKey = data_get($finding->evidence_jsonb, 'subject_key');
|
|
|
|
if (is_string($subjectKey) && trim($subjectKey) !== '') {
|
|
return trim($subjectKey);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function cellKey(int $tenantId, string $subjectKey): string
|
|
{
|
|
return $tenantId.'|'.trim(mb_strtolower($subjectKey));
|
|
}
|
|
|
|
private function subjectDisplayName(BaselineSnapshotItem $item): ?string
|
|
{
|
|
$displayName = data_get($item->meta_jsonb, 'display_name');
|
|
|
|
if (is_string($displayName) && trim($displayName) !== '') {
|
|
return trim($displayName);
|
|
}
|
|
|
|
return is_string($item->subject_key) && trim($item->subject_key) !== ''
|
|
? Str::headline($item->subject_key)
|
|
: null;
|
|
}
|
|
|
|
private function cellFor(
|
|
BaselineSnapshotItem $item,
|
|
Tenant $tenant,
|
|
BaselineSnapshot $referenceSnapshot,
|
|
?OperationRun $latestRun,
|
|
?OperationRun $completedRun,
|
|
?Finding $finding,
|
|
): array {
|
|
$subjectKey = (string) $item->subject_key;
|
|
$policyType = (string) $item->policy_type;
|
|
$completedAt = $completedRun?->finished_at;
|
|
$policyTypeCovered = $this->policyTypeCovered($completedRun, $policyType);
|
|
$subjectReasons = $completedRun instanceof OperationRun
|
|
? (BaselineCompareEvidenceGapDetails::subjectReasonsFromOperationRun($completedRun)[BaselineCompareEvidenceGapDetails::subjectCompositeKey($policyType, $subjectKey)] ?? [])
|
|
: [];
|
|
$reasonCode = $subjectReasons[0] ?? $this->runReasonCode($completedRun);
|
|
$changeType = is_string(data_get($finding?->evidence_jsonb, 'change_type')) ? (string) data_get($finding?->evidence_jsonb, 'change_type') : null;
|
|
$staleResult = $this->isStaleResult($completedRun, $referenceSnapshot);
|
|
$tenantTrustLevel = $this->tenantTrustLevel($completedRun);
|
|
|
|
$state = match (true) {
|
|
! $completedRun instanceof OperationRun => 'not_compared',
|
|
(string) $completedRun->outcome === OperationRunOutcome::Failed->value => 'not_compared',
|
|
! $policyTypeCovered => 'not_compared',
|
|
$staleResult => 'stale_result',
|
|
$subjectReasons !== [] => 'ambiguous',
|
|
$changeType === 'missing_policy' => 'missing',
|
|
$finding instanceof Finding => 'differ',
|
|
default => 'match',
|
|
};
|
|
|
|
$trustLevel = match ($state) {
|
|
'not_compared' => 'unusable',
|
|
'stale_result' => 'limited_confidence',
|
|
'ambiguous' => 'diagnostic_only',
|
|
default => $tenantTrustLevel,
|
|
};
|
|
|
|
return [
|
|
'tenantId' => (int) $tenant->getKey(),
|
|
'subjectKey' => $subjectKey,
|
|
'state' => $state,
|
|
'severity' => $finding instanceof Finding ? (string) $finding->severity : null,
|
|
'trustLevel' => $trustLevel,
|
|
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
|
|
'attentionLevel' => $this->attentionLevel($state, $finding instanceof Finding ? (string) $finding->severity : null),
|
|
'reasonSummary' => $this->reasonSummary($state, $reasonCode, $policyTypeCovered),
|
|
'reasonCode' => $reasonCode,
|
|
'compareRunId' => $completedRun?->getKey(),
|
|
'findingId' => $finding?->getKey(),
|
|
'findingWorkflowState' => $finding instanceof Finding ? (string) $finding->status : null,
|
|
'lastComparedAt' => $completedAt?->toIso8601String(),
|
|
'policyTypeCovered' => $policyTypeCovered,
|
|
'latestRunId' => $latestRun?->getKey(),
|
|
'latestRunStatus' => $latestRun?->status,
|
|
];
|
|
}
|
|
|
|
private function policyTypeCovered(?OperationRun $run, string $policyType): bool
|
|
{
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
$coverage = data_get($run->context, 'baseline_compare.coverage');
|
|
|
|
if (! is_array($coverage)) {
|
|
return true;
|
|
}
|
|
|
|
$coveredTypes = is_array($coverage['covered_types'] ?? null)
|
|
? array_values(array_filter($coverage['covered_types'], 'is_string'))
|
|
: [];
|
|
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null)
|
|
? array_values(array_filter($coverage['uncovered_types'], 'is_string'))
|
|
: [];
|
|
|
|
if (in_array($policyType, $uncoveredTypes, true)) {
|
|
return false;
|
|
}
|
|
|
|
if ($coveredTypes === []) {
|
|
return true;
|
|
}
|
|
|
|
return in_array($policyType, $coveredTypes, true);
|
|
}
|
|
|
|
private function runReasonCode(?OperationRun $run): ?string
|
|
{
|
|
$reasonCode = data_get($run?->context, 'baseline_compare.reason_code');
|
|
|
|
return is_string($reasonCode) && trim($reasonCode) !== ''
|
|
? trim($reasonCode)
|
|
: null;
|
|
}
|
|
|
|
private function attentionLevel(string $state, ?string $severity): string
|
|
{
|
|
if (in_array($state, ['differ', 'missing', 'ambiguous'], true) || in_array($severity, [
|
|
Finding::SEVERITY_HIGH,
|
|
Finding::SEVERITY_CRITICAL,
|
|
], true)) {
|
|
return 'needs_attention';
|
|
}
|
|
|
|
if (in_array($state, ['stale_result', 'not_compared'], true)) {
|
|
return 'refresh_recommended';
|
|
}
|
|
|
|
if ($state === 'match') {
|
|
return 'aligned';
|
|
}
|
|
|
|
return 'review';
|
|
}
|
|
|
|
private function reasonSummary(string $state, ?string $reasonCode, bool $policyTypeCovered): ?string
|
|
{
|
|
return match ($state) {
|
|
'differ' => 'A baseline compare finding exists for this subject.',
|
|
'missing' => 'The reference subject is missing from the tenant result.',
|
|
'ambiguous' => $reasonCode !== null
|
|
? Str::headline(str_replace(['.', '_'], ' ', $reasonCode))
|
|
: 'Identity or evidence stayed ambiguous.',
|
|
'stale_result' => 'Refresh recommended before acting on this result.',
|
|
'not_compared' => $policyTypeCovered
|
|
? 'No completed compare result is available yet.'
|
|
: 'Policy type coverage was not proven in the latest compare run.',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool
|
|
{
|
|
if (! $run instanceof OperationRun || ! $run->finished_at) {
|
|
return false;
|
|
}
|
|
|
|
$runSnapshotId = data_get($run->context, 'baseline_snapshot_id');
|
|
|
|
if (is_numeric($runSnapshotId) && (int) $runSnapshotId !== (int) $referenceSnapshot->getKey()) {
|
|
return true;
|
|
}
|
|
|
|
if ($referenceSnapshot->captured_at && $run->finished_at->lt($referenceSnapshot->captured_at)) {
|
|
return true;
|
|
}
|
|
|
|
return BaselineCompareSummaryAssessor::isStaleComparedAt($run->finished_at);
|
|
}
|
|
|
|
private function tenantTrustLevel(?OperationRun $run): string
|
|
{
|
|
return BadgeCatalog::normalizeState(
|
|
$this->explanationRegistry->trustLevelForRun($run),
|
|
) ?? 'unusable';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $subject
|
|
* @param list<array<string, mixed>> $cells
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
private function rowMatchesFilters(array $subject, array $cells, array $filters): bool
|
|
{
|
|
if ($filters['policyTypes'] !== [] && ! in_array((string) $subject['policyType'], $filters['policyTypes'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if ($filters['focusedSubjectKey'] !== null && (string) $subject['subjectKey'] !== $filters['focusedSubjectKey']) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($cells as $cell) {
|
|
if ($filters['states'] !== [] && ! in_array((string) ($cell['state'] ?? ''), $filters['states'], true)) {
|
|
continue;
|
|
}
|
|
|
|
if ($filters['severities'] !== [] && ! in_array((string) ($cell['severity'] ?? ''), $filters['severities'], true)) {
|
|
continue;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return $filters['states'] === [] && $filters['severities'] === [];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $cells
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function subjectSummary(array $subject, array $cells): array
|
|
{
|
|
return [
|
|
'subjectKey' => $subject['subjectKey'],
|
|
'policyType' => $subject['policyType'],
|
|
'displayName' => $subject['displayName'],
|
|
'baselineExternalId' => $subject['baselineExternalId'],
|
|
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
|
|
'missingBreadth' => $this->countStates($cells, ['missing']),
|
|
'ambiguousBreadth' => $this->countStates($cells, ['ambiguous']),
|
|
'notComparedBreadth' => $this->countStates($cells, ['not_compared']),
|
|
'maxSeverity' => $this->maxSeverity($cells),
|
|
'trustLevel' => $this->worstTrustLevel($cells),
|
|
'attentionLevel' => $this->worstAttentionLevel($cells),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, Tenant> $visibleTenants
|
|
* @param Collection<int, OperationRun> $latestRuns
|
|
* @param Collection<int, OperationRun> $completedRuns
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function tenantSummaries(
|
|
Collection $visibleTenants,
|
|
Collection $latestRuns,
|
|
Collection $completedRuns,
|
|
array $rows,
|
|
BaselineSnapshot $referenceSnapshot,
|
|
): array {
|
|
$summaries = [];
|
|
|
|
foreach ($visibleTenants as $tenant) {
|
|
$tenantId = (int) $tenant->getKey();
|
|
$latestRun = $latestRuns->get($tenantId);
|
|
$completedRun = $completedRuns->get($tenantId);
|
|
$cells = array_map(
|
|
static fn (array $row): array => collect($row['cells'])->firstWhere('tenantId', $tenantId) ?? [],
|
|
$rows,
|
|
);
|
|
|
|
$summaries[] = [
|
|
'tenantId' => $tenantId,
|
|
'tenantName' => (string) $tenant->name,
|
|
'compareRunId' => $latestRun?->getKey(),
|
|
'compareRunStatus' => $latestRun?->status,
|
|
'compareRunOutcome' => $latestRun?->outcome,
|
|
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
|
|
'lastComparedAt' => $completedRun?->finished_at?->toIso8601String(),
|
|
'matchedCount' => $this->countStates($cells, ['match']),
|
|
'differingCount' => $this->countStates($cells, ['differ']),
|
|
'missingCount' => $this->countStates($cells, ['missing']),
|
|
'ambiguousCount' => $this->countStates($cells, ['ambiguous']),
|
|
'notComparedCount' => $this->countStates($cells, ['not_compared']),
|
|
'maxSeverity' => $this->maxSeverity($cells),
|
|
'trustLevel' => $this->worstTrustLevel($cells),
|
|
];
|
|
}
|
|
|
|
return $summaries;
|
|
}
|
|
|
|
private function freshnessState(?OperationRun $completedRun, BaselineSnapshot $referenceSnapshot): string
|
|
{
|
|
if (! $completedRun instanceof OperationRun) {
|
|
return 'never_compared';
|
|
}
|
|
|
|
if ((string) $completedRun->outcome === OperationRunOutcome::Failed->value) {
|
|
return 'unknown';
|
|
}
|
|
|
|
if ($this->isStaleResult($completedRun, $referenceSnapshot)) {
|
|
return 'stale';
|
|
}
|
|
|
|
return 'fresh';
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $cells
|
|
* @param array<int, string> $states
|
|
*/
|
|
private function countStates(array $cells, array $states): int
|
|
{
|
|
return count(array_filter(
|
|
$cells,
|
|
static fn (array $cell): bool => in_array((string) ($cell['state'] ?? ''), $states, true),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $cells
|
|
*/
|
|
private function maxSeverity(array $cells): ?string
|
|
{
|
|
$ranked = collect($cells)
|
|
->map(static fn (array $cell): ?string => is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null)
|
|
->filter()
|
|
->sortByDesc(fn (string $severity): int => $this->severityRank($severity))
|
|
->values();
|
|
|
|
return $ranked->first();
|
|
}
|
|
|
|
private function severityRank(string $severity): int
|
|
{
|
|
return match ($severity) {
|
|
Finding::SEVERITY_CRITICAL => 4,
|
|
Finding::SEVERITY_HIGH => 3,
|
|
Finding::SEVERITY_MEDIUM => 2,
|
|
Finding::SEVERITY_LOW => 1,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $cells
|
|
*/
|
|
private function worstTrustLevel(array $cells): string
|
|
{
|
|
return collect($cells)
|
|
->map(static fn (array $cell): string => (string) ($cell['trustLevel'] ?? 'unusable'))
|
|
->sortByDesc(fn (string $trust): int => $this->trustRank($trust))
|
|
->first() ?? 'unusable';
|
|
}
|
|
|
|
private function trustRank(string $trustLevel): int
|
|
{
|
|
return match ($trustLevel) {
|
|
'unusable' => 4,
|
|
'diagnostic_only' => 3,
|
|
'limited_confidence' => 2,
|
|
'trustworthy' => 1,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $cells
|
|
*/
|
|
private function worstAttentionLevel(array $cells): string
|
|
{
|
|
return collect($cells)
|
|
->map(static fn (array $cell): string => (string) ($cell['attentionLevel'] ?? 'review'))
|
|
->sortByDesc(fn (string $level): int => $this->attentionRank($level))
|
|
->first() ?? 'review';
|
|
}
|
|
|
|
private function attentionRank(string $attentionLevel): int
|
|
{
|
|
return match ($attentionLevel) {
|
|
'needs_attention' => 4,
|
|
'refresh_recommended' => 3,
|
|
'review' => 2,
|
|
'aligned' => 1,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function sortRows(array $rows, string $sort): array
|
|
{
|
|
usort($rows, function (array $left, array $right) use ($sort): int {
|
|
$leftSubject = $left['subject'] ?? [];
|
|
$rightSubject = $right['subject'] ?? [];
|
|
|
|
return match ($sort) {
|
|
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))]
|
|
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))],
|
|
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
|
|
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
|
|
default => [
|
|
-1 * (int) ($leftSubject['deviationBreadth'] ?? 0),
|
|
-1 * (int) ($leftSubject['ambiguousBreadth'] ?? 0),
|
|
Str::lower((string) ($leftSubject['displayName'] ?? '')),
|
|
] <=> [
|
|
-1 * (int) ($rightSubject['deviationBreadth'] ?? 0),
|
|
-1 * (int) ($rightSubject['ambiguousBreadth'] ?? 0),
|
|
Str::lower((string) ($rightSubject['displayName'] ?? '')),
|
|
],
|
|
};
|
|
});
|
|
|
|
return array_values($rows);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $tenantSummaries
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function sortTenantSummaries(array $tenantSummaries, string $sort): array
|
|
{
|
|
usort($tenantSummaries, function (array $left, array $right) use ($sort): int {
|
|
return match ($sort) {
|
|
'deviation_count' => [
|
|
-1 * ((int) ($left['differingCount'] ?? 0) + (int) ($left['missingCount'] ?? 0) + (int) ($left['ambiguousCount'] ?? 0)),
|
|
Str::lower((string) ($left['tenantName'] ?? '')),
|
|
] <=> [
|
|
-1 * ((int) ($right['differingCount'] ?? 0) + (int) ($right['missingCount'] ?? 0) + (int) ($right['ambiguousCount'] ?? 0)),
|
|
Str::lower((string) ($right['tenantName'] ?? '')),
|
|
],
|
|
'freshness_urgency' => [
|
|
-1 * $this->freshnessRank((string) ($left['freshnessState'] ?? 'unknown')),
|
|
Str::lower((string) ($left['tenantName'] ?? '')),
|
|
] <=> [
|
|
-1 * $this->freshnessRank((string) ($right['freshnessState'] ?? 'unknown')),
|
|
Str::lower((string) ($right['tenantName'] ?? '')),
|
|
],
|
|
default => Str::lower((string) ($left['tenantName'] ?? '')) <=> Str::lower((string) ($right['tenantName'] ?? '')),
|
|
};
|
|
});
|
|
|
|
return array_values($tenantSummaries);
|
|
}
|
|
|
|
private function freshnessRank(string $freshnessState): int
|
|
{
|
|
return match ($freshnessState) {
|
|
'stale' => 4,
|
|
'unknown' => 3,
|
|
'never_compared' => 2,
|
|
'fresh' => 1,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $cells
|
|
* @param list<array<string, mixed>> $tenantSummaries
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function sortCellsForTenants(array $cells, array $tenantSummaries): array
|
|
{
|
|
$order = collect($tenantSummaries)
|
|
->values()
|
|
->mapWithKeys(static fn (array $summary, int $index): array => [
|
|
(int) ($summary['tenantId'] ?? 0) => $index,
|
|
]);
|
|
|
|
usort($cells, static fn (array $left, array $right): int => ($order[(int) ($left['tenantId'] ?? 0)] ?? 9999) <=> ($order[(int) ($right['tenantId'] ?? 0)] ?? 9999));
|
|
|
|
return array_values($cells);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @param list<array<string, mixed>> $tenantSummaries
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function compactResults(array $rows, array $tenantSummaries): array
|
|
{
|
|
if (count($tenantSummaries) !== 1) {
|
|
return [];
|
|
}
|
|
|
|
$tenantSummary = $tenantSummaries[0];
|
|
$tenantId = (int) ($tenantSummary['tenantId'] ?? 0);
|
|
|
|
if ($tenantId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_map(function (array $row) use ($tenantId, $tenantSummary): array {
|
|
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
|
$cell = collect($row['cells'] ?? [])->firstWhere('tenantId', $tenantId) ?? [];
|
|
|
|
return [
|
|
'tenantId' => $tenantId,
|
|
'tenantName' => (string) ($tenantSummary['tenantName'] ?? 'Tenant'),
|
|
'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
|
|
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
|
|
'policyType' => (string) ($subject['policyType'] ?? ''),
|
|
'baselineExternalId' => $subject['baselineExternalId'] ?? null,
|
|
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
|
|
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
|
|
'ambiguousBreadth' => (int) ($subject['ambiguousBreadth'] ?? 0),
|
|
'state' => (string) ($cell['state'] ?? 'not_compared'),
|
|
'freshnessState' => (string) ($cell['freshnessState'] ?? 'unknown'),
|
|
'trustLevel' => (string) ($cell['trustLevel'] ?? 'unusable'),
|
|
'attentionLevel' => (string) ($cell['attentionLevel'] ?? 'review'),
|
|
'severity' => $cell['severity'] ?? null,
|
|
'reasonSummary' => $cell['reasonSummary'] ?? null,
|
|
'reasonCode' => $cell['reasonCode'] ?? null,
|
|
'compareRunId' => $cell['compareRunId'] ?? null,
|
|
'findingId' => $cell['findingId'] ?? null,
|
|
'lastComparedAt' => $cell['lastComparedAt'] ?? null,
|
|
'policyTypeCovered' => $cell['policyTypeCovered'] ?? true,
|
|
];
|
|
}, $rows));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $reference
|
|
* @return array{title: string, body: string}|null
|
|
*/
|
|
private function emptyState(
|
|
array $reference,
|
|
int $snapshotItemsCount,
|
|
int $visibleTenantsCount,
|
|
int $renderedRowsCount = 0,
|
|
): ?array {
|
|
if (($reference['referenceState'] ?? null) !== 'ready') {
|
|
return [
|
|
'title' => 'No usable reference snapshot',
|
|
'body' => 'Capture a complete baseline snapshot before using the compare matrix.',
|
|
];
|
|
}
|
|
|
|
if ((int) ($reference['assignedTenantCount'] ?? 0) === 0) {
|
|
return [
|
|
'title' => 'No assigned tenants',
|
|
'body' => 'Assign tenants to this baseline profile to build the visible compare set.',
|
|
];
|
|
}
|
|
|
|
if ($visibleTenantsCount === 0) {
|
|
return [
|
|
'title' => 'No visible assigned tenants',
|
|
'body' => 'This baseline has assigned tenants, but none are visible in your current tenant scope.',
|
|
];
|
|
}
|
|
|
|
if ($snapshotItemsCount === 0) {
|
|
return [
|
|
'title' => 'No baseline subjects',
|
|
'body' => 'The active reference snapshot completed without any baseline subjects to compare.',
|
|
];
|
|
}
|
|
|
|
if ($renderedRowsCount === 0) {
|
|
return [
|
|
'title' => 'No rows match the current filters',
|
|
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $values
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function legendSpecs(BadgeDomain $domain, array $values): array
|
|
{
|
|
return array_map(
|
|
static function (string $value) use ($domain): array {
|
|
$spec = BadgeCatalog::spec($domain, $value);
|
|
|
|
return [
|
|
'value' => $value,
|
|
'label' => $spec->label,
|
|
'color' => $spec->color,
|
|
'icon' => $spec->icon,
|
|
'iconColor' => $spec->iconColor,
|
|
];
|
|
},
|
|
$values,
|
|
);
|
|
}
|
|
}
|