Compare commits
1 Commits
dev
...
189-portfo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd84a16b8 |
9
.github/agents/copilot-instructions.md
vendored
9
.github/agents/copilot-instructions.md
vendored
@ -165,10 +165,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
|
||||||
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
|
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
|
|
||||||
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
|
||||||
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -203,7 +199,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
- 189-portfolio-triage-review-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns
|
||||||
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
|
- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries
|
||||||
|
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -110,13 +109,6 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, mixed>|null */
|
/** @var array<string, mixed>|null */
|
||||||
public ?array $summaryAssessment = null;
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
|
||||||
public ?array $navigationContextPayload = null;
|
|
||||||
|
|
||||||
public ?int $matrixBaselineProfileId = null;
|
|
||||||
|
|
||||||
public ?string $matrixSubjectKey = null;
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -138,12 +130,6 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
|
||||||
$baselineProfileId = request()->query('baseline_profile_id');
|
|
||||||
$subjectKey = request()->query('subject_key');
|
|
||||||
|
|
||||||
$this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null;
|
|
||||||
$this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
|
||||||
$this->refreshStats();
|
$this->refreshStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,9 +244,6 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
|
||||||
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
|
||||||
'matrixSubjectKey' => $this->matrixSubjectKey,
|
|
||||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
@ -319,19 +302,9 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = [];
|
return [
|
||||||
$navigationContext = $this->navigationContext();
|
$this->compareNowAction(),
|
||||||
|
];
|
||||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
|
||||||
$actions[] = Action::make('backToOrigin')
|
|
||||||
->label($navigationContext->backLinkLabel)
|
|
||||||
->color('gray')
|
|
||||||
->url($navigationContext->backLinkUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = $this->compareNowAction();
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function compareNowAction(): Action
|
private function compareNowAction(): Action
|
||||||
@ -416,7 +389,7 @@ private function compareNowAction(): Action
|
|||||||
->actions($run instanceof OperationRun ? [
|
->actions($run instanceof OperationRun ? [
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('Open operation')
|
->label('Open operation')
|
||||||
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
|
->url(OperationRunLinks::view($run, $tenant)),
|
||||||
] : [])
|
] : [])
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -463,15 +436,4 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats
|
|||||||
|
|
||||||
return $aggregate;
|
return $aggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function navigationContext(): ?CanonicalNavigationContext
|
|
||||||
{
|
|
||||||
if (! is_array($this->navigationContextPayload)) {
|
|
||||||
return CanonicalNavigationContext::fromRequest(request());
|
|
||||||
}
|
|
||||||
|
|
||||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
|
||||||
|
|
||||||
return CanonicalNavigationContext::fromRequest($request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,756 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -136,7 +135,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -448,16 +447,10 @@ public static function getPages(): array
|
|||||||
'index' => Pages\ListBaselineProfiles::route('/'),
|
'index' => Pages\ListBaselineProfiles::route('/'),
|
||||||
'create' => Pages\CreateBaselineProfile::route('/create'),
|
'create' => Pages\CreateBaselineProfile::route('/create'),
|
||||||
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
||||||
'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'),
|
|
||||||
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function compareMatrixUrl(BaselineProfile|int $profile): string
|
|
||||||
{
|
|
||||||
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -44,8 +44,6 @@ protected function getHeaderActions(): array
|
|||||||
->color('gray'),
|
->color('gray'),
|
||||||
$this->captureAction(),
|
$this->captureAction(),
|
||||||
$this->compareNowAction(),
|
$this->compareNowAction(),
|
||||||
$this->openCompareMatrixAction(),
|
|
||||||
$this->compareAssignedTenantsAction(),
|
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn (): bool => $this->hasManageCapability()),
|
->visible(fn (): bool => $this->hasManageCapability()),
|
||||||
];
|
];
|
||||||
@ -309,80 +307,6 @@ private function compareNowAction(): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function openCompareMatrixAction(): Action
|
|
||||||
{
|
|
||||||
return Action::make('openCompareMatrix')
|
|
||||||
->label('Open compare matrix')
|
|
||||||
->icon('heroicon-o-squares-2x2')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => BaselineProfileResource::compareMatrixUrl($this->getRecord()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function compareAssignedTenantsAction(): Action
|
|
||||||
{
|
|
||||||
$action = 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(function (): 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(allTenants: true)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('No baseline compares were started')
|
|
||||||
->body($summary)
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
});
|
|
||||||
|
|
||||||
return WorkspaceUiEnforcement::forAction(
|
|
||||||
$action,
|
|
||||||
fn (): ?Workspace => Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
||||||
->preserveDisabled()
|
|
||||||
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -483,48 +407,4 @@ private function profileHasConsumableSnapshot(): bool
|
|||||||
|
|
||||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function compareAssignedTenantsDisabledReason(): ?string
|
|
||||||
{
|
|
||||||
/** @var BaselineProfile $profile */
|
|
||||||
$profile = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $this->profileHasConsumableSnapshot()) {
|
|
||||||
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->visibleAssignedTenantCount($profile) === 0) {
|
|
||||||
return 'No visible assigned tenants are available for compare.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function visibleAssignedTenantCount(BaselineProfile $profile): int
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = BaselineTenantAssignment::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->pluck('tenant_id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return Tenant::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->whereIn('id', $tenantIds)
|
|
||||||
->get(['id'])
|
|
||||||
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
|
||||||
->count();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1257,16 +1257,6 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false
|
|||||||
|
|
||||||
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
||||||
{
|
{
|
||||||
$incomingContext = CanonicalNavigationContext::fromRequest(request());
|
|
||||||
|
|
||||||
if (
|
|
||||||
$incomingContext instanceof CanonicalNavigationContext
|
|
||||||
&& str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix')
|
|
||||||
&& $incomingContext->backLinkUrl !== null
|
|
||||||
) {
|
|
||||||
return $incomingContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
return new CanonicalNavigationContext(
|
return new CanonicalNavigationContext(
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -24,17 +23,7 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = [];
|
return [
|
||||||
$navigationContext = $this->navigationContext();
|
|
||||||
|
|
||||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
|
||||||
$actions[] = Actions\Action::make('back_to_origin')
|
|
||||||
->label($navigationContext->backLinkLabel)
|
|
||||||
->color('gray')
|
|
||||||
->url($navigationContext->backLinkUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_merge($actions, [
|
|
||||||
Actions\Action::make('primary_related')
|
Actions\Action::make('primary_related')
|
||||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||||
@ -64,16 +53,11 @@ protected function getHeaderActions(): array
|
|||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSubheading(): string|Htmlable|null
|
public function getSubheading(): string|Htmlable|null
|
||||||
{
|
{
|
||||||
return FindingResource::findingSubheading($this->getRecord());
|
return FindingResource::findingSubheading($this->getRecord());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function navigationContext(): ?CanonicalNavigationContext
|
|
||||||
{
|
|
||||||
return CanonicalNavigationContext::fromRequest(request());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
@ -38,30 +37,4 @@ public function assignedByUser(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'assigned_by_user_id');
|
return $this->belongsTo(User::class, 'assigned_by_user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeForBaselineProfile(Builder $query, BaselineProfile|int $profile): Builder
|
|
||||||
{
|
|
||||||
$profileId = $profile instanceof BaselineProfile
|
|
||||||
? (int) $profile->getKey()
|
|
||||||
: (int) $profile;
|
|
||||||
|
|
||||||
return $query->where('baseline_profile_id', $profileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeInWorkspace(Builder $query, int $workspaceId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('workspace_id', $workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function assignedTenantIdsForProfile(BaselineProfile|int $profile, ?int $workspaceId = null): array
|
|
||||||
{
|
|
||||||
return static::query()
|
|
||||||
->when($workspaceId !== null, fn (Builder $query): Builder => $query->inWorkspace($workspaceId))
|
|
||||||
->forBaselineProfile($profile)
|
|
||||||
->pluck('tenant_id')
|
|
||||||
->map(static fn (mixed $tenantId): int => (int) $tenantId)
|
|
||||||
->filter(static fn (int $tenantId): bool => $tenantId > 0)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -274,18 +274,6 @@ public function scopeOpenDrift(Builder $query): Builder
|
|||||||
->openWorkflow();
|
->openWorkflow();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
|
|
||||||
{
|
|
||||||
$profileId = $profile instanceof BaselineProfile
|
|
||||||
? (int) $profile->getKey()
|
|
||||||
: (int) $profile;
|
|
||||||
|
|
||||||
return $query
|
|
||||||
->drift()
|
|
||||||
->where('source', 'baseline.compare')
|
|
||||||
->where('scope_key', 'baseline_profile:'.$profileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeOverdueOpen(Builder $query): Builder
|
public function scopeOverdueOpen(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query
|
return $query
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -90,17 +89,6 @@ public function scopeTerminalFailure(Builder $query): Builder
|
|||||||
->where('outcome', OperationRunOutcome::Failed->value);
|
->where('outcome', OperationRunOutcome::Failed->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
|
|
||||||
{
|
|
||||||
$profileId = $profile instanceof BaselineProfile
|
|
||||||
? (int) $profile->getKey()
|
|
||||||
: (int) $profile;
|
|
||||||
|
|
||||||
return $query
|
|
||||||
->where('type', OperationRunType::BaselineCompare->value)
|
|
||||||
->where('context->baseline_profile_id', $profileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
||||||
{
|
{
|
||||||
$policy ??= app(OperationLifecyclePolicy::class);
|
$policy ??= app(OperationLifecyclePolicy::class);
|
||||||
@ -296,34 +284,6 @@ public function isGovernanceArtifactOperation(): bool
|
|||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $tenantIds
|
|
||||||
* @return \Illuminate\Support\Collection<int, self>
|
|
||||||
*/
|
|
||||||
public static function latestBaselineCompareRunsForProfile(
|
|
||||||
BaselineProfile|int $profile,
|
|
||||||
array $tenantIds,
|
|
||||||
?int $workspaceId = null,
|
|
||||||
bool $completedOnly = false,
|
|
||||||
): \Illuminate\Support\Collection {
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
$runs = static::query()
|
|
||||||
->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
|
|
||||||
->whereIn('tenant_id', $tenantIds)
|
|
||||||
->baselineCompareForProfile($profile)
|
|
||||||
->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value))
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $runs
|
|
||||||
->unique(static fn (self $run): int => (int) $run->tenant_id)
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
||||||
{
|
{
|
||||||
if ($tenantId <= 0) {
|
if ($tenantId <= 0) {
|
||||||
|
|||||||
@ -32,7 +32,6 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\FontProviders\LocalFontProvider;
|
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
use Filament\Navigation\NavigationItem;
|
use Filament\Navigation\NavigationItem;
|
||||||
@ -63,7 +62,6 @@ public function panel(Panel $panel): Panel
|
|||||||
->brandLogoHeight('2rem')
|
->brandLogoHeight('2rem')
|
||||||
->homeUrl(fn (): string => route('admin.home'))
|
->homeUrl(fn (): string => route('admin.home'))
|
||||||
->favicon(asset('favicon.ico'))
|
->favicon(asset('favicon.ico'))
|
||||||
->font(null, provider: LocalFontProvider::class, preload: [])
|
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
ChooseWorkspace::registerRoutes($panel);
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
use App\Support\Filament\PanelThemeAsset;
|
||||||
use Filament\FontProviders\LocalFontProvider;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -32,7 +31,6 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('system')
|
->path('system')
|
||||||
->authGuard('platform')
|
->authGuard('platform')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->font(null, provider: LocalFontProvider::class, preload: [])
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\FontProviders\LocalFontProvider;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -41,7 +40,6 @@ public function panel(Panel $panel): Panel
|
|||||||
->brandLogo(fn () => view('filament.admin.logo'))
|
->brandLogo(fn () => view('filament.admin.logo'))
|
||||||
->brandLogoHeight('2rem')
|
->brandLogoHeight('2rem')
|
||||||
->favicon(asset('favicon.ico'))
|
->favicon(asset('favicon.ico'))
|
||||||
->font(null, provider: LocalFontProvider::class, preload: [])
|
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix(null)
|
->tenantRoutePrefix(null)
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
@ -29,7 +28,6 @@ public function __construct(
|
|||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,34 +47,12 @@ public function startCompare(
|
|||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = $assignment->baselineProfile;
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->startCompareForProfile($profile, $tenant, $initiator, $baselineSnapshotId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
|
||||||
*/
|
|
||||||
public function startCompareForProfile(
|
|
||||||
BaselineProfile $profile,
|
|
||||||
Tenant $tenant,
|
|
||||||
User $initiator,
|
|
||||||
?int $baselineSnapshotId = null,
|
|
||||||
): array {
|
|
||||||
$assignment = BaselineTenantAssignment::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
$precondition = $this->validatePreconditions($profile);
|
$precondition = $this->validatePreconditions($profile);
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
@ -148,103 +124,6 @@ public function startCompareForProfile(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* baselineProfileId: int,
|
|
||||||
* visibleAssignedTenantCount: int,
|
|
||||||
* queuedCount: int,
|
|
||||||
* alreadyQueuedCount: int,
|
|
||||||
* blockedCount: int,
|
|
||||||
* targets: list<array{tenantId: int, runId: ?int, launchState: string, reasonCode: ?string}>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function startCompareForVisibleAssignments(BaselineProfile $profile, User $initiator): array
|
|
||||||
{
|
|
||||||
$assignments = BaselineTenantAssignment::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->with('tenant')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$queuedCount = 0;
|
|
||||||
$alreadyQueuedCount = 0;
|
|
||||||
$blockedCount = 0;
|
|
||||||
$targets = [];
|
|
||||||
|
|
||||||
foreach ($assignments as $assignment) {
|
|
||||||
$tenant = $assignment->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->capabilityResolver->isMember($initiator, $tenant)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_VIEW)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_SYNC)) {
|
|
||||||
$blockedCount++;
|
|
||||||
$targets[] = [
|
|
||||||
'tenantId' => (int) $tenant->getKey(),
|
|
||||||
'runId' => null,
|
|
||||||
'launchState' => 'blocked',
|
|
||||||
'reasonCode' => 'tenant_sync_required',
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->startCompareForProfile($profile, $tenant, $initiator);
|
|
||||||
$run = $result['run'] ?? null;
|
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : null;
|
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false) || ! $run instanceof OperationRun) {
|
|
||||||
$blockedCount++;
|
|
||||||
$targets[] = [
|
|
||||||
'tenantId' => (int) $tenant->getKey(),
|
|
||||||
'runId' => null,
|
|
||||||
'launchState' => 'blocked',
|
|
||||||
'reasonCode' => $reasonCode,
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->wasRecentlyCreated) {
|
|
||||||
$queuedCount++;
|
|
||||||
$targets[] = [
|
|
||||||
'tenantId' => (int) $tenant->getKey(),
|
|
||||||
'runId' => (int) $run->getKey(),
|
|
||||||
'launchState' => 'queued',
|
|
||||||
'reasonCode' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$alreadyQueuedCount++;
|
|
||||||
$targets[] = [
|
|
||||||
'tenantId' => (int) $tenant->getKey(),
|
|
||||||
'runId' => (int) $run->getKey(),
|
|
||||||
'launchState' => 'already_queued',
|
|
||||||
'reasonCode' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'baselineProfileId' => (int) $profile->getKey(),
|
|
||||||
'visibleAssignedTenantCount' => count($targets),
|
|
||||||
'queuedCount' => $queuedCount,
|
|
||||||
'alreadyQueuedCount' => $alreadyQueuedCount,
|
|
||||||
'blockedCount' => $blockedCount,
|
|
||||||
'targets' => $targets,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
|
|||||||
@ -66,9 +66,6 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||||
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
||||||
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
||||||
BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class,
|
|
||||||
BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class,
|
|
||||||
BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -57,7 +57,4 @@ enum BadgeDomain: string
|
|||||||
case SystemHealth = 'system_health';
|
case SystemHealth = 'system_health';
|
||||||
case ReferenceResolutionState = 'reference_resolution_state';
|
case ReferenceResolutionState = 'reference_resolution_state';
|
||||||
case DiffRowStatus = 'diff_row_status';
|
case DiffRowStatus = 'diff_row_status';
|
||||||
case BaselineCompareMatrixState = 'baseline_compare_matrix_state';
|
|
||||||
case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness';
|
|
||||||
case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class BaselineCompareMatrixFreshnessBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
return match (BadgeCatalog::normalizeState($value)) {
|
|
||||||
'fresh' => new BadgeSpec('Current result', 'success', 'heroicon-m-check-badge'),
|
|
||||||
'stale' => new BadgeSpec('Refresh recommended', 'warning', 'heroicon-m-arrow-path'),
|
|
||||||
'never_compared' => new BadgeSpec('Not compared yet', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
'unknown' => new BadgeSpec('Freshness unknown', 'info', 'heroicon-m-question-mark-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class BaselineCompareMatrixStateBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
return match (BadgeCatalog::normalizeState($value)) {
|
|
||||||
'match' => new BadgeSpec('Reference aligned', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'differ' => new BadgeSpec('Drift detected', 'danger', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'missing' => new BadgeSpec('Missing from tenant', 'warning', 'heroicon-m-minus-circle'),
|
|
||||||
'ambiguous' => new BadgeSpec('Identity ambiguous', 'info', 'heroicon-m-question-mark-circle'),
|
|
||||||
'not_compared' => new BadgeSpec('Not compared', 'gray', 'heroicon-m-clock'),
|
|
||||||
'stale_result' => new BadgeSpec('Result stale', 'warning', 'heroicon-m-arrow-path'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class BaselineCompareMatrixTrustBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -245,57 +245,6 @@ public static function topReasons(array $byReason, int $limit = 5): array
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>
|
|
||||||
*/
|
|
||||||
public static function subjectReasonsFromOperationRun(?OperationRun $run): array
|
|
||||||
{
|
|
||||||
$details = self::fromOperationRun($run);
|
|
||||||
$buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : [];
|
|
||||||
$reasonMap = [];
|
|
||||||
|
|
||||||
foreach ($buckets as $bucket) {
|
|
||||||
if (! is_array($bucket)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = self::stringOrNull($bucket['reason_code'] ?? null);
|
|
||||||
|
|
||||||
if ($reasonCode === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
if (! is_array($row)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
|
||||||
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
|
||||||
|
|
||||||
if ($policyType === null || $subjectKey === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$compositeKey = self::subjectCompositeKey($policyType, $subjectKey);
|
|
||||||
$reasonMap[$compositeKey] ??= [];
|
|
||||||
$reasonMap[$compositeKey][] = $reasonCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_map(
|
|
||||||
static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))),
|
|
||||||
$reasonMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function subjectCompositeKey(string $policyType, string $subjectKey): string
|
|
||||||
{
|
|
||||||
return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, mixed>> $buckets
|
* @param list<array<string, mixed>> $buckets
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||||
@ -19,36 +18,6 @@ public function __construct(
|
|||||||
private readonly ReasonPresenter $reasonPresenter,
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function trustLevelForRun(?OperationRun $run): string
|
|
||||||
{
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return TrustworthinessLevel::Unusable->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
$baselineCompare = is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [];
|
|
||||||
$coverage = is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : [];
|
|
||||||
$evidenceGaps = is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : [];
|
|
||||||
$reasonCode = is_string($baselineCompare['reason_code'] ?? null) ? trim((string) $baselineCompare['reason_code']) : null;
|
|
||||||
$proof = is_bool($coverage['proof'] ?? null) ? (bool) $coverage['proof'] : null;
|
|
||||||
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) ? $coverage['uncovered_types'] : [];
|
|
||||||
$evidenceGapCount = is_numeric($evidenceGaps['count'] ?? null) ? (int) $evidenceGaps['count'] : 0;
|
|
||||||
|
|
||||||
if ($run->status !== 'completed' || $run->outcome === 'failed') {
|
|
||||||
return TrustworthinessLevel::Unusable->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($proof === false || $reasonCode !== null) {
|
|
||||||
return TrustworthinessLevel::DiagnosticOnly->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($uncoveredTypes !== [] || $evidenceGapCount > 0) {
|
|
||||||
return TrustworthinessLevel::LimitedConfidence->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TrustworthinessLevel::Trustworthy->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
$reason = $stats->reasonCode !== null
|
$reason = $stats->reasonCode !== null
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -12,28 +12,6 @@ final class BaselineCompareSummaryAssessor
|
|||||||
{
|
{
|
||||||
private const int STALE_AFTER_DAYS = 7;
|
private const int STALE_AFTER_DAYS = 7;
|
||||||
|
|
||||||
public static function staleAfterDays(): int
|
|
||||||
{
|
|
||||||
return self::STALE_AFTER_DAYS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isStaleComparedAt(\DateTimeInterface|string|null $value): bool
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$comparedAt = $value instanceof \DateTimeInterface
|
|
||||||
? CarbonImmutable::instance(\DateTimeImmutable::createFromInterface($value))
|
|
||||||
: CarbonImmutable::parse($value);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $comparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
||||||
{
|
{
|
||||||
$explanation = $stats->operatorExplanation();
|
$explanation = $stats->operatorExplanation();
|
||||||
@ -398,6 +376,12 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::isStaleComparedAt($stats->lastComparedIso);
|
try {
|
||||||
|
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Navigation;
|
namespace App\Support\Navigation;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
|
||||||
use App\Models\BaselineProfile;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final readonly class CanonicalNavigationContext
|
final readonly class CanonicalNavigationContext
|
||||||
@ -66,31 +63,4 @@ public function toQuery(): array
|
|||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $filters
|
|
||||||
*/
|
|
||||||
public static function forBaselineCompareMatrix(
|
|
||||||
BaselineProfile $profile,
|
|
||||||
array $filters = [],
|
|
||||||
?Tenant $tenant = null,
|
|
||||||
?string $subjectKey = null,
|
|
||||||
): self {
|
|
||||||
$parameters = array_filter([
|
|
||||||
'record' => $profile,
|
|
||||||
...$filters,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
sourceSurface: 'baseline_compare_matrix',
|
|
||||||
canonicalRouteName: BaselineCompareMatrix::getRouteName(),
|
|
||||||
tenantId: $tenant?->getKey(),
|
|
||||||
backLinkLabel: 'Back to compare matrix',
|
|
||||||
backLinkUrl: BaselineCompareMatrix::getUrl($parameters, panel: 'admin'),
|
|
||||||
filterPayload: array_filter([
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use ReflectionObject;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,10 +39,6 @@ final class WorkspaceUiEnforcement
|
|||||||
|
|
||||||
private Model|Closure|null $record = null;
|
private Model|Closure|null $record = null;
|
||||||
|
|
||||||
private bool $preserveExistingVisibility = false;
|
|
||||||
|
|
||||||
private bool $preserveExistingDisabled = false;
|
|
||||||
|
|
||||||
private function __construct(Action $action)
|
private function __construct(Action $action)
|
||||||
{
|
{
|
||||||
$this->action = $action;
|
$this->action = $action;
|
||||||
@ -63,14 +58,6 @@ public static function forTableAction(Action $action, Model|Closure $record): se
|
|||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forAction(Action $action, Model|Closure|null $record = null): self
|
|
||||||
{
|
|
||||||
$instance = new self($action);
|
|
||||||
$instance->record = $record;
|
|
||||||
|
|
||||||
return $instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function requireMembership(bool $require = true): self
|
public function requireMembership(bool $require = true): self
|
||||||
{
|
{
|
||||||
$this->requireMembership = $require;
|
$this->requireMembership = $require;
|
||||||
@ -108,20 +95,6 @@ public function tooltip(string $message): self
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function preserveVisibility(): self
|
|
||||||
{
|
|
||||||
$this->preserveExistingVisibility = true;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function preserveDisabled(): self
|
|
||||||
{
|
|
||||||
$this->preserveExistingDisabled = true;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function apply(): Action
|
public function apply(): Action
|
||||||
{
|
{
|
||||||
$this->applyVisibility();
|
$this->applyVisibility();
|
||||||
@ -138,22 +111,10 @@ private function applyVisibility(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingVisibility = $this->preserveExistingVisibility
|
$this->action->visible(function (?Model $record = null): bool {
|
||||||
? $this->getExistingVisibilityCondition()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
if (! $context->isMember) {
|
return $context->isMember;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existingVisibility === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,15 +126,7 @@ private function applyDisabledState(): void
|
|||||||
|
|
||||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||||
|
|
||||||
$existingDisabled = $this->preserveExistingDisabled
|
$this->action->disabled(function (?Model $record = null): bool {
|
||||||
? $this->getExistingDisabledCondition()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool {
|
|
||||||
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
if (! $context->isMember) {
|
if (! $context->isMember) {
|
||||||
@ -220,96 +173,6 @@ private function applyServerSideGuard(): void
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getExistingVisibilityCondition(): bool|Closure|null
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$ref = new ReflectionObject($this->action);
|
|
||||||
|
|
||||||
if (! $ref->hasProperty('isVisible')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$property = $ref->getProperty('isVisible');
|
|
||||||
$property->setAccessible(true);
|
|
||||||
|
|
||||||
/** @var bool|Closure $value */
|
|
||||||
$value = $property->getValue($this->action);
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
|
||||||
{
|
|
||||||
if (is_bool($condition)) {
|
|
||||||
return $condition;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$reflection = new \ReflectionFunction($condition);
|
|
||||||
$parameters = $reflection->getParameters();
|
|
||||||
|
|
||||||
if ($parameters === []) {
|
|
||||||
return (bool) $condition();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (bool) $condition($record);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getExistingDisabledCondition(): bool|Closure|null
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$ref = new ReflectionObject($this->action);
|
|
||||||
|
|
||||||
if (! $ref->hasProperty('isDisabled')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$property = $ref->getProperty('isDisabled');
|
|
||||||
$property->setAccessible(true);
|
|
||||||
|
|
||||||
/** @var bool|Closure $value */
|
|
||||||
$value = $property->getValue($this->action);
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
|
|
||||||
{
|
|
||||||
if (is_bool($condition)) {
|
|
||||||
return $condition;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$reflection = new \ReflectionFunction($condition);
|
|
||||||
$parameters = $reflection->getParameters();
|
|
||||||
|
|
||||||
if ($parameters === []) {
|
|
||||||
return (bool) $condition();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (bool) $condition($record);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -6,12 +6,10 @@
|
|||||||
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
use ReflectionClass;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use RecursiveDirectoryIterator;
|
use RecursiveDirectoryIterator;
|
||||||
use RecursiveIteratorIterator;
|
use RecursiveIteratorIterator;
|
||||||
use SplFileInfo;
|
use SplFileInfo;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class ActionSurfaceDiscovery
|
final class ActionSurfaceDiscovery
|
||||||
{
|
{
|
||||||
@ -102,10 +100,7 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
|
|||||||
{
|
{
|
||||||
$scopes = [ActionSurfacePanelScope::Tenant];
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
||||||
|
|
||||||
if (
|
if (in_array($className, $adminScopedClasses, true)) {
|
||||||
in_array($className, $adminScopedClasses, true)
|
|
||||||
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
|
|
||||||
) {
|
|
||||||
$scopes[] = ActionSurfacePanelScope::Admin;
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,37 +228,6 @@ private function isDeclaredSystemTablePage(string $className): bool
|
|||||||
&& method_exists($className, 'actionSurfaceDeclaration');
|
&& method_exists($className, 'actionSurfaceDeclaration');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resource-owned Filament pages can live under app/Filament/Pages and be routed
|
|
||||||
* from the resource instead of being panel-registered directly. When that happens,
|
|
||||||
* inherit admin scope from the owning resource so discovery stays truthful.
|
|
||||||
*
|
|
||||||
* @param array<int, string> $adminScopedClasses
|
|
||||||
*/
|
|
||||||
private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool
|
|
||||||
{
|
|
||||||
if (! class_exists($className)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$reflection = new ReflectionClass($className);
|
|
||||||
|
|
||||||
if (! $reflection->hasProperty('resource')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$defaults = $reflection->getDefaultProperties();
|
|
||||||
$resourceClass = $defaults['resource'] ?? null;
|
|
||||||
|
|
||||||
return is_string($resourceClass)
|
|
||||||
&& $resourceClass !== ''
|
|
||||||
&& in_array($resourceClass, $adminScopedClasses, true);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -9,8 +9,6 @@
|
|||||||
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
||||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
||||||
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : 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'] : []);
|
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
||||||
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
||||||
@ -28,28 +26,6 @@
|
|||||||
};
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@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)
|
@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 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">
|
<div class="flex items-start gap-3">
|
||||||
|
|||||||
@ -1,868 +0,0 @@
|
|||||||
<x-filament::page>
|
|
||||||
@php
|
|
||||||
$reference = is_array($reference ?? null) ? $reference : [];
|
|
||||||
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
|
|
||||||
$denseRows = is_array($denseRows ?? null) ? $denseRows : [];
|
|
||||||
$compactResults = is_array($compactResults ?? null) ? $compactResults : [];
|
|
||||||
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
|
|
||||||
$tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : [];
|
|
||||||
$subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : [];
|
|
||||||
$stateLegend = is_array($stateLegend ?? null) ? $stateLegend : [];
|
|
||||||
$freshnessLegend = is_array($freshnessLegend ?? null) ? $freshnessLegend : [];
|
|
||||||
$trustLegend = is_array($trustLegend ?? null) ? $trustLegend : [];
|
|
||||||
$emptyState = is_array($emptyState ?? null) ? $emptyState : null;
|
|
||||||
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
|
|
||||||
$draftFilters = is_array($draftFilters ?? null) ? $draftFilters : [];
|
|
||||||
$presentationState = is_array($presentationState ?? null) ? $presentationState : [];
|
|
||||||
$supportSurfaceState = is_array($supportSurfaceState ?? null) ? $supportSurfaceState : [];
|
|
||||||
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
|
|
||||||
$activeFilterCount = $this->activeFilterCount();
|
|
||||||
$activeFilterSummary = $this->activeFilterSummary();
|
|
||||||
$stagedFilterSummary = $this->stagedFilterSummary();
|
|
||||||
$hasStagedFilterChanges = (bool) ($presentationState['hasStagedFilterChanges'] ?? false);
|
|
||||||
$requestedMode = (string) ($presentationState['requestedMode'] ?? 'auto');
|
|
||||||
$resolvedMode = (string) ($presentationState['resolvedMode'] ?? 'compact');
|
|
||||||
$visibleTenantCount = (int) ($presentationState['visibleTenantCount'] ?? 0);
|
|
||||||
$autoRefreshActive = (bool) ($presentationState['autoRefreshActive'] ?? false);
|
|
||||||
$lastUpdatedAt = $presentationState['lastUpdatedAt'] ?? null;
|
|
||||||
$compactModeAvailable = (bool) ($presentationState['compactModeAvailable'] ?? false);
|
|
||||||
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - $visibleTenantCount);
|
|
||||||
|
|
||||||
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
|
|
||||||
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
|
|
||||||
$trustBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixTrust, $value);
|
|
||||||
$severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value);
|
|
||||||
$profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value);
|
|
||||||
$profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null);
|
|
||||||
$modeBadgeColor = match ($resolvedMode) {
|
|
||||||
'dense' => 'info',
|
|
||||||
'compact' => 'success',
|
|
||||||
default => 'gray',
|
|
||||||
};
|
|
||||||
$modeLabel = $this->presentationModeLabel($resolvedMode);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($autoRefreshActive)
|
|
||||||
<div aria-hidden="true" wire:poll.5s="pollMatrix"></div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-filament::section heading="Reference overview">
|
|
||||||
<x-slot name="description">
|
|
||||||
Compare assigned tenants remains simulation only. This operator view changes presentation density, not compare truth, visible-set scope, or the existing drilldown path.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<x-filament::badge :color="$profileStatusSpec->color" :icon="$profileStatusSpec->icon" size="sm">
|
|
||||||
{{ $profileStatusSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge :color="$referenceReady ? 'success' : 'warning'" :icon="$referenceReady ? 'heroicon-m-check-badge' : 'heroicon-m-exclamation-triangle'" size="sm">
|
|
||||||
{{ $referenceReady ? 'Reference snapshot ready' : 'Reference snapshot blocked' }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge :color="$modeBadgeColor" size="sm">
|
|
||||||
{{ $modeLabel }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
@if (filled($reference['referenceSnapshotId'] ?? null))
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($hiddenAssignedTenantCount > 0)
|
|
||||||
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
|
|
||||||
{{ $hiddenAssignedTenantCount }} hidden by access scope
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-950 dark:text-white" data-testid="baseline-compare-matrix-profile">
|
|
||||||
{{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}.
|
|
||||||
Visible tenants: {{ $visibleTenantCount }}.
|
|
||||||
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
|
|
||||||
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
|
|
||||||
@endif
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Auto mode resolves from the visible tenant set. Manual mode stays local to this route and never becomes stored preference truth.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (filled($reference['referenceReasonCode'] ?? null))
|
|
||||||
<p class="text-sm text-warning-700 dark:text-warning-300">
|
|
||||||
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="grid gap-3 sm:grid-cols-2 xl:w-[28rem]">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tenants</dt>
|
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $visibleTenantCount }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Rendered subjects</dt>
|
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $resolvedMode === 'compact' ? count($compactResults) : count($denseRows) }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Active filters</dt>
|
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
@if ($activeFilterCount === 0)
|
|
||||||
All visible results
|
|
||||||
@else
|
|
||||||
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
|
|
||||||
@endif
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Resolved mode</dt>
|
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ $modeLabel }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(19rem,23rem)]">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="baseline-compare-matrix-mode-switcher">
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Presentation mode</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Requested: {{ $this->presentationModeLabel($requestedMode) }}. Resolved: {{ $modeLabel }}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::button tag="a" :href="$this->modeUrl('auto')" :color="$requestedMode === 'auto' ? 'primary' : 'gray'" size="sm">
|
|
||||||
Auto
|
|
||||||
</x-filament::button>
|
|
||||||
|
|
||||||
<x-filament::button tag="a" :href="$this->modeUrl('dense')" :color="$requestedMode === 'dense' ? 'primary' : 'gray'" size="sm">
|
|
||||||
Dense
|
|
||||||
</x-filament::button>
|
|
||||||
|
|
||||||
@if ($compactModeAvailable)
|
|
||||||
<x-filament::button tag="a" :href="$this->modeUrl('compact')" :color="$requestedMode === 'compact' ? 'primary' : 'gray'" size="sm">
|
|
||||||
Compact
|
|
||||||
</x-filament::button>
|
|
||||||
@else
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Compact unlocks at one visible tenant
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2" data-testid="baseline-compare-matrix-last-updated">
|
|
||||||
@if (($supportSurfaceState['showLastUpdated'] ?? true) && filled($lastUpdatedAt))
|
|
||||||
<x-filament::badge color="gray" icon="heroicon-m-clock" size="sm">
|
|
||||||
Last updated {{ \Illuminate\Support\Carbon::parse($lastUpdatedAt)->diffForHumans() }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (($supportSurfaceState['showAutoRefreshHint'] ?? false) && $autoRefreshActive)
|
|
||||||
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
|
|
||||||
Passive auto-refresh every 5 seconds
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div wire:loading.flex wire:target="refreshMatrix,applyFilters,resetFilters" class="items-center">
|
|
||||||
<x-filament::badge color="warning" icon="heroicon-m-arrow-path" size="sm">
|
|
||||||
Refreshing now
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<x-filament::button type="button" wire:click="refreshMatrix" wire:loading.attr="disabled" wire:target="refreshMatrix,applyFilters,resetFilters" color="gray" size="sm">
|
|
||||||
Refresh matrix
|
|
||||||
</x-filament::button>
|
|
||||||
|
|
||||||
@if ($hiddenAssignedTenantCount > 0)
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Visible-set only. Hidden tenants never contribute to summaries or drilldowns.
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<x-filament::section heading="Filters">
|
|
||||||
<x-slot name="description">
|
|
||||||
Heavy filters stage locally first. The matrix keeps rendering the applied scope until you explicitly apply or reset the draft.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="space-y-4" data-testid="baseline-compare-matrix-filters">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
|
|
||||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
|
||||||
<div class="space-y-2 min-w-0">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Applied matrix scope</div>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
||||||
@if ($activeFilterCount === 0)
|
|
||||||
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
|
|
||||||
@else
|
|
||||||
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are already shaping the rendered matrix.
|
|
||||||
@endif
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if ($activeFilterSummary !== [])
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($activeFilterSummary as $label => $value)
|
|
||||||
<x-filament::badge color="info" size="sm">
|
|
||||||
{{ $label }}: {{ $value }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
|
||||||
<x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
|
|
||||||
@if ($activeFilterCount === 0)
|
|
||||||
All visible results
|
|
||||||
@else
|
|
||||||
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
|
|
||||||
@endif
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Tenant sort: {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Subject sort: {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($hasStagedFilterChanges)
|
|
||||||
<div class="mt-3 rounded-xl border border-primary-200 bg-primary-50/70 px-3 py-3 dark:border-primary-900/60 dark:bg-primary-950/20" data-testid="baseline-compare-matrix-staged-filters">
|
|
||||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
|
|
||||||
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
|
|
||||||
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($stagedFilterSummary !== [])
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($stagedFilterSummary as $label => $value)
|
|
||||||
<x-filament::badge color="primary" size="sm">
|
|
||||||
{{ $label }}: {{ is_string($value) ? \Illuminate\Support\Str::headline(str_replace('_', ' ', $value)) : $value }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form wire:submit.prevent="applyFilters" class="space-y-4">
|
|
||||||
{{ $this->form }}
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
<span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
|
|
||||||
|
|
||||||
@if (filled($currentFilters['subject_key'] ?? null))
|
|
||||||
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
|
|
||||||
{{ $currentFilters['subject_key'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
|
|
||||||
Clear subject focus
|
|
||||||
</x-filament::button>
|
|
||||||
@else
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">
|
|
||||||
None set yet. Use Focus subject from a row when you want a subject-first drilldown.
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
|
||||||
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
|
|
||||||
Apply filters
|
|
||||||
</x-filament::button>
|
|
||||||
|
|
||||||
<x-filament::button type="button" wire:click="resetFilters" color="gray" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
|
|
||||||
Reset filters
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<x-filament-actions::modals />
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<x-filament::section heading="Support context">
|
|
||||||
<x-slot name="description">
|
|
||||||
Status, legends, and refresh cues stay compact so the matrix body remains the primary working surface.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current scope</div>
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ $visibleTenantCount }} visible {{ \Illuminate\Support\Str::plural('tenant', $visibleTenantCount) }}.
|
|
||||||
{{ $resolvedMode === 'dense' ? 'State-first dense scan stays active.' : 'Compact single-tenant review stays active.' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if ($policyTypeOptions !== [])
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
{{ count($policyTypeOptions) }} searchable policy types
|
|
||||||
</x-filament::badge>
|
|
||||||
@if ($hiddenAssignedTenantCount > 0)
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Visible-set only
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Refresh honesty</div>
|
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Manual refresh shows a blocking state only while you explicitly redraw. Background polling remains a passive hint.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if ($autoRefreshActive)
|
|
||||||
<div class="mt-3">
|
|
||||||
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
|
|
||||||
Compare work is still queued or running
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm open:bg-white dark:border-gray-800 dark:bg-gray-900/50 dark:open:bg-gray-900/70">
|
|
||||||
<summary class="cursor-pointer list-none">
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Grouped legend</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
State, freshness, and trust stay available on demand without pushing the matrix down the page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge color="gray" size="sm">{{ count($stateLegend) }} states</x-filament::badge>
|
|
||||||
<x-filament::badge color="gray" size="sm">{{ count($freshnessLegend) }} freshness cues</x-filament::badge>
|
|
||||||
<x-filament::badge color="gray" size="sm">{{ count($trustLegend) }} trust cues</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3">
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
|
||||||
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($stateLegend as $item)
|
|
||||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
|
||||||
{{ $item['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
|
||||||
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($freshnessLegend as $item)
|
|
||||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
|
||||||
{{ $item['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
|
||||||
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($trustLegend as $item)
|
|
||||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
|
||||||
{{ $item['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<div class="relative" data-testid="baseline-compare-matrix-results">
|
|
||||||
@if ($emptyState !== null)
|
|
||||||
<x-filament::section heading="Results">
|
|
||||||
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-8 dark:border-gray-700 dark:bg-gray-900/40">
|
|
||||||
<div class="space-y-3" data-testid="baseline-compare-matrix-empty-state">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] ?? 'Nothing to show' }}</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}</p>
|
|
||||||
|
|
||||||
@if ($activeFilterCount > 0)
|
|
||||||
<div class="pt-1">
|
|
||||||
<x-filament::button type="button" wire:click="resetFilters" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
|
|
||||||
Reset filters
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@elseif ($resolvedMode === 'compact')
|
|
||||||
@php
|
|
||||||
$compactTenant = $tenantSummaries[0] ?? null;
|
|
||||||
$compactTenantFreshnessSpec = $freshnessBadge($compactTenant['freshnessState'] ?? null);
|
|
||||||
$compactTenantTrustSpec = $trustBadge($compactTenant['trustLevel'] ?? null);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-filament::section heading="Compact compare results">
|
|
||||||
<x-slot name="description">
|
|
||||||
One visible tenant remains in scope, so the matrix collapses into a shorter subject-result list instead of a pseudo-grid.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
@if ($compactTenant)
|
|
||||||
<div class="mb-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70" data-testid="baseline-compare-matrix-compact-shell">
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $compactTenant['tenantName'] }}</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Compact mode stays visible-set only. Subject drilldowns and run links still preserve the matrix context.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge :color="$compactTenantFreshnessSpec->color" :icon="$compactTenantFreshnessSpec->icon" size="sm">
|
|
||||||
{{ $compactTenantFreshnessSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge :color="$compactTenantTrustSpec->color" :icon="$compactTenantTrustSpec->icon" size="sm">
|
|
||||||
{{ $compactTenantTrustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
@foreach ($compactResults as $result)
|
|
||||||
@php
|
|
||||||
$stateSpec = $stateBadge($result['state'] ?? null);
|
|
||||||
$freshnessSpec = $freshnessBadge($result['freshnessState'] ?? null);
|
|
||||||
$trustSpec = $trustBadge($result['trustLevel'] ?? null);
|
|
||||||
$severitySpec = filled($result['severity'] ?? null) ? $severityBadge($result['severity']) : null;
|
|
||||||
$tenantId = (int) ($result['tenantId'] ?? 0);
|
|
||||||
$subjectKey = $result['subjectKey'] ?? null;
|
|
||||||
$primaryUrl = filled($result['findingId'] ?? null)
|
|
||||||
? $this->findingUrl($tenantId, (int) $result['findingId'], $subjectKey)
|
|
||||||
: $this->tenantCompareUrl($tenantId, $subjectKey);
|
|
||||||
$runUrl = filled($result['compareRunId'] ?? null)
|
|
||||||
? $this->runUrl((int) $result['compareRunId'], $tenantId, $subjectKey)
|
|
||||||
: null;
|
|
||||||
$attentionClasses = match ((string) ($result['attentionLevel'] ?? 'review')) {
|
|
||||||
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
|
|
||||||
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
|
|
||||||
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
|
|
||||||
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
||||||
};
|
|
||||||
$attentionLabel = \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($result['attentionLevel'] ?? 'review')));
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-base font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $result['policyType'] ?? 'Unknown policy type' }}
|
|
||||||
</div>
|
|
||||||
@if (filled($result['baselineExternalId'] ?? null))
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Reference ID: {{ $result['baselineExternalId'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon" size="sm">
|
|
||||||
{{ $stateSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
|
||||||
{{ $freshnessSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
||||||
{{ $trustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@if ($severitySpec)
|
|
||||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
|
||||||
{{ $severitySpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
|
|
||||||
{{ $attentionLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($result['reasonSummary'] ?? null) || filled($result['lastComparedAt'] ?? null))
|
|
||||||
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
@if (filled($result['reasonSummary'] ?? null))
|
|
||||||
<div>{{ $result['reasonSummary'] }}</div>
|
|
||||||
@endif
|
|
||||||
@if (filled($result['lastComparedAt'] ?? null))
|
|
||||||
<div>Compared {{ \Illuminate\Support\Carbon::parse($result['lastComparedAt'])->diffForHumans() }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 xl:items-end">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Drift breadth {{ (int) ($result['deviationBreadth'] ?? 0) }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Missing {{ (int) ($result['missingBreadth'] ?? 0) }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Ambiguous {{ (int) ($result['ambiguousBreadth'] ?? 0) }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 text-sm">
|
|
||||||
@if ($primaryUrl)
|
|
||||||
<x-filament::link :href="$primaryUrl" size="sm">
|
|
||||||
{{ filled($result['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($runUrl)
|
|
||||||
<x-filament::link :href="$runUrl" color="gray" size="sm">
|
|
||||||
Open run
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled($result['subjectKey'] ?? null))
|
|
||||||
<x-filament::link :href="$this->filterUrl(['subject_key' => $result['subjectKey']])" color="gray" size="sm">
|
|
||||||
Focus subject
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@else
|
|
||||||
<x-filament::section heading="Dense multi-tenant scan">
|
|
||||||
<x-slot name="description">
|
|
||||||
The matrix body is state-first. Row click stays forbidden, the subject column stays pinned, and repeated follow-up actions move behind compact secondary reveals.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="mb-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
|
||||||
@foreach ($tenantSummaries as $tenantSummary)
|
|
||||||
@php
|
|
||||||
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
|
||||||
$trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null);
|
|
||||||
$tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null;
|
|
||||||
$tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']);
|
|
||||||
$tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null)
|
|
||||||
? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId'])
|
|
||||||
: null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
|
||||||
{{ $freshnessSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
||||||
{{ $trustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@if ($tenantSeveritySpec)
|
|
||||||
<x-filament::badge :color="$tenantSeveritySpec->color" :icon="$tenantSeveritySpec->icon" size="sm">
|
|
||||||
{{ $tenantSeveritySpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Aligned</div>
|
|
||||||
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Drift</div>
|
|
||||||
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Missing</div>
|
|
||||||
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Ambiguous</div>
|
|
||||||
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 text-sm">
|
|
||||||
@if ($tenantCompareUrl)
|
|
||||||
<x-filament::link :href="$tenantCompareUrl" size="sm">
|
|
||||||
Open tenant compare
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($tenantRunUrl)
|
|
||||||
<x-filament::link :href="$tenantRunUrl" color="gray" size="sm">
|
|
||||||
Open latest run
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
|
|
||||||
<div class="min-w-[82rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800" data-testid="baseline-compare-matrix-dense-shell">
|
|
||||||
<table class="min-w-full border-separate border-spacing-0">
|
|
||||||
<thead class="bg-gray-50 dark:bg-gray-950/70">
|
|
||||||
<tr>
|
|
||||||
<th class="sticky left-0 z-20 w-[22rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
|
|
||||||
Baseline subject
|
|
||||||
</th>
|
|
||||||
|
|
||||||
@foreach ($tenantSummaries as $tenantSummary)
|
|
||||||
@php
|
|
||||||
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<th class="min-w-[16rem] border-b border-gray-200 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
|
||||||
{{ $freshnessSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
@endforeach
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
|
||||||
@foreach ($denseRows as $row)
|
|
||||||
@php
|
|
||||||
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
|
||||||
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
|
|
||||||
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
|
|
||||||
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
|
|
||||||
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
|
|
||||||
$rowSurfaceClasses = $loop->even
|
|
||||||
? 'bg-gray-50/70 dark:bg-gray-950/20'
|
|
||||||
: 'bg-white dark:bg-gray-900/60';
|
|
||||||
$subjectAttentionClasses = match ((string) ($subject['attentionLevel'] ?? 'review')) {
|
|
||||||
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
|
|
||||||
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
|
|
||||||
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
|
|
||||||
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}" data-testid="baseline-compare-matrix-row">
|
|
||||||
<td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $subject['policyType'] ?? 'Unknown policy type' }}
|
|
||||||
</div>
|
|
||||||
@if (filled($subject['baselineExternalId'] ?? null))
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Reference ID: {{ $subject['baselineExternalId'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
Missing {{ (int) ($subject['missingBreadth'] ?? 0) }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge :color="$subjectTrustSpec->color" :icon="$subjectTrustSpec->icon" size="sm">
|
|
||||||
{{ $subjectTrustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@if ($subjectSeveritySpec)
|
|
||||||
<x-filament::badge :color="$subjectSeveritySpec->color" :icon="$subjectSeveritySpec->icon" size="sm">
|
|
||||||
{{ $subjectSeveritySpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $subjectAttentionClasses }}">
|
|
||||||
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($subject['attentionLevel'] ?? 'review'))) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($subject['subjectKey'] ?? null))
|
|
||||||
<div class="flex flex-wrap gap-3 text-sm">
|
|
||||||
<x-filament::link :href="$this->filterUrl(['subject_key' => $subject['subjectKey']])" color="gray" size="sm" data-testid="matrix-focus-subject">
|
|
||||||
Focus subject
|
|
||||||
</x-filament::link>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
@foreach ($cells as $cell)
|
|
||||||
@php
|
|
||||||
$cellStateSpec = $stateBadge($cell['state'] ?? null);
|
|
||||||
$cellFreshnessSpec = $freshnessBadge($cell['freshnessState'] ?? null);
|
|
||||||
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
|
|
||||||
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
|
|
||||||
$tenantId = (int) ($cell['tenantId'] ?? 0);
|
|
||||||
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
|
|
||||||
$primaryUrl = filled($cell['findingId'] ?? null)
|
|
||||||
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
|
|
||||||
: $this->tenantCompareUrl($tenantId, $subjectKey);
|
|
||||||
$runUrl = filled($cell['compareRunId'] ?? null)
|
|
||||||
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
|
|
||||||
: null;
|
|
||||||
$attentionClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
|
|
||||||
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
|
|
||||||
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
|
|
||||||
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
|
|
||||||
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
||||||
};
|
|
||||||
$cellSurfaceClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
|
|
||||||
'needs_attention' => 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40',
|
|
||||||
'refresh_recommended' => 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40',
|
|
||||||
'aligned' => 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10',
|
|
||||||
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40',
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
|
|
||||||
<div class="flex h-full flex-col gap-3 rounded-xl border p-3 text-xs transition-colors group-hover:border-primary-200 dark:group-hover:border-primary-900 {{ $cellSurfaceClasses }}">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
|
|
||||||
{{ $cellStateSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@if ($cellSeveritySpec)
|
|
||||||
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
|
|
||||||
{{ $cellSeveritySpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
|
|
||||||
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($cell['attentionLevel'] ?? 'review'))) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<x-filament::badge :color="$cellFreshnessSpec->color" :icon="$cellFreshnessSpec->icon" size="sm">
|
|
||||||
{{ $cellFreshnessSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
|
||||||
{{ $cellTrustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($cell['reasonSummary'] ?? null) || filled($cell['lastComparedAt'] ?? null))
|
|
||||||
<div class="space-y-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
|
||||||
@if (filled($cell['reasonSummary'] ?? null))
|
|
||||||
<div>{{ $cell['reasonSummary'] }}</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled($cell['lastComparedAt'] ?? null))
|
|
||||||
<div>Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="mt-auto space-y-2">
|
|
||||||
@if ($primaryUrl)
|
|
||||||
<div class="text-sm">
|
|
||||||
<x-filament::link :href="$primaryUrl" size="sm">
|
|
||||||
{{ filled($cell['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
|
|
||||||
</x-filament::link>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($runUrl || filled($subjectKey))
|
|
||||||
<details class="rounded-lg border border-gray-200 bg-white/70 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-950/50">
|
|
||||||
<summary class="cursor-pointer list-none text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
More follow-up
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div class="mt-2 flex flex-wrap gap-3 text-sm">
|
|
||||||
@if ($runUrl)
|
|
||||||
<x-filament::link :href="$runUrl" color="gray" size="sm">
|
|
||||||
Open run
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled($subjectKey))
|
|
||||||
<x-filament::link :href="$this->filterUrl(['subject_key' => $subjectKey])" color="gray" size="sm">
|
|
||||||
Focus subject
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
@endforeach
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::page>
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
pest()->browser()->timeout(15_000);
|
|
||||||
|
|
||||||
it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => Finding::SEVERITY_CRITICAL],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($fixture['user'])->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
|
||||||
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
|
||||||
|
|
||||||
$page = visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']));
|
|
||||||
|
|
||||||
$page
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('Dense multi-tenant scan')
|
|
||||||
->assertSee('Grouped legend')
|
|
||||||
->assertSee('Open finding')
|
|
||||||
->assertSee('More follow-up')
|
|
||||||
->click('Open finding')
|
|
||||||
->waitForText('Back to compare matrix')
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->assertSee('Back to compare matrix');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('smokes the compact single-tenant path when only one visible tenant remains', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => Finding::SEVERITY_HIGH],
|
|
||||||
);
|
|
||||||
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$viewer->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($viewer)->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
|
||||||
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
|
||||||
|
|
||||||
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
|
||||||
->assertSee('Compact compare results')
|
|
||||||
->assertSee('Open finding');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
attributes: [
|
|
||||||
'status' => \App\Support\OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
|
||||||
'completed_at' => null,
|
|
||||||
'started_at' => now(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($fixture['user'])->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
|
||||||
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
|
||||||
|
|
||||||
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->waitForText('No rows match the current filters')
|
|
||||||
->assertSee('Passive auto-refresh every 5 seconds')
|
|
||||||
->click('Reset filters')
|
|
||||||
->waitForText('Dense multi-tenant scan')
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertNoJavaScriptErrors();
|
|
||||||
});
|
|
||||||
@ -1,317 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$visibleRunTwo = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$hiddenRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['hiddenTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$visibleRunTwo,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['hiddenTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$hiddenRun,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
$wifiRow = collect($matrix['denseRows'])->first(
|
|
||||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
|
||||||
->and($matrix['reference']['visibleTenantCount'])->toBe(2)
|
|
||||||
->and(collect($matrix['tenantSummaries'])->pluck('tenantName')->all())->toEqualCanonicalizing([
|
|
||||||
(string) $fixture['visibleTenant']->name,
|
|
||||||
(string) $fixture['visibleTenantTwo']->name,
|
|
||||||
])
|
|
||||||
->and($wifiRow)->not->toBeNull()
|
|
||||||
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
|
|
||||||
->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention')
|
|
||||||
->and(count($wifiRow['cells']))->toBe(2)
|
|
||||||
->and($matrix['denseRows'])->toHaveCount(2)
|
|
||||||
->and($matrix['compactResults'])->toBeEmpty()
|
|
||||||
->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped')
|
|
||||||
->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse()
|
|
||||||
->and($matrix['lastUpdatedAt'])->not->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$matchTenant = $fixture['visibleTenant'];
|
|
||||||
$differTenant = $fixture['visibleTenantTwo'];
|
|
||||||
|
|
||||||
$missingTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Missing',
|
|
||||||
]);
|
|
||||||
$ambiguousTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Ambiguous',
|
|
||||||
]);
|
|
||||||
$notComparedTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Uncovered',
|
|
||||||
]);
|
|
||||||
$staleTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Stale',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $missingTenant->getKey() => ['role' => 'owner'],
|
|
||||||
(int) $ambiguousTenant->getKey() => ['role' => 'owner'],
|
|
||||||
(int) $notComparedTenant->getKey() => ['role' => 'owner'],
|
|
||||||
(int) $staleTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $missingTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $ambiguousTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $notComparedTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $staleTenant);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun($matchTenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
|
|
||||||
$differRun = $this->makeBaselineCompareMatrixRun($differTenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
$this->makeBaselineCompareMatrixFinding($differTenant, $fixture['profile'], $differRun, 'wifi-corp-profile', [
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
'change_type' => 'different_version',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$missingRun = $this->makeBaselineCompareMatrixRun($missingTenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
$this->makeBaselineCompareMatrixFinding($missingTenant, $fixture['profile'], $missingRun, 'wifi-corp-profile', [
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
'change_type' => 'missing_policy',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$ambiguousRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$ambiguousTenant,
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[
|
|
||||||
'baseline_compare' => [
|
|
||||||
'evidence_gaps' => [
|
|
||||||
'count' => 1,
|
|
||||||
'by_reason' => ['ambiguous_match' => 1],
|
|
||||||
'subjects' => [
|
|
||||||
$this->baselineCompareMatrixGap('deviceConfiguration', 'wifi-corp-profile', [
|
|
||||||
'reason_code' => 'ambiguous_match',
|
|
||||||
'resolution_outcome' => 'ambiguous_match',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$this->makeBaselineCompareMatrixFinding($ambiguousTenant, $fixture['profile'], $ambiguousRun, 'wifi-corp-profile', [
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
'change_type' => 'missing_policy',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$notComparedTenant,
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[
|
|
||||||
'baseline_compare' => [
|
|
||||||
'coverage' => [
|
|
||||||
'proof' => true,
|
|
||||||
'effective_types' => ['deviceConfiguration'],
|
|
||||||
'covered_types' => [],
|
|
||||||
'uncovered_types' => ['deviceConfiguration'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$staleTenant,
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
'completed_at' => $fixture['snapshot']->captured_at->copy()->subDay(),
|
|
||||||
'context' => [
|
|
||||||
'baseline_profile_id' => (int) $fixture['profile']->getKey(),
|
|
||||||
'baseline_snapshot_id' => (int) $fixture['snapshot']->getKey() - 1,
|
|
||||||
'baseline_compare' => [
|
|
||||||
'coverage' => [
|
|
||||||
'proof' => true,
|
|
||||||
'effective_types' => ['deviceConfiguration'],
|
|
||||||
'covered_types' => ['deviceConfiguration'],
|
|
||||||
'uncovered_types' => [],
|
|
||||||
],
|
|
||||||
'evidence_gaps' => [
|
|
||||||
'count' => 0,
|
|
||||||
'by_reason' => [],
|
|
||||||
'subjects' => [],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
$wifiRow = collect($matrix['denseRows'])->first(
|
|
||||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$cellsByTenant = collect($wifiRow['cells'] ?? [])
|
|
||||||
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match')
|
|
||||||
->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned')
|
|
||||||
->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ')
|
|
||||||
->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
|
||||||
->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.')
|
|
||||||
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
|
|
||||||
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
|
||||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
|
|
||||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
|
|
||||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
|
|
||||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
|
|
||||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$visibleRun,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$deviceOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
|
||||||
'policyTypes' => ['deviceConfiguration'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$driftOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
|
||||||
'states' => ['differ'],
|
|
||||||
'severities' => ['critical'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$subjectFocus = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
|
||||||
'focusedSubjectKey' => 'wifi-corp-profile',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(count($deviceOnly['denseRows']))->toBe(1)
|
|
||||||
->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
|
||||||
->and(count($driftOnly['denseRows']))->toBe(1)
|
|
||||||
->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
|
||||||
->and(count($subjectFocus['denseRows']))->toBe(1)
|
|
||||||
->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$viewer->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer);
|
|
||||||
|
|
||||||
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
|
||||||
->and($matrix['reference']['visibleTenantCount'])->toBe(1)
|
|
||||||
->and($matrix['compactResults'])->toHaveCount(2)
|
|
||||||
->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()])
|
|
||||||
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ')
|
|
||||||
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention');
|
|
||||||
});
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('fans out compare starts across the visible assigned set without creating a workspace umbrella run', function (): void {
|
|
||||||
Queue::fake();
|
|
||||||
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$readonlyTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Readonly Contoso',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $readonlyTenant->getKey() => ['role' => 'readonly'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $readonlyTenant);
|
|
||||||
|
|
||||||
$service = app(BaselineCompareService::class);
|
|
||||||
|
|
||||||
$existingRunResult = $service->startCompareForProfile(
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['user'],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($existingRunResult['ok'] ?? false)->toBeTrue();
|
|
||||||
|
|
||||||
$result = $service->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
expect($result['visibleAssignedTenantCount'])->toBe(3)
|
|
||||||
->and($result['queuedCount'])->toBe(1)
|
|
||||||
->and($result['alreadyQueuedCount'])->toBe(1)
|
|
||||||
->and($result['blockedCount'])->toBe(1);
|
|
||||||
|
|
||||||
$launchStates = collect($result['targets'])
|
|
||||||
->mapWithKeys(static fn (array $target): array => [(int) $target['tenantId'] => (string) $target['launchState']])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($launchStates[(int) $fixture['visibleTenant']->getKey()] ?? null)->toBe('queued')
|
|
||||||
->and($launchStates[(int) $fixture['visibleTenantTwo']->getKey()] ?? null)->toBe('already_queued')
|
|
||||||
->and($launchStates[(int) $readonlyTenant->getKey()] ?? null)->toBe('blocked');
|
|
||||||
|
|
||||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
|
||||||
|
|
||||||
$activeRuns = OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
expect($activeRuns)->toHaveCount(2)
|
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
|
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
|
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
|
|
||||||
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
|
|
||||||
Queue::fake();
|
|
||||||
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
Livewire::actingAs($fixture['user'])
|
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
|
||||||
->assertActionEnabled('compareAssignedTenants')
|
|
||||||
->callAction('compareAssignedTenants')
|
|
||||||
->assertStatus(200);
|
|
||||||
|
|
||||||
Queue::assertPushed(CompareBaselineToTenantJob::class, 2);
|
|
||||||
|
|
||||||
expect(OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->whereNull('tenant_id')
|
|
||||||
->count())->toBe(0);
|
|
||||||
});
|
|
||||||
@ -12,13 +12,8 @@
|
|||||||
use App\Services\Drift\DriftHasher;
|
use App\Services\Drift\DriftHasher;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
|
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
|
||||||
bindFailHardGraphClient();
|
bindFailHardGraphClient();
|
||||||
@ -105,28 +100,3 @@
|
|||||||
expect($code)->toBeString();
|
expect($code)->toBeString();
|
||||||
expect($code)->toContain('->chunk(');
|
expect($code)->toContain('->chunk(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps matrix aggregation query-bounded over the visible assigned set', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
foreach (range(1, 6) as $index) {
|
|
||||||
$tenant = \App\Models\Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Matrix Tenant '.$index,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $tenant);
|
|
||||||
$this->makeBaselineCompareMatrixRun($tenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::enableQueryLog();
|
|
||||||
DB::flushQueryLog();
|
|
||||||
|
|
||||||
app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(20);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,272 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Concerns;
|
|
||||||
|
|
||||||
use App\Models\BaselineProfile;
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineSnapshotItem;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
|
|
||||||
|
|
||||||
trait BuildsBaselineCompareMatrixFixtures
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* user: User,
|
|
||||||
* workspace: Workspace,
|
|
||||||
* profile: BaselineProfile,
|
|
||||||
* snapshot: BaselineSnapshot,
|
|
||||||
* visibleTenant: Tenant,
|
|
||||||
* visibleTenantTwo: Tenant,
|
|
||||||
* hiddenTenant: Tenant,
|
|
||||||
* subjects: array<string, BaselineSnapshotItem>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
protected function makeBaselineCompareMatrixFixture(
|
|
||||||
string $viewerRole = 'owner',
|
|
||||||
?string $workspaceRole = null,
|
|
||||||
): array {
|
|
||||||
[$user, $visibleTenant] = createUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole);
|
|
||||||
|
|
||||||
$workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id);
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => BaselineProfileStatus::Active->value,
|
|
||||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
|
||||||
'name' => 'Visible-set baseline',
|
|
||||||
'scope_jsonb' => [
|
|
||||||
'policy_types' => ['deviceConfiguration', 'compliancePolicy'],
|
|
||||||
'foundation_types' => [],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'captured_at' => now()->subHours(2),
|
|
||||||
'completed_at' => now()->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->forceFill([
|
|
||||||
'active_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$visibleTenantTwo = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Northwind',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Hidden Fabrikam',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $visibleTenantTwo->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
WorkspaceMembership::query()->updateOrCreate([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
], [
|
|
||||||
'role' => $workspaceRole ?? $viewerRole,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($profile, $visibleTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($profile, $visibleTenantTwo);
|
|
||||||
$this->assignTenantToBaselineProfile($profile, $hiddenTenant);
|
|
||||||
|
|
||||||
$subjects = [
|
|
||||||
'wifi-corp-profile' => $this->makeBaselineCompareMatrixSubject(
|
|
||||||
$snapshot,
|
|
||||||
'deviceConfiguration',
|
|
||||||
'wifi-corp-profile',
|
|
||||||
'WiFi Corp Profile',
|
|
||||||
'dc:wifi-corp-profile',
|
|
||||||
),
|
|
||||||
'windows-compliance' => $this->makeBaselineCompareMatrixSubject(
|
|
||||||
$snapshot,
|
|
||||||
'compliancePolicy',
|
|
||||||
'windows-compliance',
|
|
||||||
'Windows Compliance',
|
|
||||||
'cp:windows-compliance',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'user' => $user,
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'profile' => $profile,
|
|
||||||
'snapshot' => $snapshot,
|
|
||||||
'visibleTenant' => $visibleTenant,
|
|
||||||
'visibleTenantTwo' => $visibleTenantTwo,
|
|
||||||
'hiddenTenant' => $hiddenTenant,
|
|
||||||
'subjects' => $subjects,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function makeBaselineCompareMatrixSubject(
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
string $policyType,
|
|
||||||
string $subjectKey,
|
|
||||||
string $displayName,
|
|
||||||
?string $subjectExternalId = null,
|
|
||||||
): BaselineSnapshotItem {
|
|
||||||
return BaselineSnapshotItem::factory()->create([
|
|
||||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
'subject_external_id' => $subjectExternalId ?? $policyType.':'.$subjectKey,
|
|
||||||
'meta_jsonb' => ['display_name' => $displayName],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function assignTenantToBaselineProfile(BaselineProfile $profile, Tenant $tenant): BaselineTenantAssignment
|
|
||||||
{
|
|
||||||
return BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $profile->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $contextOverrides
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
*/
|
|
||||||
protected function makeBaselineCompareMatrixRun(
|
|
||||||
Tenant $tenant,
|
|
||||||
BaselineProfile $profile,
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
array $contextOverrides = [],
|
|
||||||
array $attributes = [],
|
|
||||||
): OperationRun {
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'initiator_name' => 'Spec190 Matrix',
|
|
||||||
'summary_counts' => [
|
|
||||||
'matched_items' => 1,
|
|
||||||
'different_items' => 0,
|
|
||||||
'missing_items' => 0,
|
|
||||||
'unexpected_items' => 0,
|
|
||||||
],
|
|
||||||
'context' => array_replace_recursive([
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'baseline_compare' => [
|
|
||||||
'reason_code' => null,
|
|
||||||
'subjects_total' => 2,
|
|
||||||
'fidelity' => 'content',
|
|
||||||
'coverage' => [
|
|
||||||
'proof' => true,
|
|
||||||
'effective_types' => ['deviceConfiguration', 'compliancePolicy'],
|
|
||||||
'covered_types' => ['deviceConfiguration', 'compliancePolicy'],
|
|
||||||
'uncovered_types' => [],
|
|
||||||
],
|
|
||||||
'evidence_gaps' => [
|
|
||||||
'count' => 0,
|
|
||||||
'by_reason' => [],
|
|
||||||
'subjects' => [],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $contextOverrides),
|
|
||||||
'started_at' => now()->subMinutes(5),
|
|
||||||
'completed_at' => now()->subMinute(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return OperationRun::factory()->create(array_replace_recursive($defaults, $attributes));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $overrides
|
|
||||||
*/
|
|
||||||
protected function makeBaselineCompareMatrixFinding(
|
|
||||||
Tenant $tenant,
|
|
||||||
BaselineProfile $profile,
|
|
||||||
OperationRun $run,
|
|
||||||
string $subjectKey,
|
|
||||||
array $overrides = [],
|
|
||||||
): Finding {
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
'source' => 'baseline.compare',
|
|
||||||
'scope_key' => 'baseline_profile:'.(int) $profile->getKey(),
|
|
||||||
'baseline_operation_run_id' => (int) $run->getKey(),
|
|
||||||
'current_operation_run_id' => (int) $run->getKey(),
|
|
||||||
'subject_type' => 'policy',
|
|
||||||
'subject_external_id' => 'subject:'.$subjectKey,
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
'change_type' => 'different_version',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
return Finding::factory()->create(array_replace_recursive($defaults, $overrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $overrides
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function baselineCompareMatrixGap(string $policyType, string $subjectKey, array $overrides = []): array
|
|
||||||
{
|
|
||||||
return BaselineSubjectResolutionFixtures::structuredGap(array_replace([
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
], $overrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setAdminWorkspaceContext(User $user, Workspace|int $workspace, ?Tenant $rememberedTenant = null): array
|
|
||||||
{
|
|
||||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
|
||||||
|
|
||||||
$session = [
|
|
||||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($rememberedTenant instanceof Tenant) {
|
|
||||||
$session[WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY] = [
|
|
||||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->actingAs($user)->withSession($session);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
|
||||||
|
|
||||||
if ($rememberedTenant instanceof Tenant) {
|
|
||||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
||||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Filament::setCurrentPanel('admin');
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
|
|
||||||
return $session;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,279 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Visible-set baseline')
|
|
||||||
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
|
|
||||||
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
|
|
||||||
->assertDontSee('Passive auto-refresh every 5 seconds')
|
|
||||||
->assertSee('Grouped legend')
|
|
||||||
->assertSee('Apply filters')
|
|
||||||
->assertSee('Compact unlocks at one visible tenant')
|
|
||||||
->assertSee('Dense multi-tenant scan')
|
|
||||||
->assertSee('Open finding')
|
|
||||||
->assertSee('More follow-up')
|
|
||||||
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false)
|
|
||||||
->assertSee('sticky left-0', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$finding = $this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
|
||||||
|
|
||||||
$component = Livewire::withQueryParams([
|
|
||||||
'mode' => 'dense',
|
|
||||||
'policy_type' => ['deviceConfiguration'],
|
|
||||||
'state' => ['differ'],
|
|
||||||
'severity' => ['high'],
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
])
|
|
||||||
->actingAs($fixture['user'])
|
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
|
||||||
->assertSet('requestedMode', 'dense')
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('Focused subject')
|
|
||||||
->assertSee('wifi-corp-profile');
|
|
||||||
|
|
||||||
expect($component->instance()->hasStagedFilterChanges())->toBeFalse();
|
|
||||||
|
|
||||||
$component
|
|
||||||
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
|
|
||||||
->set('draftSelectedStates', ['match'])
|
|
||||||
->set('draftSelectedSeverities', [])
|
|
||||||
->set('draftTenantSort', 'freshness_urgency')
|
|
||||||
->set('draftSubjectSort', 'display_name')
|
|
||||||
->assertSee('Draft filters are staged');
|
|
||||||
|
|
||||||
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
|
|
||||||
|
|
||||||
$component->call('applyFilters')->assertRedirect(
|
|
||||||
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile'
|
|
||||||
);
|
|
||||||
|
|
||||||
$applied = Livewire::withQueryParams([
|
|
||||||
'mode' => 'dense',
|
|
||||||
'policy_type' => ['compliancePolicy'],
|
|
||||||
'state' => ['match'],
|
|
||||||
'tenant_sort' => 'freshness_urgency',
|
|
||||||
'subject_sort' => 'display_name',
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
])
|
|
||||||
->actingAs($fixture['user'])
|
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]);
|
|
||||||
|
|
||||||
$tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
|
||||||
$findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
|
||||||
|
|
||||||
expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense')
|
|
||||||
->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile')
|
|
||||||
->and(urldecode((string) $findingUrl))->toContain('mode=dense')
|
|
||||||
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
|
||||||
|
|
||||||
$applied->call('resetFilters')->assertRedirect(
|
|
||||||
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$viewer->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Requested: Auto mode. Resolved: Compact mode.')
|
|
||||||
->assertSee('Compact compare results')
|
|
||||||
->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false)
|
|
||||||
->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$fixture['snapshot']->markIncomplete();
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('No usable reference snapshot')
|
|
||||||
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$fixture['profile']->tenantAssignments()->delete();
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('No assigned tenants');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('No visible assigned tenants');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
attributes: [
|
|
||||||
'status' => \App\Support\OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
|
||||||
'completed_at' => null,
|
|
||||||
'started_at' => now(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Passive auto-refresh every 5 seconds')
|
|
||||||
->assertSee('wire:poll.5s="pollMatrix"', false)
|
|
||||||
->assertSee('Refresh matrix');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('No rows match the current filters')
|
|
||||||
->assertSee('Reset filters');
|
|
||||||
});
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
@ -9,7 +8,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -134,64 +132,3 @@
|
|||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows open-compare-matrix and compare-assigned-tenants header actions with simulation-only copy', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
||||||
|
|
||||||
BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
|
||||||
->assertActionExists('openCompareMatrix', fn (Action $action): bool => $action->getLabel() === 'Open compare matrix'
|
|
||||||
&& $action->getUrl() === BaselineProfileResource::compareMatrixUrl($profile))
|
|
||||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
|
||||||
&& $action->isConfirmationRequired()
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
||||||
|
|
||||||
BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
|
||||||
->assertActionVisible('openCompareMatrix')
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
|
||||||
->assertActionDisabled('compareAssignedTenants');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -78,8 +78,6 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
@ -209,54 +207,6 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('discovers the baseline compare matrix page as an admin-scoped drilldown-only surface', function (): void {
|
|
||||||
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
|
||||||
->keyBy('className');
|
|
||||||
|
|
||||||
$matrixPage = $components->get(\App\Filament\Pages\BaselineCompareMatrix::class);
|
|
||||||
|
|
||||||
expect($matrixPage)->not->toBeNull('BaselineCompareMatrix should be discovered by action surface discovery')
|
|
||||||
->and($matrixPage?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
|
|
||||||
|
|
||||||
$declaration = \App\Filament\Pages\BaselineCompareMatrix::actionSurfaceDeclaration();
|
|
||||||
|
|
||||||
expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? ''))
|
|
||||||
->toContain('compare fan-out')
|
|
||||||
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
|
|
||||||
->toContain('forbids row click');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
||||||
|
|
||||||
BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(\App\Filament\Pages\BaselineCompareMatrix::class, ['record' => $profile->getKey()])
|
|
||||||
->assertActionVisible('backToBaselineProfile')
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
|
||||||
->assertActionDisabled('compareAssignedTenants')
|
|
||||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to compare the visible assigned set.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
it('does not contain ad-hoc status-like badge semantics', function () {
|
it('does not contain ad-hoc status-like badge semantics', function () {
|
||||||
@ -115,9 +113,3 @@
|
|||||||
|
|
||||||
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
|
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps baseline compare matrix state and freshness surfaces on centralized badge domains', function (): void {
|
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\Badges\OperatorStateClassification;
|
use App\Support\Badges\OperatorStateClassification;
|
||||||
|
|
||||||
@ -43,15 +41,3 @@
|
|||||||
|
|
||||||
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
|
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps baseline compare matrix trust aligned with the adopted trust taxonomy and qualified labels', function (): void {
|
|
||||||
$matrixTrust = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, 'diagnostic_only');
|
|
||||||
$operatorTrust = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, 'diagnostic_only');
|
|
||||||
$matrixMissing = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing');
|
|
||||||
$matrixStale = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result');
|
|
||||||
|
|
||||||
expect($matrixTrust->label)->toBe($operatorTrust->label)
|
|
||||||
->and($matrixTrust->color)->toBe($operatorTrust->color)
|
|
||||||
->and($matrixMissing->label)->toBe('Missing from tenant')
|
|
||||||
->and($matrixStale->label)->toBe('Result stale');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('returns 404 for non-members on the workspace baseline compare matrix', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$nonMember = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($nonMember)->withSession([
|
|
||||||
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for workspace members missing baseline view capability on the matrix route', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
\App\Models\WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'readonly',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')->andReturnFalse();
|
|
||||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$this->actingAs($viewer)->withSession([
|
|
||||||
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for matrix tenant drilldowns when the actor is not a tenant member', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$nonMember = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($nonMember)->withSession([
|
|
||||||
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
|
|
||||||
$fixture['profile'],
|
|
||||||
subjectKey: 'wifi-corp-profile',
|
|
||||||
)->toQuery();
|
|
||||||
|
|
||||||
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for matrix tenant drilldowns when tenant view capability is missing', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(CapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')->andReturnFalse();
|
|
||||||
app()->instance(CapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$this->actingAs($fixture['user']);
|
|
||||||
$fixture['visibleTenant']->makeCurrent();
|
|
||||||
|
|
||||||
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
|
|
||||||
$fixture['profile'],
|
|
||||||
subjectKey: 'wifi-corp-profile',
|
|
||||||
tenant: $fixture['visibleTenant'],
|
|
||||||
)->toQuery();
|
|
||||||
|
|
||||||
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for matrix finding drilldowns when findings view capability is missing', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$finding = $this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($fixture['user']);
|
|
||||||
$fixture['visibleTenant']->makeCurrent();
|
|
||||||
|
|
||||||
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
|
|
||||||
|
|
||||||
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
|
|
||||||
$fixture['profile'],
|
|
||||||
subjectKey: 'wifi-corp-profile',
|
|
||||||
tenant: $fixture['visibleTenant'],
|
|
||||||
)->toQuery();
|
|
||||||
|
|
||||||
$this->get(FindingResource::getUrl('view', [
|
|
||||||
'record' => $finding,
|
|
||||||
...$query,
|
|
||||||
], tenant: $fixture['visibleTenant']))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
it('maps baseline compare matrix state badges through centralized semantics', function (): void {
|
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'match')->label)->toBe('Reference aligned')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing')->label)->toBe('Missing from tenant')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'ambiguous')->label)->toBe('Identity ambiguous')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'not_compared')->label)->toBe('Not compared')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps baseline compare matrix freshness badges through centralized semantics', function (): void {
|
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'fresh')->label)->toBe('Current result')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'never_compared')->label)->toBe('Not compared yet')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'unknown')->label)->toBe('Freshness unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses operator trustworthiness semantics for matrix trust badges', function (): void {
|
|
||||||
foreach (['trustworthy', 'limited_confidence', 'diagnostic_only', 'unusable'] as $state) {
|
|
||||||
$matrixSpec = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, $state);
|
|
||||||
$operatorSpec = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state);
|
|
||||||
|
|
||||||
expect($matrixSpec->label)->toBe($operatorSpec->label)
|
|
||||||
->and($matrixSpec->color)->toBe($operatorSpec->color)
|
|
||||||
->and($matrixSpec->icon)->toBe($operatorSpec->icon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
# Specification Quality Checklist: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
||||||
**Created**: 2026-04-11
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] No implementation details (languages, frameworks, APIs)
|
|
||||||
- [x] Focused on user value and business needs
|
|
||||||
- [x] Written for non-technical stakeholders
|
|
||||||
- [x] All mandatory sections completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Success criteria are measurable
|
|
||||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
||||||
- [x] All acceptance scenarios are defined
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded
|
|
||||||
- [x] Dependencies and assumptions identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] All functional requirements have clear acceptance criteria
|
|
||||||
- [x] User scenarios cover primary flows
|
|
||||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
||||||
- [x] No implementation details leak into specification
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Validation pass completed on 2026-04-11.
|
|
||||||
- Route names, capability names, and action-surface identifiers remain in the spec because the repository template and constitution require explicit operator-surface, RBAC, and action-surface contracts.
|
|
||||||
- The spec intentionally keeps the matrix derived from existing baseline, compare, finding, and run truth and rejects new persisted cross-tenant compare artifacts in V1.
|
|
||||||
@ -1,468 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Workspace Baseline Compare Matrix Internal Surface Contract
|
|
||||||
version: 0.1.0
|
|
||||||
summary: Internal logical contract for workspace baseline compare matrix reads, drilldown context, and compare-all launch behavior
|
|
||||||
description: |
|
|
||||||
This contract is an internal planning artifact for Spec 190. The affected routes still
|
|
||||||
render HTML through Filament and Livewire. The schemas below define the bounded read
|
|
||||||
models and action payloads that must be derivable from existing baseline, compare,
|
|
||||||
finding, and run truth before the matrix surface can render or trigger compare-all.
|
|
||||||
servers:
|
|
||||||
- url: /internal
|
|
||||||
x-baseline-compare-matrix-consumers:
|
|
||||||
- surface: baseline.profile.detail
|
|
||||||
sourceFiles:
|
|
||||||
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
|
||||||
mustRender:
|
|
||||||
- baseline_profile_id
|
|
||||||
- reference_snapshot_id
|
|
||||||
- visible_tenant_count
|
|
||||||
- open_compare_matrix_action
|
|
||||||
- compare_assigned_tenants_action
|
|
||||||
- surface: baseline.compare.matrix
|
|
||||||
sourceFiles:
|
|
||||||
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
|
||||||
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
|
||||||
mustRender:
|
|
||||||
- reference
|
|
||||||
- filters
|
|
||||||
- tenant_summaries
|
|
||||||
- subject_summaries
|
|
||||||
- matrix_rows
|
|
||||||
- freshness_legend
|
|
||||||
- trust_legend
|
|
||||||
mustAccept:
|
|
||||||
- policy_type
|
|
||||||
- state
|
|
||||||
- severity
|
|
||||||
- tenant_sort
|
|
||||||
- subject_sort
|
|
||||||
- subject_key
|
|
||||||
- surface: tenant.compare.drilldown
|
|
||||||
sourceFiles:
|
|
||||||
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
|
|
||||||
- apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
|
|
||||||
mustRender:
|
|
||||||
- matrix_source_context
|
|
||||||
- baseline_profile_id
|
|
||||||
- subject_key
|
|
||||||
- back_link
|
|
||||||
paths:
|
|
||||||
/admin/baseline-profiles/{profile}/compare-matrix:
|
|
||||||
get:
|
|
||||||
summary: Render the workspace baseline compare matrix for one selected baseline profile
|
|
||||||
operationId: viewBaselineCompareMatrix
|
|
||||||
parameters:
|
|
||||||
- name: profile
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
- name: policy_type
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
- name: state
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
- name: severity
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/FindingSeverity'
|
|
||||||
- name: tenant_sort
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/TenantSort'
|
|
||||||
- name: subject_sort
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SubjectSort'
|
|
||||||
- name: subject_key
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: Optional focused subject row selector for subject-first drilldown within the same matrix route.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Rendered workspace matrix plus a fully derived matrix bundle
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
application/vnd.tenantpilot.baseline-compare-matrix+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/BaselineCompareMatrixBundle'
|
|
||||||
'403':
|
|
||||||
description: Actor is in scope but lacks workspace baseline view capability
|
|
||||||
'404':
|
|
||||||
description: Workspace or baseline profile is outside actor scope
|
|
||||||
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-assigned-tenants:
|
|
||||||
post:
|
|
||||||
summary: Launch compare for all visible assigned tenants under the selected baseline profile
|
|
||||||
operationId: compareAssignedTenants
|
|
||||||
parameters:
|
|
||||||
- name: workspace
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
- name: profile
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Compare-all launch summary derived from underlying tenant compare starts
|
|
||||||
content:
|
|
||||||
application/vnd.tenantpilot.compare-assigned-tenants+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CompareAssignedTenantsLaunchResult'
|
|
||||||
'403':
|
|
||||||
description: Actor is in scope but lacks workspace baseline manage capability
|
|
||||||
'404':
|
|
||||||
description: Workspace or baseline profile is outside actor scope
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
MatrixCellState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- match
|
|
||||||
- differ
|
|
||||||
- missing
|
|
||||||
- ambiguous
|
|
||||||
- not_compared
|
|
||||||
- stale_result
|
|
||||||
FindingSeverity:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- low
|
|
||||||
- medium
|
|
||||||
- high
|
|
||||||
- critical
|
|
||||||
FreshnessState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- fresh
|
|
||||||
- stale
|
|
||||||
- never_compared
|
|
||||||
- unknown
|
|
||||||
TrustLevel:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- trustworthy
|
|
||||||
- limited_confidence
|
|
||||||
- diagnostic_only
|
|
||||||
- unusable
|
|
||||||
TenantSort:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- tenant_name
|
|
||||||
- deviation_count
|
|
||||||
- freshness_urgency
|
|
||||||
SubjectSort:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- deviation_breadth
|
|
||||||
- policy_type
|
|
||||||
- display_name
|
|
||||||
MatrixReference:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- workspaceId
|
|
||||||
- baselineProfileId
|
|
||||||
- baselineProfileName
|
|
||||||
- baselineStatus
|
|
||||||
- referenceState
|
|
||||||
- assignedTenantCount
|
|
||||||
- visibleTenantCount
|
|
||||||
properties:
|
|
||||||
workspaceId:
|
|
||||||
type: integer
|
|
||||||
baselineProfileId:
|
|
||||||
type: integer
|
|
||||||
baselineProfileName:
|
|
||||||
type: string
|
|
||||||
baselineStatus:
|
|
||||||
type: string
|
|
||||||
referenceSnapshotId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
referenceSnapshotCapturedAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
referenceState:
|
|
||||||
type: string
|
|
||||||
referenceReasonCode:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
assignedTenantCount:
|
|
||||||
type: integer
|
|
||||||
visibleTenantCount:
|
|
||||||
type: integer
|
|
||||||
MatrixTenantSummary:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- tenantId
|
|
||||||
- tenantName
|
|
||||||
- freshnessState
|
|
||||||
- matchedCount
|
|
||||||
- differingCount
|
|
||||||
- missingCount
|
|
||||||
- ambiguousCount
|
|
||||||
- notComparedCount
|
|
||||||
- trustLevel
|
|
||||||
properties:
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
tenantName:
|
|
||||||
type: string
|
|
||||||
compareRunId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
compareRunStatus:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
compareRunOutcome:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
freshnessState:
|
|
||||||
$ref: '#/components/schemas/FreshnessState'
|
|
||||||
lastComparedAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
matchedCount:
|
|
||||||
type: integer
|
|
||||||
differingCount:
|
|
||||||
type: integer
|
|
||||||
missingCount:
|
|
||||||
type: integer
|
|
||||||
ambiguousCount:
|
|
||||||
type: integer
|
|
||||||
notComparedCount:
|
|
||||||
type: integer
|
|
||||||
maxSeverity:
|
|
||||||
anyOf:
|
|
||||||
- $ref: '#/components/schemas/FindingSeverity'
|
|
||||||
- type: 'null'
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
MatrixSubjectSummary:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- subjectKey
|
|
||||||
- policyType
|
|
||||||
- deviationBreadth
|
|
||||||
- missingBreadth
|
|
||||||
- ambiguousBreadth
|
|
||||||
- notComparedBreadth
|
|
||||||
- trustLevel
|
|
||||||
properties:
|
|
||||||
subjectKey:
|
|
||||||
type: string
|
|
||||||
policyType:
|
|
||||||
type: string
|
|
||||||
displayName:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
baselineExternalId:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
deviationBreadth:
|
|
||||||
type: integer
|
|
||||||
missingBreadth:
|
|
||||||
type: integer
|
|
||||||
ambiguousBreadth:
|
|
||||||
type: integer
|
|
||||||
notComparedBreadth:
|
|
||||||
type: integer
|
|
||||||
maxSeverity:
|
|
||||||
anyOf:
|
|
||||||
- $ref: '#/components/schemas/FindingSeverity'
|
|
||||||
- type: 'null'
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
MatrixCell:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- tenantId
|
|
||||||
- subjectKey
|
|
||||||
- state
|
|
||||||
- trustLevel
|
|
||||||
- policyTypeCovered
|
|
||||||
properties:
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
subjectKey:
|
|
||||||
type: string
|
|
||||||
state:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
severity:
|
|
||||||
anyOf:
|
|
||||||
- $ref: '#/components/schemas/FindingSeverity'
|
|
||||||
- type: 'null'
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
reasonCode:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
compareRunId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
findingId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
findingWorkflowState:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
lastComparedAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
policyTypeCovered:
|
|
||||||
type: boolean
|
|
||||||
MatrixRow:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- subject
|
|
||||||
- cells
|
|
||||||
properties:
|
|
||||||
subject:
|
|
||||||
$ref: '#/components/schemas/MatrixSubjectSummary'
|
|
||||||
cells:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixCell'
|
|
||||||
MatrixFilterState:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
policyTypes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
states:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
severities:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/FindingSeverity'
|
|
||||||
tenantSort:
|
|
||||||
$ref: '#/components/schemas/TenantSort'
|
|
||||||
subjectSort:
|
|
||||||
$ref: '#/components/schemas/SubjectSort'
|
|
||||||
focusedSubjectKey:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
BaselineCompareMatrixBundle:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- reference
|
|
||||||
- filters
|
|
||||||
- tenantSummaries
|
|
||||||
- subjectSummaries
|
|
||||||
- rows
|
|
||||||
properties:
|
|
||||||
reference:
|
|
||||||
$ref: '#/components/schemas/MatrixReference'
|
|
||||||
filters:
|
|
||||||
$ref: '#/components/schemas/MatrixFilterState'
|
|
||||||
tenantSummaries:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixTenantSummary'
|
|
||||||
subjectSummaries:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixSubjectSummary'
|
|
||||||
rows:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixRow'
|
|
||||||
CompareAssignedTenantTarget:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- tenantId
|
|
||||||
- launchState
|
|
||||||
properties:
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
runId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
launchState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- queued
|
|
||||||
- already_queued
|
|
||||||
- blocked
|
|
||||||
reasonCode:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
CompareAssignedTenantsLaunchResult:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- baselineProfileId
|
|
||||||
- visibleAssignedTenantCount
|
|
||||||
- queuedCount
|
|
||||||
- alreadyQueuedCount
|
|
||||||
- blockedCount
|
|
||||||
- targets
|
|
||||||
properties:
|
|
||||||
baselineProfileId:
|
|
||||||
type: integer
|
|
||||||
visibleAssignedTenantCount:
|
|
||||||
type: integer
|
|
||||||
queuedCount:
|
|
||||||
type: integer
|
|
||||||
alreadyQueuedCount:
|
|
||||||
type: integer
|
|
||||||
blockedCount:
|
|
||||||
type: integer
|
|
||||||
targets:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/CompareAssignedTenantTarget'
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
# Data Model: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This feature introduces no new persisted entity. The matrix is a derived workspace-scoped read model over existing baseline reference truth, tenant compare execution truth, and compare-created finding truth.
|
|
||||||
|
|
||||||
## Existing Source Truths
|
|
||||||
|
|
||||||
### Baseline reference truth
|
|
||||||
|
|
||||||
**Types**: Existing workspace-owned source of truth
|
|
||||||
**Sources**: `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotTruthResolver`, `BaselineSnapshotItem`
|
|
||||||
|
|
||||||
| Source | Purpose | Key Fields |
|
|
||||||
|--------|---------|------------|
|
|
||||||
| `BaselineProfile` | Selects the reference standard and active snapshot pointer | `id`, `workspace_id`, `name`, `status`, `active_snapshot_id`, `scope_jsonb`, `capture_mode` |
|
|
||||||
| `BaselineSnapshot` | Represents the effective baseline reference state | `id`, `baseline_profile_id`, `workspace_id`, `state`, `completed_at`, `operation_run_id` |
|
|
||||||
| `BaselineSnapshotItem` | Defines the subject row axis for the matrix | `baseline_snapshot_id`, `policy_type`, `subject_key`, `external_id`, `display_name` |
|
|
||||||
|
|
||||||
### Assignment truth
|
|
||||||
|
|
||||||
**Type**: Existing workspace-to-tenant mapping truth
|
|
||||||
**Source**: `BaselineTenantAssignment`
|
|
||||||
|
|
||||||
| Source | Purpose | Key Fields |
|
|
||||||
|--------|---------|------------|
|
|
||||||
| `BaselineTenantAssignment` | Defines which tenants belong in the matrix target set | `workspace_id`, `tenant_id`, `baseline_profile_id`, `override_scope_jsonb` |
|
|
||||||
|
|
||||||
### Tenant compare truth
|
|
||||||
|
|
||||||
**Type**: Existing tenant-owned execution and diagnostic truth
|
|
||||||
**Sources**: `OperationRun` with `type = baseline_compare`, `BaselineCompareStats`, `baseline_compare` run context
|
|
||||||
|
|
||||||
| Source | Purpose | Key Fields |
|
|
||||||
|--------|---------|------------|
|
|
||||||
| `OperationRun` | Stores compare lifecycle, timestamps, summary counts, and context | `tenant_id`, `workspace_id`, `type`, `status`, `outcome`, `completed_at`, `summary_counts`, `context` |
|
|
||||||
| `OperationRun.context.baseline_compare` | Stores compare diagnostics needed for trust and no-result interpretation | `coverage`, `reason_code`, `reason_translation`, `evidence_gaps`, `subjects_total`, `fidelity`, `inventory_sync_run_id` |
|
|
||||||
| `BaselineCompareStats` | Existing single-tenant projection for freshness, coverage, findings count, and operator explanation | `state`, `reasonCode`, `reasonMessage`, `lastComparedIso`, `coverageStatus`, `evidenceGapDetails`, `operatorExplanation` |
|
|
||||||
|
|
||||||
### Drift finding truth
|
|
||||||
|
|
||||||
**Type**: Existing tenant-owned technical drift and workflow truth
|
|
||||||
**Source**: `Finding` with `finding_type = drift` and `source = baseline.compare`
|
|
||||||
|
|
||||||
| Source | Purpose | Key Fields |
|
|
||||||
|--------|---------|------------|
|
|
||||||
| `Finding` | Stores subject-level technical differences plus workflow metadata | `tenant_id`, `workspace_id`, `scope_key`, `subject_external_id`, `subject_type`, `severity`, `status`, `baseline_operation_run_id`, `current_operation_run_id`, `evidence_jsonb` |
|
|
||||||
|
|
||||||
## New Derived Read Models
|
|
||||||
|
|
||||||
### BaselineCompareMatrixReference
|
|
||||||
|
|
||||||
**Type**: Request-scoped reference bundle
|
|
||||||
**Source**: `BaselineProfile` + resolved effective `BaselineSnapshot`
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `workspaceId` | integer | Active workspace scope |
|
|
||||||
| `baselineProfileId` | integer | Selected baseline profile |
|
|
||||||
| `baselineProfileName` | string | Operator-facing reference label |
|
|
||||||
| `baselineStatus` | string | Existing profile lifecycle/status |
|
|
||||||
| `referenceSnapshotId` | integer or null | Null when compare is blocked |
|
|
||||||
| `referenceSnapshotCapturedAt` | datetime or null | For freshness context |
|
|
||||||
| `referenceState` | string | `ready` or a blocked state such as `no_snapshot` |
|
|
||||||
| `referenceReasonCode` | string or null | Existing compare-snapshot truth reason |
|
|
||||||
| `assignedTenantCount` | integer | Total assigned tenant count in workspace |
|
|
||||||
| `visibleTenantCount` | integer | Count after visibility filtering |
|
|
||||||
|
|
||||||
### MatrixTenantSummary
|
|
||||||
|
|
||||||
**Type**: Request-scoped visible tenant summary
|
|
||||||
**Source**: visible tenant set + latest relevant compare run + derived cell states
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `tenantId` | integer | Visible tenant identifier |
|
|
||||||
| `tenantName` | string | Operator-facing column label |
|
|
||||||
| `compareRunId` | integer or null | Latest relevant compare run for this baseline |
|
|
||||||
| `compareRunStatus` | string or null | `queued`, `running`, `completed`, or null |
|
|
||||||
| `compareRunOutcome` | string or null | Existing `OperationRun` outcome |
|
|
||||||
| `freshnessState` | string | `fresh`, `stale`, `never_compared`, or `unknown` |
|
|
||||||
| `lastComparedAt` | datetime or null | Latest completed compare time |
|
|
||||||
| `matchedCount` | integer | Derived from cell states |
|
|
||||||
| `differingCount` | integer | Derived from cell states |
|
|
||||||
| `missingCount` | integer | Derived from cell states |
|
|
||||||
| `ambiguousCount` | integer | Derived from cell states |
|
|
||||||
| `notComparedCount` | integer | Derived from cell states |
|
|
||||||
| `maxSeverity` | string or null | Highest visible severity among differing cells |
|
|
||||||
| `trustLevel` | string | Existing trustworthiness semantics reused at tenant level |
|
|
||||||
|
|
||||||
### MatrixSubjectSummary
|
|
||||||
|
|
||||||
**Type**: Request-scoped baseline subject summary
|
|
||||||
**Source**: `BaselineSnapshotItem` + visible tenant cell states
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `subjectKey` | string | Reused existing subject identity |
|
|
||||||
| `policyType` | string | For filter and grouping |
|
|
||||||
| `displayName` | string or null | Operator-facing row label |
|
|
||||||
| `baselineExternalId` | string or null | Secondary drilldown metadata |
|
|
||||||
| `deviationBreadth` | integer | Count of visible tenants in `differ` or `missing` |
|
|
||||||
| `missingBreadth` | integer | Count of visible tenants in `missing` |
|
|
||||||
| `ambiguousBreadth` | integer | Count of visible tenants in `ambiguous` |
|
|
||||||
| `notComparedBreadth` | integer | Count of visible tenants in `not_compared` |
|
|
||||||
| `maxSeverity` | string or null | Highest visible severity across differing cells |
|
|
||||||
| `trustLevel` | string | Highest-risk trust signal across visible cells |
|
|
||||||
|
|
||||||
### MatrixCell
|
|
||||||
|
|
||||||
**Type**: Request-scoped cell read model
|
|
||||||
**Source**: `BaselineSnapshotItem` + latest relevant compare run context + compare-created findings
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `tenantId` | integer | Visible tenant column key |
|
|
||||||
| `subjectKey` | string | Subject row key |
|
|
||||||
| `state` | string | `match`, `differ`, `missing`, `ambiguous`, `not_compared`, or `stale_result` |
|
|
||||||
| `severity` | string or null | From current technical drift finding when present |
|
|
||||||
| `trustLevel` | string | Reused trustworthiness semantics or cell-level downgraded trust |
|
|
||||||
| `reasonCode` | string or null | Existing reason/evidence-gap code when the state is not a plain match |
|
|
||||||
| `compareRunId` | integer or null | Latest relevant compare run |
|
|
||||||
| `findingId` | integer or null | Existing finding drilldown target when a related finding exists |
|
|
||||||
| `findingWorkflowState` | string or null | Secondary workflow context; never overrides technical state |
|
|
||||||
| `lastComparedAt` | datetime or null | Latest compare timestamp for this tenant |
|
|
||||||
| `policyTypeCovered` | boolean | False when the run never covered this subject's policy type |
|
|
||||||
|
|
||||||
### CompareAssignedTenantsLaunchResult
|
|
||||||
|
|
||||||
**Type**: Request-scoped action result bundle
|
|
||||||
**Source**: repeated calls to existing `BaselineCompareService::startCompare()`
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `baselineProfileId` | integer | Selected baseline profile |
|
|
||||||
| `visibleAssignedTenantCount` | integer | Size of eligible visible set considered by the action |
|
|
||||||
| `queuedCount` | integer | New compare runs started |
|
|
||||||
| `alreadyQueuedCount` | integer | Existing active runs reused |
|
|
||||||
| `blockedCount` | integer | Compare starts refused by normal service preconditions |
|
|
||||||
| `targets` | array | Per-tenant action outcome bundle with tenant id, run id or reason code |
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
### Visibility rules
|
|
||||||
|
|
||||||
- Only tenants visible to the current actor may produce columns, counts, or drilldown targets.
|
|
||||||
- Summary counts are always computed from the visible tenant set only.
|
|
||||||
- Hidden tenants are never represented as anonymous remainder counts.
|
|
||||||
|
|
||||||
### Reference rules
|
|
||||||
|
|
||||||
- The matrix may render technical compare truth only when the selected baseline profile resolves to a usable effective snapshot.
|
|
||||||
- If no usable snapshot exists, the page remains in a blocked state and no cell state may imply compare health.
|
|
||||||
|
|
||||||
### Cell derivation precedence
|
|
||||||
|
|
||||||
1. If no usable reference snapshot exists, the page is blocked and no cells are materialized.
|
|
||||||
2. If the tenant has no completed compare against this baseline reference or the subject's policy type was not covered, the cell state is `not_compared`.
|
|
||||||
3. If the latest completed compare result predates the current effective snapshot or is stale under existing stale-result policy, the cell state is `stale_result` unless a stronger blocked or absent condition applies.
|
|
||||||
4. If the latest relevant compare run records an evidence-gap or reason code indicating ambiguous or low-confidence identity matching for the subject, the cell state is `ambiguous`.
|
|
||||||
5. If the latest relevant compare run records the subject as missing from tenant truth, the cell state is `missing`.
|
|
||||||
6. If the latest relevant compare output created or updated a drift finding for the subject under the current compare run, the cell state is `differ` regardless of finding workflow status.
|
|
||||||
7. Otherwise the cell state is `match`.
|
|
||||||
|
|
||||||
### Freshness rules
|
|
||||||
|
|
||||||
- `fresh` means the tenant has a completed compare result against the current effective snapshot and it does not exceed the existing stale-result threshold.
|
|
||||||
- `stale` means the compare result predates the current effective snapshot or breaches the existing stale-result threshold.
|
|
||||||
- `never_compared` means no relevant compare run exists for the tenant and selected baseline.
|
|
||||||
- `unknown` is reserved for unexpected cases where timestamps or run truth are missing but a cell still exists.
|
|
||||||
|
|
||||||
### Trust rules
|
|
||||||
|
|
||||||
- Trust levels reuse existing compare explanation semantics rather than a new matrix-only taxonomy.
|
|
||||||
- `match` may only be shown when the subject was covered and no ambiguity or missing-basis signal exists.
|
|
||||||
- `not_compared` and uncovered policy types are treated as low-trust or unusable for operator interpretation.
|
|
||||||
|
|
||||||
## Relationships
|
|
||||||
|
|
||||||
- One `BaselineProfile` resolves to zero or one effective `BaselineSnapshot` for compare.
|
|
||||||
- One `BaselineSnapshot` has many `BaselineSnapshotItem` rows.
|
|
||||||
- One `BaselineProfile` has many `BaselineTenantAssignment` rows.
|
|
||||||
- One visible tenant may have many `baseline_compare` `OperationRun` rows over time, but the matrix resolves one latest relevant run per tenant for the selected baseline.
|
|
||||||
- One latest relevant compare run may have many related drift `Finding` rows.
|
|
||||||
- One matrix cell may link to zero or one preferred finding drilldown and always belongs to exactly one visible tenant and one subject row.
|
|
||||||
|
|
||||||
## Rendering Rules
|
|
||||||
|
|
||||||
- Technical deviation state is primary; finding workflow state is secondary.
|
|
||||||
- Tenant running or queued compare state is shown at tenant-summary level and does not replace the last known completed technical truth unless the latest completed truth is absent.
|
|
||||||
- Subject-focused views reuse the same row and cell models and simply reduce the visible subject set to one selected subject.
|
|
||||||
- Empty and degraded states remain page-level states rather than synthetic match rows.
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
# Implementation Plan: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
**Branch**: `190-baseline-compare-matrix` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/spec.md`
|
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add one workspace-scoped Filament page anchored on `BaselineProfile` that renders a tenant-by-subject compare matrix from the current effective baseline snapshot, visible assigned tenants, the latest relevant tenant `baseline_compare` runs, and compare-created findings. Reuse the existing tenant compare start path for `Compare assigned tenants`, reuse `CanonicalNavigationContext` for drilldown continuity, and keep V1 derived and read-only so no new persisted cross-tenant compare artifact or workspace umbrella run is introduced.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15
|
|
||||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
|
|
||||||
**Storage**: PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned
|
|
||||||
**Testing**: Pest 4 feature and browser tests, Filament or Livewire page coverage, focused RBAC regressions, run through Laravel Sail
|
|
||||||
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
|
|
||||||
**Project Type**: web application
|
|
||||||
**Performance Goals**: Keep matrix reads query-bounded over the visible tenant set and reference snapshot, avoid per-cell N+1 queries, keep render-time surfaces DB-only, and keep `Compare assigned tenants` enqueue-only by reusing tenant compare run starts
|
|
||||||
**Constraints**: No new persisted cross-tenant compare artifact; no new workspace umbrella `OperationRun`; hidden tenants must not leak by name or count; `Compare assigned tenants` must remain confirmation-gated and `simulation only`; matrix uses a narrow custom grid exception because a one-axis table cannot represent subject-by-tenant truth cleanly; no remote calls at render time
|
|
||||||
**Scale/Scope**: One workspace, one baseline profile at a time, all visible assigned tenants for that profile, all in-scope baseline subjects for the effective snapshot, one new page and view, one narrow derived matrix builder, additive baseline-detail header actions, and focused feature plus smoke-test coverage
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
|
||||||
|
|
||||||
| Principle | Pre-Research | Post-Design | Notes |
|
|
||||||
|-----------|--------------|-------------|-------|
|
|
||||||
| Inventory-first / snapshots-second | PASS | PASS | The matrix reads current tenant evidence through existing compare runs and baseline snapshots; it does not redefine last-observed truth. |
|
|
||||||
| Read/write separation | PASS | PASS | The feature is read-only apart from compare start. `Compare assigned tenants` remains confirmation-gated and simulation-only, with existing run and audit semantics. |
|
|
||||||
| Graph contract path | N/A | N/A | No new Graph calls or contract-registry changes are required. |
|
|
||||||
| Deterministic capabilities | PASS | PASS | Existing capability constants `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, and `TENANT_FINDINGS_VIEW` remain canonical. |
|
|
||||||
| Workspace + tenant isolation | PASS | PASS | The matrix is workspace-scoped, its columns are limited to visible assigned tenants, and all drilldowns re-check tenant entitlement. |
|
|
||||||
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; in-scope members missing required capability remain `403`; server-side authorization remains authoritative. |
|
|
||||||
| Run observability / Ops-UX | PASS | PASS | `Compare assigned tenants` fans out to existing tenant `baseline_compare` runs only. No new shadow run model or inline remote work is introduced. |
|
|
||||||
| Data minimization | PASS | PASS | The matrix reads existing compare and finding metadata only; no new payload copies or exported artifacts are stored. |
|
|
||||||
| Proportionality / no premature abstraction | PASS | PASS | The design adds one narrow matrix builder and one new page instead of a generalized compare framework or persisted portfolio layer. |
|
|
||||||
| Persisted truth / behavioral state | PASS | PASS | No new persistence or state family is added. Matrix states remain derived from existing truth. |
|
|
||||||
| UI semantics / few layers | PASS | PASS | Trust, stale, and ambiguity reuse existing compare semantics and do not create a new UI taxonomy. |
|
|
||||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature stays inside current Filament v5 and Livewire v4 patterns. |
|
|
||||||
| Provider registration location | PASS | PASS | No panel or provider changes are required. Provider registration remains in `bootstrap/providers.php`. |
|
|
||||||
| Global search hard rule | PASS | PASS | No new globally searchable resource is added. Existing baseline resources already satisfy current search constraints. |
|
|
||||||
| Destructive action safety | PASS | PASS | No destructive action is added. Existing destructive baseline actions remain unchanged and confirmation-gated. |
|
|
||||||
| Asset strategy | PASS | PASS | No new assets are planned. Existing deployment behavior for `filament:assets` remains unchanged. |
|
|
||||||
| Filament-native UI / Action Surface Contract | PASS WITH NARROW EXCEPTION | PASS WITH NARROW EXCEPTION | The matrix uses native Filament page structure and actions, but the core body is a custom two-dimensional grid because a one-axis table is insufficient. |
|
|
||||||
| Filament UX-001 | PASS WITH NARROW EXCEPTION | PASS WITH NARROW EXCEPTION | Sections, filters, legends, and empty states remain standard. The matrix body itself is the documented UX-001 exception. |
|
|
||||||
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The test plan focuses on aggregation correctness, hidden-tenant safety, stale or ambiguous truth, compare-all fan-out, and drilldown continuity. |
|
|
||||||
|
|
||||||
## Phase 0 Research
|
|
||||||
|
|
||||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/research.md`.
|
|
||||||
|
|
||||||
Key decisions:
|
|
||||||
|
|
||||||
- Use `/admin/baseline-profiles/{record}/compare-matrix` as the canonical route and anchor the workflow under the existing baseline profile resource.
|
|
||||||
- Derive matrix cells and summaries from `BaselineSnapshotItem` plus the latest relevant `baseline_compare` run plus compare-created findings; findings alone are insufficient.
|
|
||||||
- Treat technical deviation state as primary and finding workflow state as secondary metadata so closed or risk-accepted findings do not hide real current drift.
|
|
||||||
- Reuse the existing stale-result threshold and current-reference comparison rather than inventing matrix-specific freshness rules.
|
|
||||||
- Reuse existing evidence-gap and trustworthiness semantics for ambiguity and low-confidence signals.
|
|
||||||
- Reuse `CanonicalNavigationContext` for matrix drilldown continuity instead of extending `PortfolioArrivalContext`.
|
|
||||||
- Implement `Compare assigned tenants` as visible-tenant fan-out over existing tenant compare starts without a workspace umbrella run.
|
|
||||||
- Validate primarily with focused feature tests plus one browser smoke test.
|
|
||||||
|
|
||||||
## Phase 1 Design
|
|
||||||
|
|
||||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/`:
|
|
||||||
|
|
||||||
- `data-model.md`: existing source truths, derived matrix read models, and cell-state precedence
|
|
||||||
- `contracts/baseline-compare-matrix.logical.openapi.yaml`: internal logical contract for matrix reads and compare-all launch behavior
|
|
||||||
- `quickstart.md`: implementation sequence and focused verification workflow
|
|
||||||
|
|
||||||
Design decisions:
|
|
||||||
|
|
||||||
- The matrix stays fully derived and introduces no new table, artifact, or workspace-level run type.
|
|
||||||
- The new page is baseline-profile-scoped and reuses existing baseline detail, compare landing, finding detail, and operation-run viewer destinations.
|
|
||||||
- The core builder lives alongside existing baseline support code and returns plain derived arrays or value-oriented payloads rather than a new presenter framework.
|
|
||||||
- `Compare assigned tenants` iterates the visible assigned tenant set and reuses `BaselineCompareService::startCompare()` per tenant, reporting queued, already queued, and blocked results honestly.
|
|
||||||
- Cell state precedence is fixed so `not_compared`, `stale_result`, `ambiguous`, `missing`, and `differ` remain visibly distinct from `match`.
|
|
||||||
- Matrix drilldowns preserve a canonical return path by reusing `CanonicalNavigationContext` query propagation rather than inventing a new navigation token system.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/190-baseline-compare-matrix/
|
|
||||||
├── spec.md
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── baseline-compare-matrix.logical.openapi.yaml
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ ├── Pages/
|
|
||||||
│ │ │ ├── BaselineCompareLanding.php
|
|
||||||
│ │ │ └── BaselineCompareMatrix.php
|
|
||||||
│ │ └── Resources/
|
|
||||||
│ │ ├── BaselineProfileResource.php
|
|
||||||
│ │ ├── BaselineProfileResource/
|
|
||||||
│ │ │ └── Pages/ViewBaselineProfile.php
|
|
||||||
│ │ ├── FindingResource.php
|
|
||||||
│ │ └── OperationRunResource.php
|
|
||||||
│ ├── Models/
|
|
||||||
│ │ ├── BaselineProfile.php
|
|
||||||
│ │ ├── BaselineSnapshot.php
|
|
||||||
│ │ ├── BaselineSnapshotItem.php
|
|
||||||
│ │ ├── BaselineTenantAssignment.php
|
|
||||||
│ │ ├── Finding.php
|
|
||||||
│ │ └── OperationRun.php
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ └── Baselines/BaselineCompareService.php
|
|
||||||
│ ├── Support/
|
|
||||||
│ │ ├── Baselines/
|
|
||||||
│ │ │ ├── BaselineCompareEvidenceGapDetails.php
|
|
||||||
│ │ │ ├── BaselineCompareMatrixBuilder.php
|
|
||||||
│ │ │ ├── BaselineCompareStats.php
|
|
||||||
│ │ │ ├── BaselineCompareSummaryAssessor.php
|
|
||||||
│ │ │ └── BaselineSnapshotTruthResolver.php
|
|
||||||
│ │ ├── Navigation/
|
|
||||||
│ │ │ ├── CanonicalNavigationContext.php
|
|
||||||
│ │ │ └── RelatedNavigationResolver.php
|
|
||||||
│ │ └── Rbac/
|
|
||||||
│ │ └── UiEnforcement.php
|
|
||||||
│ └── resources/views/filament/pages/
|
|
||||||
│ └── baseline-compare-matrix.blade.php
|
|
||||||
└── tests/
|
|
||||||
├── Browser/
|
|
||||||
│ └── Spec190BaselineCompareMatrixSmokeTest.php
|
|
||||||
├── Feature/
|
|
||||||
│ ├── Baselines/
|
|
||||||
│ │ ├── BaselineCompareMatrixBuilderTest.php
|
|
||||||
│ │ └── BaselineCompareMatrixCompareAllActionTest.php
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── BaselineCompareMatrixPageTest.php
|
|
||||||
│ └── Rbac/
|
|
||||||
│ └── BaselineCompareMatrixAuthorizationTest.php
|
|
||||||
└── Feature/Guards/
|
|
||||||
└── ActionSurfaceContractTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Keep the work inside the existing Laravel monolith under `apps/platform`. Add one new workspace page, one new view, one narrow baseline-support matrix builder, and additive changes to existing baseline detail and drilldown surfaces instead of creating a new domain package, new persistence layer, or new top-level workspace app.
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### Phase A — Add The Workspace Entry Surface
|
|
||||||
|
|
||||||
**Goal**: Introduce the canonical matrix route and entry points without changing baseline ownership or navigation semantics.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| A.1 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Add the new workspace page class, scope it to one `BaselineProfile`, expose filter state, and declare the page contract plus action-surface intent |
|
|
||||||
| A.2 | `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | Add the page view using Filament sections, summaries, legends, and one narrow custom grid body |
|
|
||||||
| A.3 | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Add `Open compare matrix` and `Compare assigned tenants` header actions with confirmation and capability gating |
|
|
||||||
|
|
||||||
### Phase B — Build The Live Matrix Aggregation
|
|
||||||
|
|
||||||
**Goal**: Derive visible rows, columns, cells, summaries, freshness, and trust from existing truth only.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| B.1 | `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` | Add the narrow read-model builder that resolves reference snapshot, visible assigned tenants, subject rows, matrix cells, and summaries |
|
|
||||||
| B.2 | Existing baseline support classes such as `BaselineCompareStats.php`, `BaselineCompareEvidenceGapDetails.php`, and `BaselineCompareSummaryAssessor.php` | Reuse or extract helper logic for freshness, trust, evidence-gap interpretation, and no-result handling instead of duplicating semantics |
|
|
||||||
| B.3 | Existing models `BaselineTenantAssignment`, `Finding`, and `OperationRun` | Add or reuse scoped queries for visible tenant assignments, latest relevant compare runs per tenant, and compare-created findings keyed to the selected baseline profile |
|
|
||||||
|
|
||||||
### Phase C — Add Compare-All Fan-Out Over Existing Tenant Runs
|
|
||||||
|
|
||||||
**Goal**: Start compare for the visible assigned set without creating a workspace umbrella run.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| C.1 | `apps/platform/app/Services/Baselines/BaselineCompareService.php` | Add or extend a batch-start path that iterates visible assigned tenants and reuses `startCompare()` for each target |
|
|
||||||
| C.2 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Surface `Compare assigned tenants`, call the batch-start path, render queued, already queued, and blocked outcomes honestly, and preserve visible-disabled helper text for in-scope users missing manage capability |
|
|
||||||
| C.3 | Existing operation feedback helpers such as `OperationUxPresenter` and `OpsUxBrowserEvents` | Reuse current queued feedback and run-link behavior; do not invent matrix-specific run notifications |
|
|
||||||
|
|
||||||
### Phase D — Bind Filters, Drilldowns, And Degraded States
|
|
||||||
|
|
||||||
**Goal**: Make the matrix operator-scanable, trustworthy, and continuous with existing follow-up surfaces.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| D.1 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Add policy-type, state, severity, tenant-sort, subject-sort, and focused-subject state |
|
|
||||||
| D.2 | `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and related call sites | Reuse canonical navigation context so cell, tenant, finding, and run drilldowns preserve a return path to the matrix |
|
|
||||||
| D.3 | Existing drilldown destinations such as `BaselineCompareLanding.php`, `FindingResource.php`, and `OperationRunResource.php` | Accept bounded matrix source context and render a meaningful back link or source hint without adding a new navigation system |
|
|
||||||
| D.4 | Matrix view and builder | Render explicit empty, blocked, partial, stale, ambiguous, and no-result states without collapsing them into matches |
|
|
||||||
|
|
||||||
### Phase E — Regression Protection And Hardening
|
|
||||||
|
|
||||||
**Goal**: Prove business truth, hidden-tenant safety, and action-surface compliance.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| E.1 | `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` | Cover row and cell derivation, stale or no-result logic, ambiguity, and visible-set-only summaries |
|
|
||||||
| E.2 | `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` | Cover compare-all fan-out, per-tenant queued or blocked outcomes, and no workspace umbrella run creation |
|
|
||||||
| E.3 | `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` | Cover page mount, filters, subject focus, degraded states, and drilldown link presence |
|
|
||||||
| E.4 | `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` | Cover non-member `404`, member-without-capability `403`, visible-set filtering, and allowed access |
|
|
||||||
| E.5 | `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` | Add one smoke test for the rendered matrix, one core filter interaction, and one drilldown or compare-all affordance |
|
|
||||||
| E.6 | `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` | Keep the new page and baseline detail actions aligned with action-surface rules |
|
|
||||||
|
|
||||||
## Key Design Decisions
|
|
||||||
|
|
||||||
### D-001 — Keep the matrix fully derived
|
|
||||||
|
|
||||||
The page must read from existing baseline, compare, finding, and run truth only. No `CrossTenantCompare`, no stored matrix snapshot, and no new reporting artifact are introduced in V1.
|
|
||||||
|
|
||||||
### D-002 — Keep baseline profile as the visible standard
|
|
||||||
|
|
||||||
The matrix route lives under the selected baseline profile because the operator question is not generic cross-tenant compare; it is reference-to-many compare against one workspace-owned standard.
|
|
||||||
|
|
||||||
### D-003 — Reuse tenant compare runs only
|
|
||||||
|
|
||||||
`Compare assigned tenants` fans out across the visible assigned tenant set through existing tenant compare starts. Aggregate progress on the matrix page derives from those tenant runs instead of a workspace batch run.
|
|
||||||
|
|
||||||
### D-004 — Technical drift comes before workflow posture
|
|
||||||
|
|
||||||
Cells and counts reflect technical compare truth first. Finding workflow state, risk acceptance, or acknowledgement remain secondary metadata and do not suppress a current `differ` or `missing` state.
|
|
||||||
|
|
||||||
### D-005 — Reuse canonical navigation context
|
|
||||||
|
|
||||||
Drilldown continuity uses existing canonical navigation patterns rather than extending portfolio-triage tokens or adding a new navigation framework.
|
|
||||||
|
|
||||||
### D-006 — Accept one narrow matrix-grid UI exception
|
|
||||||
|
|
||||||
The matrix body is the only major UI exception because a standard table cannot represent subject-by-tenant truth. Everything around the grid remains Filament-native: actions, sections, legends, empty states, and badges.
|
|
||||||
|
|
||||||
## Risk Assessment
|
|
||||||
|
|
||||||
| Risk | Impact | Likelihood | Mitigation |
|
|
||||||
|------|--------|------------|------------|
|
|
||||||
| Hidden-tenant leakage through summaries or totals | High | Medium | Build every column and summary from the visible tenant set only; add explicit negative RBAC tests. |
|
|
||||||
| Findings-only aggregation understates current drift | High | Medium | Derive technical state from latest compare run plus findings; treat finding workflow as secondary metadata. |
|
|
||||||
| Stale or no-result truth looks calm | High | Medium | Reuse stale-result semantics, explicit `not_compared`, and blocked-state rendering; test all degraded cases. |
|
|
||||||
| Matrix render becomes query-heavy | High | Medium | Build one narrow aggregation layer that batches tenant runs, findings, and snapshot items instead of resolving per cell. |
|
|
||||||
| Compare-all introduces a shadow batch model | Medium | Medium | Reuse `BaselineCompareService::startCompare()` per tenant and return a simple launch summary only. |
|
|
||||||
| The page grows into a generalized standardization platform | Medium | Medium | Keep route, copy, and design anchored on one baseline profile, one reference snapshot, and read-only workflow scope. |
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
- Add focused feature coverage for matrix aggregation, cell precedence, tenant and subject summaries, stale or no-result truth, and visible-set-only counts.
|
|
||||||
- Add action coverage for compare-all fan-out, per-tenant queued or blocked outcomes, reuse of the existing tenant compare run path, service-owned run transitions, canonical summary-count invariants, and notification-surface limits.
|
|
||||||
- Add RBAC coverage for workspace membership, capability denial, partial tenant visibility, visible-disabled helper-text states for in-scope users missing manage capability, and drilldown authorization semantics.
|
|
||||||
- Add query-shape regression coverage so wide tenant/subject matrices stay batched and avoid per-cell N+1 resolution.
|
|
||||||
- Add one browser smoke test for matrix rendering plus a core filter or drilldown interaction.
|
|
||||||
- Keep action-surface and existing baseline compare suites green so the new page does not regress current baseline workflows.
|
|
||||||
- Run the minimum focused Sail pack plus `pint --dirty --format agent` before implementation sign-off.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| Two-dimensional matrix grid on a Filament page | The operator must scan subjects across multiple visible tenant columns in one surface | A standard Filament table or tenant-by-tenant list cannot represent both axes without destroying the core workflow question |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: Operators can already define baselines and compare one tenant at a time, but they still cannot answer cross-tenant baseline deviation questions without manual aggregation.
|
|
||||||
- **Existing structure is insufficient because**: Existing tenant compare and finding surfaces are tenant-first. They do not present one visible-set baseline view across all assigned tenants.
|
|
||||||
- **Narrowest correct implementation**: Add one baseline-scoped matrix page, one narrow derived matrix builder, one centralized badge adapter that reuses canonical compare truth, and one compare-all fan-out path over existing tenant compare starts.
|
|
||||||
- **Ownership cost created**: One new page, one view, one derived aggregation helper, one centralized badge adapter, additive baseline-detail actions, and focused feature plus browser regression coverage.
|
|
||||||
- **Alternative intentionally rejected**: A persisted cross-tenant compare artifact, a generalized compare engine, or a workspace umbrella run were rejected because they add durable complexity beyond V1's read-only portfolio visibility goal.
|
|
||||||
- **Release truth**: Current-release truth. The feature closes a present operator workflow gap using already-shipped baseline, finding, and run foundations.
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
# Quickstart: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Implement one workspace-scoped baseline compare matrix that lets an operator inspect visible assigned tenants against one baseline reference, trigger compare execution across the visible assigned set, and drill into existing tenant compare or finding surfaces without introducing a new persisted cross-tenant compare artifact.
|
|
||||||
|
|
||||||
## Implementation Sequence
|
|
||||||
|
|
||||||
1. Add the new workspace matrix page and baseline entry action.
|
|
||||||
- Add a new workspace Filament page for `/admin/baseline-profiles/{record}/compare-matrix`.
|
|
||||||
- Add `Open compare matrix` to the existing baseline profile detail header.
|
|
||||||
- Keep the page scoped to one selected baseline profile and one explicit reference snapshot.
|
|
||||||
|
|
||||||
2. Build the live aggregation layer over existing truth.
|
|
||||||
- Create a narrow matrix builder under the existing baseline-support namespace.
|
|
||||||
- Use `BaselineSnapshotTruthResolver` and `BaselineSnapshotItem` for the reference axis.
|
|
||||||
- Use latest relevant `baseline_compare` runs plus their `context['baseline_compare']` payload for freshness, coverage, and trust.
|
|
||||||
- Use compare-created findings for technical difference severity and drilldown targets.
|
|
||||||
- Keep the matrix derived only; do not add persistence.
|
|
||||||
|
|
||||||
3. Add compare-all fan-out without a workspace umbrella run.
|
|
||||||
- Extend the baseline compare start path so the matrix and baseline detail can iterate visible assigned tenants and call the existing tenant compare start logic.
|
|
||||||
- Keep confirmation, queued toast behavior, and run observability aligned with existing `OperationRun` semantics.
|
|
||||||
- Report partial success, already queued, and blocked starts honestly from the underlying per-tenant results.
|
|
||||||
|
|
||||||
4. Bind filtering, subject focus, and drilldown continuity.
|
|
||||||
- Add policy-type, state, and severity filters.
|
|
||||||
- Add tenant and subject sorting.
|
|
||||||
- Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and existing destination routes for tenant, finding, and run drilldowns.
|
|
||||||
- Preserve a clear return path to the matrix.
|
|
||||||
|
|
||||||
5. Add regression coverage.
|
|
||||||
- Cover live aggregation, compare-all, stale/no-result/ambiguous truth, visible-set RBAC filtering, and drilldown continuity.
|
|
||||||
- Add one browser smoke test to prove the interactive matrix surface renders and performs the core operator flow.
|
|
||||||
|
|
||||||
## Suggested Test Files
|
|
||||||
|
|
||||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
|
|
||||||
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
|
|
||||||
## Existing Suites To Extend Or Keep Green
|
|
||||||
|
|
||||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Filament/WorkspaceOverview*` suites that currently consume baseline attention summaries
|
|
||||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
|
|
||||||
## Minimum Verification Commands
|
|
||||||
|
|
||||||
Run all commands through Sail from `apps/platform`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareStatsTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Acceptance Checklist
|
|
||||||
|
|
||||||
1. Open a baseline profile with a usable reference snapshot and verify `Open compare matrix` lands on the new workspace matrix page.
|
|
||||||
2. Confirm the page shows the selected baseline profile, reference snapshot, visible assigned tenant count, and truthful per-tenant and per-subject summaries.
|
|
||||||
3. Filter the matrix by policy type and by state and confirm the visible rows, columns, and counts update without implying hidden tenants.
|
|
||||||
4. Open a differing, missing, or ambiguous cell and confirm the drilldown reaches an existing tenant compare or finding surface with a clear return path.
|
|
||||||
5. Trigger `Compare assigned tenants` and confirm the modal describes `simulation only`, the launch fans out to visible assigned tenants only, and partial success is visible.
|
|
||||||
6. Verify a tenant with no prior compare reads as `Not compared`, not healthy.
|
|
||||||
7. Verify a tenant with stale compare truth reads as stale and does not look current.
|
|
||||||
8. Verify a user with partial tenant visibility sees only allowed tenants and no hidden-tenant aggregate counts.
|
|
||||||
|
|
||||||
## Deployment Notes
|
|
||||||
|
|
||||||
- No new database migration is expected.
|
|
||||||
- No new assets are planned; normal Filament asset publishing behavior remains unchanged.
|
|
||||||
- `Compare assigned tenants` reuses existing tenant compare runs only, so no new queue worker topology or deployment artifact is required.
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
# Research: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
## Decision: Build the matrix as a baseline-scoped workspace page under the existing baseline profile resource
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The existing baseline workflow is already anchored on `BaselineProfileResource` and `ViewBaselineProfile`. A matrix is meaningful only in the context of one explicit baseline reference, so the narrowest route is a child workspace page such as `/admin/baseline-profiles/{record}/compare-matrix`. This keeps the baseline profile as the visible standard, preserves existing navigation expectations, and avoids introducing a new top-level workspace workbench.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Add a separate top-level workspace navigation item: rejected because it weakens the explicit baseline reference and creates a second entry pattern for the same workflow.
|
|
||||||
- Extend the existing tenant `BaselineCompareLanding` page to become cross-tenant: rejected because that page is tenant-scoped by design and would blur tenant and workspace concerns.
|
|
||||||
|
|
||||||
## Decision: Derive matrix truth from `BaselineSnapshotItem` plus the latest relevant tenant compare run plus compare-created findings
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
No single existing artifact is sufficient by itself. `BaselineSnapshotItem` defines the immutable subject row axis and reference truth. The latest `baseline_compare` `OperationRun` provides freshness, coverage, evidence-gap, and trust metadata. Findings created with `source = baseline.compare` provide durable per-subject technical drift detail and severity. Combining those existing sources yields matrix rows, columns, summaries, and drilldowns without adding a second persisted compare model.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Use findings alone: rejected because findings do not represent matches, do not fully encode no-result or stale truth, and can be filtered by workflow status in ways that would understate current technical deviation.
|
|
||||||
- Use run context alone: rejected because findings remain the durable subject-level drilldown truth and severity carrier across compare executions.
|
|
||||||
- Persist a new cross-tenant compare report: rejected because V1 can be derived live from existing truth.
|
|
||||||
|
|
||||||
## Decision: Treat technical deviation state as primary and finding workflow state as secondary metadata
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The matrix must answer whether a tenant currently differs from the baseline, not whether someone already triaged the resulting finding. A current compare may still indicate drift even if a related finding is acknowledged, risk accepted, or closed. Therefore cell and summary counts must derive from the latest compare truth for the selected baseline and visible tenant, while any finding workflow state remains optional secondary metadata for drilldown or badges.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Count only open findings: rejected because terminal workflow states can hide still-present technical drift.
|
|
||||||
- Ignore findings entirely: rejected because the operator still needs severity and existing finding drilldown continuity.
|
|
||||||
|
|
||||||
## Decision: Reuse existing stale-result semantics and reference-snapshot freshness rules
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The repo already defines baseline compare staleness in `BaselineCompareSummaryAssessor`, including a seven-day age threshold and `stale_result` evidence semantics. The matrix should reuse that rule and additionally mark results stale when the latest completed compare predates the current effective baseline snapshot. This keeps tenant and workspace compare freshness language aligned.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Invent a matrix-specific freshness threshold: rejected because it would create a parallel freshness language.
|
|
||||||
- Use only elapsed time: rejected because a result can become stale immediately when the baseline reference snapshot changes.
|
|
||||||
|
|
||||||
## Decision: Reuse evidence-gap and operator-explanation metadata for trust and ambiguity signals
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
`baseline_compare` run context already records evidence gaps, uncovered policy types, and related diagnostics. `BaselineCompareExplanationRegistry` and the existing trustworthiness semantics already distinguish trustworthy, limited-confidence, diagnostic-only, and unusable result conditions. The matrix should reuse those signals at tenant and subject level, while mapping per-cell ambiguity from evidence-gap reason codes such as ambiguous or low-confidence match situations.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Add a new trust or certainty taxonomy for the matrix: rejected because the compare domain already has operator-trust semantics.
|
|
||||||
- Hide uncertainty behind summary prose only: rejected because the spec explicitly forbids hidden ambiguity.
|
|
||||||
|
|
||||||
## Decision: Use existing `CanonicalNavigationContext` patterns for matrix drilldown continuity
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The codebase already uses `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` to preserve source context and return paths across related surfaces. The matrix only needs source baseline, source subject, and return path continuity. Reusing canonical navigation context is narrower than extending `PortfolioArrivalContext`, which is currently specialized for backup-health and recovery-evidence triage families.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Reuse `PortfolioArrivalContext`: rejected because the matrix is not a backup or recovery concern-family workflow and would pollute that token's allowlist and semantics.
|
|
||||||
- Introduce a brand-new context token type: rejected because `CanonicalNavigationContext` already solves the generic return-path problem.
|
|
||||||
|
|
||||||
## Decision: Implement `Compare assigned tenants` as visible-tenant fan-out over existing tenant compare starts, without a workspace umbrella run
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
`BaselineCompareService::startCompare()` and tenant-scoped `OperationRun` creation already implement canonical baseline compare start semantics, deduplication, queued feedback, and run observability. V1 compare-all should iterate the visible assigned tenant set and reuse those existing starts. Aggregate progress on the matrix page can then be derived from the underlying tenant runs. This avoids inventing a second run identity or a workspace-level shadow batch truth.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Create one workspace umbrella `OperationRun`: rejected because the spec explicitly avoids a new parallel run truth and the repo already treats tenant compare as tenant-owned execution.
|
|
||||||
- Start compare directly in the page without reusing the service: rejected because it would duplicate preconditions, snapshot truth resolution, and dedupe behavior.
|
|
||||||
|
|
||||||
## Decision: Cover the feature primarily with focused feature tests plus one browser smoke test
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The matrix combines query-heavy aggregation, RBAC filtering, and one new interactive page surface. Focused feature tests should verify aggregation correctness, compare-all launch behavior, and authorization semantics. One browser smoke test is sufficient to prove the two-dimensional page and its core drilldown or action affordances render correctly under Livewire and Filament, without turning the spec into a browser-heavy suite.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Browser-test the full feature exhaustively: rejected because most business truth can be validated faster and more deterministically through feature tests.
|
|
||||||
- Limit tests to one page smoke test: rejected because the feature's core risks are incorrect aggregation, stale or ambiguous truth, and hidden-tenant leakage.
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
# Feature Specification: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
**Feature Branch**: `190-baseline-compare-matrix`
|
|
||||||
**Created**: 2026-04-11
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Spec 190 - Workspace Baseline Compare Matrix V1"
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: Workspace operators can define baselines, assign tenants, and review tenant-level compare truth, but they still cannot see one workspace-scoped picture of how all assigned visible tenants deviate from the same baseline standard.
|
|
||||||
- **Today's failure**: Portfolio governance remains tenant-by-tenant. Operators must open tenants individually, cannot rank the worst deviators quickly, cannot see which subjects drift across many tenants, and can misread stale, absent, or ambiguous compare truth as calm.
|
|
||||||
- **User-visible improvement**: One workspace matrix shows the reference baseline, the visible assigned tenants, the breadth of deviation by tenant and subject, freshness, and trust, with direct drilldown into existing compare and finding surfaces.
|
|
||||||
- **Smallest enterprise-capable version**: A read-only workspace-scoped matrix for one `BaselineProfile` at a time, powered by existing snapshot, compare, finding, and `OperationRun` truth, plus one `Compare assigned tenants` action.
|
|
||||||
- **Explicit non-goals**: Promotion, rollout orchestration, approval, cross-workspace compare, persisted portfolio compare reports, manual match overrides, remediation actions, compliance mapping, exports, and a generalized standardization framework.
|
|
||||||
- **Permanent complexity imported**: One new workspace page, one derived aggregation shape for rows, columns, cells, and summaries, additional filter and sort semantics, compare-all orchestration over existing runs, drilldown continuity, and focused feature/browser regression coverage. No new persisted domain artifact is imported.
|
|
||||||
- **Why now**: The baseline, finding, `OperationRun`, and workspace portfolio foundations already exist. Without a workspace-level compare projection, a high-value MSP/operator workflow stays fragmented even though the underlying truth is already in the product.
|
|
||||||
- **Why not local**: Tenant-local compare and finding pages cannot answer cross-tenant questions such as "which visible tenant deviates most from this baseline right now?" or "which subject is recurring across the portfolio?" without repeated context switching and manual aggregation by the operator.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: `New Achsen` because a matrix can accidentally become a second truth layer, and `New Meta-Infrastructure` because cross-tenant aggregation can tempt a generalized compare platform. Defense: V1 is baseline-referenced, read-only, live-aggregated from existing truth, and explicitly forbids new persisted cross-tenant compare artifacts.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/admin/baseline-profiles` as the existing workspace list and selection entry for baseline standards
|
|
||||||
- `/admin/baseline-profiles/{record}` as the existing baseline profile detail that gains the canonical matrix entry action
|
|
||||||
- `/admin/baseline-profiles/{record}/compare-matrix` as the new canonical workspace route for one baseline compare matrix
|
|
||||||
- `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown landing
|
|
||||||
- `/admin/findings` plus finding detail as existing finding drilldown surfaces
|
|
||||||
- Monitoring -> Operation Run Detail for compare-all follow-up and tenant compare run truth
|
|
||||||
- **Data Ownership**:
|
|
||||||
- Workspace-owned reference truth remains `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotItem`, and baseline-to-tenant assignment records.
|
|
||||||
- Tenant-owned compare truth remains the existing tenant compare outputs, drift findings, and `OperationRun` rows created for compare execution.
|
|
||||||
- The matrix itself is a read-only workspace projection over visible assigned tenants and does not persist a new cross-tenant compare result, report, or standardization artifact.
|
|
||||||
- **RBAC**:
|
|
||||||
- Workspace membership plus `WORKSPACE_BASELINES_VIEW` is required to open the matrix and inspect workspace baseline reference truth.
|
|
||||||
- `WORKSPACE_BASELINES_MANAGE` is required to start `Compare assigned tenants`.
|
|
||||||
- In-scope members who can view the baseline but lack `WORKSPACE_BASELINES_MANAGE` still see `Compare assigned tenants` on the baseline detail and matrix surfaces in a disabled state with helper text; forced execution remains `403`.
|
|
||||||
- Matrix columns are limited to tenants the actor may already see under existing workspace and tenant visibility rules; drilldowns continue to enforce their destination capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`.
|
|
||||||
- Non-members of the workspace or a tenant scope remain `404`; in-scope members missing the required capability remain `403`.
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Baseline profile detail | Detail / action-launch surface | Explicit `Open compare matrix` header action | forbidden | Detail header actions | Existing archive remains in detail header | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}` | Active workspace, baseline status, assignment count, reference snapshot truth | Baseline profile | Whether this baseline is compare-ready and how many tenants are assigned | none |
|
|
||||||
| Workspace baseline compare matrix | Workspace matrix / report surface | Explicit tenant, subject, and cell drilldowns | forbidden | Header toolbar, summary strips, and focused in-matrix controls | none | `/admin/baseline-profiles/{record}/compare-matrix` | Same route with focused tenant or subject state, plus existing drilldowns to tenant compare or finding surfaces | Active workspace, selected baseline profile, reference snapshot, visible tenant count, filter scope, freshness legend | Baseline compare matrix | Tenant deviation breadth, subject breadth, freshness, ambiguity, and not-compared truth | matrix-grid surface |
|
|
||||||
| Tenant compare and finding drilldowns from the matrix | Existing detail/list drilldown surfaces | Explicit matrix drilldown links only | forbidden | Existing local actions remain in their current placements | Existing destructive or lifecycle actions remain where already defined | `/admin/t/{tenant}/baseline-compare` and `/admin/findings` | Existing tenant compare and finding detail routes | Tenant context, source baseline profile, source subject focus, arrival source | Baseline compare / Finding | Why the operator drilled from the matrix and what subject or deviation is being followed up | canonical-navigation extension |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Baseline profile detail | Workspace manager | Detail / launch surface | Is this the right reference baseline, and should I open or refresh its matrix now? | Profile name and status, effective snapshot, assignment count, matrix entry, last compare coverage summary | Snapshot capture history, run diagnostics, low-level compare details | baseline lifecycle, snapshot completeness, visible assignment coverage | `TenantPilot only` for profile edits, `simulation only` for compare starts | Open compare matrix, Compare assigned tenants, Capture baseline | Archive baseline profile |
|
|
||||||
| Workspace baseline compare matrix | Workspace operator | Workspace matrix / drilldown hub | Which visible assigned tenants diverge from this baseline, how fresh and trustworthy is that truth, and where should I drill next? | Reference baseline, reference snapshot, visible-vs-assigned counts, per-tenant summaries, per-subject summaries, cell states, freshness legend, trust legend | Matching method detail, evidence-gap reasons, run identifiers, raw subject keys | compare result state, freshness, trust or ambiguity, visible severity breadth | `simulation only` for compare starts; otherwise read-only | Compare assigned tenants, filter matrix, focus subject, open tenant compare, open finding follow-up | none |
|
|
||||||
| Tenant compare and finding drilldowns from the matrix | Workspace operator continuing investigation | Existing follow-up surface | What exactly is wrong for this tenant and subject, and what existing workflow should I use next? | Tenant context, baseline context, subject context, current compare or finding truth, return path | Raw evidence payloads, underlying run payload, low-level diagnostic detail | tenant compare readiness, finding workflow state, drift severity, evidence completeness | Existing destination-specific mutation scope only | View compare context, view finding context, return to matrix | Existing destination-specific dangerous actions only |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes, one narrow request-scoped matrix builder plus one centralized badge adapter for matrix state semantics
|
|
||||||
- **New enum/state/reason family?**: no
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: The product has strong tenant-level baseline compare truth but still lacks a portfolio answer to "who deviates most from this baseline right now?"
|
|
||||||
- **Existing structure is insufficient because**: Existing tenant compare and finding surfaces are tenant-first or item-first. They cannot show visible-set deviation breadth, freshness, and trust across all assigned tenants without forcing the operator to aggregate manually.
|
|
||||||
- **Narrowest correct implementation**: One baseline-scoped matrix page, one narrow request-scoped matrix builder, one centralized badge adapter that reuses canonical compare truth, plus one batch compare trigger that reuses current snapshots, compare outputs, findings, run truth, and drilldown surfaces.
|
|
||||||
- **Ownership cost**: One new page, one narrow derived builder, one centralized badge adapter, derived aggregation queries, one narrow matrix-grid presentation exception, additional filter/sort coverage, and focused feature/browser regression tests.
|
|
||||||
- **Alternative intentionally rejected**: A new `CrossTenantCompare`, stored report model, generalized tenant-vs-tenant compare engine, promotion workflow, or broader standardization platform.
|
|
||||||
- **Release truth**: current-release portfolio governance visibility
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Scan visible drift across assigned tenants (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace operator, I want one matrix for one baseline profile so I can see which visible assigned tenants and subjects deviate most without opening tenants one by one.
|
|
||||||
|
|
||||||
**Why this priority**: This is the core product outcome. If the matrix cannot answer the portfolio question directly, the feature misses its reason to exist.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix for a baseline profile with mixed compare outcomes and verify that the reference baseline, visible tenant set, per-tenant summaries, per-subject summaries, and cell states are all understandable from one page.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a usable baseline reference and multiple visible assigned tenants with existing compare outcomes, **When** the operator opens the compare matrix, **Then** the page shows one column per visible assigned tenant, one row per baseline subject, and truthful cell states for match, differ, missing, ambiguous, stale, or not compared.
|
|
||||||
2. **Given** the actor can see only a subset of assigned tenants, **When** the matrix opens, **Then** only visible tenants appear and every count and summary stays scoped to that visible set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Refresh compare truth for the visible assigned set (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace operator, I want to compare all visible assigned tenants in one action so I can refresh portfolio truth without visiting each tenant.
|
|
||||||
|
|
||||||
**Why this priority**: The matrix becomes operationally useful only if an authorized operator can refresh stale portfolio truth from the same workspace surface.
|
|
||||||
|
|
||||||
**Independent Test**: Start `Compare assigned tenants` from the baseline detail or matrix page and verify that the product reuses normal compare execution semantics, shows honest run progress, and reflects partial completion accurately.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an authorized operator and visible assigned tenants eligible for compare, **When** the operator confirms `Compare assigned tenants`, **Then** the system starts or reuses normal tenant compare execution for each eligible visible tenant and returns the operator to honest queued or running matrix truth.
|
|
||||||
2. **Given** some visible assigned tenants succeed and others fail or remain running, **When** the matrix refreshes, **Then** the page distinguishes completed, running, failed, stale, and never-compared tenants without collapsing them into a single calm state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Drill from the matrix into existing follow-up surfaces (Priority: P2)
|
|
||||||
|
|
||||||
As a workspace operator, I want cell, tenant, and subject drilldowns so I can move from the portfolio view into existing compare or finding workflows without reconstructing context.
|
|
||||||
|
|
||||||
**Why this priority**: The matrix must be a decision surface, not a dead-end report.
|
|
||||||
|
|
||||||
**Independent Test**: Open a differing, missing, or ambiguous cell and confirm the drilldown lands in an existing tenant compare or finding surface with the tenant, subject, and baseline context still understandable.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a differing or missing matrix cell, **When** the operator opens its drilldown, **Then** the product lands in an existing tenant compare or finding context for that tenant and subject with the baseline reference still clear.
|
|
||||||
2. **Given** a subject row that deviates across several visible tenants, **When** the operator focuses that subject, **Then** the product shows the subject-first picture across the visible tenant set without inventing a new persisted subject report.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Stay honest in degraded or low-trust conditions (Priority: P2)
|
|
||||||
|
|
||||||
As a workspace operator, I want empty, partial, stale, and ambiguous states to stay explicit so the matrix never reads as healthier than the underlying truth.
|
|
||||||
|
|
||||||
**Why this priority**: False calmness would be worse than having no matrix at all.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix for a baseline profile with no usable snapshot, no prior compare runs, stale results, and ambiguous matches, and verify that each degraded condition is visibly distinct from a normal match state.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the baseline profile has no usable reference snapshot, **When** the operator opens the matrix, **Then** the page does not imply compare truth exists and instead shows one clear next-step call to action.
|
|
||||||
2. **Given** compare truth is ambiguous, stale, or absent, **When** the matrix renders, **Then** those cells and summaries remain visibly distinct from matches and point the operator toward the right next compare or drilldown action.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A baseline profile has assigned tenants but no usable reference snapshot; the matrix must block compare interpretation instead of rendering empty matches.
|
|
||||||
- A baseline profile has assigned tenants but the current actor can see none of them; the page must explain visible-set scoping without leaking hidden tenant counts or names.
|
|
||||||
- A visible assigned tenant has never been compared against this baseline; the cell and tenant summary must read as `Not compared` rather than healthy.
|
|
||||||
- A visible tenant has compare results against an older baseline reference than the current one; the result must read as stale rather than current.
|
|
||||||
- Subject identity is ambiguous for one or more tenants; the matrix must show ambiguity instead of forcing a match or differ conclusion.
|
|
||||||
- `Compare assigned tenants` starts successfully for some visible tenants but not others; the surface must stay honest about partial start and partial completion.
|
|
||||||
- One policy type has high trust while another has low trust; filtering by policy type must preserve the correct trust explanation for the visible slice.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature adds one workspace-scoped operator surface and reuses existing compare execution. It introduces no new Microsoft Graph contract path, no new write workflow beyond compare start, and no new source of baseline or finding truth. `Compare assigned tenants` remains a simulation-only governance action that must respect confirmation, tenant isolation, auditability through existing run truth, and focused regression coverage.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature stays within the default bias of deriving before persisting. The matrix is a thin derived projection over existing baseline snapshots, compare outputs, findings, and run context. V1 must not create new persistence, a generalized compare framework, or a new persisted state family just to support the matrix.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** `Compare assigned tenants` reuses existing `baseline_compare` run semantics only. Toast feedback remains intent-only, progress remains on active-ops and run-detail surfaces, and terminal notification behavior remains initiator-aware. `OperationRun.status` and `OperationRun.outcome` remain service-owned via `OperationRunService`. Any summary counts written to runs remain numeric-only and must use canonical summary keys. Scheduled or system-run semantics stay unchanged: no initiator means no terminal DB notification, and Monitoring remains the audit surface. Regression coverage must prove that compare-all does not invent shadow statuses or a second batch truth.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** This feature affects the workspace-admin plane on `/admin/baseline-profiles` and the tenant follow-up plane on `/admin/t/{tenant}/baseline-compare`, plus existing finding drilldowns. Cross-plane access remains deny-as-not-found. Non-members of the workspace or tenant scope receive `404`. In-scope members missing `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, or `TENANT_FINDINGS_VIEW` as required by the destination surface receive `403`. Server-side enforcement remains mandatory for matrix view, compare start, and every drilldown. Global search behavior stays unchanged; the matrix itself is not introduced as a separate global-search result.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that this feature does not introduce auth-handshake HTTP behavior.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** Any new matrix legends, badges, or label mappings for match, differ, missing, ambiguous, not compared, stale result, freshness, or trust must stay centralized and derived from canonical compare truth. No page-local color language may redefine those meanings. Tests must cover any new or changed centralized mappings.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** The feature should use a Filament page, Filament header actions, sections, stats, filters, and shared badge or alert primitives wherever possible. The tenant-by-subject grid itself may require custom Blade markup because standard one-axis tables do not represent two-dimensional matrix inspection well enough; this is the only approved exception. Even with that exception, buttons, alerts, legends, empty states, and state badges must still use shared primitives and central semantics rather than page-local styling rules.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must stay consistent across the matrix, baseline detail, run titles, and notifications: `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, `Match`, `Differs`, `Missing`, `Ambiguous match`, `Not compared`, and `Stale result`. Internal implementation terms such as `cross-tenant compare engine`, `matrix resolver`, or `portfolio deviation artifact` must stay out of primary labels.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The new surface is classified as a workspace matrix/report because the operator task is cross-tenant inspection, not CRUD. It has one primary inspect model: explicit tenant, subject, and cell drilldowns. Row click is forbidden because matrix rows and columns each represent different drilldown intents. Secondary actions live in the page header and matrix summaries. The matrix has no destructive actions. Its canonical collection route and canonical detail route are the same matrix route with focused state, while deeper investigation moves into existing tenant compare or finding routes. Scope signals must show active workspace, selected baseline profile, reference snapshot, visible-vs-assigned counts, and filter state. The critical truth visible by default is deviation breadth plus freshness and trust for the visible tenant set.
|
|
||||||
|
|
||||||
**Constitution alignment (OPSURF-001):** Default-visible matrix content must remain operator-first: baseline reference, visible tenant set, per-tenant deviation summary, per-subject breadth, freshness, and low-trust signals. Diagnostics such as raw subject keys, matching mechanics, and run identifiers must stay secondary. Status dimensions must remain separate: compare result state, freshness, trust or ambiguity, and visible severity breadth. `Compare assigned tenants` must communicate `simulation only` before execution. Workspace and tenant context must remain explicit in the matrix, the drilldowns, and the return path.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct exposure of tenant compare truth alone is insufficient because the operator needs a visible-set portfolio decision surface. The solution must stay a thin derived aggregation layer rather than a second truth system. Tests must focus on business consequences such as wrong visible-set counts, stale or absent truth being mistaken for calm, ambiguous matches being hidden, and drilldowns losing context.
|
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `BaselineProfileResource` keeps its existing primary inspect model and adds `Open compare matrix` plus `Compare assigned tenants` in the detail header. The new matrix page has no row-click primary open, no redundant `View` action, no empty action groups, and no destructive actions. UI-FIL-001 remains satisfied with the narrow matrix-grid exception described above.
|
|
||||||
|
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The new matrix page must use sections or cards for reference truth, summaries, filters, and the matrix body. Because the core surface is two-dimensional, the matrix body is a narrow UX-001 exemption from standard Filament table layout. That exemption does not remove the need for search/filter/sort over core dimensions, a specific empty-state title plus explanation plus exactly one call to action, or BADGE-001-compliant state markers. No new create or edit form is introduced.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-190-001 Workspace entry surface**: The system MUST provide a new workspace-scoped compare matrix page for one baseline profile at a time, reachable from existing workspace baseline context and directly from the selected baseline profile detail.
|
|
||||||
- **FR-190-002 Reference truth visibility**: The matrix MUST identify the selected baseline profile, the profile status, the reference snapshot or effective baseline state in use, the total assigned tenants, and the currently visible tenant count.
|
|
||||||
- **FR-190-003 Explicit compare source truth**: The matrix MUST derive from one explicit baseline reference state. If no usable reference state exists, the page MUST not imply compare truth and MUST explain the blocked state.
|
|
||||||
- **FR-190-004 Visible target set**: Matrix columns MUST be limited to tenants assigned to the selected baseline profile in the same workspace that the current actor is allowed to see. Hidden tenants MUST NOT leak by name, count, or indirect summary.
|
|
||||||
- **FR-190-005 Subject identity reuse**: Matrix rows MUST reuse the existing baseline subject identity and matching strategy. V1 MUST NOT introduce a competing subject identity system.
|
|
||||||
- **FR-190-006 Cell truth states**: Each subject-by-tenant cell MUST resolve to a truthful visible outcome representing at least `Match`, `Differs`, `Missing`, `Ambiguous match`, `Not compared`, or `Stale result`, even if the final UI wording is slightly adjusted.
|
|
||||||
- **FR-190-007 No false calmness**: `Ambiguous match`, `Not compared`, `Stale result`, and insufficient-basis conditions MUST remain visibly distinct from `Match` and MUST NOT contribute to healthy counts as if they were matches.
|
|
||||||
- **FR-190-008 Per-tenant summary**: The page MUST show a summary for each visible tenant including compared subject count, differing count, missing count, ambiguous count, highest visible severity or attention level, and compare freshness.
|
|
||||||
- **FR-190-009 Per-subject summary**: The page MUST show a summary for each subject including deviation breadth across visible tenants, missing breadth, ambiguous breadth, and highest visible severity or attention level.
|
|
||||||
- **FR-190-010 Filtering**: The matrix MUST support filtering by policy type, state group at minimum (`All`, `Deviations only`, `Missing only`, `Ambiguous only`, and a stale/no-result view), and visible severity band.
|
|
||||||
- **FR-190-011 Sorting**: The matrix MUST support sorting by tenant name, tenant deviation count, tenant freshness urgency, and subject deviation breadth.
|
|
||||||
- **FR-190-012 Compare-all availability**: Authorized users MUST be able to start `Compare assigned tenants` from the matrix page and from the selected baseline profile detail when the baseline has a usable reference state.
|
|
||||||
- **FR-190-013 Compare-all execution model**: `Compare assigned tenants` MUST start or reuse normal tenant compare execution only for eligible visible assigned tenants and MUST NOT invent a second persisted batch truth or shadow status model.
|
|
||||||
- **FR-190-014 Compare-all honesty**: The matrix MUST surface queued, running, completed, failed, partial, stale, and never-compared truth from the underlying compare runs and findings without collapsing mixed outcomes into one success state.
|
|
||||||
- **FR-190-015 Freshness visibility**: The matrix MUST show when each visible tenant was last compared against the selected baseline reference, whether that result predates the current reference state, and whether the tenant has never been compared.
|
|
||||||
- **FR-190-016 Trust visibility**: The matrix MUST show the applicable matching or identity trust signal for low-confidence cells or subjects, including ambiguous matches and missing compare basis, without forcing raw technical diagnostics into the primary scan path.
|
|
||||||
- **FR-190-017 Cell drilldown**: A matrix cell with meaningful follow-up MUST open an existing tenant compare or finding surface that keeps the tenant, subject, and baseline context understandable on arrival.
|
|
||||||
- **FR-190-018 Tenant drilldown**: A tenant summary or header interaction MUST open the existing tenant baseline compare landing or equivalent tenant follow-up surface for that tenant under the selected baseline context.
|
|
||||||
- **FR-190-019 Subject focus**: A subject interaction MUST open or switch to a subject-focused view across the current visible tenant set without creating a new persisted subject report artifact.
|
|
||||||
- **FR-190-020 Existing-truth-first aggregation**: V1 MUST derive matrix cells, summaries, freshness, and trust from existing baseline snapshots, compare outputs, findings, inventory subject identity, and `OperationRun` context before considering any new stored projection.
|
|
||||||
- **FR-190-021 No new portfolio persistence**: V1 MUST NOT introduce a first-class `CrossTenantCompare`, `CrossTenantCompareResult`, `PortfolioDeviationReport`, or equivalent persisted artifact unless a later spec proves live aggregation insufficient.
|
|
||||||
- **FR-190-022 RBAC-safe degradation**: Whenever the actor can see only part of the assigned tenant set, every column, summary, filter total, and subject breadth count MUST be computed from the visible set only or explicitly labeled as visible-set-only.
|
|
||||||
- **FR-190-023 Empty and degraded states**: The page MUST provide explicit operator-readable states for no usable snapshot, no assigned tenants, no visible tenants, no compare results, partially complete compare coverage, and all-low-trust results.
|
|
||||||
- **FR-190-024 Read-only boundary**: V1 MUST remain read-only apart from starting compare execution. It MUST NOT add remediation, restore, promotion, exception, approval, or manual match-override actions to matrix cells or summaries.
|
|
||||||
- **FR-190-025 Centralized semantics**: Any new matrix-specific legends, labels, or badges MUST stay centrally mapped from existing canonical truth and MUST NOT create a new persisted status family.
|
|
||||||
- **FR-190-026 Automated coverage**: Automated coverage MUST verify the core matrix flow, compare-all, visible-set RBAC filtering, ambiguous matching, stale/no-result honesty, policy-type filtering, subject focus, drilldown continuity, degraded-state behavior, and query-bounded read shapes for wide visible tenant and subject sets.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No policy promotion or push between tenants
|
|
||||||
- No rollout waves, rings, or fleet orchestration
|
|
||||||
- No approval workflow or accepted-deviation flow
|
|
||||||
- No cross-workspace compare
|
|
||||||
- No ad hoc tenant-A-vs-tenant-B compare
|
|
||||||
- No manual match override workflow
|
|
||||||
- No deep field-by-field compare editor
|
|
||||||
- No compare export or stored report artifact in V1
|
|
||||||
- No portfolio-level finding persistence beyond existing truth
|
|
||||||
- No automatic remediation
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Existing `BaselineProfile`, `BaselineSnapshot`, tenant compare outputs, findings, and `OperationRun` truth remain the canonical building blocks for V1.
|
|
||||||
- One baseline profile is the reference frame for one matrix at a time.
|
|
||||||
- Existing baseline subject identity and matching strategy are already authoritative enough to support portfolio aggregation, as long as ambiguity stays explicit.
|
|
||||||
- `Compare assigned tenants` can fan out to existing tenant compare execution without requiring a second persisted batch artifact.
|
|
||||||
- Existing tenant compare and finding surfaces can accept bounded canonical navigation context so the operator can understand why they drilled from the matrix.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Existing workspace baseline resources and snapshot truth
|
|
||||||
- Existing tenant baseline compare landing and compare execution
|
|
||||||
- Existing finding generation, finding detail, and finding triage surfaces
|
|
||||||
- Existing `baseline_compare` `OperationRun` lifecycle and Monitoring surfaces
|
|
||||||
- Existing `CanonicalNavigationContext`, `RelatedNavigationResolver`, and return-path helpers for bounded matrix drilldown continuity
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- The matrix could expand into a generalized standardization platform unless V1 stays baseline-referenced and read-only.
|
|
||||||
- Low-trust subject matching could be over-read as certainty unless ambiguity stays highly visible.
|
|
||||||
- Workspace aggregation could leak hidden tenants unless every summary is computed from the visible set only.
|
|
||||||
- Very wide tenant or subject sets could reduce scanability unless filtering and subject focus stay first-class.
|
|
||||||
- Old compare results could be misread as current truth unless freshness stays explicit and prominent.
|
|
||||||
|
|
||||||
## Review Questions
|
|
||||||
|
|
||||||
- Does the matrix clearly show which baseline reference state it is using?
|
|
||||||
- Does V1 aggregate existing compare and finding truth instead of inventing a second compare system?
|
|
||||||
- Are stale, absent, and ambiguous states visibly distinct from matches?
|
|
||||||
- Are visible-set RBAC boundaries fully preserved in columns, summaries, and drilldowns?
|
|
||||||
- Does `Compare assigned tenants` stay consistent with existing `OperationRun` semantics?
|
|
||||||
- Does the matrix stay operator-first rather than becoming a technical dashboard?
|
|
||||||
- Does drilldown land in existing follow-up workflows with enough context preserved?
|
|
||||||
- Has the spec stayed read-only apart from compare start and avoided new persistence?
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
This feature is complete when:
|
|
||||||
|
|
||||||
- a workspace-scoped compare matrix exists for one selected baseline profile,
|
|
||||||
- the matrix uses existing baseline, compare, finding, and run truth rather than a new persisted portfolio artifact,
|
|
||||||
- visible tenants are strictly filtered by RBAC and hidden tenants do not leak through summaries,
|
|
||||||
- `Compare assigned tenants` is available to authorized operators and reuses normal compare execution,
|
|
||||||
- per-tenant deviation and freshness summary is visible,
|
|
||||||
- per-subject deviation breadth is visible,
|
|
||||||
- ambiguous, stale, and not-compared states are rendered honestly,
|
|
||||||
- drilldown to existing tenant compare or finding surfaces works with understandable context,
|
|
||||||
- no promotion, approval, remediation, or manual match-override logic has been introduced,
|
|
||||||
- feature and, where appropriate, browser tests cover the core operator flows.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Baseline profiles resource | `app/Filament/Resources/BaselineProfileResource.php` and `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Existing list-header actions remain | Existing explicit `View` inspect affordance remains | Existing `View`; `Edit` and `Archive baseline profile` remain under `More` where already used | None | Existing create CTA remains | `Open compare matrix`, `Compare assigned tenants`, existing `Capture baseline`, `Edit`, and `Archive baseline profile` | Existing save/cancel unchanged | Yes | `Open compare matrix` requires `WORKSPACE_BASELINES_VIEW`. `Compare assigned tenants` requires `WORKSPACE_BASELINES_MANAGE`, is confirmation-gated, is `simulation only`, and remains visible-disabled with helper text for in-scope members who can view but cannot manage. `Archive baseline profile` remains the only destructive action and stays confirmation-gated. |
|
|
||||||
| Workspace baseline compare matrix page | New workspace page at `app/Filament/Pages/BaselineCompareMatrix.php` or equivalent | `Compare assigned tenants`, `Back to baseline profile` | Explicit tenant, subject, and cell drilldowns only; no row click | Inline `Focus subject` at most; all other follow-up opens use explicit drilldowns | None | One context-specific CTA such as `Capture baseline` or `Compare assigned tenants` depending on the blocked or empty state | Same as header actions | Not applicable | Yes through underlying compare runs | Action Surface Contract remains satisfied. The matrix is a narrow grid-surface exemption because a two-dimensional tenant-by-subject view is not a normal one-axis table. `Compare assigned tenants` remains visible-disabled with helper text for in-scope users missing manage capability, and forced execution still fails with `403`. No destructive actions are added. |
|
|
||||||
| Tenant compare and finding drilldowns from matrix context | `app/Filament/Pages/BaselineCompareLanding.php` and existing finding resource/detail surfaces | Existing destination actions remain in place | Existing destination inspect models remain in place | Existing destination row actions remain unchanged | Existing destination bulk actions remain unchanged | Existing destination empty-state behavior remains, but matrix arrival context must preserve return meaning | Existing destination view-header actions remain | Not applicable | Existing destination audit behavior remains | This spec reuses destination surfaces and adds bounded matrix-arrival context only. It does not add new destructive actions or competing inspect models there. |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Baseline compare matrix view**: The derived workspace projection for one selected baseline profile across visible assigned tenants and baseline subjects.
|
|
||||||
- **Compare target tenant**: A visible tenant assigned to the selected baseline profile whose existing compare truth can be refreshed or inspected.
|
|
||||||
- **Compare subject row**: The reusable baseline subject identity shown across the visible tenant set.
|
|
||||||
- **Matrix cell state**: The derived outcome for one subject and one visible tenant, including compare outcome, freshness, and trust.
|
|
||||||
- **Tenant freshness summary**: The per-tenant view of last compare time, deviation counts, and urgency against the selected baseline reference.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-190-001**: In validation scenarios with a selected baseline profile and multiple visible assigned tenants, an operator can identify the most divergent visible tenant and the broadest recurring subject from the matrix without opening tenant detail pages first.
|
|
||||||
- **SC-190-002**: In acceptance coverage, 100% of visible tenants or cells with stale, absent, or ambiguous compare truth remain visually distinct from matched truth.
|
|
||||||
- **SC-190-003**: An authorized operator can start compare execution for the full visible assigned set in one action and the product shows honest queued, running, completed, or partial progress without inventing a separate batch status language.
|
|
||||||
- **SC-190-004**: In negative visibility coverage, matrix summaries and counts disclose no hidden tenant identity or hidden-tenant aggregate when the actor can see only part of the assigned set.
|
|
||||||
- **SC-190-005**: From a differing, missing, or ambiguous cell, the operator can reach an existing tenant-level follow-up surface in one drilldown step with enough preserved context to understand why they arrived there.
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
# Tasks: Workspace Baseline Compare Matrix V1
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/190-baseline-compare-matrix/`
|
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-compare-matrix.logical.openapi.yaml`, `quickstart.md`
|
|
||||||
|
|
||||||
**Tests**: Tests are REQUIRED for this feature. Use Pest unit coverage for centralized matrix badge semantics, Pest feature coverage for matrix aggregation, page rendering, compare-all fan-out, and RBAC semantics, plus one browser smoke test for the rendered matrix surface and one core interaction.
|
|
||||||
**Operations**: This feature must reuse existing tenant-owned `baseline_compare` `OperationRun` semantics only. Tasks must preserve the Ops-UX 3-surface feedback contract, avoid any workspace umbrella run or shadow batch truth, keep `OperationRun.status` and `OperationRun.outcome` service-owned, keep reused `summary_counts` canonical via `OperationSummaryKeys` and numeric-only, prevent ad hoc queued, running, or completion database notifications, and keep compare-all feedback limited to canonical queued feedback plus existing Monitoring drilldowns.
|
|
||||||
**RBAC**: Existing workspace membership and tenant visibility remain authoritative. Tasks must preserve deny-as-not-found `404` behavior for non-members, `403` behavior for in-scope members missing `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, or `TENANT_FINDINGS_VIEW`, visible-disabled compare actions plus helper text for in-scope members missing capability where the surface contract requires it, and visible-set-only aggregation so hidden tenants never leak through counts, summaries, or drilldowns.
|
|
||||||
**Operator Surfaces**: The affected operator surfaces are baseline profile detail, the new workspace baseline compare matrix page, tenant compare landing, finding follow-up surfaces, and Monitoring run drilldowns.
|
|
||||||
**Filament UI Action Surfaces**: Baseline profile detail keeps its existing inspect model and gains `Open compare matrix` plus confirmation-gated `Compare assigned tenants` header actions. The matrix page exposes explicit tenant, subject, cell, and run drilldowns only, forbids row click, adds no destructive actions, and uses one narrow matrix-grid exception for the two-dimensional body.
|
|
||||||
**Filament UI UX-001**: The new page must keep Filament-native sections, summaries, legends, filters, and empty states. The grid body is the only approved UX-001 exception because a one-axis table cannot represent subject-by-tenant truth.
|
|
||||||
**Badges**: Matrix state, freshness, and trust surfaces must use centralized badge semantics through `BadgeDomain`, `BadgeCatalog`, and `BadgeRenderer`. No page-local status color or ad hoc legend mapping is allowed.
|
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently once the shared matrix foundation is in place.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Matrix Harness)
|
|
||||||
|
|
||||||
**Purpose**: Prepare reusable fixtures and acceptance scaffolds for multi-tenant baseline matrix scenarios shared across all stories.
|
|
||||||
|
|
||||||
- [X] T001 [P] Add reusable visible-set baseline matrix fixture builders in `apps/platform/tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php`
|
|
||||||
- [X] T002 [P] Stage matrix acceptance scaffolds in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Shared matrix fixtures and empty acceptance seams exist for builder, page, compare-all, RBAC, and browser smoke coverage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Matrix Core)
|
|
||||||
|
|
||||||
**Purpose**: Establish centralized badge semantics, action-surface guards, and reusable query seams that every user story depends on.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T003 [P] Add centralized matrix state and trust badge coverage in `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`
|
|
||||||
- [X] T004 [P] Add matrix header-action, visible-disabled compare-action helper-text, and grid-surface guard coverage in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
- [X] T005 [P] Register matrix state and trust badge semantics in `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixStateBadge.php`
|
|
||||||
- [X] T006 [P] Add visible-assignment and latest-compare query helpers for query-bounded matrix loading in `apps/platform/app/Models/BaselineTenantAssignment.php`, `apps/platform/app/Models/OperationRun.php`, and `apps/platform/app/Models/Finding.php`
|
|
||||||
- [X] T007 Reuse reference snapshot, freshness, and explanation seams for matrix aggregation in `apps/platform/app/Support/Baselines/BaselineSnapshotTruthResolver.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`, and `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Central badge semantics and data-access seams are ready, so page, compare-all, drilldown, and degraded-state work can build on one authoritative matrix foundation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Scan Visible Drift Across Assigned Tenants (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Let workspace operators open one baseline-scoped matrix and understand visible tenant drift, subject breadth, freshness, and trust from a single page.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix for a baseline profile with mixed compare outcomes and verify that reference truth, visible tenant columns, subject rows, summaries, filters, and cell states are truthful without opening tenant pages first.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [X] T008 [P] [US1] Add matrix aggregation coverage for visible-set-only counts, subject-axis derivation, and cell precedence in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
- [X] T009 [P] [US1] Add matrix page coverage for reference truth, summaries, filters, and grid rendering in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T010 [US1] Implement derived reference, tenant, subject, and cell bundle assembly in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
|
||||||
- [X] T011 [US1] Add the workspace matrix page class, route state, and filter schema in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T012 [US1] Render reference sections, visible-set summaries, legends, and the subject-by-tenant grid in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T013 [US1] Add `Open compare matrix` header navigation on baseline detail in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
|
||||||
- [X] T014 [US1] Register the matrix page entry seam on the baseline resource in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
|
||||||
- [X] T015 [US1] Run focused US1 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Operators can scan one baseline-scoped visible-set matrix and identify the most divergent tenants and subjects without leaving the workspace surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Refresh Compare Truth For The Visible Assigned Set (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Let authorized operators trigger compare across all visible assigned tenants from the baseline detail or matrix surface without inventing a workspace umbrella run.
|
|
||||||
|
|
||||||
**Independent Test**: Start `Compare assigned tenants` from the baseline detail or matrix page and verify that eligible visible tenants reuse normal compare execution, blocked tenants stay explicit, and no second batch truth is created.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [X] T016 [P] [US2] Add compare-all fan-out and Ops-UX regression coverage for queued, already-queued, blocked, no-umbrella-run, service-owned run transitions, canonical `summary_counts`, and no ad hoc non-terminal database notifications in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
|
||||||
- [X] T017 [P] [US2] Extend compare-start surface, confirmation, and visible-disabled helper-text coverage for `Compare assigned tenants` in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T018 [US2] Implement visible-tenant batch compare start reuse in `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
|
||||||
- [X] T019 [US2] Add confirmation-gated `Compare assigned tenants` execution, honest launch summaries, and visible-disabled helper text on the matrix page in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Rbac/UiEnforcement.php`
|
|
||||||
- [X] T020 [US2] Add confirmation-gated `Compare assigned tenants` execution with capability-gated disabled state on baseline detail in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` and `apps/platform/app/Support/Rbac/UiEnforcement.php`
|
|
||||||
- [ ] T021 [US2] Reuse canonical queued feedback, initiator-only run links, service-owned run transitions, canonical `summary_counts`, and no ad hoc notification or status writes for compare-all outcomes in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Services/OperationRunService.php`
|
|
||||||
- [X] T022 [US2] Run focused US2 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Authorized operators can refresh compare truth for the visible assigned set from one action while Monitoring and tenant compare runs remain the only execution truth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Drill From The Matrix Into Existing Follow-Up Surfaces (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Let operators move from matrix cells, tenant summaries, and subject focus into existing compare, finding, and run detail surfaces without reconstructing context.
|
|
||||||
|
|
||||||
**Independent Test**: Open a differing, missing, or ambiguous matrix cell and confirm the product lands on the existing tenant compare or finding path with a bounded return path back to the matrix.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [X] T023 [P] [US3] Add subject-focus and drilldown continuity coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- [X] T024 [P] [US3] Add matrix-to-tenant and matrix-to-finding authorization coverage for `404` versus `403` semantics in `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T025 [US3] Add tenant, subject, finding, and run drilldown state handling in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T026 [US3] Reuse canonical matrix return-path context in `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
|
|
||||||
- [X] T027 [US3] Accept matrix source context on tenant compare and finding follow-up surfaces in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Resources/FindingResource.php`
|
|
||||||
- [ ] T028 [US3] Expose bounded back-link and source-hint rendering for matrix arrivals in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Resources/OperationRunResource.php`
|
|
||||||
- [X] T029 [US3] Run focused US3 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: The matrix becomes a decision surface instead of a dead-end report because operators can drill directly into existing follow-up workflows and return cleanly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: User Story 4 - Stay Honest In Degraded Or Low-Trust Conditions (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Keep blocked, stale, ambiguous, not-compared, and low-trust states visibly distinct so the matrix never reads calmer than the underlying truth.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix for a baseline profile with no usable snapshot, no visible tenants, stale results, ambiguous matches, and uncovered policy types, and verify that none of those states render as healthy matches.
|
|
||||||
|
|
||||||
### Tests for User Story 4
|
|
||||||
|
|
||||||
- [X] T030 [P] [US4] Add degraded-state page coverage for no usable snapshot, no assigned tenants, no visible tenants, and no compare results in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- [X] T031 [P] [US4] Add ambiguity, stale-result, uncovered-policy-type, policy-type filter honesty, and query-shape guard coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` and `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 4
|
|
||||||
|
|
||||||
- [X] T032 [US4] Reuse stale-result and evidence-gap semantics inside `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`
|
|
||||||
- [X] T033 [US4] Render explicit blocked, empty, stale, ambiguous, and low-trust states in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T034 [US4] Add policy-type, state-group, severity, tenant-sort, subject-sort, and subject-focus state handling in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T035 [US4] Run focused US4 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: The matrix stays honest when data is stale, missing, ambiguous, or invisible, so operators do not mistake degraded truth for clean posture.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Finalize guard coverage, browser confidence, copy review, formatting, and the focused verification pack across all stories.
|
|
||||||
|
|
||||||
- [X] T036 [P] Add no-ad-hoc-badge and no-diagnostic-warning guard coverage for matrix state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
|
|
||||||
- [X] T037 [P] Add browser smoke coverage for matrix render, one filter interaction, and one drilldown or compare affordance in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
- [X] T038 [P] Review `Verb + Object`, `visible-set only`, and `simulation only` copy in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T041 [P] Tighten matrix scanability with active-filter scope summaries, visible-set scope disclosure, non-blocking refresh feedback, sticky subject-column treatment, and focused UI regression coverage in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
- [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/190-baseline-compare-matrix/quickstart.md`
|
|
||||||
- [ ] T040 Run the focused verification pack from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Setup (Phase 1)**: Starts immediately and prepares shared fixtures plus empty acceptance seams.
|
|
||||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user-story work until centralized badge semantics, action-surface guards, and query seams exist.
|
|
||||||
- **User Story 1 (Phase 3)**: Starts after Foundational and is the recommended engineering MVP because it delivers the first truthful read-only matrix.
|
|
||||||
- **User Story 2 (Phase 4)**: Starts after User Story 1 because compare-all actions reuse the matrix page and baseline-detail seams established in the MVP.
|
|
||||||
- **User Story 3 (Phase 5)**: Starts after User Story 1 because drilldown continuity depends on matrix page state and cell metadata.
|
|
||||||
- **User Story 4 (Phase 6)**: Starts after User Story 1 and can proceed in parallel with User Story 2 or User Story 3 once the core matrix bundle and page exist.
|
|
||||||
- **Polish (Phase 7)**: Starts after all desired user stories are complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1**: Depends only on the shared matrix foundation.
|
|
||||||
- **US2**: Depends on US1 for the matrix page, baseline-detail entry seams, and derived visible-set truth.
|
|
||||||
- **US3**: Depends on US1 for matrix page state and cell metadata used by drilldowns.
|
|
||||||
- **US4**: Depends on US1 for the matrix builder and page, then hardens degraded-state and filter honesty across the same surface.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Write or extend the story tests first and confirm they fail before implementation is considered complete.
|
|
||||||
- Land shared service, builder, or navigation changes before view and copy wiring in the same story.
|
|
||||||
- Keep each story shippable on its own before moving to the next priority.
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- `T001` and `T002` can run in parallel during Setup.
|
|
||||||
- `T003` and `T004` can run in parallel before the shared badge and action-surface seams land.
|
|
||||||
- `T005` and `T006` can run in parallel once the corresponding tests exist.
|
|
||||||
- Within US1, `T008` and `T009` can run in parallel, then `T011` and `T012` can split after `T010` defines the matrix bundle.
|
|
||||||
- Within US2, `T016` and `T017` can run in parallel, then `T019` and `T020` can split after `T018` lands the batch compare seam.
|
|
||||||
- Within US3, `T023` and `T024` can run in parallel, then `T026`, `T027`, and `T028` can split after `T025` defines the page drilldown state.
|
|
||||||
- Within US4, `T030` and `T031` can run in parallel, then `T033` and `T034` can split after `T032` lands the degraded-state semantics.
|
|
||||||
- Within Phase 7, `T036`, `T037`, and `T038` can run in parallel before formatting and the final verification pack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 1 tests in parallel
|
|
||||||
T008 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
|
|
||||||
T009 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
|
||||||
|
|
||||||
# User Story 1 implementation split after the matrix bundle exists
|
|
||||||
T011 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
|
||||||
T012 apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 2 tests in parallel
|
|
||||||
T016 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
|
||||||
T017 apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
|
|
||||||
|
|
||||||
# User Story 2 implementation split after the batch start seam lands
|
|
||||||
T019 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
|
||||||
T020 apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 3
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 3 tests in parallel
|
|
||||||
T023 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
|
||||||
T024 apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
|
|
||||||
|
|
||||||
# User Story 3 implementation split after drilldown state exists
|
|
||||||
T026 apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
|
|
||||||
T027 apps/platform/app/Filament/Pages/BaselineCompareLanding.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 4
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 4 tests in parallel
|
|
||||||
T030 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
|
||||||
T031 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
|
|
||||||
|
|
||||||
# User Story 4 implementation split after degraded-state semantics land
|
|
||||||
T033 apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
|
||||||
T034 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First
|
|
||||||
|
|
||||||
1. Complete Phase 1: Setup.
|
|
||||||
2. Complete Phase 2: Foundational.
|
|
||||||
3. Complete Phase 3: User Story 1.
|
|
||||||
4. **STOP and VALIDATE**: Confirm the read-only matrix answers the visible-set portfolio question honestly before adding refresh or drilldown behavior.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Deliver Setup plus Foundational to lock shared matrix fixtures, centralized badge semantics, action-surface guards, and query seams.
|
|
||||||
2. Deliver User Story 1 so operators can scan one truthful cross-tenant baseline matrix.
|
|
||||||
3. Deliver User Story 2 so operators can refresh visible-set compare truth from the same workflow.
|
|
||||||
4. Deliver User Story 3 so the matrix becomes a decision surface with bounded follow-up continuity.
|
|
||||||
5. Deliver User Story 4 so degraded and low-trust conditions remain explicit under the final filter set.
|
|
||||||
6. Finish with browser confidence, copy review, formatting, and the focused verification pack.
|
|
||||||
|
|
||||||
### Parallel Team Strategy
|
|
||||||
|
|
||||||
1. One contributor can prepare fixture scaffolds and browser seams while another adds badge and action-surface guards.
|
|
||||||
2. After the foundation lands, one contributor can own the matrix builder while another wires the page shell and Blade view.
|
|
||||||
3. Once the matrix page exists, one contributor can take compare-all fan-out while another handles drilldown continuity.
|
|
||||||
4. Degraded-state hardening can proceed in parallel with drilldown work once the base matrix bundle and page filters are stable.
|
|
||||||
5. Rejoin for Polish so guard suites, browser smoke coverage, copy review, formatting, and the final verification pack land together.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `[P]` tasks target different files or safe concurrent work after prerequisite seams are in place.
|
|
||||||
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the user stories in `spec.md`.
|
|
||||||
- The recommended engineering MVP scope is Phase 1 through Phase 3. The product-complete P1 scope is Phase 1 through Phase 4.
|
|
||||||
- This task plan stays compliant with Filament v5 on Livewire v4, makes no panel-provider changes in `bootstrap/providers.php`, introduces no new globally searchable resource, adds no destructive action, and requires no asset-strategy change beyond the existing deployment process.
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# Requirements Checklist: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
- [x] Spec candidate check is complete and scores the candidate before approval.
|
|
||||||
- [x] The spec is explicitly scoped as a follow-up to the existing workspace matrix rather than a new domain truth.
|
|
||||||
- [x] Multi-tenant dense mode is defined as the primary operator-density gain.
|
|
||||||
- [x] Single-tenant compact mode is defined as a separate adaptive presentation path.
|
|
||||||
- [x] Filters, legends, actions, and refresh surfaces are explicitly compressed as supporting context.
|
|
||||||
- [x] Visible-set-only semantics and existing RBAC rules are preserved.
|
|
||||||
- [x] No new persisted artifact, state family, or generalized UI framework is introduced.
|
|
||||||
- [x] Manual presentation override is local to the route and not stored as domain truth.
|
|
||||||
- [x] Functional requirements include mode selection, action calming, filter workflow, and last-updated visibility.
|
|
||||||
- [x] Definition of done is testable and aligned with operator scanability rather than generic visual polish.
|
|
||||||
- [x] Tasks are grouped by user story and include focused verification work.
|
|
||||||
@ -1,518 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Baseline Compare Matrix Operator Mode Internal Surface Contract
|
|
||||||
version: 0.2.0
|
|
||||||
summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route
|
|
||||||
description: |
|
|
||||||
This contract is an internal planning artifact for Spec 191. The affected surface
|
|
||||||
still renders HTML through Filament and Livewire. The schemas below define the
|
|
||||||
bounded request-scoped presentation models and staged filter interactions that must
|
|
||||||
be derivable from existing Spec 190 matrix truth before the operator-density
|
|
||||||
refactor can render safely.
|
|
||||||
servers:
|
|
||||||
- url: /internal
|
|
||||||
x-baseline-compare-operator-mode-consumers:
|
|
||||||
- surface: baseline.compare.matrix
|
|
||||||
sourceFiles:
|
|
||||||
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
|
||||||
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
|
||||||
mustRender:
|
|
||||||
- reference
|
|
||||||
- requested_vs_resolved_mode
|
|
||||||
- presentation_state
|
|
||||||
- support_surface_state
|
|
||||||
- applied_filters
|
|
||||||
- draft_filters
|
|
||||||
- staged_filter_changes
|
|
||||||
- tenant_summaries
|
|
||||||
- dense_rows_or_compact_results
|
|
||||||
- last_updated_at
|
|
||||||
- auto_refresh_state
|
|
||||||
mustAccept:
|
|
||||||
- mode
|
|
||||||
- policy_type
|
|
||||||
- state
|
|
||||||
- severity
|
|
||||||
- tenant_sort
|
|
||||||
- subject_sort
|
|
||||||
- subject_key
|
|
||||||
mustStage:
|
|
||||||
- selectedPolicyTypes
|
|
||||||
- selectedStates
|
|
||||||
- selectedSeverities
|
|
||||||
- tenantSort
|
|
||||||
- subjectSort
|
|
||||||
paths:
|
|
||||||
/admin/baseline-profiles/{profile}/compare-matrix:
|
|
||||||
get:
|
|
||||||
summary: Render the existing baseline compare matrix using adaptive operator-density presentation
|
|
||||||
operationId: viewBaselineCompareOperatorMode
|
|
||||||
parameters:
|
|
||||||
- name: profile
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
- name: mode
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/PresentationMode'
|
|
||||||
- name: policy_type
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
- name: state
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
- name: severity
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/FindingSeverity'
|
|
||||||
- name: tenant_sort
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: subject_sort
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: subject_key
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Rendered matrix plus adaptive operator-density read models
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
|
|
||||||
'403':
|
|
||||||
description: Actor is in scope but lacks workspace baseline view capability
|
|
||||||
'404':
|
|
||||||
description: Workspace or baseline profile is outside actor scope
|
|
||||||
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/apply-filters:
|
|
||||||
post:
|
|
||||||
summary: Apply staged heavy filters to the operator-density matrix route
|
|
||||||
operationId: applyBaselineCompareOperatorFilters
|
|
||||||
parameters:
|
|
||||||
- name: workspace
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
- name: profile
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/MatrixFilterDraft'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated operator-density bundle using the applied filter state
|
|
||||||
content:
|
|
||||||
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
|
|
||||||
'403':
|
|
||||||
description: Actor is in scope but lacks workspace baseline view capability
|
|
||||||
'404':
|
|
||||||
description: Workspace or baseline profile is outside actor scope
|
|
||||||
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/reset-filters:
|
|
||||||
post:
|
|
||||||
summary: Reset staged and applied heavy filters for the operator-density matrix route
|
|
||||||
operationId: resetBaselineCompareOperatorFilters
|
|
||||||
parameters:
|
|
||||||
- name: workspace
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
- name: profile
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated operator-density bundle with default filter state restored
|
|
||||||
content:
|
|
||||||
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
|
|
||||||
'403':
|
|
||||||
description: Actor is in scope but lacks workspace baseline view capability
|
|
||||||
'404':
|
|
||||||
description: Workspace or baseline profile is outside actor scope
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
PresentationMode:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- auto
|
|
||||||
- dense
|
|
||||||
- compact
|
|
||||||
MatrixCellState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- match
|
|
||||||
- differ
|
|
||||||
- missing
|
|
||||||
- ambiguous
|
|
||||||
- not_compared
|
|
||||||
- stale_result
|
|
||||||
FindingSeverity:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- low
|
|
||||||
- medium
|
|
||||||
- high
|
|
||||||
- critical
|
|
||||||
FreshnessState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- fresh
|
|
||||||
- stale
|
|
||||||
- never_compared
|
|
||||||
- unknown
|
|
||||||
TrustLevel:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- trustworthy
|
|
||||||
- limited_confidence
|
|
||||||
- diagnostic_only
|
|
||||||
- unusable
|
|
||||||
AttentionLevel:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- aligned
|
|
||||||
- review
|
|
||||||
- refresh_recommended
|
|
||||||
- needs_attention
|
|
||||||
MatrixReference:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- baselineProfileId
|
|
||||||
- baselineProfileName
|
|
||||||
- referenceState
|
|
||||||
- assignedTenantCount
|
|
||||||
- visibleTenantCount
|
|
||||||
properties:
|
|
||||||
baselineProfileId:
|
|
||||||
type: integer
|
|
||||||
baselineProfileName:
|
|
||||||
type: string
|
|
||||||
referenceSnapshotId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
referenceState:
|
|
||||||
type: string
|
|
||||||
assignedTenantCount:
|
|
||||||
type: integer
|
|
||||||
visibleTenantCount:
|
|
||||||
type: integer
|
|
||||||
MatrixFilterDraft:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- selectedPolicyTypes
|
|
||||||
- selectedStates
|
|
||||||
- selectedSeverities
|
|
||||||
- tenantSort
|
|
||||||
- subjectSort
|
|
||||||
properties:
|
|
||||||
selectedPolicyTypes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
selectedStates:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
selectedSeverities:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/FindingSeverity'
|
|
||||||
tenantSort:
|
|
||||||
type: string
|
|
||||||
subjectSort:
|
|
||||||
type: string
|
|
||||||
focusedSubjectKey:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
MatrixPresentationState:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- requestedMode
|
|
||||||
- resolvedMode
|
|
||||||
- visibleTenantCount
|
|
||||||
- activeFilterCount
|
|
||||||
- hasStagedFilterChanges
|
|
||||||
- autoRefreshActive
|
|
||||||
- lastUpdatedAt
|
|
||||||
- canOverrideMode
|
|
||||||
- compactModeAvailable
|
|
||||||
properties:
|
|
||||||
requestedMode:
|
|
||||||
$ref: '#/components/schemas/PresentationMode'
|
|
||||||
resolvedMode:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- dense
|
|
||||||
- compact
|
|
||||||
description: |
|
|
||||||
Final render mode after evaluating the requested route mode against the
|
|
||||||
visible tenant count. A requested `compact` mode may still resolve to
|
|
||||||
`dense` when more than one visible tenant remains in scope.
|
|
||||||
visibleTenantCount:
|
|
||||||
type: integer
|
|
||||||
activeFilterCount:
|
|
||||||
type: integer
|
|
||||||
hasStagedFilterChanges:
|
|
||||||
type: boolean
|
|
||||||
autoRefreshActive:
|
|
||||||
type: boolean
|
|
||||||
lastUpdatedAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
canOverrideMode:
|
|
||||||
type: boolean
|
|
||||||
compactModeAvailable:
|
|
||||||
type: boolean
|
|
||||||
MatrixTenantSummary:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- tenantId
|
|
||||||
- tenantName
|
|
||||||
- freshnessState
|
|
||||||
- differingCount
|
|
||||||
- missingCount
|
|
||||||
- ambiguousCount
|
|
||||||
- trustLevel
|
|
||||||
properties:
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
tenantName:
|
|
||||||
type: string
|
|
||||||
freshnessState:
|
|
||||||
$ref: '#/components/schemas/FreshnessState'
|
|
||||||
lastComparedAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
differingCount:
|
|
||||||
type: integer
|
|
||||||
missingCount:
|
|
||||||
type: integer
|
|
||||||
ambiguousCount:
|
|
||||||
type: integer
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
maxSeverity:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
DenseCellView:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- tenantId
|
|
||||||
- subjectKey
|
|
||||||
- state
|
|
||||||
- freshnessState
|
|
||||||
- trustLevel
|
|
||||||
- attentionLevel
|
|
||||||
properties:
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
subjectKey:
|
|
||||||
type: string
|
|
||||||
state:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
freshnessState:
|
|
||||||
$ref: '#/components/schemas/FreshnessState'
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
severity:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
attentionLevel:
|
|
||||||
$ref: '#/components/schemas/AttentionLevel'
|
|
||||||
reasonSummary:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
primaryDrilldownUrl:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
secondaryDrilldownUrls:
|
|
||||||
type: object
|
|
||||||
additionalProperties:
|
|
||||||
type: string
|
|
||||||
DenseSubjectRowView:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- subjectKey
|
|
||||||
- displayName
|
|
||||||
- policyType
|
|
||||||
- deviationBreadth
|
|
||||||
- missingBreadth
|
|
||||||
- ambiguousBreadth
|
|
||||||
- trustLevel
|
|
||||||
- cells
|
|
||||||
properties:
|
|
||||||
subjectKey:
|
|
||||||
type: string
|
|
||||||
displayName:
|
|
||||||
type: string
|
|
||||||
policyType:
|
|
||||||
type: string
|
|
||||||
baselineExternalId:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
deviationBreadth:
|
|
||||||
type: integer
|
|
||||||
missingBreadth:
|
|
||||||
type: integer
|
|
||||||
ambiguousBreadth:
|
|
||||||
type: integer
|
|
||||||
maxSeverity:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
cells:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/DenseCellView'
|
|
||||||
CompactSubjectResultView:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- tenantId
|
|
||||||
- subjectKey
|
|
||||||
- displayName
|
|
||||||
- policyType
|
|
||||||
- state
|
|
||||||
- freshnessState
|
|
||||||
- trustLevel
|
|
||||||
properties:
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
subjectKey:
|
|
||||||
type: string
|
|
||||||
displayName:
|
|
||||||
type: string
|
|
||||||
policyType:
|
|
||||||
type: string
|
|
||||||
state:
|
|
||||||
$ref: '#/components/schemas/MatrixCellState'
|
|
||||||
freshnessState:
|
|
||||||
$ref: '#/components/schemas/FreshnessState'
|
|
||||||
trustLevel:
|
|
||||||
$ref: '#/components/schemas/TrustLevel'
|
|
||||||
severity:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
reasonSummary:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
primaryDrilldownUrl:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
runUrl:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
MatrixSupportSurfaceState:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- legendMode
|
|
||||||
- showActiveFilterSummary
|
|
||||||
- showLastUpdated
|
|
||||||
- showAutoRefreshHint
|
|
||||||
- showBlockingRefreshState
|
|
||||||
properties:
|
|
||||||
legendMode:
|
|
||||||
type: string
|
|
||||||
showActiveFilterSummary:
|
|
||||||
type: boolean
|
|
||||||
showLastUpdated:
|
|
||||||
type: boolean
|
|
||||||
showAutoRefreshHint:
|
|
||||||
type: boolean
|
|
||||||
showBlockingRefreshState:
|
|
||||||
type: boolean
|
|
||||||
BaselineCompareOperatorModeBundle:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- reference
|
|
||||||
- presentation
|
|
||||||
- supportSurface
|
|
||||||
- appliedFilters
|
|
||||||
- draftFilters
|
|
||||||
- tenantSummaries
|
|
||||||
properties:
|
|
||||||
reference:
|
|
||||||
$ref: '#/components/schemas/MatrixReference'
|
|
||||||
presentation:
|
|
||||||
$ref: '#/components/schemas/MatrixPresentationState'
|
|
||||||
supportSurface:
|
|
||||||
$ref: '#/components/schemas/MatrixSupportSurfaceState'
|
|
||||||
appliedFilters:
|
|
||||||
$ref: '#/components/schemas/MatrixFilterDraft'
|
|
||||||
draftFilters:
|
|
||||||
$ref: '#/components/schemas/MatrixFilterDraft'
|
|
||||||
tenantSummaries:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/MatrixTenantSummary'
|
|
||||||
denseRows:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/DenseSubjectRowView'
|
|
||||||
compactResults:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/CompactSubjectResultView'
|
|
||||||
$ref: '#/components/schemas/CompactSubjectResultView'
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
# Data Model: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This follow-up introduces no new persisted entity. It reuses the existing Spec 190 matrix truth and adds derived presentation models for operator density, staged filtering, and non-blocking status cues.
|
|
||||||
|
|
||||||
## Existing Source Truths Reused Without Change
|
|
||||||
|
|
||||||
### Baseline compare truth from Spec 190
|
|
||||||
|
|
||||||
The following derived or canonical inputs remain authoritative and are not redefined by this spec:
|
|
||||||
|
|
||||||
- workspace-scoped baseline reference truth
|
|
||||||
- visible tenant summaries
|
|
||||||
- subject summaries
|
|
||||||
- subject-by-tenant matrix cells
|
|
||||||
- compare-start availability and existing drilldown destinations
|
|
||||||
|
|
||||||
This spec changes how those inputs are rendered and interacted with, not how they are computed.
|
|
||||||
|
|
||||||
## New Derived Presentation Models
|
|
||||||
|
|
||||||
### MatrixPresentationState
|
|
||||||
|
|
||||||
**Type**: request-scoped page presentation contract
|
|
||||||
**Source**: route/query state + visible tenant count + existing run state
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `requestedMode` | string | `auto`, `dense`, or `compact` from route/query state |
|
|
||||||
| `resolvedMode` | string | Final mode used for rendering: `dense` or `compact` |
|
|
||||||
| `visibleTenantCount` | integer | Existing visible-set count from the matrix bundle |
|
|
||||||
| `activeFilterCount` | integer | Count of currently applied filters |
|
|
||||||
| `hasStagedFilterChanges` | boolean | Whether filter draft state differs from applied state |
|
|
||||||
| `autoRefreshActive` | boolean | True when background polling is active because compare work is queued or running |
|
|
||||||
| `lastUpdatedAt` | datetime or null | Timestamp for the currently rendered matrix data |
|
|
||||||
| `canOverrideMode` | boolean | Whether the operator may locally switch away from `auto` |
|
|
||||||
|
|
||||||
### MatrixFilterDraft
|
|
||||||
|
|
||||||
**Type**: request-scoped staged filter model
|
|
||||||
**Source**: page form state only
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `selectedPolicyTypes` | array<string> | Draft policy-type filter selection |
|
|
||||||
| `selectedStates` | array<string> | Draft state-group selection |
|
|
||||||
| `selectedSeverities` | array<string> | Draft severity selection |
|
|
||||||
| `tenantSort` | string | Current tenant sort choice |
|
|
||||||
| `subjectSort` | string | Current subject sort choice |
|
|
||||||
| `focusedSubjectKey` | string or null | Optional current subject focus |
|
|
||||||
|
|
||||||
### DenseSubjectRowView
|
|
||||||
|
|
||||||
**Type**: request-scoped dense-mode row view
|
|
||||||
**Source**: existing subject summary + existing matrix cells
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `subjectKey` | string | Stable row key |
|
|
||||||
| `displayName` | string | Primary row label |
|
|
||||||
| `policyType` | string | Compact secondary label |
|
|
||||||
| `baselineExternalId` | string or null | Optional secondary context |
|
|
||||||
| `deviationBreadth` | integer | Existing subject summary metric |
|
|
||||||
| `missingBreadth` | integer | Existing subject summary metric |
|
|
||||||
| `ambiguousBreadth` | integer | Existing subject summary metric |
|
|
||||||
| `maxSeverity` | string or null | Existing subject summary severity |
|
|
||||||
| `trustLevel` | string | Existing subject summary trust |
|
|
||||||
| `cells` | array<DenseCellView> | One condensed cell per visible tenant |
|
|
||||||
|
|
||||||
### DenseCellView
|
|
||||||
|
|
||||||
**Type**: request-scoped dense-mode cell view
|
|
||||||
**Source**: existing matrix cell + existing tenant summary freshness
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `tenantId` | integer | Visible tenant identifier |
|
|
||||||
| `subjectKey` | string | Subject row key |
|
|
||||||
| `state` | string | Existing Spec 190 state |
|
|
||||||
| `freshnessState` | string | Freshness signal shown in compact form |
|
|
||||||
| `trustLevel` | string | Trust signal shown in compact form |
|
|
||||||
| `severity` | string or null | Optional attention signal |
|
|
||||||
| `attentionLevel` | string | Derived presentation label such as `aligned`, `refresh_recommended`, or `needs_attention` |
|
|
||||||
| `reasonSummary` | string or null | Short secondary explanation for compact reveal surfaces |
|
|
||||||
| `primaryDrilldownUrl` | string or null | Preferred next follow-up action |
|
|
||||||
| `secondaryDrilldownUrls` | array<string, string> | Additional compact follow-up links when available |
|
|
||||||
|
|
||||||
### CompactSubjectResultView
|
|
||||||
|
|
||||||
**Type**: request-scoped single-tenant row view
|
|
||||||
**Source**: one visible tenant summary + existing matrix cell + existing subject summary
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `tenantId` | integer | The single visible tenant in compact mode |
|
|
||||||
| `subjectKey` | string | Stable subject key |
|
|
||||||
| `displayName` | string | Primary subject label |
|
|
||||||
| `policyType` | string | Secondary grouping/context |
|
|
||||||
| `state` | string | Existing Spec 190 state |
|
|
||||||
| `freshnessState` | string | Compact freshness label |
|
|
||||||
| `trustLevel` | string | Compact trust label |
|
|
||||||
| `severity` | string or null | Optional attention indicator |
|
|
||||||
| `reasonSummary` | string or null | Short explanation line |
|
|
||||||
| `primaryDrilldownUrl` | string or null | Main follow-up action |
|
|
||||||
| `runUrl` | string or null | Secondary run-level follow-up |
|
|
||||||
|
|
||||||
### MatrixSupportSurfaceState
|
|
||||||
|
|
||||||
**Type**: request-scoped supporting-context contract
|
|
||||||
**Source**: page state + existing legends + refresh metadata
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|------|------|-------|
|
|
||||||
| `legendMode` | string | `grouped`, `collapsed`, or equivalent compact support behavior |
|
|
||||||
| `showActiveFilterSummary` | boolean | Whether applied filters are summarized inline |
|
|
||||||
| `showLastUpdated` | boolean | Whether the page displays last-updated metadata |
|
|
||||||
| `showAutoRefreshHint` | boolean | Whether passive auto-refresh copy is visible |
|
|
||||||
| `showBlockingRefreshState` | boolean | Reserved for deliberate user-triggered reloads only |
|
|
||||||
|
|
||||||
## Rendering and Resolution Rules
|
|
||||||
|
|
||||||
### Mode resolution rules
|
|
||||||
|
|
||||||
1. If `requestedMode = auto` and `visibleTenantCount > 1`, resolve to `dense`.
|
|
||||||
2. If `requestedMode = auto` and `visibleTenantCount = 1`, resolve to `compact`.
|
|
||||||
3. If a manual override is present, use it unless it would produce an invalid empty layout.
|
|
||||||
4. Manual override remains route-local and must never be persisted as product truth.
|
|
||||||
|
|
||||||
### Dense-mode rules
|
|
||||||
|
|
||||||
- The subject column remains sticky during horizontal scroll.
|
|
||||||
- The primary visible content per cell is state, trust, freshness, and attention.
|
|
||||||
- Long explanatory text and repeated action links do not render as the dominant cell body.
|
|
||||||
|
|
||||||
### Compact single-tenant rules
|
|
||||||
|
|
||||||
- The tenant header does not repeat as a pseudo-column structure.
|
|
||||||
- Each subject entry shows one primary status line and a reduced set of secondary metadata.
|
|
||||||
- Existing subject focus and drilldown continuity remain available.
|
|
||||||
|
|
||||||
### Filter workflow rules
|
|
||||||
|
|
||||||
- Heavy multi-select filters use staged state first and apply only when the operator confirms.
|
|
||||||
- Applied filter count and scope summary reflect the applied state, not merely the draft state.
|
|
||||||
- Reset may clear both draft and applied state in one explicit action.
|
|
||||||
|
|
||||||
### Status signal rules
|
|
||||||
|
|
||||||
- `blocking refresh` is reserved for deliberate user-triggered reload or recalculation moments.
|
|
||||||
- `auto-refresh active` indicates passive polling while compare work is still queued or running.
|
|
||||||
- `lastUpdatedAt` reflects the timestamp of the rendered matrix payload, not merely the latest compare run in the system.
|
|
||||||
|
|
||||||
### Safety rules
|
|
||||||
|
|
||||||
- No rendering path may widen tenant visibility beyond the existing visible set.
|
|
||||||
- No presentation-state change may change the underlying compare state, trust, or freshness semantics.
|
|
||||||
- No grouped legend or compact cell may invent new status vocabulary outside existing centralized badge semantics.
|
|
||||||
|
|
||||||
## Relationships
|
|
||||||
|
|
||||||
- One `MatrixPresentationState` governs one rendered matrix page.
|
|
||||||
- One `MatrixFilterDraft` belongs to one `MatrixPresentationState`.
|
|
||||||
- In dense mode, one `DenseSubjectRowView` maps to many `DenseCellView` entries.
|
|
||||||
- In compact mode, one visible tenant yields many `CompactSubjectResultView` entries.
|
|
||||||
- One `MatrixSupportSurfaceState` coordinates legends, refresh hints, and active-filter summaries for the same page render.
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
# Implementation Plan: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
**Branch**: `191-baseline-compare-operator-mode` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md`
|
|
||||||
|
|
||||||
**Note**: This plan formalizes the existing 191 spec slice and keeps the work strictly inside the already-shipped Spec 190 matrix surface.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Refactor the existing workspace baseline compare matrix into an adaptive operator-density surface. The route, baseline reference, visible-set-only truth, compare-start behavior, and drilldowns stay unchanged, but the page gains local presentation-mode state, dense multi-tenant scanning, compact single-tenant rendering, staged heavy-filter application, grouped legends, and clearer separation between blocking refresh, passive auto-refresh, and last-updated status.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15
|
|
||||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
|
||||||
**Storage**: PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned
|
|
||||||
**Testing**: Pest feature tests and browser smoke coverage run through Laravel Sail
|
|
||||||
**Target Platform**: Laravel monolith web application under `apps/platform`
|
|
||||||
**Project Type**: web application
|
|
||||||
**Performance Goals**: Improve scan throughput without increasing query shape beyond Spec 190, keep heavy filter changes non-chatty, and preserve DB-only render-time matrix surfaces
|
|
||||||
**Constraints**: No compare-logic change, no new persistence, no hidden-tenant leakage, no generalized density framework, no provider or panel changes, and no new asset pipeline
|
|
||||||
**Scale/Scope**: One existing matrix page, one existing Blade view, one existing builder, one logical contract file, and focused feature plus browser regressions
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
|
||||||
|
|
||||||
| Principle | Pre-Research | Post-Design | Notes |
|
|
||||||
|-----------|--------------|-------------|-------|
|
|
||||||
| Inventory-first / snapshots-second | PASS | PASS | The spec changes presentation only and keeps Spec 190 truth sources intact. |
|
|
||||||
| Read/write separation | PASS | PASS | `Compare assigned tenants` remains the only mutation and is unchanged. |
|
|
||||||
| Graph contract path | N/A | N/A | No new Graph behavior or contract-registry work is introduced. |
|
|
||||||
| Deterministic capabilities | PASS | PASS | Existing capabilities remain canonical and unchanged. |
|
|
||||||
| Workspace + tenant isolation | PASS | PASS | Visible-set-only aggregation and drilldown scope remain unchanged. |
|
|
||||||
| RBAC-UX authorization semantics | PASS | PASS | Existing `404` vs `403` semantics and server-side enforcement remain unchanged. |
|
|
||||||
| Run observability / Ops-UX | PASS | PASS | Compare-run truth is reused exactly as in Spec 190; this spec only clarifies the visual cues around it. |
|
|
||||||
| Data minimization | PASS | PASS | No new data copies, exports, or persisted UI artifacts are introduced. |
|
|
||||||
| Proportionality / anti-bloat | PASS | PASS | The work stays local to one page and does not add a new abstraction or stored artifact. |
|
|
||||||
| Persisted truth / behavioral state | PASS | PASS | Presentation mode and staged filter state remain request-scoped only. |
|
|
||||||
| UI semantics / few layers | PASS | PASS | Existing state, trust, freshness, and severity semantics are reused rather than redefined. |
|
|
||||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The work remains inside the existing Filament page and Livewire-backed route. |
|
|
||||||
| Provider registration location | PASS | PASS | No provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
|
|
||||||
| Global search hard rule | PASS | PASS | No new searchable resource or page is introduced. |
|
|
||||||
| Destructive action safety | PASS | PASS | No destructive action is added. Existing confirmation behavior for compare-start remains unchanged. |
|
|
||||||
| Asset strategy | PASS | PASS | No new assets are required. Existing deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged. |
|
|
||||||
|
|
||||||
## Filament-Specific Compliance Notes
|
|
||||||
|
|
||||||
- **Livewire v4.0+ compliance**: This plan remains on Filament v5 + Livewire v4 and does not introduce legacy APIs.
|
|
||||||
- **Provider registration location**: No panel or provider changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
|
||||||
- **Global search**: The feature does not add a new globally searchable resource. Existing baseline-resource search behavior is unchanged.
|
|
||||||
- **Destructive actions**: No new destructive action is introduced. Existing compare-start actions remain confirmation-gated where already defined.
|
|
||||||
- **Asset strategy**: No new global or on-demand asset registration is planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
|
||||||
- **Testing plan**: Extend the existing matrix feature, builder, guard, and browser suites to cover presentation mode, staged filter application, and non-blocking status surfaces.
|
|
||||||
|
|
||||||
## Phase 0 Research
|
|
||||||
|
|
||||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/research.md`.
|
|
||||||
|
|
||||||
Key decisions:
|
|
||||||
|
|
||||||
- Keep the existing matrix route and truth model and change presentation only.
|
|
||||||
- Resolve `auto`, `dense`, and `compact` mode from visible tenant count, with a route-local override only.
|
|
||||||
- Make dense mode state-first rather than action-first.
|
|
||||||
- Render single-tenant review as a compact compare list rather than a one-column matrix.
|
|
||||||
- Convert heavy filters to staged apply/reset semantics.
|
|
||||||
- Replace the long policy-type checkbox stack with a more compact operator-first selector.
|
|
||||||
- Group legends into compact support context and separate blocking refresh from passive auto-refresh and last-updated cues.
|
|
||||||
- Reuse existing drilldown and visible-set semantics unchanged.
|
|
||||||
|
|
||||||
## Phase 1 Design
|
|
||||||
|
|
||||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/`:
|
|
||||||
|
|
||||||
- `research.md`: decisions and rejected alternatives for local operator-density work
|
|
||||||
- `data-model.md`: request-scoped presentation models for mode state, staged filters, dense rows, compact results, and support-surface state
|
|
||||||
- `contracts/baseline-compare-operator-mode.logical.openapi.yaml`: internal logical contract for adaptive rendering and staged filter application
|
|
||||||
- `quickstart.md`: implementation and verification sequence for the follow-up spec
|
|
||||||
|
|
||||||
Design decisions:
|
|
||||||
|
|
||||||
- `auto` remains the default requested mode and resolves to `dense` for multiple visible tenants and `compact` for exactly one visible tenant.
|
|
||||||
- Manual mode override remains route-local and must never become stored product truth.
|
|
||||||
- Dense mode reuses existing compare truth but condenses cell content to state, trust, freshness, and attention.
|
|
||||||
- Compact mode reuses the same truth but removes pseudo-matrix structure once only one visible tenant remains.
|
|
||||||
- Heavy filter inputs stage locally and apply explicitly; lightweight route-state changes may remain immediate.
|
|
||||||
- Grouped legends, passive auto-refresh, and last-updated signals become support context rather than competing top-level content.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/191-baseline-compare-operator-mode/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── spec.md
|
|
||||||
├── tasks.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── baseline-compare-operator-mode.logical.openapi.yaml
|
|
||||||
└── checklists/
|
|
||||||
└── requirements.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── Pages/
|
|
||||||
│ │ └── BaselineCompareMatrix.php
|
|
||||||
│ └── Support/
|
|
||||||
│ └── Baselines/
|
|
||||||
│ └── BaselineCompareMatrixBuilder.php
|
|
||||||
├── resources/views/filament/pages/
|
|
||||||
│ └── baseline-compare-matrix.blade.php
|
|
||||||
└── tests/
|
|
||||||
├── Browser/
|
|
||||||
│ └── Spec190BaselineCompareMatrixSmokeTest.php
|
|
||||||
├── Feature/
|
|
||||||
│ ├── Baselines/
|
|
||||||
│ │ └── BaselineCompareMatrixBuilderTest.php
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── BaselineCompareMatrixPageTest.php
|
|
||||||
│ └── Guards/
|
|
||||||
│ └── ActionSurfaceContractTest.php
|
|
||||||
└── Unit/
|
|
||||||
└── Badges/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Keep the work inside the existing Spec 190 matrix implementation surface. This is a presentation refactor of one existing page and its supporting builder/view behavior, not a new domain slice or a new application area.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| None | N/A | The follow-up stays within the existing page, builder, and test surfaces and introduces no new structural violation. |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning.
|
|
||||||
- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant workflows, so supporting context, action repetition, and cell chrome are too heavy in both cases.
|
|
||||||
- **Narrowest correct implementation**: Keep the same route, truth sources, drilldowns, and compare semantics while adding route-local presentation state, denser rendering, and staged filter application.
|
|
||||||
- **Ownership cost created**: Additional view-state logic, a logical contract file, and focused regression coverage for mode resolution, filter workflow, and status visibility.
|
|
||||||
- **Alternative intentionally rejected**: A generalized density framework, a separate dense-report route, or a stored matrix artifact were rejected because the problem is local to the existing matrix surface.
|
|
||||||
- **Release truth**: current-release operator workflow compression
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### Phase A — Presentation Mode Contract
|
|
||||||
|
|
||||||
- Add route-local `auto`, `dense`, and `compact` mode state.
|
|
||||||
- Resolve the active mode from visible tenant count unless manually overridden.
|
|
||||||
- Expose `lastUpdatedAt`, `hasStagedFilterChanges`, and passive auto-refresh state to the page.
|
|
||||||
|
|
||||||
### Phase B — Dense Multi-Tenant Surface
|
|
||||||
|
|
||||||
- Keep the subject column sticky during horizontal scroll.
|
|
||||||
- Condense dense cells to state, trust, freshness, and attention signals.
|
|
||||||
- Move repeated actions into compact secondary affordances without breaking drilldown continuity.
|
|
||||||
|
|
||||||
### Phase C — Compact Single-Tenant Surface
|
|
||||||
|
|
||||||
- Replace pseudo-matrix rendering with a compact subject-result list when only one visible tenant remains.
|
|
||||||
- Remove repeated tenant headers and duplicated secondary metadata.
|
|
||||||
- Preserve subject focus and the existing compare/finding/run destinations.
|
|
||||||
|
|
||||||
### Phase D — Supporting Context Compression
|
|
||||||
|
|
||||||
- Convert heavy matrix filters to staged apply/reset behavior.
|
|
||||||
- Replace the current long policy-type control with a more compact selector.
|
|
||||||
- Group or collapse legends.
|
|
||||||
- Separate blocking refresh from passive auto-refresh and last-updated status.
|
|
||||||
|
|
||||||
### Phase E — Verification
|
|
||||||
|
|
||||||
- Extend focused feature coverage for mode resolution, staged filter behavior, and support-surface state.
|
|
||||||
- Extend browser smoke coverage for one dense-mode path and one compact-mode path.
|
|
||||||
- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green.
|
|
||||||
|
|
||||||
## Risk Assessment
|
|
||||||
|
|
||||||
| Risk | Impact | Likelihood | Mitigation |
|
|
||||||
|------|--------|------------|------------|
|
|
||||||
| Dense mode becomes another framework | Medium | Low | Keep presentation logic local to the matrix page and avoid generalized shared abstractions. |
|
|
||||||
| Compact mode hides too much follow-up value | Medium | Medium | Preserve one clear primary drilldown per subject and keep existing follow-up destinations intact. |
|
|
||||||
| Staged filtering feels slow or unclear | Medium | Medium | Show explicit staged/applied state and keep reset obvious. |
|
|
||||||
| Manual override confuses operators | Low | Medium | Keep `auto` as the default and surface the resolved mode clearly. |
|
|
||||||
| Last-updated and auto-refresh cues drift out of sync | Medium | Low | Derive both cues from the same rendered matrix payload and active-run state. |
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
- Extend `BaselineCompareMatrixPageTest` for requested vs resolved mode, active filter application, compact vs dense rendering, and non-blocking refresh cues.
|
|
||||||
- Extend `BaselineCompareMatrixBuilderTest` for any new derived presentation metadata required by the page.
|
|
||||||
- Keep `ActionSurfaceContractTest` green so calmer actions do not regress the surface contract.
|
|
||||||
- Extend `Spec190BaselineCompareMatrixSmokeTest` to prove one dense-mode and one compact-mode operator path on the Livewire page.
|
|
||||||
- Run the focused Sail verification pack from `quickstart.md` and re-run `update-agent-context.sh copilot` after the plan is finalized.
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# Quickstart: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Turn the existing baseline compare matrix into a denser operator surface without changing its underlying compare truth. Multi-tenant use should favor high-density cross-tenant scanning, while single-tenant use should collapse into a calmer compact comparison view.
|
|
||||||
|
|
||||||
## Implementation Sequence
|
|
||||||
|
|
||||||
1. Add page-level presentation state.
|
|
||||||
- Add `auto`, `dense`, and `compact` route-local mode state.
|
|
||||||
- Resolve the active mode from visible tenant count unless the operator explicitly overrides it.
|
|
||||||
- Expose `lastUpdatedAt`, staged-filter state, and passive auto-refresh state on the page.
|
|
||||||
|
|
||||||
2. Build the dense multi-tenant rendering contract.
|
|
||||||
- Keep the subject column sticky.
|
|
||||||
- Reduce dense-cell chrome to state, trust, freshness, and attention.
|
|
||||||
- Move repeated follow-up links into compact secondary affordances.
|
|
||||||
|
|
||||||
3. Build the compact single-tenant rendering contract.
|
|
||||||
- Replace the pseudo-matrix layout with a compact subject-result list.
|
|
||||||
- Remove repeated tenant headers and repeated metadata blocks.
|
|
||||||
- Preserve subject focus and existing drilldowns.
|
|
||||||
|
|
||||||
4. Compress supporting context.
|
|
||||||
- Convert heavy filters to staged apply/reset semantics.
|
|
||||||
- Replace the current long policy-type list with a more compact operator-first control.
|
|
||||||
- Group or collapse legends so they remain available without dominating the page.
|
|
||||||
- Separate blocking refresh from passive auto-refresh and last-updated status.
|
|
||||||
|
|
||||||
5. Extend regression coverage.
|
|
||||||
- Cover mode resolution, dense multi-tenant layout, compact single-tenant layout, staged filters, and non-blocking refresh cues.
|
|
||||||
- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green.
|
|
||||||
|
|
||||||
## Suggested Test Files
|
|
||||||
|
|
||||||
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
## Minimum Verification Commands
|
|
||||||
|
|
||||||
Run all commands through Sail from `apps/platform`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Acceptance Checklist
|
|
||||||
|
|
||||||
1. Open a baseline profile whose matrix has multiple visible tenants and confirm `auto` resolves to dense mode.
|
|
||||||
2. Verify the first subject column remains visible while horizontally scrolling dense mode.
|
|
||||||
3. Confirm dense cells foreground compare state, trust, freshness, and attention before links or long prose.
|
|
||||||
4. Open a matrix that resolves to one visible tenant and confirm `auto` resolves to compact mode instead of a one-column matrix.
|
|
||||||
5. Change heavy filters and confirm the page stages those changes until the operator applies them.
|
|
||||||
6. Confirm active filter count and filter summary reflect the applied state clearly.
|
|
||||||
7. Confirm legends are still understandable but no longer dominate the top of the page.
|
|
||||||
8. Trigger or observe queued/running compare work and confirm passive auto-refresh does not look like a permanent blocking load.
|
|
||||||
9. Confirm the page shows when the current matrix payload was last updated.
|
|
||||||
10. Verify tenant compare, finding, and run drilldowns still preserve the existing matrix context.
|
|
||||||
|
|
||||||
## Deployment Notes
|
|
||||||
|
|
||||||
- No migration is expected.
|
|
||||||
- No new asset registration is expected.
|
|
||||||
- No queue topology change is expected because compare execution semantics stay unchanged.
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
# Research: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
## Decision: Keep the existing matrix route and truth model, and change presentation only
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
Spec 190 already established the correct workspace route, the correct baseline reference model, and the correct visible-set-only compare truth. The operator-density follow-up should stay on `/admin/baseline-profiles/{record}/compare-matrix` and must not introduce a second route, a second report artifact, or a second source of matrix truth.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Add a separate `dense report` page: rejected because it would duplicate the same baseline-scoped workflow on a second route.
|
|
||||||
- Add a stored matrix snapshot: rejected because the operator problem is scan efficiency, not missing persistence.
|
|
||||||
|
|
||||||
## Decision: Resolve presentation mode from visible tenant count, with a local override only
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The core operator split is real: one visible tenant is a compact review problem, while several visible tenants create a cross-tenant scan problem. The narrowest implementation is one requested mode (`auto`, `dense`, or `compact`) and one resolved mode at render time. `auto` should remain the default, while manual override stays local to the matrix route and must not become stored user preference or domain truth.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Separate feature flags or separate navigation entries for each mode: rejected because the matrix should remain one operator surface.
|
|
||||||
- Persist mode preference per user: rejected because the current need is local workflow control, not profile-level personalization.
|
|
||||||
|
|
||||||
## Decision: Dense mode must be state-first, not action-first
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
In multi-tenant reading, the primary questions are where drift exists, how severe it is, whether the signal is trustworthy, and what deserves follow-up next. Dense cells should therefore foreground compare state, trust, freshness, and attention, while detailed reasons and repeated links move into compact secondary affordances.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Keep the current repeated open-link pattern in every cell: rejected because repeated actions visually outrank the state being scanned.
|
|
||||||
- Remove cell-level follow-up completely: rejected because the matrix must remain a decision surface, not a dead-end report.
|
|
||||||
|
|
||||||
## Decision: Single-tenant mode should be a compact compare list, not a one-column matrix
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
Once only one visible tenant remains, the value of cross-tenant columns disappears. The surface should switch to a shorter subject-result list that reuses the same truth but removes repeated tenant headers, empty width, and oversized cell chrome.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Reuse dense mode even for one tenant: rejected because it preserves the wrong reading model.
|
|
||||||
- Route single-tenant viewing away to the tenant compare page: rejected because the operator still started from the workspace baseline matrix context and should not lose that context automatically.
|
|
||||||
|
|
||||||
## Decision: Heavy filters should use staged apply/reset semantics
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The current matrix is dense enough that chatty recomputation on every multi-select click works against operator flow. Policy types and other heavy matrix filters should stage changes locally, show that staged state clearly, and apply them deliberately. This improves calmness and makes the surface feel less like a form page.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Keep all filters live: rejected because heavy multi-select controls create noisy redraw behavior.
|
|
||||||
- Convert every filter to manual apply: rejected because lightweight interactions such as mode switching or focused-subject clearing should remain immediate.
|
|
||||||
|
|
||||||
## Decision: Replace the long policy-type checkbox stack with a more compact operator-first selector
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The policy-type filter is the most visually expensive control on the page. The follow-up spec should use a denser selection pattern such as searchable multi-select, type-to-find, or another compact control that exposes the same filter truth without the current long vertical list.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Keep the long checkbox list and only restyle it: rejected because vertical space is the actual product problem.
|
|
||||||
- Hide policy type filtering behind a modal by default: rejected because the filter remains core enough to deserve immediate access.
|
|
||||||
|
|
||||||
## Decision: Legends should become grouped support context, optionally collapsible
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
State, freshness, and trust legends remain semantically valuable, especially for onboarding or occasional operators, but they should no longer compete with the matrix for top-of-screen attention. Grouped, compact legend blocks are the narrowest way to preserve semantics while reducing dominance.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Remove legends entirely: rejected because trust and freshness semantics still need an on-page reference.
|
|
||||||
- Leave three separate full-width legend sections: rejected because they displace the primary working surface.
|
|
||||||
|
|
||||||
## Decision: Separate loading, auto-refresh, and last-updated cues
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
Spec 190 already exposed the risk of background polling reading like permanent blocking load. This follow-up should make three states explicit: active loading for user-triggered refresh, passive auto-refresh while queued or running compare work exists, and last-updated time for the currently rendered matrix.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Reuse one generic refresh chip for all states: rejected because operators cannot tell whether the page is blocked or simply polling.
|
|
||||||
- Hide refresh state entirely: rejected because operator trust depends on understanding when the matrix is current.
|
|
||||||
|
|
||||||
## Decision: Reuse the existing drilldown and visible-set semantics without change
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
This spec is a presentation refactor, not a navigation or authorization redesign. The existing tenant compare, finding, run-detail, and canonical-navigation context from Spec 190 remain correct and should carry forward unchanged.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Introduce a dense-mode-specific drilldown model: rejected because it would create new behavior where existing follow-up paths are already sufficient.
|
|
||||||
- Add aggregated hidden-tenant remainder summaries: rejected because visible-set-only semantics explicitly avoid hidden-tenant leakage.
|
|
||||||
|
|
||||||
## Decision: Validate primarily with focused page, builder, guard, and browser coverage
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
The highest-risk changes are mode resolution, dense-cell hierarchy, compact single-tenant rendering, filter apply behavior, and non-blocking refresh cues. These are best covered with focused feature tests plus one browser smoke path for the interactive Livewire surface.
|
|
||||||
|
|
||||||
### Alternatives considered
|
|
||||||
|
|
||||||
- Browser-test every combination exhaustively: rejected because most of the behavior is deterministic and cheaper to validate through feature tests.
|
|
||||||
- Limit validation to visual inspection: rejected because mode resolution and filter workflow are important enough to guard in CI.
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
# Feature Specification: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
**Feature Branch**: `191-baseline-compare-operator-mode`
|
|
||||||
**Created**: 2026-04-11
|
|
||||||
**Status**: Approved
|
|
||||||
**Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode"
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: The current baseline compare matrix is semantically strong but still too visually heavy for repeat operator use, especially when several visible tenants must be scanned quickly.
|
|
||||||
- **Today's failure**: Operators reach the right truth, but the page spends too much space on supporting context, repeated actions, and vertically expensive cells. Multi-tenant comparison is slower than it should be, and single-tenant viewing still feels like a stretched matrix instead of a compact operator surface.
|
|
||||||
- **User-visible improvement**: The same matrix route becomes faster to scan, calmer to use, and more obviously centered on drift detection. Multi-tenant work gets a true dense scan mode, while single-tenant work gets a compact compare list.
|
|
||||||
- **Smallest enterprise-capable version**: Rework the existing matrix route with adaptive presentation only: `auto` mode picks dense multi-tenant view for more than one visible tenant and compact single-tenant view for one visible tenant, while filters, legends, actions, and refresh feedback are compressed without changing compare logic.
|
|
||||||
- **Explicit non-goals**: No change to compare truth, no new finding semantics, no new persisted matrix artifact, no generalized table engine, no mobile-first redesign, no broader design-system rewrite.
|
|
||||||
- **Permanent complexity imported**: One page-level presentation-mode contract, denser cell-layout rules, compact control behavior, route/query persistence for local mode override, and focused regression coverage for the new operator surface behavior.
|
|
||||||
- **Why now**: Spec 190 established the truthful workspace compare surface. The next real bottleneck is not domain correctness but operator throughput and scan efficiency on the page that now exists.
|
|
||||||
- **Why not local**: Small CSS-only tweaks will not solve the actual product problem because the core issue is presentation mode, action hierarchy, and default information density rather than isolated spacing bugs.
|
|
||||||
- **Approval class**: Workflow Compression
|
|
||||||
- **Red flags triggered**: `New Meta-Infrastructure` risk if presentation-mode work grows into a reusable UI framework. Defense: this spec keeps all mode logic page-local to the existing baseline compare matrix and forbids a generalized density framework.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/admin/baseline-profiles/{record}/compare-matrix` as the existing workspace matrix route that gains dense and compact operator modes
|
|
||||||
- `/admin/baseline-profiles/{record}` as the existing baseline profile detail that remains the canonical entry point into the matrix
|
|
||||||
- `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown destination
|
|
||||||
- `/admin/findings` and finding detail as the existing follow-up destinations
|
|
||||||
- Monitoring run-detail routes as existing compare-run drilldowns
|
|
||||||
- **Data Ownership**:
|
|
||||||
- Workspace-owned baseline profile, snapshot, and assignment truth remain unchanged.
|
|
||||||
- Tenant-owned compare runs and findings remain unchanged.
|
|
||||||
- Presentation mode, filter compaction, and dense cell rendering remain derived UI behavior only and introduce no new persisted truth.
|
|
||||||
- **RBAC**:
|
|
||||||
- Matrix access remains gated by workspace membership plus `WORKSPACE_BASELINES_VIEW`.
|
|
||||||
- `Compare assigned tenants` remains gated by `WORKSPACE_BASELINES_MANAGE`.
|
|
||||||
- Tenant and finding drilldowns continue to enforce their existing tenant-scope capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`.
|
|
||||||
- Presentation-mode changes MUST NOT widen visibility, leak hidden tenants, or relax `404` vs `403` semantics already established in Spec 190.
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace baseline compare matrix | Workspace matrix / operator surface | Explicit subject, cell, and tenant drilldown controls | forbidden | Header controls, compact cell action slot, focused subject utilities | none | `/admin/baseline-profiles/{record}/compare-matrix` | same route with filter and presentation state | Active workspace, baseline profile, visible tenant count, active filter count, presentation mode, last updated | Baseline compare matrix | Drift hotspots, trust, freshness, and next follow-up path | dense-grid + compact-single-tenant exception |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none |
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace baseline compare matrix | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | `Compare assigned tenants` remains the sole primary header action; presentation mode, refresh status, and filter state stay in contextual support surfaces rather than the header | Explicit subject, cell, and tenant drilldown controls only; row click remains forbidden | none; follow-up links remain inside compact cell or compact-result affordances only | none | `Reset filters` becomes the single primary CTA when filters reduce the visible row set to zero; otherwise the surface keeps the existing compare-start guidance and no duplicate empty-state CTA | No separate detail header exists; the matrix route remains the canonical working surface | n/a | Existing compare-start run and audit semantics remain unchanged; no new audit event is introduced by presentation changes | Dense-grid and compact-single-tenant rendering are approved custom surface exceptions, but HDR-001 still applies: no pure-navigation header actions and only one primary visible header action |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: no
|
|
||||||
- **New enum/state/reason family?**: no
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning.
|
|
||||||
- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant use cases, so supporting context, cell chrome, and repeated actions stay too heavy for both.
|
|
||||||
- **Narrowest correct implementation**: Keep the same route, same truth sources, same drilldowns, and same compare semantics while adding one adaptive presentation contract and denser default rendering.
|
|
||||||
- **Ownership cost**: More page-view branching, additional view-state tests, and stricter UI regression coverage for density, action noise, and status visibility.
|
|
||||||
- **Alternative intentionally rejected**: A generalized dense-table framework or a second persisted reporting artifact was rejected because this need is local to the baseline compare matrix.
|
|
||||||
- **Release truth**: current-release operator workflow compression
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace operator, I want the matrix to switch into a true high-density view when multiple visible tenants are in scope so I can read drift patterns quickly without losing the subject axis.
|
|
||||||
|
|
||||||
**Why this priority**: Multi-tenant scanning is the core operator value of the matrix. If this remains visually slow, the page does not earn its workspace-level role.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix for a baseline profile with multiple visible tenants and verify that one subject row and one tenant column remain readable in a dense layout with a sticky subject column and compact cell states.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a baseline profile with more than one visible assigned tenant, **When** the operator opens the matrix route in auto mode, **Then** the page renders the dense multi-tenant mode with one subject row per baseline subject and one tenant column per visible tenant.
|
|
||||||
2. **Given** the operator scrolls horizontally in dense mode, **When** the matrix remains wider than the viewport, **Then** the first subject column stays visible and anchored for cross-tenant reading.
|
|
||||||
3. **Given** a dense-mode cell represents a visible tenant and subject, **When** the page renders it, **Then** the primary visible signal is the technical state plus condensed trust and freshness rather than a block of repeated links or prose.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
|
|
||||||
|
|
||||||
As a workspace operator, I want the matrix to stop pretending to be multi-tenant when only one visible tenant remains so the screen becomes shorter and calmer.
|
|
||||||
|
|
||||||
**Why this priority**: A single-tenant compare surface should not spend horizontal and vertical space simulating columns that do not exist.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix for a baseline profile where only one tenant is visible and verify that the page uses a compact compare-list mode instead of the dense cross-tenant layout.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode.
|
|
||||||
2. **Given** more than one tenant is assigned to the baseline profile but RBAC scoping leaves only one tenant visible to the current actor, **When** the operator opens the matrix in auto mode, **Then** the page still resolves to compact mode and all counts and drilldowns remain visible-set-only.
|
|
||||||
3. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2)
|
|
||||||
|
|
||||||
As a workspace operator, I want supporting controls to stay available but compact so the matrix remains the primary working surface above the fold.
|
|
||||||
|
|
||||||
**Why this priority**: Filtering, legends, and refresh status are necessary, but they should support the matrix rather than compete with it.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix, apply policy-type or state filters, and verify that active filter count, filter application, legend compaction, and refresh signals remain visible without dominating the page.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click.
|
|
||||||
2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression.
|
|
||||||
3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand.
|
|
||||||
4. **Given** staged or applied filters reduce the visible subject set to zero, **When** the page renders the filtered result, **Then** it preserves the active presentation mode, shows a clear zero-results empty state, and offers `Reset filters` as the single primary CTA.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- If total assigned tenants are greater than one but only one tenant is visible to the current actor, auto mode MUST choose compact mode, not dense mode.
|
|
||||||
- If the operator manually overrides `auto` to `dense` or `compact`, the override MUST stay local to the matrix route and MUST NOT create a persisted user preference or domain artifact.
|
|
||||||
- If filters reduce the visible row set to zero, the page MUST preserve the active mode and still show a clear empty state.
|
|
||||||
- If compare runs are queued or running while the page is open, the refresh signal MUST remain distinct from a blocking loading state.
|
|
||||||
- If dense mode cannot fit all compact cell details legibly, secondary detail MUST move behind tooltip, popover, expand, or a deliberate drilldown instead of widening every cell again.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This follow-up spec changes only the operator presentation of the existing matrix surface. It introduces no new Microsoft Graph path, no new baseline or finding truth, and no new mutation beyond the already-existing compare-start behavior.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature must remain a local presentation refactor on top of Spec 190. It MUST NOT introduce a new persisted report, a new compare artifact, a new domain state family, or a reusable density framework.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** Any compare-start controls remain bound to the existing `baseline_compare` run semantics from Spec 190. This spec only changes the presentation around those controls and their feedback, not the run model.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** All existing `404` versus `403` semantics, visible-set-only counts, drilldown authorization, and capability checks remain unchanged. Dense or compact mode MUST never reveal more tenant truth than the current actor can already see.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** Dense and compact mode MUST reuse centralized state, freshness, trust, and severity badge semantics. This spec MUST NOT create page-local status colors or a second status vocabulary.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** The matrix page should continue to use Filament-native sections, actions, and shared primitives. The dense matrix body and compact single-tenant layout may use custom Blade composition where Filament's one-axis primitives are insufficient, but the page MUST avoid inventing a local semantic component framework.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must stay aligned with the vocabulary established in Spec 190, including `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, and the existing compare-state labels.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-REVIEW-001):** The matrix remains a workspace operator surface with explicit inspect controls and forbidden row click. The primary working surface is the matrix body, while filters, legends, and status strips become supporting context.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-191-001 Primary working surface**: On desktop operator viewports (`>= 1280px`), the initial render MUST show the first dense matrix row or first compact result without scrolling past expanded legends or a long filter stack. Reference context, filter summary, and legend summary MAY remain above the working surface, but detailed legend or helper text MUST stay collapsed or secondary by default.
|
|
||||||
- **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope.
|
|
||||||
- **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference.
|
|
||||||
- **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column.
|
|
||||||
- **FR-191-005 Dense cell contract**: Dense mode cells MUST default to compact state, trust, and freshness signals. Detailed reasons, long helper text, and multiple secondary links MUST NOT dominate the default cell chrome.
|
|
||||||
- **FR-191-006 Single-tenant compact layout**: Compact mode MUST render a shorter subject-result list optimized for one visible tenant instead of a pseudo-matrix with repeated tenant headers and oversized cells.
|
|
||||||
- **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome.
|
|
||||||
- **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact.
|
|
||||||
- **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click.
|
|
||||||
- **FR-191-010 Policy type usability**: Policy type filtering MUST replace the long checkbox stack with a searchable multi-select or an equivalent compact selector that supports type-to-find behavior and stays one compact control when closed.
|
|
||||||
- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST default to one grouped support block with summary labels visible and detailed explanatory text hidden behind an explicit reveal so they do not displace the matrix in daily use.
|
|
||||||
- **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates.
|
|
||||||
- **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed.
|
|
||||||
- **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns.
|
|
||||||
- **FR-191-015 Drilldown continuity preserved**: Switching presentation mode MUST NOT break subject focus, tenant drilldowns, finding drilldowns, or return-path continuity already established on the matrix route.
|
|
||||||
- **FR-191-016 No compare-logic changes**: This spec MUST NOT change how drift, trust, freshness, severity, or evidence gaps are calculated.
|
|
||||||
- **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact.
|
|
||||||
- **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics.
|
|
||||||
|
|
||||||
## Measurable Acceptance Thresholds
|
|
||||||
|
|
||||||
- Dense auto mode is accepted only when a multi-tenant matrix render shows the first sticky subject row without scrolling past expanded legends or a long filter stack on a desktop operator viewport.
|
|
||||||
- Compact auto mode is accepted only when the RBAC-visible single-tenant edge case renders the compact result list instead of the dense grid while preserving visible-set-only counts and drilldown continuity.
|
|
||||||
- Staged filtering is accepted only when draft multi-select or sort changes do not redraw the matrix until the operator explicitly applies or resets them, and the active filter summary continues to describe the applied route state.
|
|
||||||
- Support-surface compression is accepted only when legends stay grouped behind an explicit reveal, passive auto-refresh remains visibly distinct from deliberate refresh, and last-updated context stays visible on the page.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No change to baseline compare logic or evidence resolution
|
|
||||||
- No new matrix export or stored report artifact
|
|
||||||
- No new generic dense-table framework for other pages
|
|
||||||
- No new finding workflow or remediation workflow
|
|
||||||
- No mobile-first redesign of the matrix surface
|
|
||||||
- No cross-workspace or tenant-vs-tenant compare feature
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Spec 190 remains the canonical domain-truth foundation for the matrix.
|
|
||||||
- Existing builder outputs can be extended or re-rendered without introducing new persistence.
|
|
||||||
- Existing drilldown URLs and canonical navigation context can carry any local presentation override that must survive navigation.
|
|
||||||
- Existing badge semantics already cover the status information needed for denser rendering.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Spec 190 baseline compare matrix route and builder
|
|
||||||
- Existing matrix page and view
|
|
||||||
- Existing badge semantics for state, freshness, trust, and severity
|
|
||||||
- Existing tenant compare, finding, and run-detail destinations
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Dense mode could drift into a local mini-framework if rendering rules become over-generalized.
|
|
||||||
- Compacting actions too aggressively could hide next steps instead of calming them.
|
|
||||||
- Apply/reset filtering could feel slower if the staged-filter state is not clearly signaled.
|
|
||||||
- Manual mode override could create confusion if `auto` behavior and override state are not explicit.
|
|
||||||
|
|
||||||
## Review Questions
|
|
||||||
|
|
||||||
- Does the page now clearly separate supporting context from the primary working surface?
|
|
||||||
- Is dense mode truly optimized for cross-tenant scanning rather than just a tighter version of the old layout?
|
|
||||||
- Is single-tenant mode clearly calmer and shorter than the current matrix?
|
|
||||||
- Are repeated actions secondary without becoming hard to discover?
|
|
||||||
- Are filter count, legend compression, and last-updated feedback visible without dominating the page?
|
|
||||||
- Does the spec stay local to the matrix surface and avoid importing a reusable UI framework?
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
This feature is complete when:
|
|
||||||
|
|
||||||
- the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior,
|
|
||||||
- multi-tenant auto mode renders a clearly denser matrix with a sticky subject column,
|
|
||||||
- the RBAC-scoped case where more than one tenant is assigned but only one tenant is visible resolves to compact mode while preserving visible-set-only counts and drilldowns,
|
|
||||||
- single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout,
|
|
||||||
- supporting context is visibly lighter than the matrix body,
|
|
||||||
- repeated per-cell or per-row actions no longer dominate the reading flow,
|
|
||||||
- active filters are counted and heavy filters use an explicit apply/reset pattern,
|
|
||||||
- zero-result filtered states preserve the active mode and offer `Reset filters` as the single primary CTA,
|
|
||||||
- legends remain available but are grouped and visually compressed,
|
|
||||||
- page-level refresh and last-updated signals are honest and non-blocking,
|
|
||||||
- no compare logic, trust logic, freshness logic, or RBAC semantics have changed,
|
|
||||||
- and focused feature plus browser coverage proves the new operator-density behavior.
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
# Tasks: Baseline Compare Matrix: High-Density Operator Mode
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/191-baseline-compare-operator-mode/`
|
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/baseline-compare-operator-mode.logical.openapi.yaml`
|
|
||||||
|
|
||||||
**Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route.
|
|
||||||
**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, run-summary contract, or notification channel should be introduced.
|
|
||||||
**RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior.
|
|
||||||
**Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only.
|
|
||||||
**Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added.
|
|
||||||
**Badges**: Dense and compact rendering must continue to use centralized matrix state, trust, freshness, and severity semantics.
|
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Spec and Acceptance Seams)
|
|
||||||
|
|
||||||
**Purpose**: Lock the implementation contract and acceptance seams before page behavior changes.
|
|
||||||
|
|
||||||
- [X] T001 Finalize the UI Action Matrix, operator-surface assumptions, and measurable acceptance thresholds in `specs/191-baseline-compare-operator-mode/spec.md`
|
|
||||||
- [X] T002 [P] Reconcile the staged filter and presentation-mode interaction contract in `specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml`
|
|
||||||
- [X] T003 [P] Add acceptance scaffolds for multi-tenant, single-tenant, and staged-filter scenarios in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
- [X] T004 [P] Extend browser and action-surface guard seams in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: The spec contract and test seams are ready for implementation work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Presentation Contract)
|
|
||||||
|
|
||||||
**Purpose**: Establish page-level presentation state and derived read models before reshaping dense and compact layouts.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T005 Add requested, resolved, and manual presentation-mode query handling plus staged filter state as request-scoped-only route state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T006 [P] Extend matrix bundle outputs for dense rows, compact results, support-surface state, and last-updated metadata in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
|
||||||
- [X] T007 [P] Add foundational builder coverage for requested or resolved mode, filter metadata, support-surface state, and unchanged compare state, trust, freshness, and severity outputs in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
- [X] T008 [P] Add foundational page coverage for mode resolution, route-state persistence, and derived-only non-persistence guarantees in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: The page can resolve `auto`, `dense`, and `compact` mode and expose all derived state needed by the UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Make multi-tenant reading materially denser and faster without changing compare truth.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix with multiple visible tenants and verify dense mode, sticky subject behavior, and state-first cells.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [X] T009 [P] [US1] Add dense-mode assertions for auto resolution, sticky subject behavior, and compact cell hierarchy in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- [X] T010 [P] [US1] Extend browser smoke coverage for dense-mode scanning and dense-mode drilldowns in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T011 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T012 [US1] Surface condensed dense-cell state, trust, freshness, and attention summaries in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T013 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T014 [US1] Preserve focused-subject and visible-set drilldown continuity for dense mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T015 [US1] Run focused dense-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Replace pseudo-matrix rendering with a compact comparison surface when only one visible tenant remains.
|
|
||||||
|
|
||||||
**Independent Test**: Open the matrix with one visible tenant and verify compact mode in auto state plus drilldown continuity.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [X] T016 [P] [US2] Add compact single-tenant page assertions for auto-to-compact resolution, including the assigned-greater-than-visible RBAC edge case, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- [X] T017 [P] [US2] Add compact single-tenant builder assertions for visible-set-only compact resolution and unchanged compare semantics in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T018 [US2] Emit compact single-tenant result entries, compact drilldown metadata, and visible-set-only compact resolution when assigned tenants exceed visible tenants in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
|
||||||
- [X] T019 [US2] Render the compact single-tenant compare list and reduced metadata shell in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T020 [US2] Preserve manual override, subject focus, and drilldown continuity for compact mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T021 [US2] Run focused compact-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Compress supporting context so it stays useful without pushing the matrix down or increasing visual noise.
|
|
||||||
|
|
||||||
**Independent Test**: Apply filters, inspect legends, and observe background refresh behavior without losing scanability.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [X] T022 [P] [US3] Add staged-filter, legend-compaction, refresh-cue, and zero-result empty-state assertions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- [X] T023 [P] [US3] Add browser smoke coverage for apply/reset filters, passive auto-refresh cues, and filtered zero-result empty states in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T024 [US3] Implement staged heavy-filter draft, apply, reset, and zero-result empty-state behavior in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T025 [US3] Replace the long policy-type control with a searchable compact selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
|
||||||
- [X] T026 [US3] Render applied-versus-draft filter summaries, one grouped collapsed legend block, and compressed support context in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T027 [US3] Render honest manual-refresh, passive polling, and last-updated cues in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T028 [US3] Keep calmer actions and forbidden row-click behavior enforced in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
- [X] T029 [US3] Run focused support-surface verification, including zero-result empty-state behavior, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Finalize copy, formatting, and the focused verification pack.
|
|
||||||
|
|
||||||
- [X] T030 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
|
||||||
- [X] T031 [P] Verify shared badge semantics remain centralized in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
|
||||||
- [X] T032 [P] Run formatting for changed implementation files in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
|
||||||
- [X] T033 Run the focused verification pack and confirm no compare-truth or persistence regressions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Setup (Phase 1)**: No dependencies. Start immediately.
|
|
||||||
- **Foundational (Phase 2)**: Depends on Phase 1. Blocks all user-story implementation.
|
|
||||||
- **User Story 1 (Phase 3)**: Depends on Phase 2. This is the MVP slice.
|
|
||||||
- **User Story 2 (Phase 4)**: Depends on Phase 2. Can proceed after the shared presentation contract is stable.
|
|
||||||
- **User Story 3 (Phase 5)**: Depends on Phase 2. Should land after the dense and compact layout branches exist.
|
|
||||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1**: Independent after Phase 2 and should be delivered first.
|
|
||||||
- **US2**: Independent after Phase 2, but it reuses the shared presentation contract from US1-era foundational work.
|
|
||||||
- **US3**: Independent after Phase 2, but it should align with the final dense and compact layout structure.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Tests for that story should be written and made to fail before implementation.
|
|
||||||
- Builder and page state updates should land before Blade branching that depends on them.
|
|
||||||
- Each story must remain independently testable when finished.
|
|
||||||
|
|
||||||
## Parallel Execution Examples
|
|
||||||
|
|
||||||
### User Story 1
|
|
||||||
|
|
||||||
- Run `T009` and `T010` in parallel because they touch separate test files.
|
|
||||||
- After `T011` lands, `T012` can proceed while `T014` is prepared if the route-state contract is already stable.
|
|
||||||
|
|
||||||
### User Story 2
|
|
||||||
|
|
||||||
- Run `T016` and `T017` in parallel because they cover separate test layers.
|
|
||||||
- `T018` should land before `T019` because the compact Blade path depends on compact result entries.
|
|
||||||
|
|
||||||
### User Story 3
|
|
||||||
|
|
||||||
- Run `T022` and `T023` in parallel because they touch separate test files.
|
|
||||||
- `T024` and `T025` can be split between staged filter flow and selector compaction if coordinated on `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`.
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First
|
|
||||||
|
|
||||||
1. Finish Setup and Foundational work.
|
|
||||||
2. Deliver US1 dense multi-tenant mode as the MVP operator gain.
|
|
||||||
3. Verify US1 independently before moving on.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Add US2 compact single-tenant mode on top of the shared presentation contract.
|
|
||||||
2. Add US3 filter, legend, and refresh-surface compression once both layout branches are stable.
|
|
||||||
3. Finish with copy review, formatting, and the focused verification pack.
|
|
||||||
|
|
||||||
### Validation Rule
|
|
||||||
|
|
||||||
1. Do not mark a story complete until its focused verification task passes.
|
|
||||||
2. Keep the existing Spec 190 truth, RBAC semantics, and drilldown continuity intact while implementing each story.
|
|
||||||
Loading…
Reference in New Issue
Block a user