## 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
415 lines
14 KiB
PHP
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();
|
|
}
|
|
}
|