feat: add workspace baseline compare matrix (#221)
## Summary - add a workspace-scoped baseline compare matrix page under baseline profiles - derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings - add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run - preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics - include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190 ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #221
This commit is contained in:
parent
2f45ff5a84
commit
eca19819d1
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -165,6 +165,8 @@ ## 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -199,8 +201,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 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
|
||||||
- 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
|
- 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
|
||||||
- 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
|
- 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,6 +15,7 @@
|
|||||||
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;
|
||||||
@ -109,6 +110,13 @@ 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();
|
||||||
@ -130,6 +138,12 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,6 +258,9 @@ 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,
|
||||||
@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
$this->compareNowAction(),
|
$navigationContext = $this->navigationContext();
|
||||||
];
|
|
||||||
|
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
|
||||||
@ -389,7 +416,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)),
|
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
|
||||||
] : [])
|
] : [])
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -436,4 +463,15 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
414
apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
Normal file
414
apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
|
||||||
|
use Filament\Resources\Pages\Page;
|
||||||
|
|
||||||
|
class BaselineCompareMatrix extends Page
|
||||||
|
{
|
||||||
|
use InteractsWithRecord;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string $resource = BaselineProfileResource::class;
|
||||||
|
|
||||||
|
protected static ?string $breadcrumb = 'Compare matrix';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.baseline-compare-matrix';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedPolicyTypes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedStates = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedSeverities = [];
|
||||||
|
|
||||||
|
public string $tenantSort = 'tenant_name';
|
||||||
|
|
||||||
|
public string $subjectSort = 'deviation_breadth';
|
||||||
|
|
||||||
|
public ?string $focusedSubjectKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $matrix = [];
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
$this->record = $this->resolveRecord($record);
|
||||||
|
$this->hydrateFiltersFromRequest();
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
return 'Compare matrix: '.$profile->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
|
||||||
|
->label('Compare assigned tenants')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Compare assigned tenants')
|
||||||
|
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
||||||
|
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
||||||
|
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
||||||
|
->action(fn (): mixed => $this->compareAssignedTenants());
|
||||||
|
|
||||||
|
$compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction(
|
||||||
|
$compareAssignedTenantsAction,
|
||||||
|
fn (): ?Workspace => $this->workspace(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
|
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('backToBaselineProfile')
|
||||||
|
->label('Back to baseline profile')
|
||||||
|
->color('gray')
|
||||||
|
->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')),
|
||||||
|
$compareAssignedTenantsAction,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshMatrix(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [
|
||||||
|
'policyTypes' => $this->selectedPolicyTypes,
|
||||||
|
'states' => $this->selectedStates,
|
||||||
|
'severities' => $this->selectedSeverities,
|
||||||
|
'tenantSort' => $this->tenantSort,
|
||||||
|
'subjectSort' => $this->subjectSort,
|
||||||
|
'focusedSubjectKey' => $this->focusedSubjectKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant($tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineCompareLanding::getUrl(
|
||||||
|
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
|
||||||
|
panel: 'tenant',
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant($tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl(
|
||||||
|
'view',
|
||||||
|
[
|
||||||
|
'record' => $findingId,
|
||||||
|
...$this->navigationContext($tenant, $subjectKey)->toQuery(),
|
||||||
|
],
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string
|
||||||
|
{
|
||||||
|
return OperationRunLinks::tenantlessView(
|
||||||
|
$runId,
|
||||||
|
$this->navigationContext(
|
||||||
|
$tenantId !== null ? $this->tenant($tenantId) : null,
|
||||||
|
$subjectKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearSubjectFocusUrl(): string
|
||||||
|
{
|
||||||
|
return static::getUrl($this->routeParameters([
|
||||||
|
'subject_key' => null,
|
||||||
|
]), panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterUrl(array $overrides = []): string
|
||||||
|
{
|
||||||
|
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectedPolicyTypes(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectedStates(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectedSeverities(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedTenantSort(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSubjectSort(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedFocusedSubjectKey(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
return array_merge($this->matrix, [
|
||||||
|
'profile' => $this->getRecord(),
|
||||||
|
'currentFilters' => [
|
||||||
|
'policy_type' => $this->selectedPolicyTypes,
|
||||||
|
'state' => $this->selectedStates,
|
||||||
|
'severity' => $this->selectedSeverities,
|
||||||
|
'tenant_sort' => $this->tenantSort,
|
||||||
|
'subject_sort' => $this->subjectSort,
|
||||||
|
'subject_key' => $this->focusedSubjectKey,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateFiltersFromRequest(): void
|
||||||
|
{
|
||||||
|
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
|
||||||
|
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
|
||||||
|
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
|
||||||
|
$this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name';
|
||||||
|
$this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth';
|
||||||
|
$subjectKey = request()->query('subject_key');
|
||||||
|
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeQueryList(mixed $value): array
|
||||||
|
{
|
||||||
|
$values = is_array($value) ? $value : [$value];
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string {
|
||||||
|
if (! is_string($item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($item);
|
||||||
|
|
||||||
|
return $normalized !== '' ? $normalized : null;
|
||||||
|
}, $values))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenantsDisabledReason(): ?string
|
||||||
|
{
|
||||||
|
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
||||||
|
|
||||||
|
if (($reference['referenceState'] ?? null) !== 'ready') {
|
||||||
|
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
|
||||||
|
return 'No visible assigned tenants are available for compare.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenants(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
|
||||||
|
$summary = sprintf(
|
||||||
|
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
|
||||||
|
(int) $result['queuedCount'],
|
||||||
|
(int) $result['alreadyQueuedCount'],
|
||||||
|
(int) $result['blockedCount'],
|
||||||
|
(int) $result['visibleAssignedTenantCount'],
|
||||||
|
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
$toast = (int) $result['queuedCount'] > 0
|
||||||
|
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||||
|
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||||
|
|
||||||
|
$toast
|
||||||
|
->body($summary.' Open Operations for progress and next steps.')
|
||||||
|
->actions([
|
||||||
|
Action::make('open_operations')
|
||||||
|
->label('Open operations')
|
||||||
|
->url(OperationRunLinks::index(
|
||||||
|
context: $this->navigationContext(),
|
||||||
|
allTenants: true,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('No baseline compares were started')
|
||||||
|
->body($summary)
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function routeParameters(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'record' => $this->getRecord(),
|
||||||
|
'policy_type' => $this->selectedPolicyTypes,
|
||||||
|
'state' => $this->selectedStates,
|
||||||
|
'severity' => $this->selectedSeverities,
|
||||||
|
'tenant_sort' => $this->tenantSort,
|
||||||
|
'subject_sort' => $this->subjectSort,
|
||||||
|
'subject_key' => $this->focusedSubjectKey,
|
||||||
|
...$overrides,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$subjectKey ??= $this->focusedSubjectKey;
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::forBaselineCompareMatrix(
|
||||||
|
profile: $profile,
|
||||||
|
filters: $this->routeParameters(),
|
||||||
|
tenant: $tenant,
|
||||||
|
subjectKey: $subjectKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenant(int $tenantId): ?Tenant
|
||||||
|
{
|
||||||
|
return Tenant::query()
|
||||||
|
->whereKey($tenantId)
|
||||||
|
->where('workspace_id', (int) $this->getRecord()->workspace_id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -135,7 +136,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 + edit actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -447,10 +448,16 @@ 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,6 +44,8 @@ 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()),
|
||||||
];
|
];
|
||||||
@ -307,6 +309,80 @@ 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>
|
||||||
*/
|
*/
|
||||||
@ -407,4 +483,48 @@ 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,6 +1257,16 @@ 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,6 +5,7 @@
|
|||||||
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;
|
||||||
@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
|
$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')
|
||||||
@ -53,11 +64,16 @@ 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,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -37,4 +38,30 @@ 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,6 +274,18 @@ 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,6 +6,7 @@
|
|||||||
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;
|
||||||
@ -89,6 +90,17 @@ 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);
|
||||||
@ -284,6 +296,34 @@ 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) {
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
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;
|
||||||
@ -28,6 +29,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,12 +49,34 @@ public function startCompare(
|
|||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = $assignment->baselineProfile;
|
||||||
|
|
||||||
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) {
|
||||||
@ -124,6 +148,103 @@ public function startCompare(
|
|||||||
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,6 +66,9 @@ 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,4 +57,7 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
<?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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?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,6 +245,57 @@ 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,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -18,6 +19,36 @@ 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
|
||||||
|
|||||||
@ -0,0 +1,884 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
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\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class BaselineCompareMatrixBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly BaselineCompareExplanationRegistry $explanationRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function build(BaselineProfile $profile, User $user, array $filters = []): array
|
||||||
|
{
|
||||||
|
$normalizedFilters = $this->normalizeFilters($filters);
|
||||||
|
|
||||||
|
$assignments = BaselineTenantAssignment::query()
|
||||||
|
->inWorkspace((int) $profile->workspace_id)
|
||||||
|
->forBaselineProfile($profile)
|
||||||
|
->with('tenant')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$visibleTenants = $this->visibleTenants($assignments, $user);
|
||||||
|
$referenceResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile);
|
||||||
|
$referenceSnapshot = $this->resolvedSnapshot($referenceResolution);
|
||||||
|
$referenceReasonCode = is_string($referenceResolution['reason_code'] ?? null)
|
||||||
|
? trim((string) $referenceResolution['reason_code'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$reference = [
|
||||||
|
'workspaceId' => (int) $profile->workspace_id,
|
||||||
|
'baselineProfileId' => (int) $profile->getKey(),
|
||||||
|
'baselineProfileName' => (string) $profile->name,
|
||||||
|
'baselineStatus' => $profile->status instanceof BaselineProfileStatus
|
||||||
|
? $profile->status->value
|
||||||
|
: (string) $profile->status,
|
||||||
|
'referenceSnapshotId' => $referenceSnapshot?->getKey(),
|
||||||
|
'referenceSnapshotCapturedAt' => $referenceSnapshot?->captured_at?->toIso8601String(),
|
||||||
|
'referenceState' => $referenceSnapshot instanceof BaselineSnapshot ? 'ready' : 'no_snapshot',
|
||||||
|
'referenceReasonCode' => $referenceReasonCode,
|
||||||
|
'assignedTenantCount' => $assignments->count(),
|
||||||
|
'visibleTenantCount' => $visibleTenants->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$snapshotItems = $referenceSnapshot instanceof BaselineSnapshot
|
||||||
|
? BaselineSnapshotItem::query()
|
||||||
|
->where('baseline_snapshot_id', (int) $referenceSnapshot->getKey())
|
||||||
|
->orderBy('policy_type')
|
||||||
|
->orderBy('subject_key')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$policyTypeOptions = $snapshotItems
|
||||||
|
->pluck('policy_type')
|
||||||
|
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->mapWithKeys(static fn (string $type): array => [
|
||||||
|
$type => InventoryPolicyTypeMeta::label($type) ?? $type,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$bundle = [
|
||||||
|
'reference' => $reference,
|
||||||
|
'filters' => [
|
||||||
|
'policyTypes' => $normalizedFilters['policyTypes'],
|
||||||
|
'states' => $normalizedFilters['states'],
|
||||||
|
'severities' => $normalizedFilters['severities'],
|
||||||
|
'tenantSort' => $normalizedFilters['tenantSort'],
|
||||||
|
'subjectSort' => $normalizedFilters['subjectSort'],
|
||||||
|
'focusedSubjectKey' => $normalizedFilters['focusedSubjectKey'],
|
||||||
|
],
|
||||||
|
'policyTypeOptions' => $policyTypeOptions,
|
||||||
|
'stateOptions' => BadgeCatalog::options(BadgeDomain::BaselineCompareMatrixState, [
|
||||||
|
'match',
|
||||||
|
'differ',
|
||||||
|
'missing',
|
||||||
|
'ambiguous',
|
||||||
|
'not_compared',
|
||||||
|
'stale_result',
|
||||||
|
]),
|
||||||
|
'severityOptions' => BadgeCatalog::options(BadgeDomain::FindingSeverity, [
|
||||||
|
Finding::SEVERITY_LOW,
|
||||||
|
Finding::SEVERITY_MEDIUM,
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
]),
|
||||||
|
'tenantSortOptions' => [
|
||||||
|
'tenant_name' => 'Tenant name',
|
||||||
|
'deviation_count' => 'Deviation count',
|
||||||
|
'freshness_urgency' => 'Freshness urgency',
|
||||||
|
],
|
||||||
|
'subjectSortOptions' => [
|
||||||
|
'deviation_breadth' => 'Deviation breadth',
|
||||||
|
'policy_type' => 'Policy type',
|
||||||
|
'display_name' => 'Display name',
|
||||||
|
],
|
||||||
|
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
|
||||||
|
'match',
|
||||||
|
'differ',
|
||||||
|
'missing',
|
||||||
|
'ambiguous',
|
||||||
|
'not_compared',
|
||||||
|
'stale_result',
|
||||||
|
]),
|
||||||
|
'freshnessLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixFreshness, [
|
||||||
|
'fresh',
|
||||||
|
'stale',
|
||||||
|
'never_compared',
|
||||||
|
'unknown',
|
||||||
|
]),
|
||||||
|
'trustLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixTrust, [
|
||||||
|
'trustworthy',
|
||||||
|
'limited_confidence',
|
||||||
|
'diagnostic_only',
|
||||||
|
'unusable',
|
||||||
|
]),
|
||||||
|
'tenantSummaries' => [],
|
||||||
|
'subjectSummaries' => [],
|
||||||
|
'rows' => [],
|
||||||
|
'emptyState' => $this->emptyState(
|
||||||
|
reference: $reference,
|
||||||
|
snapshotItemsCount: $snapshotItems->count(),
|
||||||
|
visibleTenantsCount: $visibleTenants->count(),
|
||||||
|
),
|
||||||
|
'hasActiveRuns' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $referenceSnapshot instanceof BaselineSnapshot) {
|
||||||
|
return $bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visibleTenants->isEmpty()) {
|
||||||
|
return $bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshotItems->isEmpty()) {
|
||||||
|
return $bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = $visibleTenants
|
||||||
|
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$latestRuns = OperationRun::latestBaselineCompareRunsForProfile(
|
||||||
|
profile: $profile,
|
||||||
|
tenantIds: $tenantIds,
|
||||||
|
workspaceId: (int) $profile->workspace_id,
|
||||||
|
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
||||||
|
|
||||||
|
$completedRuns = OperationRun::latestBaselineCompareRunsForProfile(
|
||||||
|
profile: $profile,
|
||||||
|
tenantIds: $tenantIds,
|
||||||
|
workspaceId: (int) $profile->workspace_id,
|
||||||
|
completedOnly: true,
|
||||||
|
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
||||||
|
|
||||||
|
$findingMap = $this->findingMap($profile, $tenantIds, $completedRuns);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($snapshotItems as $item) {
|
||||||
|
if (! $item instanceof BaselineSnapshotItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectKey = is_string($item->subject_key) ? trim($item->subject_key) : '';
|
||||||
|
|
||||||
|
if ($subjectKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = [
|
||||||
|
'subjectKey' => $subjectKey,
|
||||||
|
'policyType' => (string) $item->policy_type,
|
||||||
|
'displayName' => $this->subjectDisplayName($item),
|
||||||
|
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$cells = [];
|
||||||
|
|
||||||
|
foreach ($visibleTenants as $tenant) {
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$latestRun = $latestRuns->get($tenantId);
|
||||||
|
$completedRun = $completedRuns->get($tenantId);
|
||||||
|
$cells[] = $this->cellFor(
|
||||||
|
item: $item,
|
||||||
|
tenant: $tenant,
|
||||||
|
referenceSnapshot: $referenceSnapshot,
|
||||||
|
latestRun: $latestRun instanceof OperationRun ? $latestRun : null,
|
||||||
|
completedRun: $completedRun instanceof OperationRun ? $completedRun : null,
|
||||||
|
finding: $findingMap[$this->cellKey($tenantId, $subjectKey)] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->rowMatchesFilters($subject, $cells, $normalizedFilters)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'subject' => $this->subjectSummary($subject, $cells),
|
||||||
|
'cells' => $cells,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->sortRows($rows, $normalizedFilters['subjectSort']);
|
||||||
|
$tenantSummaries = $this->sortTenantSummaries(
|
||||||
|
tenantSummaries: $this->tenantSummaries($visibleTenants, $latestRuns, $completedRuns, $rows, $referenceSnapshot),
|
||||||
|
sort: $normalizedFilters['tenantSort'],
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$row['cells'] = $this->sortCellsForTenants($row['cells'], $tenantSummaries);
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$bundle['tenantSummaries'] = $tenantSummaries;
|
||||||
|
$bundle['subjectSummaries'] = array_map(
|
||||||
|
static fn (array $row): array => $row['subject'],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
$bundle['rows'] = $rows;
|
||||||
|
$bundle['emptyState'] = $this->emptyState(
|
||||||
|
reference: $reference,
|
||||||
|
snapshotItemsCount: $snapshotItems->count(),
|
||||||
|
visibleTenantsCount: $visibleTenants->count(),
|
||||||
|
renderedRowsCount: count($rows),
|
||||||
|
);
|
||||||
|
$bundle['hasActiveRuns'] = collect($tenantSummaries)
|
||||||
|
->contains(static fn (array $summary): bool => in_array((string) ($summary['compareRunStatus'] ?? ''), [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
], true));
|
||||||
|
|
||||||
|
return $bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{
|
||||||
|
* policyTypes: list<string>,
|
||||||
|
* states: list<string>,
|
||||||
|
* severities: list<string>,
|
||||||
|
* tenantSort: string,
|
||||||
|
* subjectSort: string,
|
||||||
|
* focusedSubjectKey: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function normalizeFilters(array $filters): array
|
||||||
|
{
|
||||||
|
$policyTypes = $this->normalizeStringList($filters['policy_type'] ?? $filters['policyTypes'] ?? []);
|
||||||
|
$states = array_values(array_intersect(
|
||||||
|
$this->normalizeStringList($filters['state'] ?? $filters['states'] ?? []),
|
||||||
|
['match', 'differ', 'missing', 'ambiguous', 'not_compared', 'stale_result'],
|
||||||
|
));
|
||||||
|
$severities = array_values(array_intersect(
|
||||||
|
$this->normalizeStringList($filters['severity'] ?? $filters['severities'] ?? []),
|
||||||
|
[Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL],
|
||||||
|
));
|
||||||
|
$tenantSort = in_array((string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name'), [
|
||||||
|
'tenant_name',
|
||||||
|
'deviation_count',
|
||||||
|
'freshness_urgency',
|
||||||
|
], true)
|
||||||
|
? (string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name')
|
||||||
|
: 'tenant_name';
|
||||||
|
$subjectSort = in_array((string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth'), [
|
||||||
|
'deviation_breadth',
|
||||||
|
'policy_type',
|
||||||
|
'display_name',
|
||||||
|
], true)
|
||||||
|
? (string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth')
|
||||||
|
: 'deviation_breadth';
|
||||||
|
$focusedSubjectKey = $filters['subject_key'] ?? $filters['focusedSubjectKey'] ?? null;
|
||||||
|
$focusedSubjectKey = is_string($focusedSubjectKey) && trim($focusedSubjectKey) !== ''
|
||||||
|
? trim($focusedSubjectKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policyTypes' => $policyTypes,
|
||||||
|
'states' => $states,
|
||||||
|
'severities' => $severities,
|
||||||
|
'tenantSort' => $tenantSort,
|
||||||
|
'subjectSort' => $subjectSort,
|
||||||
|
'focusedSubjectKey' => $focusedSubjectKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeStringList(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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BaselineTenantAssignment> $assignments
|
||||||
|
* @return Collection<int, Tenant>
|
||||||
|
*/
|
||||||
|
private function visibleTenants(Collection $assignments, User $user): Collection
|
||||||
|
{
|
||||||
|
return $assignments
|
||||||
|
->map(static fn (BaselineTenantAssignment $assignment): ?Tenant => $assignment->tenant)
|
||||||
|
->filter(fn (?Tenant $tenant): bool => $tenant instanceof Tenant
|
||||||
|
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||||
|
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||||
|
->sortBy(static fn (Tenant $tenant): string => Str::lower((string) $tenant->name))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $resolution
|
||||||
|
*/
|
||||||
|
private function resolvedSnapshot(array $resolution): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
$snapshot = $resolution['effective_snapshot'] ?? $resolution['snapshot'] ?? null;
|
||||||
|
|
||||||
|
return $snapshot instanceof BaselineSnapshot ? $snapshot : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $tenantIds
|
||||||
|
* @param Collection<int, OperationRun> $completedRuns
|
||||||
|
* @return array<string, Finding>
|
||||||
|
*/
|
||||||
|
private function findingMap(BaselineProfile $profile, array $tenantIds, Collection $completedRuns): array
|
||||||
|
{
|
||||||
|
$findings = Finding::query()
|
||||||
|
->baselineCompareForProfile($profile)
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->orderByDesc('last_seen_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $finding->tenant_id;
|
||||||
|
$subjectKey = $this->subjectKeyForFinding($finding);
|
||||||
|
|
||||||
|
if ($subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$completedRun = $completedRuns->get($tenantId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$completedRun instanceof OperationRun
|
||||||
|
&& (int) ($finding->current_operation_run_id ?? 0) !== (int) $completedRun->getKey()
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cellKey = $this->cellKey($tenantId, $subjectKey);
|
||||||
|
|
||||||
|
if (! array_key_exists($cellKey, $map)) {
|
||||||
|
$map[$cellKey] = $finding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectKeyForFinding(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
$subjectKey = data_get($finding->evidence_jsonb, 'subject_key');
|
||||||
|
|
||||||
|
if (is_string($subjectKey) && trim($subjectKey) !== '') {
|
||||||
|
return trim($subjectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cellKey(int $tenantId, string $subjectKey): string
|
||||||
|
{
|
||||||
|
return $tenantId.'|'.trim(mb_strtolower($subjectKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectDisplayName(BaselineSnapshotItem $item): ?string
|
||||||
|
{
|
||||||
|
$displayName = data_get($item->meta_jsonb, 'display_name');
|
||||||
|
|
||||||
|
if (is_string($displayName) && trim($displayName) !== '') {
|
||||||
|
return trim($displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($item->subject_key) && trim($item->subject_key) !== ''
|
||||||
|
? Str::headline($item->subject_key)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cellFor(
|
||||||
|
BaselineSnapshotItem $item,
|
||||||
|
Tenant $tenant,
|
||||||
|
BaselineSnapshot $referenceSnapshot,
|
||||||
|
?OperationRun $latestRun,
|
||||||
|
?OperationRun $completedRun,
|
||||||
|
?Finding $finding,
|
||||||
|
): array {
|
||||||
|
$subjectKey = (string) $item->subject_key;
|
||||||
|
$policyType = (string) $item->policy_type;
|
||||||
|
$completedAt = $completedRun?->finished_at;
|
||||||
|
$policyTypeCovered = $this->policyTypeCovered($completedRun, $policyType);
|
||||||
|
$subjectReasons = $completedRun instanceof OperationRun
|
||||||
|
? (BaselineCompareEvidenceGapDetails::subjectReasonsFromOperationRun($completedRun)[BaselineCompareEvidenceGapDetails::subjectCompositeKey($policyType, $subjectKey)] ?? [])
|
||||||
|
: [];
|
||||||
|
$reasonCode = $subjectReasons[0] ?? $this->runReasonCode($completedRun);
|
||||||
|
$changeType = is_string(data_get($finding?->evidence_jsonb, 'change_type')) ? (string) data_get($finding?->evidence_jsonb, 'change_type') : null;
|
||||||
|
$staleResult = $this->isStaleResult($completedRun, $referenceSnapshot);
|
||||||
|
$tenantTrustLevel = $this->tenantTrustLevel($completedRun);
|
||||||
|
|
||||||
|
$state = match (true) {
|
||||||
|
! $completedRun instanceof OperationRun => 'not_compared',
|
||||||
|
(string) $completedRun->outcome === OperationRunOutcome::Failed->value => 'not_compared',
|
||||||
|
! $policyTypeCovered => 'not_compared',
|
||||||
|
$staleResult => 'stale_result',
|
||||||
|
$subjectReasons !== [] => 'ambiguous',
|
||||||
|
$changeType === 'missing_policy' => 'missing',
|
||||||
|
$finding instanceof Finding => 'differ',
|
||||||
|
default => 'match',
|
||||||
|
};
|
||||||
|
|
||||||
|
$trustLevel = match ($state) {
|
||||||
|
'not_compared' => 'unusable',
|
||||||
|
'stale_result' => 'limited_confidence',
|
||||||
|
'ambiguous' => 'diagnostic_only',
|
||||||
|
default => $tenantTrustLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'subjectKey' => $subjectKey,
|
||||||
|
'state' => $state,
|
||||||
|
'severity' => $finding instanceof Finding ? (string) $finding->severity : null,
|
||||||
|
'trustLevel' => $trustLevel,
|
||||||
|
'reasonCode' => $reasonCode,
|
||||||
|
'compareRunId' => $completedRun?->getKey(),
|
||||||
|
'findingId' => $finding?->getKey(),
|
||||||
|
'findingWorkflowState' => $finding instanceof Finding ? (string) $finding->status : null,
|
||||||
|
'lastComparedAt' => $completedAt?->toIso8601String(),
|
||||||
|
'policyTypeCovered' => $policyTypeCovered,
|
||||||
|
'latestRunId' => $latestRun?->getKey(),
|
||||||
|
'latestRunStatus' => $latestRun?->status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function policyTypeCovered(?OperationRun $run, string $policyType): bool
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage = data_get($run->context, 'baseline_compare.coverage');
|
||||||
|
|
||||||
|
if (! is_array($coverage)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coveredTypes = is_array($coverage['covered_types'] ?? null)
|
||||||
|
? array_values(array_filter($coverage['covered_types'], 'is_string'))
|
||||||
|
: [];
|
||||||
|
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null)
|
||||||
|
? array_values(array_filter($coverage['uncovered_types'], 'is_string'))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (in_array($policyType, $uncoveredTypes, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($coveredTypes === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($policyType, $coveredTypes, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runReasonCode(?OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
$reasonCode = data_get($run?->context, 'baseline_compare.reason_code');
|
||||||
|
|
||||||
|
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||||
|
? trim($reasonCode)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun || ! $run->finished_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runSnapshotId = data_get($run->context, 'baseline_snapshot_id');
|
||||||
|
|
||||||
|
if (is_numeric($runSnapshotId) && (int) $runSnapshotId !== (int) $referenceSnapshot->getKey()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($referenceSnapshot->captured_at && $run->finished_at->lt($referenceSnapshot->captured_at)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineCompareSummaryAssessor::isStaleComparedAt($run->finished_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantTrustLevel(?OperationRun $run): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::normalizeState(
|
||||||
|
$this->explanationRegistry->trustLevelForRun($run),
|
||||||
|
) ?? 'unusable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
private function rowMatchesFilters(array $subject, array $cells, array $filters): bool
|
||||||
|
{
|
||||||
|
if ($filters['policyTypes'] !== [] && ! in_array((string) $subject['policyType'], $filters['policyTypes'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filters['focusedSubjectKey'] !== null && (string) $subject['subjectKey'] !== $filters['focusedSubjectKey']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($cells as $cell) {
|
||||||
|
if ($filters['states'] !== [] && ! in_array((string) ($cell['state'] ?? ''), $filters['states'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filters['severities'] !== [] && ! in_array((string) ($cell['severity'] ?? ''), $filters['severities'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filters['states'] === [] && $filters['severities'] === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function subjectSummary(array $subject, array $cells): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'subjectKey' => $subject['subjectKey'],
|
||||||
|
'policyType' => $subject['policyType'],
|
||||||
|
'displayName' => $subject['displayName'],
|
||||||
|
'baselineExternalId' => $subject['baselineExternalId'],
|
||||||
|
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
|
||||||
|
'missingBreadth' => $this->countStates($cells, ['missing']),
|
||||||
|
'ambiguousBreadth' => $this->countStates($cells, ['ambiguous']),
|
||||||
|
'notComparedBreadth' => $this->countStates($cells, ['not_compared']),
|
||||||
|
'maxSeverity' => $this->maxSeverity($cells),
|
||||||
|
'trustLevel' => $this->worstTrustLevel($cells),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Tenant> $visibleTenants
|
||||||
|
* @param Collection<int, OperationRun> $latestRuns
|
||||||
|
* @param Collection<int, OperationRun> $completedRuns
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function tenantSummaries(
|
||||||
|
Collection $visibleTenants,
|
||||||
|
Collection $latestRuns,
|
||||||
|
Collection $completedRuns,
|
||||||
|
array $rows,
|
||||||
|
BaselineSnapshot $referenceSnapshot,
|
||||||
|
): array {
|
||||||
|
$summaries = [];
|
||||||
|
|
||||||
|
foreach ($visibleTenants as $tenant) {
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$latestRun = $latestRuns->get($tenantId);
|
||||||
|
$completedRun = $completedRuns->get($tenantId);
|
||||||
|
$cells = array_map(
|
||||||
|
static fn (array $row): array => collect($row['cells'])->firstWhere('tenantId', $tenantId) ?? [],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
$summaries[] = [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'tenantName' => (string) $tenant->name,
|
||||||
|
'compareRunId' => $latestRun?->getKey(),
|
||||||
|
'compareRunStatus' => $latestRun?->status,
|
||||||
|
'compareRunOutcome' => $latestRun?->outcome,
|
||||||
|
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
|
||||||
|
'lastComparedAt' => $completedRun?->finished_at?->toIso8601String(),
|
||||||
|
'matchedCount' => $this->countStates($cells, ['match']),
|
||||||
|
'differingCount' => $this->countStates($cells, ['differ']),
|
||||||
|
'missingCount' => $this->countStates($cells, ['missing']),
|
||||||
|
'ambiguousCount' => $this->countStates($cells, ['ambiguous']),
|
||||||
|
'notComparedCount' => $this->countStates($cells, ['not_compared']),
|
||||||
|
'maxSeverity' => $this->maxSeverity($cells),
|
||||||
|
'trustLevel' => $this->worstTrustLevel($cells),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function freshnessState(?OperationRun $completedRun, BaselineSnapshot $referenceSnapshot): string
|
||||||
|
{
|
||||||
|
if (! $completedRun instanceof OperationRun) {
|
||||||
|
return 'never_compared';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $completedRun->outcome === OperationRunOutcome::Failed->value) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isStaleResult($completedRun, $referenceSnapshot)) {
|
||||||
|
return 'stale';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'fresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
* @param array<int, string> $states
|
||||||
|
*/
|
||||||
|
private function countStates(array $cells, array $states): int
|
||||||
|
{
|
||||||
|
return count(array_filter(
|
||||||
|
$cells,
|
||||||
|
static fn (array $cell): bool => in_array((string) ($cell['state'] ?? ''), $states, true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
*/
|
||||||
|
private function maxSeverity(array $cells): ?string
|
||||||
|
{
|
||||||
|
$ranked = collect($cells)
|
||||||
|
->map(static fn (array $cell): ?string => is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null)
|
||||||
|
->filter()
|
||||||
|
->sortByDesc(fn (string $severity): int => $this->severityRank($severity))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $ranked->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function severityRank(string $severity): int
|
||||||
|
{
|
||||||
|
return match ($severity) {
|
||||||
|
Finding::SEVERITY_CRITICAL => 4,
|
||||||
|
Finding::SEVERITY_HIGH => 3,
|
||||||
|
Finding::SEVERITY_MEDIUM => 2,
|
||||||
|
Finding::SEVERITY_LOW => 1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
*/
|
||||||
|
private function worstTrustLevel(array $cells): string
|
||||||
|
{
|
||||||
|
return collect($cells)
|
||||||
|
->map(static fn (array $cell): string => (string) ($cell['trustLevel'] ?? 'unusable'))
|
||||||
|
->sortByDesc(fn (string $trust): int => $this->trustRank($trust))
|
||||||
|
->first() ?? 'unusable';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trustRank(string $trustLevel): int
|
||||||
|
{
|
||||||
|
return match ($trustLevel) {
|
||||||
|
'unusable' => 4,
|
||||||
|
'diagnostic_only' => 3,
|
||||||
|
'limited_confidence' => 2,
|
||||||
|
'trustworthy' => 1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortRows(array $rows, string $sort): array
|
||||||
|
{
|
||||||
|
usort($rows, function (array $left, array $right) use ($sort): int {
|
||||||
|
$leftSubject = $left['subject'] ?? [];
|
||||||
|
$rightSubject = $right['subject'] ?? [];
|
||||||
|
|
||||||
|
return match ($sort) {
|
||||||
|
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))]
|
||||||
|
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))],
|
||||||
|
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
|
||||||
|
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
|
||||||
|
default => [
|
||||||
|
-1 * (int) ($leftSubject['deviationBreadth'] ?? 0),
|
||||||
|
-1 * (int) ($leftSubject['ambiguousBreadth'] ?? 0),
|
||||||
|
Str::lower((string) ($leftSubject['displayName'] ?? '')),
|
||||||
|
] <=> [
|
||||||
|
-1 * (int) ($rightSubject['deviationBreadth'] ?? 0),
|
||||||
|
-1 * (int) ($rightSubject['ambiguousBreadth'] ?? 0),
|
||||||
|
Str::lower((string) ($rightSubject['displayName'] ?? '')),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $tenantSummaries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortTenantSummaries(array $tenantSummaries, string $sort): array
|
||||||
|
{
|
||||||
|
usort($tenantSummaries, function (array $left, array $right) use ($sort): int {
|
||||||
|
return match ($sort) {
|
||||||
|
'deviation_count' => [
|
||||||
|
-1 * ((int) ($left['differingCount'] ?? 0) + (int) ($left['missingCount'] ?? 0) + (int) ($left['ambiguousCount'] ?? 0)),
|
||||||
|
Str::lower((string) ($left['tenantName'] ?? '')),
|
||||||
|
] <=> [
|
||||||
|
-1 * ((int) ($right['differingCount'] ?? 0) + (int) ($right['missingCount'] ?? 0) + (int) ($right['ambiguousCount'] ?? 0)),
|
||||||
|
Str::lower((string) ($right['tenantName'] ?? '')),
|
||||||
|
],
|
||||||
|
'freshness_urgency' => [
|
||||||
|
-1 * $this->freshnessRank((string) ($left['freshnessState'] ?? 'unknown')),
|
||||||
|
Str::lower((string) ($left['tenantName'] ?? '')),
|
||||||
|
] <=> [
|
||||||
|
-1 * $this->freshnessRank((string) ($right['freshnessState'] ?? 'unknown')),
|
||||||
|
Str::lower((string) ($right['tenantName'] ?? '')),
|
||||||
|
],
|
||||||
|
default => Str::lower((string) ($left['tenantName'] ?? '')) <=> Str::lower((string) ($right['tenantName'] ?? '')),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($tenantSummaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function freshnessRank(string $freshnessState): int
|
||||||
|
{
|
||||||
|
return match ($freshnessState) {
|
||||||
|
'stale' => 4,
|
||||||
|
'unknown' => 3,
|
||||||
|
'never_compared' => 2,
|
||||||
|
'fresh' => 1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
* @param list<array<string, mixed>> $tenantSummaries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortCellsForTenants(array $cells, array $tenantSummaries): array
|
||||||
|
{
|
||||||
|
$order = collect($tenantSummaries)
|
||||||
|
->values()
|
||||||
|
->mapWithKeys(static fn (array $summary, int $index): array => [
|
||||||
|
(int) ($summary['tenantId'] ?? 0) => $index,
|
||||||
|
]);
|
||||||
|
|
||||||
|
usort($cells, static fn (array $left, array $right): int => ($order[(int) ($left['tenantId'] ?? 0)] ?? 9999) <=> ($order[(int) ($right['tenantId'] ?? 0)] ?? 9999));
|
||||||
|
|
||||||
|
return array_values($cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $reference
|
||||||
|
* @return array{title: string, body: string}|null
|
||||||
|
*/
|
||||||
|
private function emptyState(
|
||||||
|
array $reference,
|
||||||
|
int $snapshotItemsCount,
|
||||||
|
int $visibleTenantsCount,
|
||||||
|
int $renderedRowsCount = 0,
|
||||||
|
): ?array {
|
||||||
|
if (($reference['referenceState'] ?? null) !== 'ready') {
|
||||||
|
return [
|
||||||
|
'title' => 'No usable reference snapshot',
|
||||||
|
'body' => 'Capture a complete baseline snapshot before using the compare matrix.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($reference['assignedTenantCount'] ?? 0) === 0) {
|
||||||
|
return [
|
||||||
|
'title' => 'No assigned tenants',
|
||||||
|
'body' => 'Assign tenants to this baseline profile to build the visible compare set.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visibleTenantsCount === 0) {
|
||||||
|
return [
|
||||||
|
'title' => 'No visible assigned tenants',
|
||||||
|
'body' => 'This baseline has assigned tenants, but none are visible in your current tenant scope.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshotItemsCount === 0) {
|
||||||
|
return [
|
||||||
|
'title' => 'No baseline subjects',
|
||||||
|
'body' => 'The active reference snapshot completed without any baseline subjects to compare.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($renderedRowsCount === 0) {
|
||||||
|
return [
|
||||||
|
'title' => 'No rows match the current filters',
|
||||||
|
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $values
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function legendSpecs(BadgeDomain $domain, array $values): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static function (string $value) use ($domain): array {
|
||||||
|
$spec = BadgeCatalog::spec($domain, $value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'value' => $value,
|
||||||
|
'label' => $spec->label,
|
||||||
|
'color' => $spec->color,
|
||||||
|
'icon' => $spec->icon,
|
||||||
|
'iconColor' => $spec->iconColor,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,28 @@ 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();
|
||||||
@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return self::isStaleComparedAt($stats->lastComparedIso);
|
||||||
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -63,4 +66,31 @@ 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,6 +12,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,6 +40,10 @@ 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;
|
||||||
@ -58,6 +63,14 @@ 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;
|
||||||
@ -95,6 +108,20 @@ 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();
|
||||||
@ -111,10 +138,22 @@ private function applyVisibility(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->action->visible(function (?Model $record = null): bool {
|
$existingVisibility = $this->preserveExistingVisibility
|
||||||
|
? $this->getExistingVisibilityCondition()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
return $context->isMember;
|
if (! $context->isMember) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingVisibility === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +165,15 @@ private function applyDisabledState(): void
|
|||||||
|
|
||||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||||
|
|
||||||
$this->action->disabled(function (?Model $record = null): bool {
|
$existingDisabled = $this->preserveExistingDisabled
|
||||||
|
? $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) {
|
||||||
@ -173,6 +220,96 @@ 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,10 +6,12 @@
|
|||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
|
|||||||
{
|
{
|
||||||
$scopes = [ActionSurfacePanelScope::Tenant];
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
||||||
|
|
||||||
if (in_array($className, $adminScopedClasses, true)) {
|
if (
|
||||||
|
in_array($className, $adminScopedClasses, true)
|
||||||
|
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
|
||||||
|
) {
|
||||||
$scopes[] = ActionSurfacePanelScope::Admin;
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,6 +233,37 @@ 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,6 +9,8 @@
|
|||||||
$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'])
|
||||||
@ -26,6 +28,28 @@
|
|||||||
};
|
};
|
||||||
@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">
|
||||||
|
|||||||
@ -0,0 +1,538 @@
|
|||||||
|
<x-filament::page>
|
||||||
|
@if (($hasActiveRuns ?? false) === true)
|
||||||
|
<div wire:poll.5s="refreshMatrix"></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php
|
||||||
|
$reference = is_array($reference ?? null) ? $reference : [];
|
||||||
|
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
|
||||||
|
$rows = is_array($rows ?? null) ? $rows : [];
|
||||||
|
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
|
||||||
|
$stateOptions = is_array($stateOptions ?? null) ? $stateOptions : [];
|
||||||
|
$severityOptions = is_array($severityOptions ?? null) ? $severityOptions : [];
|
||||||
|
$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 : [];
|
||||||
|
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
|
||||||
|
$matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null;
|
||||||
|
|
||||||
|
$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);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section heading="Reference overview">
|
||||||
|
<x-slot name="description">
|
||||||
|
Compare assigned tenants is simulation only. It reuses the existing tenant-owned baseline compare path for the visible assigned set and does not create a workspace umbrella run.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
@if ($referenceReady)
|
||||||
|
<x-filament::badge color="success" icon="heroicon-m-check-badge" size="sm">
|
||||||
|
Reference snapshot ready
|
||||||
|
</x-filament::badge>
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="warning" icon="heroicon-m-exclamation-triangle" size="sm">
|
||||||
|
Reference snapshot blocked
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($reference['referenceSnapshotId'] ?? null))
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
|
||||||
|
</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: {{ (int) ($reference['visibleTenantCount'] ?? 0) }}.
|
||||||
|
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
|
||||||
|
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
|
||||||
|
@endif
|
||||||
|
</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">
|
||||||
|
<div class="rounded-xl 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">
|
||||||
|
{{ (int) ($reference['visibleTenantCount'] ?? 0) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl 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">
|
||||||
|
{{ count($rows) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl 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">Tenant sort</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl 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">Subject sort</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Filters">
|
||||||
|
<x-slot name="description">
|
||||||
|
Narrow the matrix by policy type, technical state, severity, or one focused subject. Only the visible tenant set contributes to the rendered counts, rows, and drilldowns.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]" data-testid="baseline-compare-matrix-filters">
|
||||||
|
<div class="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Policy types</div>
|
||||||
|
|
||||||
|
@if ($policyTypeOptions !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($policyTypeOptions as $value => $label)
|
||||||
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
value="{{ $value }}"
|
||||||
|
wire:model.live="selectedPolicyTypes"
|
||||||
|
data-testid="matrix-filter-policy-type-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
||||||
|
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<span>{{ $label }}</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Policy type filters appear after a usable reference snapshot is available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Technical states</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($stateOptions as $value => $label)
|
||||||
|
@php
|
||||||
|
$spec = $stateBadge($value);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
value="{{ $value }}"
|
||||||
|
wire:model.live="selectedStates"
|
||||||
|
data-testid="matrix-filter-state-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
||||||
|
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<span class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
||||||
|
{{ $label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Severity</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($severityOptions as $value => $label)
|
||||||
|
@php
|
||||||
|
$spec = $severityBadge($value);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
value="{{ $value }}"
|
||||||
|
wire:model.live="selectedSeverities"
|
||||||
|
data-testid="matrix-filter-severity-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
||||||
|
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<span class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
||||||
|
{{ $label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<label class="space-y-2 text-sm">
|
||||||
|
<span class="font-semibold text-gray-950 dark:text-white">Tenant sort</span>
|
||||||
|
<select wire:model.live="tenantSort" data-testid="matrix-tenant-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||||
|
@foreach ($tenantSortOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2 text-sm">
|
||||||
|
<span class="font-semibold text-gray-950 dark:text-white">Subject sort</span>
|
||||||
|
<select wire:model.live="subjectSort" data-testid="matrix-subject-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||||
|
@foreach ($subjectSortOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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="text-sm font-semibold text-gray-950 dark:text-white">Focused subject</div>
|
||||||
|
|
||||||
|
@if (filled($currentFilters['subject_key'] ?? null))
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
|
||||||
|
{{ $currentFilters['subject_key'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::link :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
|
||||||
|
Clear subject focus
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Focus a single row from the matrix when you want a subject-first drilldown.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<x-filament::link :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm">
|
||||||
|
Clear all filters
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-3">
|
||||||
|
<x-filament::section heading="State legend">
|
||||||
|
<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>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Freshness legend">
|
||||||
|
<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>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Trust legend">
|
||||||
|
<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>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($emptyState !== null)
|
||||||
|
<x-filament::section>
|
||||||
|
<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-2" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@else
|
||||||
|
<x-filament::section heading="Tenant summaries">
|
||||||
|
<x-slot name="description">
|
||||||
|
Tenant-level freshness, trust, and breadth stay visible before you scan the subject-by-tenant body.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid gap-4 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-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-base font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</h3>
|
||||||
|
<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="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
@if (filled($tenantSummary['lastComparedAt'] ?? null))
|
||||||
|
Compared {{ \Illuminate\Support\Carbon::parse($tenantSummary['lastComparedAt'])->diffForHumans() }}
|
||||||
|
@else
|
||||||
|
No completed compare yet
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Aligned</dt>
|
||||||
|
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Drift</dt>
|
||||||
|
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Missing</dt>
|
||||||
|
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Ambiguous / not compared</dt>
|
||||||
|
<dd class="font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) + (int) ($tenantSummary['notComparedCount'] ?? 0) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center 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>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Subject-by-tenant matrix">
|
||||||
|
<x-slot name="description">
|
||||||
|
Row click is intentionally disabled. Use the explicit subject, tenant, finding, and run links inside the matrix cells.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto" data-testid="baseline-compare-matrix-grid">
|
||||||
|
<div class="min-w-[72rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-950/70">
|
||||||
|
<tr>
|
||||||
|
<th class="w-80 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Baseline subject
|
||||||
|
</th>
|
||||||
|
|
||||||
|
@foreach ($tenantSummaries as $tenantSummary)
|
||||||
|
@php
|
||||||
|
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
||||||
|
@endphp
|
||||||
|
<th class="min-w-64 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 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 bg-white dark:divide-gray-800 dark:bg-gray-900/60">
|
||||||
|
@foreach ($rows as $row)
|
||||||
|
@php
|
||||||
|
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
||||||
|
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
|
||||||
|
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
|
||||||
|
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<tr data-testid="baseline-compare-matrix-row">
|
||||||
|
<td class="px-4 py-4 align-top">
|
||||||
|
<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="gray" size="sm">
|
||||||
|
Ambiguous {{ (int) ($subject['ambiguousBreadth'] ?? 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
|
||||||
|
</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);
|
||||||
|
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
|
||||||
|
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
|
||||||
|
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
|
||||||
|
$tenantId = (int) ($cell['tenantId'] ?? 0);
|
||||||
|
$tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null;
|
||||||
|
$cellFindingUrl = ($tenantId > 0 && filled($cell['findingId'] ?? null))
|
||||||
|
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
|
||||||
|
: null;
|
||||||
|
$cellRunUrl = filled($cell['compareRunId'] ?? null)
|
||||||
|
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
|
||||||
|
: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<td class="px-4 py-4 align-top">
|
||||||
|
<div class="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
|
||||||
|
{{ $cellStateSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
||||||
|
{{ $cellTrustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($cellSeveritySpec)
|
||||||
|
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
|
||||||
|
{{ $cellSeveritySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
@if (filled($cell['reasonCode'] ?? null))
|
||||||
|
<div>
|
||||||
|
Reason: {{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) $cell['reasonCode'])) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($cell['lastComparedAt'] ?? null))
|
||||||
|
<div>
|
||||||
|
Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (($cell['policyTypeCovered'] ?? true) === false)
|
||||||
|
<div>Policy type coverage was not proven in the latest compare run.</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
@if ($cellFindingUrl)
|
||||||
|
<x-filament::link :href="$cellFindingUrl" size="sm">
|
||||||
|
Open finding
|
||||||
|
</x-filament::link>
|
||||||
|
@elseif ($tenantCompareUrl)
|
||||||
|
<x-filament::link :href="$tenantCompareUrl" size="sm">
|
||||||
|
Open tenant compare
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($cellRunUrl)
|
||||||
|
<x-filament::link :href="$cellRunUrl" color="gray" size="sm">
|
||||||
|
Open run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</x-filament::page>
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
|
uses(BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
it('smokes the baseline compare matrix render, filter interaction, 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'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$finding = $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('Visible-set baseline')
|
||||||
|
->assertSee('Reference overview')
|
||||||
|
->assertSee('Subject-by-tenant matrix')
|
||||||
|
->assertSee('WiFi Corp Profile')
|
||||||
|
->assertSee('Windows Compliance')
|
||||||
|
->assertSee('Open finding');
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
const input = document.querySelector('[data-testid="matrix-filter-state-differ"]');
|
||||||
|
if (input instanceof HTMLInputElement) {
|
||||||
|
input.click();
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Open finding')
|
||||||
|
->assertDontSee('Windows Compliance')
|
||||||
|
->click('Open finding')
|
||||||
|
->waitForText('Back to compare matrix')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Back to compare matrix');
|
||||||
|
});
|
||||||
@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
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 tenant and subject summaries 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['rows'])->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(count($wifiRow['cells']))->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives matrix cell precedence from compare freshness, evidence gaps, findings, and uncovered policy types', 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['rows'])->first(
|
||||||
|
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
||||||
|
);
|
||||||
|
|
||||||
|
$statesByTenant = collect($wifiRow['cells'] ?? [])
|
||||||
|
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => (string) $cell['state']])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($statesByTenant[(int) $matchTenant->getKey()] ?? null)->toBe('match')
|
||||||
|
->and($statesByTenant[(int) $differTenant->getKey()] ?? null)->toBe('differ')
|
||||||
|
->and($statesByTenant[(int) $missingTenant->getKey()] ?? null)->toBe('missing')
|
||||||
|
->and($statesByTenant[(int) $ambiguousTenant->getKey()] ?? null)->toBe('ambiguous')
|
||||||
|
->and($statesByTenant[(int) $notComparedTenant->getKey()] ?? null)->toBe('not_compared')
|
||||||
|
->and($statesByTenant[(int) $staleTenant->getKey()] ?? null)->toBe('stale_result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies policy-type, state, severity, and subject-focus filters honestly', 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['rows']))->toBe(1)
|
||||||
|
->and($deviceOnly['rows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
||||||
|
->and(count($driftOnly['rows']))->toBe(1)
|
||||||
|
->and($driftOnly['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
||||||
|
->and(count($subjectFocus['rows']))->toBe(1)
|
||||||
|
->and($subjectFocus['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
||||||
|
});
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
<?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,8 +12,13 @@
|
|||||||
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();
|
||||||
@ -100,3 +105,28 @@
|
|||||||
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);
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,272 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
|
it('renders the baseline compare matrix with reference truth, legends, and explicit drilldowns', 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('Reference overview')
|
||||||
|
->assertSee('State legend')
|
||||||
|
->assertSee('Tenant summaries')
|
||||||
|
->assertSee('Subject-by-tenant matrix')
|
||||||
|
->assertSee('WiFi Corp Profile')
|
||||||
|
->assertSee((string) $fixture['visibleTenant']->name)
|
||||||
|
->assertSee((string) $fixture['visibleTenantTwo']->name)
|
||||||
|
->assertSee('Open finding')
|
||||||
|
->assertSee('Open tenant compare');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps query-backed filters and subject focus on the matrix route and drilldown links', 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',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||||
|
|
||||||
|
$component = Livewire::withQueryParams([
|
||||||
|
'policy_type' => ['deviceConfiguration'],
|
||||||
|
'state' => ['differ'],
|
||||||
|
'severity' => ['critical'],
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
])
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||||
|
->assertSee('Clear subject focus')
|
||||||
|
->assertDontSee('Windows Compliance');
|
||||||
|
|
||||||
|
$tenantCompareUrl = $component->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
||||||
|
$findingUrl = $component->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
||||||
|
|
||||||
|
expect($tenantCompareUrl)->toContain('baseline_profile_id='.(int) $fixture['profile']->getKey())
|
||||||
|
->and($tenantCompareUrl)->toContain('subject_key=wifi-corp-profile')
|
||||||
|
->and($tenantCompareUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix')
|
||||||
|
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = \App\Models\User::factory()->create();
|
||||||
|
|
||||||
|
\App\Models\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 an empty state when no rows match the current filters', 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']).'?state[]=missing')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No rows match the current filters');
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?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;
|
||||||
@ -8,6 +9,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -132,3 +134,64 @@
|
|||||||
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,6 +78,8 @@
|
|||||||
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;
|
||||||
@ -207,6 +209,54 @@ 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,5 +1,7 @@
|
|||||||
<?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 () {
|
||||||
@ -113,3 +115,9 @@
|
|||||||
|
|
||||||
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,6 +2,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -41,3 +43,15 @@
|
|||||||
|
|
||||||
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');
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
});
|
||||||
36
specs/190-baseline-compare-matrix/checklists/requirements.md
Normal file
36
specs/190-baseline-compare-matrix/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
@ -0,0 +1,468 @@
|
|||||||
|
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'
|
||||||
192
specs/190-baseline-compare-matrix/data-model.md
Normal file
192
specs/190-baseline-compare-matrix/data-model.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# 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.
|
||||||
271
specs/190-baseline-compare-matrix/plan.md
Normal file
271
specs/190-baseline-compare-matrix/plan.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# 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.
|
||||||
83
specs/190-baseline-compare-matrix/quickstart.md
Normal file
83
specs/190-baseline-compare-matrix/quickstart.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# 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.
|
||||||
90
specs/190-baseline-compare-matrix/research.md
Normal file
90
specs/190-baseline-compare-matrix/research.md
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# 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.
|
||||||
288
specs/190-baseline-compare-matrix/spec.md
Normal file
288
specs/190-baseline-compare-matrix/spec.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# 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.
|
||||||
268
specs/190-baseline-compare-matrix/tasks.md
Normal file
268
specs/190-baseline-compare-matrix/tasks.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# 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] 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.
|
||||||
Loading…
Reference in New Issue
Block a user