TenantAtlas/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
ahmido 74210bac2e feat: add baseline compare operator modes (#224)
## 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
2026-04-11 15:48:22 +00:00

757 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class BaselineCompareMatrix extends Page implements HasForms
{
use InteractsWithForms;
use InteractsWithRecord;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string $resource = BaselineProfileResource::class;
protected static ?string $breadcrumb = 'Compare matrix';
protected string $view = 'filament.pages.baseline-compare-matrix';
public string $requestedMode = 'auto';
/**
* @var list<string>
*/
public array $selectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $selectedStates = [];
/**
* @var list<string>
*/
public array $selectedSeverities = [];
public string $tenantSort = 'tenant_name';
public string $subjectSort = 'deviation_breadth';
public ?string $focusedSubjectKey = null;
/**
* @var list<string>
*/
public array $draftSelectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $draftSelectedStates = [];
/**
* @var list<string>
*/
public array $draftSelectedSeverities = [];
public string $draftTenantSort = 'tenant_name';
public string $draftSubjectSort = 'deviation_breadth';
/**
* @var array<string, mixed>
*/
public array $matrix = [];
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
}
public function mount(int|string $record): void
{
$this->record = $this->resolveRecord($record);
$this->hydrateFiltersFromRequest();
$this->refreshMatrix();
$this->form->fill($this->filterFormState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 2,
])
->schema([
Grid::make([
'default' => 1,
'lg' => 5,
])
->schema([
Select::make('draftSelectedPolicyTypes')
->label('Policy types')
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All policy types')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.'
: null)
->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter',
])
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedStates')
->label('Technical states')
->options(fn (): array => $this->matrixOptions('stateOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All technical states')
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedSeverities')
->label('Severity')
->options(fn (): array => $this->matrixOptions('severityOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All severities'),
])
->columnSpan([
'xl' => 1,
]),
Grid::make([
'default' => 1,
'md' => 2,
'xl' => 1,
])
->schema([
Select::make('draftTenantSort')
->label('Tenant sort')
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
->default('tenant_name')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
Select::make('draftSubjectSort')
->label('Subject sort')
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
->default('deviation_breadth')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
])
->columnSpan([
'xl' => 1,
]),
]),
]);
}
protected function authorizeAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
public function getTitle(): string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return 'Compare matrix: '.$profile->name;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$profile = $this->getRecord();
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(fn (): mixed => $this->compareAssignedTenants());
$compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction(
$compareAssignedTenantsAction,
fn (): ?Workspace => $this->workspace(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
return [
Action::make('backToBaselineProfile')
->label('Back to baseline profile')
->color('gray')
->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')),
$compareAssignedTenantsAction,
];
}
public function applyFilters(): void
{
$this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes);
$this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates);
$this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities);
$this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort);
$this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort);
$this->redirect($this->filterUrl(), navigate: true);
}
public function resetFilters(): void
{
$this->selectedPolicyTypes = [];
$this->selectedStates = [];
$this->selectedSeverities = [];
$this->tenantSort = 'tenant_name';
$this->subjectSort = 'deviation_breadth';
$this->focusedSubjectKey = null;
$this->draftSelectedPolicyTypes = [];
$this->draftSelectedStates = [];
$this->draftSelectedSeverities = [];
$this->draftTenantSort = 'tenant_name';
$this->draftSubjectSort = 'deviation_breadth';
$this->redirect($this->filterUrl(), navigate: true);
}
public function refreshMatrix(): void
{
$user = auth()->user();
abort_unless($user instanceof User, 403);
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [
'policyTypes' => $this->selectedPolicyTypes,
'states' => $this->selectedStates,
'severities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
'focusedSubjectKey' => $this->focusedSubjectKey,
]);
}
public function pollMatrix(): void
{
$this->refreshMatrix();
}
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return BaselineCompareLanding::getUrl(
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
panel: 'tenant',
tenant: $tenant,
);
}
public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl(
'view',
[
'record' => $findingId,
...$this->navigationContext($tenant, $subjectKey)->toQuery(),
],
tenant: $tenant,
);
}
public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string
{
return OperationRunLinks::tenantlessView(
$runId,
$this->navigationContext(
$tenantId !== null ? $this->tenant($tenantId) : null,
$subjectKey,
),
);
}
public function clearSubjectFocusUrl(): string
{
return static::getUrl($this->routeParameters([
'subject_key' => null,
]), panel: 'admin');
}
public function modeUrl(string $mode): string
{
return $this->filterUrl([
'mode' => $this->normalizeRequestedMode($mode),
]);
}
public function filterUrl(array $overrides = []): string
{
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
}
public function activeFilterCount(): int
{
return count($this->selectedPolicyTypes)
+ count($this->selectedStates)
+ count($this->selectedSeverities)
+ ($this->focusedSubjectKey !== null ? 1 : 0);
}
public function hasStagedFilterChanges(): bool
{
return $this->draftFilterState() !== $this->appliedFilterState();
}
public function canUseCompactMode(): bool
{
return $this->visibleTenantCount() <= 1;
}
public function presentationModeLabel(string $mode): string
{
return match ($mode) {
'dense' => 'Dense mode',
'compact' => 'Compact mode',
default => 'Auto mode',
};
}
/**
* @return array<string, int|string>
*/
public function activeFilterSummary(): array
{
$summary = [];
if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes);
}
if ($this->selectedStates !== []) {
$summary['Technical states'] = count($this->selectedStates);
}
if ($this->selectedSeverities !== []) {
$summary['Severity'] = count($this->selectedSeverities);
}
if ($this->focusedSubjectKey !== null) {
$summary['Focused subject'] = $this->focusedSubjectKey;
}
return $summary;
}
/**
* @return array<string, int|string>
*/
public function stagedFilterSummary(): array
{
$summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
}
if ($this->draftSelectedStates !== $this->selectedStates) {
$summary['Technical states'] = count($this->draftSelectedStates);
}
if ($this->draftSelectedSeverities !== $this->selectedSeverities) {
$summary['Severity'] = count($this->draftSelectedSeverities);
}
if ($this->draftTenantSort !== $this->tenantSort) {
$summary['Tenant sort'] = $this->draftTenantSort;
}
if ($this->draftSubjectSort !== $this->subjectSort) {
$summary['Subject sort'] = $this->draftSubjectSort;
}
return $summary;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
return array_merge($this->matrix, [
'profile' => $this->getRecord(),
'currentFilters' => [
'mode' => $this->requestedMode,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort,
'subject_sort' => $this->subjectSort,
'subject_key' => $this->focusedSubjectKey,
],
'draftFilters' => [
'policy_type' => $this->draftSelectedPolicyTypes,
'state' => $this->draftSelectedStates,
'severity' => $this->draftSelectedSeverities,
'tenant_sort' => $this->draftTenantSort,
'subject_sort' => $this->draftSubjectSort,
],
'presentationState' => $this->presentationState(),
]);
}
private function hydrateFiltersFromRequest(): void
{
$this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto'));
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
$this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name'));
$this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth'));
$subjectKey = request()->query('subject_key');
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->draftSelectedPolicyTypes = $this->selectedPolicyTypes;
$this->draftSelectedStates = $this->selectedStates;
$this->draftSelectedSeverities = $this->selectedSeverities;
$this->draftTenantSort = $this->tenantSort;
$this->draftSubjectSort = $this->subjectSort;
}
/**
* @return array<string, mixed>
*/
private function filterFormState(): array
{
return [
'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes,
'draftSelectedStates' => $this->draftSelectedStates,
'draftSelectedSeverities' => $this->draftSelectedSeverities,
'draftTenantSort' => $this->draftTenantSort,
'draftSubjectSort' => $this->draftSubjectSort,
];
}
/**
* @return array<string, string>
*/
private function matrixOptions(string $key): array
{
$options = $this->matrix[$key] ?? null;
return is_array($options) ? $options : [];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function draftFilterState(): array
{
return [
'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes),
'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates),
'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities),
'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort),
'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort),
];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function appliedFilterState(): array
{
return [
'selectedPolicyTypes' => $this->selectedPolicyTypes,
'selectedStates' => $this->selectedStates,
'selectedSeverities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
];
}
/**
* @return list<string>
*/
private function normalizeQueryList(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))));
}
private function normalizeRequestedMode(mixed $value): string
{
return in_array((string) $value, ['auto', 'dense', 'compact'], true)
? (string) $value
: 'auto';
}
private function normalizeTenantSort(mixed $value): string
{
return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true)
? (string) $value
: 'tenant_name';
}
private function normalizeSubjectSort(mixed $value): string
{
return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true)
? (string) $value
: 'deviation_breadth';
}
private function compareAssignedTenantsDisabledReason(): ?string
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
if (($reference['referenceState'] ?? null) !== 'ready') {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function compareAssignedTenants(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(
context: $this->navigationContext(),
allTenants: true,
)),
])
->send();
} else {
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
}
$this->refreshMatrix();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function routeParameters(array $overrides = []): array
{
return array_filter([
'record' => $this->getRecord(),
'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null,
'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null,
'subject_key' => $this->focusedSubjectKey,
...$overrides,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
}
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$subjectKey ??= $this->focusedSubjectKey;
return CanonicalNavigationContext::forBaselineCompareMatrix(
profile: $profile,
filters: $this->routeParameters(),
tenant: $tenant,
subjectKey: $subjectKey,
);
}
private function tenant(int $tenantId): ?Tenant
{
return Tenant::query()
->whereKey($tenantId)
->where('workspace_id', (int) $this->getRecord()->workspace_id)
->first();
}
private function workspace(): ?Workspace
{
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
}
/**
* @return array<string, mixed>
*/
private function presentationState(): array
{
$resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount());
return [
'requestedMode' => $this->requestedMode,
'resolvedMode' => $resolvedMode,
'visibleTenantCount' => $this->visibleTenantCount(),
'activeFilterCount' => $this->activeFilterCount(),
'hasStagedFilterChanges' => $this->hasStagedFilterChanges(),
'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false),
'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null,
'canOverrideMode' => $this->visibleTenantCount() > 0,
'compactModeAvailable' => $this->canUseCompactMode(),
];
}
private function visibleTenantCount(): int
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
return (int) ($reference['visibleTenantCount'] ?? 0);
}
private function resolvePresentationMode(int $visibleTenantCount): string
{
if ($this->requestedMode === 'dense') {
return 'dense';
}
if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) {
return 'compact';
}
return $visibleTenantCount > 1 ? 'dense' : 'compact';
}
}