TenantAtlas/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
ahmido eca19819d1 feat: add workspace baseline compare matrix (#221)
## 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
2026-04-11 10:20:25 +00:00

415 lines
14 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\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
class BaselineCompareMatrix extends Page
{
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';
/**
* @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 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();
}
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 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 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 filterUrl(array $overrides = []): string
{
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
}
public function updatedSelectedPolicyTypes(): void
{
$this->refreshMatrix();
}
public function updatedSelectedStates(): void
{
$this->refreshMatrix();
}
public function updatedSelectedSeverities(): void
{
$this->refreshMatrix();
}
public function updatedTenantSort(): void
{
$this->refreshMatrix();
}
public function updatedSubjectSort(): void
{
$this->refreshMatrix();
}
public function updatedFocusedSubjectKey(): void
{
$this->refreshMatrix();
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
return array_merge($this->matrix, [
'profile' => $this->getRecord(),
'currentFilters' => [
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort,
'subject_sort' => $this->subjectSort,
'subject_key' => $this->focusedSubjectKey,
],
]);
}
private function hydrateFiltersFromRequest(): void
{
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
$this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name';
$this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth';
$subjectKey = request()->query('subject_key');
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
}
/**
* @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 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(),
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort,
'subject_sort' => $this->subjectSort,
'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();
}
}