## Summary - add a workspace-scoped baseline compare matrix page under baseline profiles - derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings - add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run - preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics - include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190 ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #221
539 lines
34 KiB
PHP
539 lines
34 KiB
PHP
<x-filament::page>
|
|
@if (($hasActiveRuns ?? false) === true)
|
|
<div wire:poll.5s="refreshMatrix"></div>
|
|
@endif
|
|
|
|
@php
|
|
$reference = is_array($reference ?? null) ? $reference : [];
|
|
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
|
|
$rows = is_array($rows ?? null) ? $rows : [];
|
|
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
|
|
$stateOptions = is_array($stateOptions ?? null) ? $stateOptions : [];
|
|
$severityOptions = is_array($severityOptions ?? null) ? $severityOptions : [];
|
|
$tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : [];
|
|
$subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : [];
|
|
$stateLegend = is_array($stateLegend ?? null) ? $stateLegend : [];
|
|
$freshnessLegend = is_array($freshnessLegend ?? null) ? $freshnessLegend : [];
|
|
$trustLegend = is_array($trustLegend ?? null) ? $trustLegend : [];
|
|
$emptyState = is_array($emptyState ?? null) ? $emptyState : null;
|
|
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
|
|
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
|
|
$matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null;
|
|
|
|
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
|
|
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
|
|
$trustBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixTrust, $value);
|
|
$severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value);
|
|
$profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value);
|
|
$profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null);
|
|
@endphp
|
|
|
|
<x-filament::section heading="Reference overview">
|
|
<x-slot name="description">
|
|
Compare assigned tenants is simulation only. It reuses the existing tenant-owned baseline compare path for the visible assigned set and does not create a workspace umbrella run.
|
|
</x-slot>
|
|
|
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
|
|
<div class="space-y-4">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<x-filament::badge :color="$profileStatusSpec->color" :icon="$profileStatusSpec->icon" size="sm">
|
|
{{ $profileStatusSpec->label }}
|
|
</x-filament::badge>
|
|
|
|
@if ($referenceReady)
|
|
<x-filament::badge color="success" icon="heroicon-m-check-badge" size="sm">
|
|
Reference snapshot ready
|
|
</x-filament::badge>
|
|
@else
|
|
<x-filament::badge color="warning" icon="heroicon-m-exclamation-triangle" size="sm">
|
|
Reference snapshot blocked
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if (filled($reference['referenceSnapshotId'] ?? null))
|
|
<x-filament::badge color="gray" size="sm">
|
|
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<h2 class="text-xl font-semibold text-gray-950 dark:text-white" data-testid="baseline-compare-matrix-profile">
|
|
{{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }}
|
|
</h2>
|
|
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}.
|
|
Visible tenants: {{ (int) ($reference['visibleTenantCount'] ?? 0) }}.
|
|
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
|
|
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
|
|
@endif
|
|
</p>
|
|
|
|
@if (filled($reference['referenceReasonCode'] ?? null))
|
|
<p class="text-sm text-warning-700 dark:text-warning-300">
|
|
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<dl class="grid gap-3 sm:grid-cols-2">
|
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tenants</dt>
|
|
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
|
|
{{ (int) ($reference['visibleTenantCount'] ?? 0) }}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Rendered subjects</dt>
|
|
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
|
|
{{ count($rows) }}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant sort</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Subject sort</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<x-filament::section heading="Filters">
|
|
<x-slot name="description">
|
|
Narrow the matrix by policy type, technical state, severity, or one focused subject. Only the visible tenant set contributes to the rendered counts, rows, and drilldowns.
|
|
</x-slot>
|
|
|
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]" data-testid="baseline-compare-matrix-filters">
|
|
<div class="grid gap-6 lg:grid-cols-3">
|
|
<div class="space-y-3">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Policy types</div>
|
|
|
|
@if ($policyTypeOptions !== [])
|
|
<div class="space-y-2">
|
|
@foreach ($policyTypeOptions as $value => $label)
|
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
<input
|
|
type="checkbox"
|
|
value="{{ $value }}"
|
|
wire:model.live="selectedPolicyTypes"
|
|
data-testid="matrix-filter-policy-type-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
|
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
|
/>
|
|
<span>{{ $label }}</span>
|
|
</label>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Policy type filters appear after a usable reference snapshot is available.</p>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Technical states</div>
|
|
|
|
<div class="space-y-2">
|
|
@foreach ($stateOptions as $value => $label)
|
|
@php
|
|
$spec = $stateBadge($value);
|
|
@endphp
|
|
|
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
<input
|
|
type="checkbox"
|
|
value="{{ $value }}"
|
|
wire:model.live="selectedStates"
|
|
data-testid="matrix-filter-state-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
|
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
|
/>
|
|
<span class="flex flex-wrap items-center gap-2">
|
|
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
|
{{ $label }}
|
|
</x-filament::badge>
|
|
</span>
|
|
</label>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Severity</div>
|
|
|
|
<div class="space-y-2">
|
|
@foreach ($severityOptions as $value => $label)
|
|
@php
|
|
$spec = $severityBadge($value);
|
|
@endphp
|
|
|
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
<input
|
|
type="checkbox"
|
|
value="{{ $value }}"
|
|
wire:model.live="selectedSeverities"
|
|
data-testid="matrix-filter-severity-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
|
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
|
/>
|
|
<span class="flex flex-wrap items-center gap-2">
|
|
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
|
{{ $label }}
|
|
</x-filament::badge>
|
|
</span>
|
|
</label>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
|
<label class="space-y-2 text-sm">
|
|
<span class="font-semibold text-gray-950 dark:text-white">Tenant sort</span>
|
|
<select wire:model.live="tenantSort" data-testid="matrix-tenant-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
@foreach ($tenantSortOptions as $value => $label)
|
|
<option value="{{ $value }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</label>
|
|
|
|
<label class="space-y-2 text-sm">
|
|
<span class="font-semibold text-gray-950 dark:text-white">Subject sort</span>
|
|
<select wire:model.live="subjectSort" data-testid="matrix-subject-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
@foreach ($subjectSortOptions as $value => $label)
|
|
<option value="{{ $value }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Focused subject</div>
|
|
|
|
@if (filled($currentFilters['subject_key'] ?? null))
|
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
|
|
{{ $currentFilters['subject_key'] }}
|
|
</x-filament::badge>
|
|
|
|
<x-filament::link :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
|
|
Clear subject focus
|
|
</x-filament::link>
|
|
</div>
|
|
@else
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Focus a single row from the matrix when you want a subject-first drilldown.</p>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<x-filament::link :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm">
|
|
Clear all filters
|
|
</x-filament::link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<div class="grid gap-4 xl:grid-cols-3">
|
|
<x-filament::section heading="State legend">
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach ($stateLegend as $item)
|
|
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
|
{{ $item['label'] }}
|
|
</x-filament::badge>
|
|
@endforeach
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<x-filament::section heading="Freshness legend">
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach ($freshnessLegend as $item)
|
|
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
|
{{ $item['label'] }}
|
|
</x-filament::badge>
|
|
@endforeach
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<x-filament::section heading="Trust legend">
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach ($trustLegend as $item)
|
|
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
|
{{ $item['label'] }}
|
|
</x-filament::badge>
|
|
@endforeach
|
|
</div>
|
|
</x-filament::section>
|
|
</div>
|
|
|
|
@if ($emptyState !== null)
|
|
<x-filament::section>
|
|
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-8 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div class="space-y-2" data-testid="baseline-compare-matrix-empty-state">
|
|
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] ?? 'Nothing to show' }}</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}</p>
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@else
|
|
<x-filament::section heading="Tenant summaries">
|
|
<x-slot name="description">
|
|
Tenant-level freshness, trust, and breadth stay visible before you scan the subject-by-tenant body.
|
|
</x-slot>
|
|
|
|
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
|
@foreach ($tenantSummaries as $tenantSummary)
|
|
@php
|
|
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
|
$trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null);
|
|
$tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null;
|
|
$tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']);
|
|
$tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null)
|
|
? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId'])
|
|
: null;
|
|
@endphp
|
|
|
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<h3 class="text-base font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</h3>
|
|
<div class="flex flex-wrap gap-2">
|
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
|
{{ $freshnessSpec->label }}
|
|
</x-filament::badge>
|
|
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
{{ $trustSpec->label }}
|
|
</x-filament::badge>
|
|
@if ($tenantSeveritySpec)
|
|
<x-filament::badge :color="$tenantSeveritySpec->color" :icon="$tenantSeveritySpec->icon" size="sm">
|
|
{{ $tenantSeveritySpec->label }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-right text-xs text-gray-500 dark:text-gray-400">
|
|
@if (filled($tenantSummary['lastComparedAt'] ?? null))
|
|
Compared {{ \Illuminate\Support\Carbon::parse($tenantSummary['lastComparedAt'])->diffForHumans() }}
|
|
@else
|
|
No completed compare yet
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<dt class="text-gray-500 dark:text-gray-400">Aligned</dt>
|
|
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-gray-500 dark:text-gray-400">Drift</dt>
|
|
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-gray-500 dark:text-gray-400">Missing</dt>
|
|
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-gray-500 dark:text-gray-400">Ambiguous / not compared</dt>
|
|
<dd class="font-semibold text-gray-950 dark:text-white">
|
|
{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) + (int) ($tenantSummary['notComparedCount'] ?? 0) }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="mt-4 flex flex-wrap items-center gap-3 text-sm">
|
|
@if ($tenantCompareUrl)
|
|
<x-filament::link :href="$tenantCompareUrl" size="sm">
|
|
Open tenant compare
|
|
</x-filament::link>
|
|
@endif
|
|
|
|
@if ($tenantRunUrl)
|
|
<x-filament::link :href="$tenantRunUrl" color="gray" size="sm">
|
|
Open latest run
|
|
</x-filament::link>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<x-filament::section heading="Subject-by-tenant matrix">
|
|
<x-slot name="description">
|
|
Row click is intentionally disabled. Use the explicit subject, tenant, finding, and run links inside the matrix cells.
|
|
</x-slot>
|
|
|
|
<div class="overflow-x-auto" data-testid="baseline-compare-matrix-grid">
|
|
<div class="min-w-[72rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
|
<thead class="bg-gray-50 dark:bg-gray-950/70">
|
|
<tr>
|
|
<th class="w-80 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
Baseline subject
|
|
</th>
|
|
|
|
@foreach ($tenantSummaries as $tenantSummary)
|
|
@php
|
|
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
|
@endphp
|
|
<th class="min-w-64 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
<div class="space-y-2">
|
|
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
|
{{ $freshnessSpec->label }}
|
|
</x-filament::badge>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900/60">
|
|
@foreach ($rows as $row)
|
|
@php
|
|
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
|
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
|
|
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
|
|
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
|
|
@endphp
|
|
|
|
<tr data-testid="baseline-compare-matrix-row">
|
|
<td class="px-4 py-4 align-top">
|
|
<div class="space-y-3">
|
|
<div class="space-y-1">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ $subject['policyType'] ?? 'Unknown policy type' }}
|
|
</div>
|
|
@if (filled($subject['baselineExternalId'] ?? null))
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
Reference ID: {{ $subject['baselineExternalId'] }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<x-filament::badge color="gray" size="sm">
|
|
Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge color="gray" size="sm">
|
|
Missing {{ (int) ($subject['missingBreadth'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge color="gray" size="sm">
|
|
Ambiguous {{ (int) ($subject['ambiguousBreadth'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge :color="$subjectTrustSpec->color" :icon="$subjectTrustSpec->icon" size="sm">
|
|
{{ $subjectTrustSpec->label }}
|
|
</x-filament::badge>
|
|
@if ($subjectSeveritySpec)
|
|
<x-filament::badge :color="$subjectSeveritySpec->color" :icon="$subjectSeveritySpec->icon" size="sm">
|
|
{{ $subjectSeveritySpec->label }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
|
|
@if (filled($subject['subjectKey'] ?? null))
|
|
<div class="flex flex-wrap gap-3 text-sm">
|
|
<x-filament::link :href="$this->filterUrl(['subject_key' => $subject['subjectKey']])" color="gray" size="sm" data-testid="matrix-focus-subject">
|
|
Focus subject
|
|
</x-filament::link>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
|
|
@foreach ($cells as $cell)
|
|
@php
|
|
$cellStateSpec = $stateBadge($cell['state'] ?? null);
|
|
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
|
|
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
|
|
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
|
|
$tenantId = (int) ($cell['tenantId'] ?? 0);
|
|
$tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null;
|
|
$cellFindingUrl = ($tenantId > 0 && filled($cell['findingId'] ?? null))
|
|
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
|
|
: null;
|
|
$cellRunUrl = filled($cell['compareRunId'] ?? null)
|
|
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
|
|
: null;
|
|
@endphp
|
|
|
|
<td class="px-4 py-4 align-top">
|
|
<div class="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950/40">
|
|
<div class="flex flex-wrap gap-2">
|
|
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
|
|
{{ $cellStateSpec->label }}
|
|
</x-filament::badge>
|
|
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
|
{{ $cellTrustSpec->label }}
|
|
</x-filament::badge>
|
|
@if ($cellSeveritySpec)
|
|
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
|
|
{{ $cellSeveritySpec->label }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
|
|
@if (filled($cell['reasonCode'] ?? null))
|
|
<div>
|
|
Reason: {{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) $cell['reasonCode'])) }}
|
|
</div>
|
|
@endif
|
|
|
|
@if (filled($cell['lastComparedAt'] ?? null))
|
|
<div>
|
|
Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}
|
|
</div>
|
|
@endif
|
|
|
|
@if (($cell['policyTypeCovered'] ?? true) === false)
|
|
<div>Policy type coverage was not proven in the latest compare run.</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-3 text-sm">
|
|
@if ($cellFindingUrl)
|
|
<x-filament::link :href="$cellFindingUrl" size="sm">
|
|
Open finding
|
|
</x-filament::link>
|
|
@elseif ($tenantCompareUrl)
|
|
<x-filament::link :href="$tenantCompareUrl" size="sm">
|
|
Open tenant compare
|
|
</x-filament::link>
|
|
@endif
|
|
|
|
@if ($cellRunUrl)
|
|
<x-filament::link :href="$cellRunUrl" color="gray" size="sm">
|
|
Open run
|
|
</x-filament::link>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
</x-filament::page>
|