## Summary - add the baseline compare landing experience for the environment dashboard productization flow - expand the environment dashboard overview and summary-building logic to support richer baseline comparison states and assessments - update the supporting Blade templates for the new compare and overview presentation - add English and German translations for the baseline compare surface - include the Spec 330 planning and task artifacts alongside the implementation ## Tests - touched browser, feature, and unit coverage for the new baseline compare flow - updated test files include `Spec330EnvironmentDashboardBaselineCompareSmokeTest`, `BaselineCompareLandingWhyNoFindingsTest`, `Spec330EnvironmentDashboardBaselineCompareProductizationTest`, `HeaderContextBarTest`, and `ManagedEnvironmentModelTest` - no additional test run was performed as part of this commit/push/PR workflow Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #392
823 lines
46 KiB
PHP
823 lines
46 KiB
PHP
<x-filament::page>
|
|
{{-- Auto-refresh while comparison is running --}}
|
|
@if ($state === 'comparing')
|
|
<div wire:poll.5s="refreshStats"></div>
|
|
@endif
|
|
|
|
@php
|
|
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
|
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
|
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
|
$reasonSemantics = is_array($reasonSemantics ?? null) ? $reasonSemantics : null;
|
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
|
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
|
|
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
|
|
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
|
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
|
: null;
|
|
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
|
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
|
|
: null;
|
|
$summaryLabel = match ($summary['stateFamily'] ?? null) {
|
|
'positive' => 'Aligned',
|
|
'caution' => 'Needs review',
|
|
'stale' => 'Refresh recommended',
|
|
'action_required' => 'Action required',
|
|
'in_progress' => 'In progress',
|
|
default => 'Unavailable',
|
|
};
|
|
$showCompareExplanation = $explanation !== null && $state !== 'no_assignment';
|
|
$baselineCompareStatusBadgeClasses = static fn (?string $tone): string => 'inline-flex items-center rounded-md border px-2 py-0.5 text-left text-xs font-medium leading-5 whitespace-normal break-words '.match ($tone) {
|
|
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-800 dark:bg-danger-500/10 dark:text-danger-300',
|
|
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-800 dark:bg-success-500/10 dark:text-success-300',
|
|
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-800 dark:bg-warning-500/10 dark:text-warning-300',
|
|
'info' => 'border-info-200 bg-info-50 text-info-700 dark:border-info-800 dark:bg-info-500/10 dark:text-info-300',
|
|
'primary' => 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-800 dark:bg-primary-500/10 dark:text-primary-300',
|
|
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-200',
|
|
};
|
|
@endphp
|
|
|
|
<x-filament::section>
|
|
<div data-testid="baseline-compare-decision-workbench" class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
|
<div class="min-w-0 space-y-4">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
|
{{ $decisionCard['question'] ?? 'Which baseline drift requires action?' }}
|
|
</h2>
|
|
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($decisionCard['tone'] ?? 'gray') }}">
|
|
{{ $decisionCard['status'] ?? 'Compare unavailable' }}
|
|
</span>
|
|
</div>
|
|
|
|
<dl class="grid gap-3 md:grid-cols-3">
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['statusLabel'] ?? 'Status' }}</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $decisionCard['status'] ?? 'Compare unavailable' }}</dd>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['reasonLabel'] ?? 'Reason' }}</dt>
|
|
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['reason'] ?? 'Compare state is unavailable.' }}</dd>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['impactLabel'] ?? 'Impact' }}</dt>
|
|
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['impact'] ?? 'No governance decision should rely on this compare state yet.' }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
@if (! empty($decisionSummaryItems))
|
|
<div class="grid gap-3 md:grid-cols-3">
|
|
@foreach ($decisionSummaryItems as $item)
|
|
<div data-testid="baseline-compare-decision-summary" class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $item['label'] }}</div>
|
|
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $item['value'] }}</div>
|
|
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $item['description'] }}</p>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<aside data-testid="baseline-compare-proof-panel" class="min-w-0 space-y-3">
|
|
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['evidenceLabel'] ?? 'Evidence path' }}</div>
|
|
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['evidence'] ?? 'Operation proof unavailable' }}</p>
|
|
|
|
<div class="mt-4 border-t border-gray-200 pt-4 dark:border-gray-800">
|
|
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['nextActionLabel'] ?? 'Next action' }}</div>
|
|
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $decisionCard['actionLabel'] ?? 'Review compare state' }}</div>
|
|
|
|
@if (filled($decisionCard['actionUrl'] ?? null))
|
|
<x-filament::button class="mt-3" tag="a" :href="$decisionCard['actionUrl']" size="sm">
|
|
{{ $decisionCard['actionLabel'] ?? 'Review compare state' }}
|
|
</x-filament::button>
|
|
@else
|
|
<x-filament::button class="mt-3" color="gray" size="sm" disabled>
|
|
{{ $decisionCard['actionLabel'] ?? 'Review compare state' }}
|
|
</x-filament::button>
|
|
@endif
|
|
|
|
@if (filled($decisionCard['helperText'] ?? null))
|
|
<p class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $decisionCard['helperText'] }}</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
@foreach ($proofPanelItems as $proof)
|
|
<div data-testid="baseline-compare-proof-item" data-proof-key="{{ $proof['key'] }}" class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
<div class="flex flex-col gap-2">
|
|
<div>
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $proof['label'] }}</div>
|
|
</div>
|
|
<div>
|
|
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($proof['tone'] ?? 'gray') }}">
|
|
{{ $proof['value'] }}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $proof['description'] }}</p>
|
|
</div>
|
|
|
|
@if (filled($proof['actionLabel'] ?? null) && filled($proof['actionUrl'] ?? null))
|
|
<x-filament::button class="mt-3" tag="a" :href="$proof['actionUrl']" size="sm" color="gray">
|
|
{{ $proof['actionLabel'] }}
|
|
</x-filament::button>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
|
|
<details data-testid="baseline-compare-diagnostics" class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
<summary class="cursor-pointer text-sm font-semibold text-gray-950 dark:text-white">
|
|
{{ $diagnosticsDisclosure['label'] ?? 'Diagnostics - Collapsed' }}
|
|
</summary>
|
|
<p class="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
{{ $diagnosticsDisclosure['summary'] ?? 'Support diagnostics stay closed until needed.' }}
|
|
</p>
|
|
</details>
|
|
</aside>
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
@if (! empty($compareReadinessFlow))
|
|
<x-filament::section>
|
|
<div data-testid="baseline-compare-readiness-flow" class="space-y-5">
|
|
<div class="space-y-1">
|
|
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
|
Compare readiness flow
|
|
</h2>
|
|
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
Baseline comparison needs an assigned baseline, linked snapshots, a compare run, and a decision output.
|
|
</p>
|
|
</div>
|
|
|
|
<ol class="flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-2" aria-label="Compare readiness pipeline">
|
|
@foreach ($compareReadinessFlow as $step)
|
|
@php
|
|
$isCurrentBlocker = $loop->first && ($step['state'] ?? null) === 'Missing';
|
|
$stepCardClasses = $isCurrentBlocker
|
|
? 'border-warning-300 bg-warning-50 shadow-sm dark:border-warning-700 dark:bg-warning-950/40'
|
|
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/50';
|
|
$stepNumberClasses = $isCurrentBlocker
|
|
? 'border-warning-300 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-300'
|
|
: 'border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300';
|
|
@endphp
|
|
|
|
<li
|
|
data-testid="baseline-compare-readiness-step"
|
|
data-step-label="{{ $step['label'] }}"
|
|
data-step-state="{{ $step['state'] }}"
|
|
data-step-current-blocker="{{ $isCurrentBlocker ? 'true' : 'false' }}"
|
|
class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
|
|
>
|
|
<div class="flex min-w-0 flex-1 flex-col rounded-lg border px-3 py-3 {{ $stepCardClasses }}">
|
|
<div class="flex min-w-0 items-start gap-3">
|
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none {{ $stepNumberClasses }}">
|
|
{{ $loop->iteration }}
|
|
</span>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-sm font-semibold leading-5 text-gray-950 dark:text-white">
|
|
{{ $step['label'] }}
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($step['tone'] ?? 'gray') }}">
|
|
{{ $step['state'] }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="mt-3 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
|
{{ $step['description'] }}
|
|
</p>
|
|
</div>
|
|
|
|
@if (! $loop->last)
|
|
<div
|
|
data-testid="baseline-compare-readiness-connector"
|
|
data-connector-label="{{ $step['label'] }} to {{ $compareReadinessFlow[$loop->index + 1]['label'] ?? '' }}"
|
|
class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-500 lg:w-8"
|
|
aria-hidden="true"
|
|
>
|
|
<span class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
|
→
|
|
</span>
|
|
</div>
|
|
@endif
|
|
</li>
|
|
@endforeach
|
|
</ol>
|
|
|
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
|
<div data-testid="baseline-compare-available-inputs" class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
Available inputs
|
|
</div>
|
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
|
Repo-backed inputs only.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-3 grid gap-2 md:grid-cols-3">
|
|
@foreach ($availableCompareInputs as $input)
|
|
<div data-testid="baseline-compare-available-input" data-input-label="{{ $input['label'] }}" class="flex min-w-0 items-start justify-between gap-3 rounded-md border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900/80">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-medium leading-5 text-gray-950 dark:text-white">
|
|
{{ $input['label'] }}
|
|
</div>
|
|
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
|
{{ $input['description'] }}
|
|
</p>
|
|
</div>
|
|
|
|
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($input['tone'] ?? 'gray') }}">
|
|
{{ $input['state'] }}
|
|
</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
@if (! empty($assignmentUnlocks))
|
|
<div data-testid="baseline-compare-assignment-unlocks" class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
What this unlocks after assignment
|
|
</div>
|
|
<ul class="mt-3 space-y-2">
|
|
@foreach ($assignmentUnlocks as $unlock)
|
|
<li class="flex gap-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
|
<x-heroicon-m-check-circle class="mt-0.5 h-4 w-4 shrink-0 text-primary-500 dark:text-primary-400" />
|
|
<span>{{ $unlock }}</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@if (filled($openCompareMatrixUrl ?? null))
|
|
<x-filament::section>
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
Launch the compare matrix with the currently known baseline profile and any carried subject focus from this environment landing.
|
|
</div>
|
|
|
|
<x-filament::button tag="a" :href="$openCompareMatrixUrl" color="gray" icon="heroicon-o-squares-2x2">
|
|
Open compare matrix
|
|
</x-filament::button>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@if ($arrivedFromCompareMatrix)
|
|
<x-filament::section>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<x-filament::badge color="info" icon="heroicon-m-squares-2x2" size="sm">
|
|
Arrived from compare matrix
|
|
</x-filament::badge>
|
|
|
|
@if ($matrixBaselineProfileId)
|
|
<x-filament::badge color="gray" size="sm">
|
|
Baseline profile #{{ (int) $matrixBaselineProfileId }}
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if (filled($matrixSubjectKey))
|
|
<x-filament::badge color="gray" size="sm">
|
|
Subject {{ $matrixSubjectKey }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@if ($duplicateNamePoliciesCountValue > 0)
|
|
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
|
<div class="flex items-start gap-3">
|
|
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
|
|
{{ __('baseline-compare.duplicate_warning_title') }}
|
|
</div>
|
|
<div class="text-sm text-warning-800 dark:text-warning-300">
|
|
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
|
|
'count' => $duplicateNamePoliciesCountValue,
|
|
'ambiguous_count' => $duplicateNameSubjectsCountValue,
|
|
'app' => config('app.name', 'TenantPilot'),
|
|
]) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@if ($showCompareExplanation)
|
|
<x-filament::section>
|
|
<div class="space-y-4">
|
|
<div class="flex flex-wrap items-start gap-2">
|
|
@if ($summary)
|
|
<x-filament::badge :color="$summary['tone'] ?? 'gray'" size="sm">
|
|
{{ $summaryLabel }}
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
|
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
|
{{ $evaluationSpec->label }}
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
|
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
{{ $trustSpec->label }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
|
{{ $summary['headline'] ?? ($explanation['headline'] ?? 'Compare explanation') }}
|
|
</div>
|
|
|
|
@if (filled($summary['supportingMessage'] ?? null))
|
|
<p class="text-sm text-gray-700 dark:text-gray-200">
|
|
{{ $summary['supportingMessage'] }}
|
|
</p>
|
|
@endif
|
|
|
|
@if (filled($explanation['reliabilityStatement'] ?? null))
|
|
<p class="text-sm text-gray-700 dark:text-gray-200">
|
|
{{ $explanation['reliabilityStatement'] }}
|
|
</p>
|
|
@endif
|
|
|
|
@if (filled(data_get($explanation, 'dominantCause.explanation')))
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
{{ data_get($explanation, 'dominantCause.explanation') }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($reasonSemantics !== null)
|
|
<dl class="grid gap-3 md:grid-cols-2">
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
@endif
|
|
|
|
<dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ $explanation['executionOutcomeLabel'] ?? 'Completed' }}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ $explanation['trustworthinessLabel'] ?? 'Needs review' }}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ data_get($summary, 'nextAction.label') ?? data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
@if (filled($explanation['coverageStatement'] ?? null))
|
|
<div class="rounded-lg border border-primary-200 bg-primary-50/70 px-4 py-3 text-sm text-primary-950 dark:border-primary-900/40 dark:bg-primary-950/20 dark:text-primary-100">
|
|
<span class="font-semibold">Coverage:</span>
|
|
{{ $explanation['coverageStatement'] }}
|
|
</div>
|
|
@endif
|
|
|
|
@if ($explanationCounts->isNotEmpty())
|
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
@foreach ($explanationCounts as $count)
|
|
@continue(! is_array($count))
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{{ $count['label'] ?? 'Count' }}
|
|
</div>
|
|
<div class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">
|
|
{{ (int) ($count['value'] ?? 0) }}
|
|
</div>
|
|
@if (filled($count['qualifier'] ?? null))
|
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ $count['qualifier'] }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
{{-- Row 1: Stats Overview --}}
|
|
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
{{-- Stat: Assigned Baseline --}}
|
|
<x-filament::section>
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_assigned_baseline') }}</div>
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
@if ($snapshotId)
|
|
<x-filament::badge color="success" size="sm" class="w-fit">
|
|
{{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }}
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if (filled($coverageStatus))
|
|
<x-filament::badge
|
|
:color="$coverageStatus === 'ok' ? 'success' : 'warning'"
|
|
size="sm"
|
|
class="w-fit"
|
|
>
|
|
{{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }}
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if (filled($fidelity))
|
|
<x-filament::badge color="gray" size="sm" class="w-fit">
|
|
{{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }}
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if ($hasEvidenceGaps)
|
|
<x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip">
|
|
{{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }}
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
|
|
<div class="mt-1 text-xs text-warning-700 dark:text-warning-300">
|
|
{{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
{{-- Stat: Total Findings --}}
|
|
<x-filament::section>
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_total_findings') }}</div>
|
|
@if ($state === 'failed')
|
|
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">{{ __('baseline-compare.stat_error') }}</div>
|
|
@else
|
|
<div class="text-3xl font-bold {{ $findingsColorClass }}">
|
|
{{ $findingsCount ?? 0 }}
|
|
</div>
|
|
@endif
|
|
@if ($state === 'comparing')
|
|
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
|
|
<x-filament::loading-indicator class="h-3 w-3" />
|
|
{{ __('baseline-compare.comparing_indicator') }}
|
|
</div>
|
|
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
|
<div class="space-y-1">
|
|
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $summary['headline'] ?? ($whyNoFindingsMessage ?? $whyNoFindingsFallback) }}</span>
|
|
|
|
@if (filled($summary['supportingMessage'] ?? null))
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ $summary['supportingMessage'] }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
{{-- Stat: Last Compared --}}
|
|
<x-filament::section>
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_last_compared') }}</div>
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
|
|
{{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
|
|
</div>
|
|
@if ($this->getRunUrl())
|
|
<x-filament::link :href="$this->getRunUrl()" size="sm">
|
|
{{ __('baseline-compare.button_view_run') }}
|
|
</x-filament::link>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
</div>
|
|
@endif
|
|
|
|
@if ($hasRbacRoleDefinitionSummary)
|
|
<x-filament::section :heading="__('baseline-compare.rbac_summary_title')">
|
|
<x-slot name="description">
|
|
{{ __('baseline-compare.rbac_summary_description') }}
|
|
</x-slot>
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<x-filament::badge color="gray">
|
|
{{ __('baseline-compare.rbac_summary_compared') }}: {{ (int) ($rbacRoleDefinitionSummary['total_compared'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge color="success">
|
|
{{ __('baseline-compare.rbac_summary_unchanged') }}: {{ (int) ($rbacRoleDefinitionSummary['unchanged'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge color="warning">
|
|
{{ __('baseline-compare.rbac_summary_modified') }}: {{ (int) ($rbacRoleDefinitionSummary['modified'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge color="danger">
|
|
{{ __('baseline-compare.rbac_summary_missing') }}: {{ (int) ($rbacRoleDefinitionSummary['missing'] ?? 0) }}
|
|
</x-filament::badge>
|
|
<x-filament::badge color="info">
|
|
{{ __('baseline-compare.rbac_summary_unexpected') }}: {{ (int) ($rbacRoleDefinitionSummary['unexpected'] ?? 0) }}
|
|
</x-filament::badge>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
{{-- Coverage warnings banner --}}
|
|
@if ($state === 'ready' && $hasCoverageWarnings)
|
|
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
|
<div class="flex items-start gap-3">
|
|
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
|
|
{{ __('baseline-compare.coverage_warning_title') }}
|
|
</div>
|
|
<div class="text-sm text-warning-800 dark:text-warning-300">
|
|
@if (($coverageStatus ?? null) === 'unproven')
|
|
{{ __('baseline-compare.coverage_unproven_body') }}
|
|
@else
|
|
{{ __('baseline-compare.coverage_incomplete_body', [
|
|
'count' => (int) ($uncoveredTypesCount ?? 0),
|
|
'types' => Str::plural('type', (int) ($uncoveredTypesCount ?? 0)),
|
|
]) }}
|
|
@endif
|
|
|
|
@if (! empty($uncoveredTypes))
|
|
<div class="mt-2 text-xs text-warning-800 dark:text-warning-300">
|
|
{{ __('baseline-compare.coverage_uncovered_label', [
|
|
'list' => implode(', ', array_slice($uncoveredTypes, 0, 6)) . (count($uncoveredTypes) > 6 ? '…' : ''),
|
|
]) }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@if ($this->getRunUrl())
|
|
<div class="mt-2">
|
|
<x-filament::button
|
|
:href="$this->getRunUrl()"
|
|
tag="a"
|
|
color="warning"
|
|
outlined
|
|
icon="heroicon-o-queue-list"
|
|
size="sm"
|
|
>
|
|
{{ __('baseline-compare.button_view_run') }}
|
|
</x-filament::button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Failed run banner --}}
|
|
@if ($state === 'failed')
|
|
<div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
|
<div class="flex items-start gap-3">
|
|
<x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
|
{{ __('baseline-compare.failed_title') }}
|
|
</div>
|
|
<div class="text-sm text-danger-700 dark:text-danger-300">
|
|
{{ $failureReason ?? __('baseline-compare.failed_body_default') }}
|
|
</div>
|
|
<div class="mt-2 flex items-center gap-3">
|
|
@if ($this->getRunUrl())
|
|
<x-filament::button
|
|
:href="$this->getRunUrl()"
|
|
tag="a"
|
|
color="danger"
|
|
outlined
|
|
icon="heroicon-o-queue-list"
|
|
size="sm"
|
|
>
|
|
{{ __('baseline-compare.button_view_failed_run') }}
|
|
</x-filament::button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Critical drift banner --}}
|
|
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
|
|
<div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
|
<div class="flex items-start gap-3">
|
|
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
|
{{ __('baseline-compare.critical_drift_title') }}
|
|
</div>
|
|
<div class="text-sm text-danger-700 dark:text-danger-300">
|
|
{{ __('baseline-compare.critical_drift_body', [
|
|
'profile' => $profileName,
|
|
'count' => $severityCounts['high'],
|
|
'findings' => Str::plural('finding', $severityCounts['high']),
|
|
]) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- State: No tenant / no assignment / no snapshot --}}
|
|
@if (in_array($state, ['no_tenant', 'no_snapshot']))
|
|
<x-filament::section>
|
|
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
|
|
@if ($state === 'no_tenant')
|
|
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_tenant') }}</div>
|
|
@elseif ($state === 'no_snapshot')
|
|
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_snapshot') }}</div>
|
|
@endif
|
|
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@if ($hasEvidenceGapDetailSection)
|
|
<x-filament::section :heading="__('baseline-compare.evidence_gap_details_heading')">
|
|
<x-slot name="description">
|
|
{{ __('baseline-compare.evidence_gap_details_description') }}
|
|
</x-slot>
|
|
|
|
@include('filament.infolists.entries.evidence-gap-subjects', [
|
|
'summary' => $evidenceGapSummary,
|
|
'buckets' => $evidenceGapBuckets ?? [],
|
|
'searchId' => 'tenant-baseline-compare-gap-search',
|
|
])
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
{{-- Severity breakdown + actions --}}
|
|
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
|
|
<x-filament::section>
|
|
<x-slot name="heading">
|
|
{{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
|
|
</x-slot>
|
|
<x-slot name="description">
|
|
{{ __('baseline-compare.findings_description') }}
|
|
</x-slot>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
@if (($severityCounts['high'] ?? 0) > 0)
|
|
<x-filament::badge color="danger">
|
|
{{ $severityCounts['high'] }} High
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if (($severityCounts['medium'] ?? 0) > 0)
|
|
<x-filament::badge color="warning">
|
|
{{ $severityCounts['medium'] }} Medium
|
|
</x-filament::badge>
|
|
@endif
|
|
|
|
@if (($severityCounts['low'] ?? 0) > 0)
|
|
<x-filament::badge color="gray">
|
|
{{ $severityCounts['low'] }} Low
|
|
</x-filament::badge>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
@if ($this->getFindingsUrl())
|
|
<x-filament::button
|
|
:href="$this->getFindingsUrl()"
|
|
tag="a"
|
|
color="gray"
|
|
icon="heroicon-o-eye"
|
|
size="sm"
|
|
>
|
|
{{ __('baseline-compare.button_view_findings') }}
|
|
</x-filament::button>
|
|
@endif
|
|
|
|
@if ($this->getRunUrl())
|
|
<x-filament::button
|
|
:href="$this->getRunUrl()"
|
|
tag="a"
|
|
color="gray"
|
|
outlined
|
|
icon="heroicon-o-queue-list"
|
|
size="sm"
|
|
>
|
|
{{ __('baseline-compare.button_review_last_run') }}
|
|
</x-filament::button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
{{-- Ready: no drift --}}
|
|
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && ! $hasCoverageWarnings)
|
|
<x-filament::section>
|
|
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
|
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.no_drift_title') }}</div>
|
|
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
|
{{ __('baseline-compare.no_drift_body') }}
|
|
</div>
|
|
@if ($this->getRunUrl())
|
|
<x-filament::button
|
|
:href="$this->getRunUrl()"
|
|
tag="a"
|
|
color="gray"
|
|
outlined
|
|
icon="heroicon-o-queue-list"
|
|
size="sm"
|
|
>
|
|
{{ __('baseline-compare.button_review_last_run') }}
|
|
</x-filament::button>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
{{-- Ready: warnings, no findings --}}
|
|
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && $hasCoverageWarnings)
|
|
<x-filament::section>
|
|
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
|
<x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" />
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.coverage_warnings_title') }}</div>
|
|
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
|
{{ __('baseline-compare.coverage_warnings_body') }}
|
|
</div>
|
|
@if ($this->getRunUrl())
|
|
<x-filament::button
|
|
:href="$this->getRunUrl()"
|
|
tag="a"
|
|
color="gray"
|
|
outlined
|
|
icon="heroicon-o-queue-list"
|
|
size="sm"
|
|
>
|
|
{{ __('baseline-compare.button_review_last_run') }}
|
|
</x-filament::button>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
{{-- Idle state --}}
|
|
@if ($state === 'idle')
|
|
<x-filament::section>
|
|
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
|
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.idle_title') }}</div>
|
|
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
|
{{ $message }}
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@if ($hasEvidenceGapDiagnostics)
|
|
<x-filament::section :heading="__('baseline-compare.evidence_gap_diagnostics_heading')">
|
|
<x-slot name="description">
|
|
{{ __('baseline-compare.evidence_gap_diagnostics_description') }}
|
|
</x-slot>
|
|
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-300">
|
|
Diagnostics are available for support review and stay outside the default operator view.
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
</x-filament::page>
|