## 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
757 lines
27 KiB
PHP
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';
|
|
}
|
|
}
|