feat: implement spec 198 monitoring page state contract (#238)
## Summary - implement Spec 198 monitoring page-state contracts across Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix - align selected-record and draft/apply behavior with query/session restoration semantics, including canonical navigation and tenant-filter normalization helpers - add Spec 198 feature and browser coverage, update closure/spec artifacts, and refresh affected regression tests that asserted pre-contract behavior ## Verification - focused Spec 198 feature pack passed through Sail - Spec 198 browser smoke passed through Sail - existing Spec 190 and Spec 194 browser smokes passed through Sail - targeted fallout tests were updated and rerun during full-suite triage ## Notes - Livewire v4 / Filament v5 compliant only; no legacy API reintroduction - no provider registration changes; Laravel 11+ provider registration remains in `bootstrap/providers.php` - no global-search behavior changed for any resource - destructive queue decision actions remain confirmation-gated and authorization-backed - no new Filament assets were added; existing deploy step for `php artisan filament:assets` remains unchanged Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #238
This commit is contained in:
parent
c0f4587d90
commit
e02799b383
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -188,6 +188,8 @@ ## Active Technologies
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned (205-compare-job-cleanup)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable` (197-shared-detail-contract)
|
||||
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state)
|
||||
- PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -222,8 +224,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 198-monitoring-page-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages
|
||||
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
|
||||
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -36,6 +38,60 @@ class BaselineCompareLanding extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'baseline_compare_landing',
|
||||
'surfaceType' => 'launch_context_support',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'baseline_profile_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'scoped_deeplink',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'subject_key',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'scoped_deeplink',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'nav',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['baseline_profile_id', 'subject_key', 'nav'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'openedBy' => ['launch_context'],
|
||||
'presentation' => 'none',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['baseline_profile_id', 'subject_key', 'nav'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
@ -137,6 +193,14 @@ public static function canAccess(): bool
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
@ -266,6 +330,7 @@ protected function getViewData(): array
|
||||
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
||||
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
||||
'matrixSubjectKey' => $this->matrixSubjectKey,
|
||||
'openCompareMatrixUrl' => $this->openCompareMatrixUrl(),
|
||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
@ -465,6 +530,26 @@ public function getRunUrl(): ?string
|
||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||
}
|
||||
|
||||
public function openCompareMatrixUrl(): ?string
|
||||
{
|
||||
$profile = $this->resolveCompareMatrixProfile();
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = BaselineProfileResource::compareMatrixUrl($profile);
|
||||
$query = array_filter([
|
||||
'subject_key' => $this->matrixSubjectKey,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
@ -482,8 +567,33 @@ private function navigationContext(): ?CanonicalNavigationContext
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||
}
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
private function resolveCompareMatrixProfile(): ?BaselineProfile
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidateIds = array_values(array_filter([
|
||||
$this->matrixBaselineProfileId,
|
||||
$this->profileId,
|
||||
], static fn (mixed $value): bool => is_int($value) && $value > 0));
|
||||
|
||||
foreach ($candidateIds as $profileId) {
|
||||
$profile = BaselineProfile::query()
|
||||
->whereKey($profileId)
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->first();
|
||||
|
||||
if ($profile instanceof BaselineProfile) {
|
||||
return $profile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,106 @@ class BaselineCompareMatrix extends Page implements HasForms
|
||||
use InteractsWithForms;
|
||||
use InteractsWithRecord;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'baseline_compare_matrix',
|
||||
'surfaceType' => 'draft_apply_analysis',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'mode',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'policy_type',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'state',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'severity',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_sort',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'subject_sort',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'subject_key',
|
||||
'stateClass' => 'inspect',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['mode', 'policy_type', 'state', 'severity', 'tenant_sort', 'subject_sort', 'subject_key'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'baseline_subject',
|
||||
'selectedStateKey' => 'focusedSubjectKey',
|
||||
'openedBy' => ['query_param', 'focus_link'],
|
||||
'presentation' => 'focused_matrix',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['mode', 'policy_type', 'state', 'severity', 'tenant_sort', 'subject_sort', 'subject_key'],
|
||||
'localOnlyStateKeys' => [
|
||||
'draftSelectedPolicyTypes',
|
||||
'draftSelectedStates',
|
||||
'draftSelectedSeverities',
|
||||
'draftTenantSort',
|
||||
'draftSubjectSort',
|
||||
],
|
||||
];
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -107,6 +207,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
$this->record = $this->resolveRecord($record);
|
||||
|
||||
@ -36,8 +36,8 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -45,6 +45,60 @@ class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'audit_log',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'event',
|
||||
'stateClass' => 'inspect',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableSearch',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id', 'action', 'actor_label', 'resource_type'],
|
||||
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => AuditLogModel::class,
|
||||
'selectedStateKey' => 'selectedAuditLogId',
|
||||
'openedBy' => ['query_param', 'inspect_action'],
|
||||
'presentation' => 'inline_detail',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['event'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
@ -82,6 +136,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
@ -92,8 +154,7 @@ public function mount(): void
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->selectedAuditLogId = $requestedEventId;
|
||||
$this->selectedAuditLogId = $this->resolveSelectedAuditLogId($requestedEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,9 +180,41 @@ protected function getHeaderActions(): array
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedAudit = $this->selectedAuditRecord();
|
||||
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
||||
? $this->auditTargetLink($selectedAudit)
|
||||
: null;
|
||||
|
||||
if ($selectedAudit instanceof AuditLogModel) {
|
||||
array_splice($actions, 1, 0, array_values(array_filter([
|
||||
Action::make('close_selected_audit_event')
|
||||
->label('Close details')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->url($this->auditLogUrl(['event' => null])),
|
||||
$selectedAuditLink !== null
|
||||
? Action::make('open_selected_audit_target')
|
||||
->label($selectedAuditLink['label'])
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url($selectedAuditLink['url'])
|
||||
: null,
|
||||
])));
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function updatedTableFilters(): void
|
||||
{
|
||||
$this->normalizeSelectedAuditLogId();
|
||||
}
|
||||
|
||||
public function updatedTableSearch(): void
|
||||
{
|
||||
$this->normalizeSelectedAuditLogId();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -192,19 +285,7 @@ public function table(Table $table): Table
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->before(function (AuditLogModel $record): void {
|
||||
$this->selectedAuditLogId = (int) $record->getKey();
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
|
||||
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
|
||||
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||
'selectedAudit' => $record,
|
||||
'selectedAuditLink' => $this->auditTargetLink($record),
|
||||
])),
|
||||
->url(fn (AuditLogModel $record): string => $this->auditLogUrl(['event' => (int) $record->getKey()])),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
@ -216,6 +297,7 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
@ -312,6 +394,12 @@ public function selectedAuditRecord(): ?AuditLogModel
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->normalizeSelectedAuditLogId();
|
||||
|
||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||
} catch (NotFoundHttpException) {
|
||||
@ -341,6 +429,137 @@ private function auditTargetLink(AuditLogModel $record): ?array
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
private function auditLogUrl(array $overrides = []): string
|
||||
{
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
['event' => $this->selectedAuditLogId],
|
||||
$overrides,
|
||||
);
|
||||
|
||||
return route(
|
||||
'admin.monitoring.audit-log',
|
||||
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function normalizeSelectedAuditLogId(): void
|
||||
{
|
||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||
$this->selectedAuditLogId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedAuditLogId = $this->resolveSelectedAuditLogId($this->selectedAuditLogId);
|
||||
}
|
||||
|
||||
private function resolveSelectedAuditLogId(int $auditLogId): ?int
|
||||
{
|
||||
try {
|
||||
$record = $this->resolveAuditLog($auditLogId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->selectedAuditVisible((int) $record->getKey())
|
||||
? (int) $record->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function selectedAuditVisible(int $auditLogId): bool
|
||||
{
|
||||
$record = $this->resolveAuditLog($auditLogId);
|
||||
|
||||
return $this->matchesSelectedAuditFilters($record)
|
||||
&& $this->matchesSelectedAuditSearch($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentTableFiltersState(): array
|
||||
{
|
||||
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
||||
|
||||
return array_replace_recursive(
|
||||
is_array($persisted) ? $persisted : [],
|
||||
$this->tableFilters ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private function currentTableSearchState(): string
|
||||
{
|
||||
$search = trim((string) ($this->tableSearch ?? ''));
|
||||
|
||||
if ($search !== '') {
|
||||
return $search;
|
||||
}
|
||||
|
||||
$persisted = session()->get($this->getTableSearchSessionKey(), '');
|
||||
|
||||
return trim(is_string($persisted) ? $persisted : '');
|
||||
}
|
||||
|
||||
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
||||
{
|
||||
$filters = $this->currentTableFiltersState();
|
||||
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
|
||||
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actionFilter = data_get($filters, 'action.value');
|
||||
|
||||
if (is_string($actionFilter) && $actionFilter !== '' && (string) $record->action !== $actionFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$outcomeFilter = data_get($filters, 'outcome.value');
|
||||
|
||||
if (is_string($outcomeFilter) && $outcomeFilter !== '' && $record->normalizedOutcome()->value !== $outcomeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actorFilter = data_get($filters, 'actor_label.value');
|
||||
|
||||
if (is_string($actorFilter) && $actorFilter !== '' && (string) $record->actor_label !== $actorFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resourceTypeFilter = data_get($filters, 'resource_type.value');
|
||||
|
||||
if (is_string($resourceTypeFilter) && $resourceTypeFilter !== '' && (string) $record->resource_type !== $resourceTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function matchesSelectedAuditSearch(AuditLogModel $record): bool
|
||||
{
|
||||
$search = Str::lower($this->currentTableSearchState());
|
||||
|
||||
if ($search === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = Str::lower(implode(' ', [
|
||||
$record->summaryText(),
|
||||
$record->actorDisplayLabel(),
|
||||
$record->targetDisplayLabel() ?? '',
|
||||
]));
|
||||
|
||||
return str_contains($haystack, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -39,6 +39,70 @@ class EvidenceOverview extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'evidence_overview',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'search',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableFilters',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableSort',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'openedBy' => ['row_navigation'],
|
||||
'presentation' => 'navigate_to_canonical_detail',
|
||||
'shareable' => false,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant_id', 'search'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -73,6 +137,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeWorkspaceAccess();
|
||||
@ -189,7 +261,7 @@ public function clearOverviewFilters(): void
|
||||
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
|
||||
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
|
||||
|
||||
$this->resetPage();
|
||||
$this->redirect($this->overviewUrl(), navigate: true);
|
||||
}
|
||||
|
||||
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||
@ -474,6 +546,14 @@ private function hasActiveOverviewFilters(): bool
|
||||
|| trim((string) $this->tableSearch) !== '';
|
||||
}
|
||||
|
||||
private function overviewUrl(array $overrides = []): string
|
||||
{
|
||||
return route(
|
||||
'admin.evidence.overview',
|
||||
array_filter($overrides, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function workspaceId(): int
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -16,8 +16,10 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
@ -40,9 +42,9 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -50,9 +52,71 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'exception',
|
||||
'stateClass' => 'inspect',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableFilters',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableSearch',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant', 'tenant_id', 'status', 'current_validity_state'],
|
||||
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => FindingException::class,
|
||||
'selectedStateKey' => 'selectedFindingExceptionId',
|
||||
'openedBy' => ['query_param', 'inspect_action'],
|
||||
'presentation' => 'summary_plus_related_actions',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant', 'exception'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public bool $showSelectedExceptionSummary = false;
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
@ -87,6 +151,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
@ -120,13 +192,12 @@ public static function canAccess(): bool
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
|
||||
$this->mountInteractsWithTable();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$requestedExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
|
||||
if ($this->selectedFindingExceptionId !== null) {
|
||||
$this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
if ($requestedExceptionId !== null) {
|
||||
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($requestedExceptionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +218,6 @@ protected function getHeaderActions(): array
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
@ -170,23 +240,21 @@ protected function getHeaderActions(): array
|
||||
Action::make('clear_selected_exception')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->clearSelectedException();
|
||||
}),
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): string => $this->queueUrl(['exception' => null])),
|
||||
|
||||
Action::make('open_selected_exception')
|
||||
->label('Open tenant detail')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): ?string => $this->selectedExceptionUrl()),
|
||||
|
||||
Action::make('open_selected_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): ?string => $this->selectedFindingUrl()),
|
||||
];
|
||||
|
||||
@ -270,7 +338,7 @@ protected function getHeaderActions(): array
|
||||
->label('Selected context')
|
||||
->icon('heroicon-o-rectangle-stack')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null);
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException);
|
||||
|
||||
$actions[] = ActionGroup::make($selectedDecisionActions)
|
||||
->label('Review selected')
|
||||
@ -353,32 +421,7 @@ public function table(Table $table): Table
|
||||
->label('Inspect exception')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->before(function (FindingException $record): void {
|
||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||
->modalHeading(function (): string {
|
||||
$record = $this->inspectedFindingException();
|
||||
|
||||
return $record instanceof FindingException
|
||||
? 'Finding exception #'.$record->getKey()
|
||||
: 'Finding exception';
|
||||
})
|
||||
->modalDescription(fn (): ?string => $this->inspectedFindingException()?->requested_at?->toDayDateTimeString())
|
||||
->modalContent(function (): View {
|
||||
$record = $this->inspectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
return view('filament.pages.monitoring.partials.finding-exception-queue-unavailable');
|
||||
}
|
||||
|
||||
return view('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
||||
'selectedException' => $record,
|
||||
]);
|
||||
}),
|
||||
->url(fn (FindingException $record): string => $this->queueUrl(['exception' => (int) $record->getKey()])),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No exceptions match this queue')
|
||||
@ -394,19 +437,38 @@ public function table(Table $table): Table
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatedTableFilters(): void
|
||||
{
|
||||
$this->normalizeSelectedFindingExceptionId();
|
||||
}
|
||||
|
||||
public function updatedTableSearch(): void
|
||||
{
|
||||
$this->normalizeSelectedFindingExceptionId();
|
||||
}
|
||||
|
||||
public function selectedFindingException(): ?FindingException
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
$this->normalizeSelectedFindingExceptionId();
|
||||
|
||||
if (! is_int($this->selectedFindingExceptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function selectedExceptionUrl(): ?string
|
||||
@ -434,7 +496,6 @@ public function selectedFindingUrl(): ?string
|
||||
public function clearSelectedException(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -542,11 +603,11 @@ private function filteredTenant(): ?Tenant
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
$tenantFilter = app(CanonicalAdminTenantFilterState::class)->currentFilterValue(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
$this->tableFilters ?? [],
|
||||
request(),
|
||||
);
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
}
|
||||
@ -571,15 +632,126 @@ private function resolveSelectedFindingException(int $findingExceptionId): Findi
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function inspectedFindingException(): ?FindingException
|
||||
private function queueUrl(array $overrides = []): string
|
||||
{
|
||||
$mountedRecord = $this->getMountedTableActionRecord();
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
[
|
||||
'tenant' => $this->filteredTenant()?->getKey(),
|
||||
'exception' => $this->selectedFindingExceptionId,
|
||||
],
|
||||
$overrides,
|
||||
);
|
||||
|
||||
if ($mountedRecord instanceof FindingException) {
|
||||
return $mountedRecord;
|
||||
return static::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function normalizeSelectedFindingExceptionId(): void
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return $this->selectedFindingException();
|
||||
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($this->selectedFindingExceptionId);
|
||||
}
|
||||
|
||||
private function resolveSelectedFindingExceptionId(int $findingExceptionId): ?int
|
||||
{
|
||||
try {
|
||||
$record = $this->resolveSelectedFindingException($findingExceptionId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->selectedFindingExceptionVisible((int) $record->getKey())
|
||||
? (int) $record->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function selectedFindingExceptionVisible(int $findingExceptionId): bool
|
||||
{
|
||||
$record = $this->resolveSelectedFindingException($findingExceptionId);
|
||||
|
||||
return $this->matchesSelectedFindingExceptionFilters($record)
|
||||
&& $this->matchesSelectedFindingExceptionSearch($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentQueueFiltersState(): array
|
||||
{
|
||||
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
||||
|
||||
return array_replace_recursive(
|
||||
is_array($persisted) ? $persisted : [],
|
||||
$this->tableFilters ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private function currentQueueSearchState(): string
|
||||
{
|
||||
$search = trim((string) ($this->tableSearch ?? ''));
|
||||
|
||||
if ($search !== '') {
|
||||
return $search;
|
||||
}
|
||||
|
||||
$persisted = session()->get($this->getTableSearchSessionKey(), '');
|
||||
|
||||
return trim(is_string($persisted) ? $persisted : '');
|
||||
}
|
||||
|
||||
private function matchesSelectedFindingExceptionFilters(FindingException $record): bool
|
||||
{
|
||||
$filters = $this->currentQueueFiltersState();
|
||||
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
|
||||
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusFilter = data_get($filters, 'status.value');
|
||||
|
||||
if (is_string($statusFilter) && $statusFilter !== '' && (string) $record->status !== $statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$validityFilter = data_get($filters, 'current_validity_state.value');
|
||||
|
||||
if (is_string($validityFilter) && $validityFilter !== '' && (string) $record->current_validity_state !== $validityFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function matchesSelectedFindingExceptionSearch(FindingException $record): bool
|
||||
{
|
||||
$search = Str::lower($this->currentQueueSearchState());
|
||||
|
||||
if ($search === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = Str::lower(implode(' ', [
|
||||
$record->tenant?->name ?? '',
|
||||
$record->finding?->resolvedSubjectDisplayName() ?? 'Finding #'.$record->finding_id,
|
||||
$record->request_reason ?? '',
|
||||
]));
|
||||
|
||||
return str_contains($haystack, $search);
|
||||
}
|
||||
|
||||
private function governanceWarning(FindingException $record): ?string
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -38,6 +39,80 @@ class Operations extends Page implements HasForms, HasTable
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'operations',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_scope',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'reset_to_default_scope',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'problemClass',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'scoped_deeplink',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'activeTab',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'livewire_property',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableFilters',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id', 'type', 'initiator_name'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'openedBy' => [],
|
||||
'presentation' => 'none',
|
||||
'shareable' => false,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public string $activeTab = 'all';
|
||||
|
||||
/**
|
||||
@ -70,6 +145,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
@ -185,15 +268,23 @@ public function landingHierarchySummary(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function tabUrl(string $tab): string
|
||||
{
|
||||
$normalizedTab = in_array($tab, self::supportedTabs(), true) ? $tab : 'all';
|
||||
|
||||
return $this->operationsUrl([
|
||||
'activeTab' => $normalizedTab !== 'all' ? $normalizedTab : null,
|
||||
'problemClass' => in_array($normalizedTab, self::problemClassTabs(), true) ? $normalizedTab : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
@ -206,11 +297,7 @@ public function table(Table $table): Table
|
||||
return OperationRunResource::table($table)
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
$tenantFilter = $this->currentTenantFilterId();
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
@ -224,8 +311,8 @@ public function table(Table $table): Table
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
$tenantFilter !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
@ -300,26 +387,22 @@ private function scopedSummaryQuery(): ?Builder
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
$tenantFilter = $this->currentTenantFilterId();
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
$tenantFilter !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
||||
);
|
||||
}
|
||||
|
||||
private function applyRequestedDashboardPrefilter(): void
|
||||
{
|
||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||
$requestedTenantId = request()->query('tenant_id');
|
||||
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('tenant_id'));
|
||||
|
||||
if (is_numeric($requestedTenantId)) {
|
||||
if ($requestedTenantId !== null) {
|
||||
$tenantId = (string) $requestedTenantId;
|
||||
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
||||
@ -328,10 +411,7 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
|
||||
$requestedProblemClass = request()->query('problemClass');
|
||||
|
||||
if (in_array($requestedProblemClass, [
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
if (in_array($requestedProblemClass, self::problemClassTabs(), true)) {
|
||||
$this->activeTab = (string) $requestedProblemClass;
|
||||
|
||||
return;
|
||||
@ -339,16 +419,7 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
|
||||
$requestedTab = request()->query('activeTab');
|
||||
|
||||
if (in_array($requestedTab, [
|
||||
'all',
|
||||
'active',
|
||||
'blocked',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
if (in_array($requestedTab, self::supportedTabs(), true)) {
|
||||
$this->activeTab = (string) $requestedTab;
|
||||
}
|
||||
}
|
||||
@ -357,4 +428,94 @@ private function shouldForceWorkspaceWideTenantScope(): bool
|
||||
{
|
||||
return request()->query('tenant_scope') === 'all';
|
||||
}
|
||||
|
||||
private function operationsUrl(array $overrides = []): string
|
||||
{
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
[
|
||||
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,
|
||||
'tenant_id' => $this->shouldForceWorkspaceWideTenantScope() ? null : $this->currentTenantFilterId(),
|
||||
'activeTab' => $this->activeTab !== 'all' ? $this->activeTab : null,
|
||||
'problemClass' => in_array($this->activeTab, self::problemClassTabs(), true) ? $this->activeTab : null,
|
||||
],
|
||||
$overrides,
|
||||
);
|
||||
|
||||
return route(
|
||||
'admin.operations.index',
|
||||
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = app(CanonicalAdminTenantFilterState::class)->currentFilterValue(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
$this->tableFilters ?? [],
|
||||
request(),
|
||||
);
|
||||
|
||||
return $this->normalizeEntitledTenantFilter($tenantFilter);
|
||||
}
|
||||
|
||||
private function normalizeEntitledTenantFilter(mixed $value): ?int
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = (int) $value;
|
||||
|
||||
return in_array($tenantId, $this->authorizedTenantIds(), true)
|
||||
? $tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function authorizedTenantIds(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $user instanceof User || ! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function supportedTabs(): array
|
||||
{
|
||||
return [
|
||||
'all',
|
||||
'active',
|
||||
'blocked',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
...self::problemClassTabs(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function problemClassTabs(): array
|
||||
{
|
||||
return [
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,32 @@ final class CanonicalAdminTenantFilterState
|
||||
|
||||
public function __construct(private readonly OperateHubShell $operateHubShell) {}
|
||||
|
||||
public function currentFilterValue(
|
||||
string $filtersSessionKey,
|
||||
?array $tableFilters = null,
|
||||
?Request $request = null,
|
||||
?string $tenantFilterName = 'tenant_id',
|
||||
): ?string {
|
||||
if ($tenantFilterName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tableFilterValue = data_get($tableFilters ?? [], "{$tenantFilterName}.value");
|
||||
|
||||
if (is_scalar($tableFilterValue) && (string) $tableFilterValue !== '') {
|
||||
return (string) $tableFilterValue;
|
||||
}
|
||||
|
||||
$persistedFilters = $this->session($request)->get($filtersSessionKey, []);
|
||||
$persistedValue = data_get(is_array($persistedFilters) ? $persistedFilters : [], "{$tenantFilterName}.value");
|
||||
|
||||
if (! is_scalar($persistedValue) || (string) $persistedValue === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $persistedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantSensitiveFilters
|
||||
*/
|
||||
|
||||
@ -260,7 +260,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
"public string \$status = 'missing';",
|
||||
"SelectFilter::make('status')",
|
||||
"'status' => ['value' => 'missing'],",
|
||||
"'status' => \$filters['status']['value'] ?? data_get(\$this->tableFilters, 'status.value'),",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
@ -272,7 +274,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
"public string \$type = 'all';",
|
||||
"SelectFilter::make('type')",
|
||||
"'type' => ['value' => 'all'],",
|
||||
"'type' => \$filters['type']['value'] ?? data_get(\$this->tableFilters, 'type.value'),",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
@ -284,7 +288,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
'public array $features = [];',
|
||||
"SelectFilter::make('features')",
|
||||
"'features' => ['values' => []],",
|
||||
"'features' => \$filters['features']['values'] ?? data_get(\$this->tableFilters, 'features.values', []),",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
@ -296,7 +302,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
"public string \$search = '';",
|
||||
'->searchable()',
|
||||
"'search' => \$search ?? \$this->tableSearch,",
|
||||
"\$this->tableSearch = '';",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
|
||||
@ -23,6 +23,18 @@ public function __construct(
|
||||
public array $filterPayload = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $payload
|
||||
*/
|
||||
public static function fromPayload(?array $payload): ?self
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromRequest(new Request(query: ['nav' => $payload]));
|
||||
}
|
||||
|
||||
public static function fromRequest(Request $request): ?self
|
||||
{
|
||||
$payload = $request->query('nav');
|
||||
@ -56,17 +68,19 @@ public static function fromRequest(Request $request): ?self
|
||||
public function toQuery(): array
|
||||
{
|
||||
$query = $this->filterPayload;
|
||||
$query['nav'] = array_filter([
|
||||
'source_surface' => $this->sourceSurface,
|
||||
'canonical_route_name' => $this->canonicalRouteName,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'back_label' => $this->backLinkLabel,
|
||||
'back_url' => $this->backLinkUrl,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
$query['nav'] = $this->navPayload();
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{nav: array<string, mixed>}
|
||||
*/
|
||||
public function navQuery(): array
|
||||
{
|
||||
return ['nav' => $this->navPayload()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
@ -93,4 +107,18 @@ public static function forBaselineCompareMatrix(
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function navPayload(): array
|
||||
{
|
||||
return array_filter([
|
||||
'source_surface' => $this->sourceSurface,
|
||||
'canonical_route_name' => $this->canonicalRouteName,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'back_label' => $this->backLinkLabel,
|
||||
'back_url' => $this->backLinkUrl,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
@php
|
||||
$surface = is_array($surface ?? null) ? $surface : [];
|
||||
$coreState = is_string($surface['coreState'] ?? null) ? (string) $surface['coreState'] : 'unavailable';
|
||||
$hostVariation = is_array($surface['hostVariation'] ?? null) ? $surface['hostVariation'] : [];
|
||||
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
|
||||
$showDiagnosticsZone = (bool) ($diagnostics['hasTechnicalZone'] ?? true)
|
||||
&& ! (bool) ($hostVariation['supportsTechnicalDetailsTrigger'] ?? false);
|
||||
$redactionNotes = is_array($redactionNotes ?? null)
|
||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||
: [];
|
||||
@ -84,8 +88,10 @@ class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shad
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('filament.components.verification-report.diagnostics', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
@if ($showDiagnosticsZone)
|
||||
@include('filament.components.verification-report.diagnostics', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -29,6 +29,20 @@
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if (filled($openCompareMatrixUrl ?? null))
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Launch the compare matrix with the currently known baseline profile and any carried subject focus from this tenant landing.
|
||||
</div>
|
||||
|
||||
<x-filament::button tag="a" :href="$openCompareMatrixUrl" color="gray" icon="heroicon-o-squares-2x2">
|
||||
Open compare matrix
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($arrivedFromCompareMatrix)
|
||||
<x-filament::section>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@ -265,7 +265,7 @@
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
|
||||
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
|
||||
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
|
||||
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix. Refreshing the page discards these unapplied draft edits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -305,6 +305,10 @@
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
||||
|
||||
@ -15,6 +15,10 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Actor, outcome, target, and readable context stay visible even when the original record changes or disappears later.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
The selected event is URL-addressable through the <span class="font-mono text-xs">event</span> query parameter. If the event is no longer visible in the current history view, the page quietly falls back to the unselected log.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>Tenant and search query seeds can reopen this overview in a specific monitoring slice.</p>
|
||||
<p>Compatible filters and sorting still restore from the last session, but row inspection always leaves the page for the canonical evidence detail.</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -10,10 +10,14 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review pending requests, expiring governance, and lapsed exception coverage across entitled tenants without leaving the Monitoring area.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
The focused review lane is bound to the <span class="font-mono text-xs">exception</span> query parameter. If that exception drops out of the current queue view, the page falls back to quiet monitoring mode without stale decision state.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($this->showSelectedExceptionSummary && $selectedException)
|
||||
@if ($selectedException)
|
||||
<x-filament::section heading="Focused review lane">
|
||||
<x-slot name="description">
|
||||
Selection-bound decisions now define the active work lane. Scope, filters, and drilldowns stay visible without competing with the current review step.
|
||||
|
||||
@ -43,48 +43,66 @@
|
||||
<x-filament::tabs label="Operations tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'all'"
|
||||
wire:click="$set('activeTab', 'all')"
|
||||
:href="$this->tabUrl('all')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
All
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'active'"
|
||||
wire:click="$set('activeTab', 'active')"
|
||||
:href="$this->tabUrl('active')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Active
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === $staleAttentionTab"
|
||||
wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
|
||||
:href="$this->tabUrl($staleAttentionTab)"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Likely stale
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === $terminalFollowUpTab"
|
||||
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
|
||||
:href="$this->tabUrl($terminalFollowUpTab)"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Terminal follow-up
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
wire:click="$set('activeTab', 'succeeded')"
|
||||
:href="$this->tabUrl('succeeded')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Succeeded
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'partial'"
|
||||
wire:click="$set('activeTab', 'partial')"
|
||||
:href="$this->tabUrl('partial')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Partial
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'failed'"
|
||||
wire:click="$set('activeTab', 'failed')"
|
||||
:href="$this->tabUrl('failed')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Failed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Tenant prefilters and the selected operations tab remain shareable through the URL. Additional table filters still restore from the last compatible session state.
|
||||
</p>
|
||||
|
||||
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
|
||||
|
||||
@ -50,6 +50,7 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
||||
->assertSee('Dense multi-tenant scan')
|
||||
->assertSee('Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.')
|
||||
->assertSee('Grouped legend')
|
||||
->assertSee('Open finding')
|
||||
->assertSee('More follow-up')
|
||||
@ -106,6 +107,7 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
||||
->assertSee('Compact compare results')
|
||||
->assertSee('Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.')
|
||||
->assertSee('Open finding');
|
||||
});
|
||||
|
||||
@ -142,6 +144,7 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('No rows match the current filters')
|
||||
->assertSee('Passive auto-refresh every 5 seconds')
|
||||
->assertSee('Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.')
|
||||
->click('Reset filters')
|
||||
->waitForText('Dense multi-tenant scan')
|
||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||
|
||||
@ -160,6 +160,7 @@ function spec194SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = '')
|
||||
->waitForText('Focused review lane')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Selection-bound decisions now define the active work lane.')
|
||||
->assertSee('Approve exception')
|
||||
->assertSee('Reject exception');
|
||||
|
||||
|
||||
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
it('smokes monitoring deeplinks for operations, audit log, finding exceptions queue, and evidence overview', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$secondTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Second Evidence Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $secondTenant, user: $user, role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$activeRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $tenant->workspace_id,
|
||||
'target_label' => 'Workspace '.$tenant->workspace_id,
|
||||
'summary' => 'Workspace selected for Workspace '.$tenant->workspace_id,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Spec198 browser queue smoke.',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
foreach ([$tenant, $secondTenant] as $snapshotTenant) {
|
||||
EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $snapshotTenant->getKey(),
|
||||
'workspace_id' => (int) $snapshotTenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
visit(route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'activeTab' => 'active',
|
||||
]))
|
||||
->waitForText('Monitoring landing')
|
||||
->assertSee('Tenant prefilters and the selected operations tab remain shareable through the URL.')
|
||||
->assertSee('Open run detail')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(route('admin.monitoring.audit-log', ['event' => (int) $audit->getKey()]))
|
||||
->waitForText('Summary-first audit history')
|
||||
->assertSee('Close details')
|
||||
->assertSee('Readable context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: ['exception' => (int) $exception->getKey()]))
|
||||
->waitForText('Focused review lane')
|
||||
->assertSee('Approve exception')
|
||||
->assertSee('Reject exception')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(route('admin.evidence.overview', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'search' => $tenant->name,
|
||||
]))
|
||||
->waitForText('Tenant and search query seeds can reopen this overview in a specific monitoring slice.')
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Clear filters')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('smokes compare landing to compare matrix handoff with carried subject focus', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$subjectKey = 'wifi-corp-profile';
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Spec198 Matrix Profile',
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$matrixUrl = BaselineProfileResource::compareMatrixUrl($profile).'?subject_key='.urlencode($subjectKey);
|
||||
|
||||
visit(BaselineCompareLanding::getUrl(
|
||||
parameters: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'subject_key' => $subjectKey,
|
||||
],
|
||||
panel: 'tenant',
|
||||
tenant: $tenant,
|
||||
))
|
||||
->waitForText('Open compare matrix')
|
||||
->assertSee('Launch the compare matrix with the currently known baseline profile and any carried subject focus from this tenant landing.');
|
||||
|
||||
visit($matrixUrl)
|
||||
->waitForText('Focused subject')
|
||||
->assertSee($subjectKey)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
@ -39,11 +39,13 @@
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Drift finding #'.$finding->getKey())
|
||||
->assertMountedActionModalSee('Open finding');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Drift finding #'.$finding->getKey())
|
||||
->assertActionVisible('open_selected_audit_target');
|
||||
});
|
||||
|
||||
it('keeps deleted findings readable while suppressing finding drill-down links', function (): void {
|
||||
@ -78,11 +80,13 @@
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Permission posture finding #'.$findingId)
|
||||
->assertMountedActionModalDontSee('Open finding');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Permission posture finding #'.$findingId)
|
||||
->assertActionDoesNotExist('open_selected_audit_target');
|
||||
});
|
||||
|
||||
it('does not render internal audit bookkeeping metadata in the inspection view', function (): void {
|
||||
@ -116,13 +120,15 @@
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalDontSee('_dedupe_key')
|
||||
->assertMountedActionModalDontSee('internal-bookkeeping-marker')
|
||||
->assertMountedActionModalDontSee('_actor_type')
|
||||
->assertMountedActionModalDontSee('hidden-actor-marker');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertDontSee('_dedupe_key')
|
||||
->assertDontSee('internal-bookkeeping-marker')
|
||||
->assertDontSee('_actor_type')
|
||||
->assertDontSee('hidden-actor-marker');
|
||||
});
|
||||
|
||||
it('hides finding audit rows for tenants outside the viewer entitlement scope', function (): void {
|
||||
@ -178,13 +184,17 @@
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||
->assertNotFound();
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Finding reopened for Drift finding #'.$findingB->getKey());
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $hidden->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
});
|
||||
|
||||
@ -170,10 +170,10 @@
|
||||
|
||||
$component
|
||||
->callAction('clear_filters')
|
||||
->assertSet('tableFilters.tenant_id.value', null)
|
||||
->assertSet('tableSearch', '')
|
||||
->assertCanSeeTableRecords([
|
||||
(string) $snapshotA->getKey(),
|
||||
(string) $snapshotB->getKey(),
|
||||
]);
|
||||
->assertRedirect(route('admin.evidence.overview'));
|
||||
|
||||
$this->get(route('admin.evidence.overview'))
|
||||
->assertOk()
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $tenantA), false)
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $tenantB), false);
|
||||
});
|
||||
|
||||
@ -85,5 +85,13 @@ function auditLogAuthorizationTestRecord(Tenant $tenant, array $attributes = [])
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||
->assertNotFound();
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Tenant B audit event');
|
||||
|
||||
Livewire::withQueryParams(['event' => (int) $hidden->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
});
|
||||
|
||||
@ -11,11 +11,17 @@
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null): Testable
|
||||
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null, ?int $selectedAuditLogId = null): Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
if ($selectedAuditLogId !== null) {
|
||||
return Livewire::withQueryParams(['event' => $selectedAuditLogId])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
@ -52,8 +58,8 @@ function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): Audit
|
||||
'summary' => 'Backup set created for Nightly iOS backup',
|
||||
]);
|
||||
|
||||
auditLogDetailTestComponent($user)
|
||||
->callTableAction('inspect', $audit)
|
||||
auditLogDetailTestComponent($user, selectedAuditLogId: (int) $audit->getKey())
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Readable context')
|
||||
->assertSee('Technical metadata')
|
||||
@ -78,8 +84,8 @@ function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): Audit
|
||||
'summary' => 'Backup set archived for Archived backup',
|
||||
]);
|
||||
|
||||
auditLogDetailTestComponent($user)
|
||||
->callTableAction('inspect', $audit)
|
||||
auditLogDetailTestComponent($user, selectedAuditLogId: (int) $audit->getKey())
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Archived backup')
|
||||
->assertSee('Technical metadata')
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BaselineProfile;
|
||||
@ -263,6 +264,41 @@
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('exposes a compare-matrix handoff that preserves carried subject focus from launch context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])->test(BaselineCompareLanding::class);
|
||||
|
||||
expect($component->instance()->openCompareMatrixUrl())
|
||||
->toBe(BaselineProfileResource::compareMatrixUrl($profile).'?subject_key=wifi-corp-profile');
|
||||
|
||||
$component->assertSee('Open compare matrix');
|
||||
});
|
||||
|
||||
it('exposes full coverage + fidelity context in stats', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -325,3 +325,37 @@
|
||||
->assertSee('No rows match the current filters')
|
||||
->assertSee('Reset filters');
|
||||
});
|
||||
|
||||
it('drops draft-only filter edits on remount while preserving the applied focus subject from the query', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
$this->makeBaselineCompareMatrixRun(
|
||||
$fixture['visibleTenant'],
|
||||
$fixture['profile'],
|
||||
$fixture['snapshot'],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertSet('focusedSubjectKey', 'wifi-corp-profile')
|
||||
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
|
||||
->set('draftSelectedStates', ['match'])
|
||||
->assertSee('Draft filters are staged');
|
||||
|
||||
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertSet('focusedSubjectKey', 'wifi-corp-profile')
|
||||
->assertSet('draftSelectedPolicyTypes', [])
|
||||
->assertSet('draftSelectedStates', [])
|
||||
->assertDontSee('Draft filters are staged');
|
||||
});
|
||||
|
||||
@ -111,10 +111,11 @@
|
||||
$versionResponse->assertSee('Enabled');
|
||||
$versionResponse->assertSee('device_vendor_msft_policy_config_system_child');
|
||||
|
||||
$versionGeneralSection = [];
|
||||
preg_match('/<section[^>]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection);
|
||||
expect($versionGeneralSection)->not->toBeEmpty();
|
||||
expect($versionGeneralSection[0])->toContain('x-cloak');
|
||||
expect($versionResponse->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-normalized-settings-variant="settings_catalog_table"')
|
||||
->toContain('data-shared-zone="settings-table"');
|
||||
})->with([
|
||||
'settingsCatalogPolicy',
|
||||
'endpointSecurityPolicy',
|
||||
|
||||
@ -139,7 +139,7 @@
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('renders the shared verification family on the tenant widget host', function (): void {
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
@ -15,6 +17,9 @@
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -34,8 +39,10 @@ function spec125AssertPersistedTableState(
|
||||
string $sortDirection,
|
||||
string $filterPath,
|
||||
mixed $filterValue,
|
||||
array $queryParams = [],
|
||||
): void {
|
||||
$component = Livewire::test($componentClass, $parameters)
|
||||
$component = Livewire::withQueryParams($queryParams)
|
||||
->test($componentClass, $parameters)
|
||||
->searchTable($search)
|
||||
->call('sortTable', $sortColumn, $sortDirection)
|
||||
->set($filterPath, $filterValue);
|
||||
@ -46,7 +53,8 @@ function spec125AssertPersistedTableState(
|
||||
expect(session()->get($instance->getTableSortSessionKey()))->toBe("{$sortColumn}:{$sortDirection}");
|
||||
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), str($filterPath)->after('tableFilters.')->value()))->toBe($filterValue);
|
||||
|
||||
Livewire::test($componentClass, $parameters)
|
||||
Livewire::withQueryParams($queryParams)
|
||||
->test($componentClass, $parameters)
|
||||
->assertSet('tableSearch', $search)
|
||||
->assertSet('tableSort', "{$sortColumn}:{$sortDirection}")
|
||||
->assertSet($filterPath, $filterValue);
|
||||
@ -284,6 +292,132 @@ function spec125AssertPersistedTableState(
|
||||
);
|
||||
});
|
||||
|
||||
it('restores operations table state while the requested tab stays query-driven', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
spec125AssertPersistedTableState(
|
||||
Operations::class,
|
||||
[],
|
||||
'baseline',
|
||||
'created_at',
|
||||
'desc',
|
||||
'tableFilters.status.value',
|
||||
'failed',
|
||||
['activeTab' => 'failed'],
|
||||
);
|
||||
|
||||
Livewire::withQueryParams(['activeTab' => 'failed'])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('activeTab', 'failed');
|
||||
});
|
||||
|
||||
it('clears selected audit event state when persisted filters no longer contain the record', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$selectedAudit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $tenant->workspace_id,
|
||||
'target_label' => 'Workspace '.$tenant->workspace_id,
|
||||
'summary' => 'Selected audit event',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'operation_run.failed',
|
||||
'status' => 'failure',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => '1',
|
||||
'target_label' => 'Run #1',
|
||||
'summary' => 'Failure event',
|
||||
'recorded_at' => now()->addSecond(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$auditComponent = Livewire::withQueryParams(['event' => (int) $selectedAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class);
|
||||
|
||||
session()->put($auditComponent->instance()->getTableFiltersSessionKey(), [
|
||||
'outcome' => ['value' => 'failure'],
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['event' => (int) $selectedAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null);
|
||||
});
|
||||
|
||||
it('clears selected exception state when persisted queue filters no longer contain the record', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$selectedException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Selected queue exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$rejectedFinding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $rejectedFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_REJECTED,
|
||||
'current_validity_state' => FindingException::VALIDITY_REJECTED,
|
||||
'request_reason' => 'Rejected queue exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'rejected_at' => now()->subHour(),
|
||||
'rejection_reason' => 'No longer needed',
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$queueComponent = Livewire::withQueryParams(['exception' => (int) $selectedException->getKey()])
|
||||
->test(FindingExceptionsQueue::class);
|
||||
|
||||
session()->put($queueComponent->instance()->getTableFiltersSessionKey(), [
|
||||
'status' => ['value' => FindingException::STATUS_REJECTED],
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['exception' => (int) $selectedException->getKey()])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null);
|
||||
});
|
||||
|
||||
it('reseeds the provider-connections tenant filter when the remembered admin tenant changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -155,14 +155,6 @@ static function (Tenant $tenant, string $label): RestoreRun {
|
||||
return RestoreRun::factory()->for($tenant)->for($backupSet)->create();
|
||||
},
|
||||
],
|
||||
'inventory-item view' => [
|
||||
InventoryItemResource::class,
|
||||
'view',
|
||||
static fn (Tenant $tenant, string $label): InventoryItem => InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => $label,
|
||||
]),
|
||||
],
|
||||
'finding view' => [
|
||||
FindingResource::class,
|
||||
'view',
|
||||
@ -244,3 +236,34 @@ static function (Tenant $tenant, string $label): BackupSchedule {
|
||||
->get($resourceClass::getUrl($page, ['record' => $blocked], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
})->with('tenant-owned-detail-pages');
|
||||
|
||||
it('returns not found for admin inventory item detail pages outside the explicit tenant query scope', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$allowed = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'display_name' => 'Allowed inventory item',
|
||||
]);
|
||||
|
||||
$blocked = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'display_name' => 'Blocked inventory item',
|
||||
]);
|
||||
|
||||
$session = tenantOwnedAdminSession($tenantA);
|
||||
$allowedUrl = InventoryItemResource::getUrl('view', ['record' => $allowed], panel: 'admin').'?tenant='.(string) $tenantA->external_id;
|
||||
$blockedUrl = InventoryItemResource::getUrl('view', ['record' => $blocked], panel: 'admin').'?tenant='.(string) $tenantA->external_id;
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession($session)
|
||||
->get($allowedUrl)
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession($session)
|
||||
->get($blockedUrl)
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('opens the selected audit event in a slideover inspection surface', function (): void {
|
||||
it('hydrates the selected audit event from the query and renders inline detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
@ -36,16 +36,20 @@
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $audit->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Workspace selected for Workspace 1')
|
||||
->assertMountedActionModalSee('Readable context')
|
||||
->assertMountedActionModalSee('Technical metadata');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Workspace selected for Workspace 1')
|
||||
->assertSee('Readable context')
|
||||
->assertSee('Technical metadata')
|
||||
->assertActionVisible('close_selected_audit_event');
|
||||
});
|
||||
|
||||
it('shows operation-run navigation only for the currently inspected operation run event', function (): void {
|
||||
it('shows related navigation only for the currently selected operation-run event', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -89,20 +93,24 @@
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $withRunLink->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$withRunLink, $withoutRunLink])
|
||||
->mountTableAction('inspect', $withRunLink)
|
||||
->assertMountedActionModalSee('Open operation');
|
||||
->assertActionVisible('open_selected_audit_target');
|
||||
|
||||
$component
|
||||
->call('replaceMountedTableAction', 'inspect', (string) $withoutRunLink->getKey())
|
||||
->assertMountedActionModalSee('Workspace selected for Workspace 1')
|
||||
->assertMountedActionModalDontSee('Open operation')
|
||||
->assertMountedActionModalDontSee('Baseline compare completed for Operation run');
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $withoutRunLink->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSee('Workspace selected for Workspace 1')
|
||||
->assertActionDoesNotExist('open_selected_audit_target');
|
||||
});
|
||||
|
||||
it('clearing the slideover closes the inspection surface cleanly', function (): void {
|
||||
it('falls back to the unselected history when the requested event is invalid or unavailable', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
@ -120,19 +128,40 @@
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$foreignTenant = \App\Models\Tenant::factory()->create();
|
||||
|
||||
$foreignAudit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $foreignTenant->workspace_id,
|
||||
'target_label' => 'Workspace 2',
|
||||
'summary' => 'Foreign workspace selected',
|
||||
'recorded_at' => now()->addSecond(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
Livewire::withQueryParams(['event' => 999999])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->mountTableAction('inspect', $audit)
|
||||
->unmountTableAction()
|
||||
->assertTableActionNotMounted('inspect');
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertActionDoesNotExist('close_selected_audit_event');
|
||||
|
||||
expect($component->instance()->getMountedTableAction())->toBeNull();
|
||||
Livewire::withQueryParams(['event' => (int) $foreignAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertActionDoesNotExist('close_selected_audit_event');
|
||||
});
|
||||
|
||||
it('keeps record inspection actions out of the global page header', function (): void {
|
||||
it('keeps selected-event actions out of the page header until an event is selected', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -143,7 +172,7 @@
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
@ -164,6 +193,13 @@
|
||||
->assertOk()
|
||||
->assertDontSee('Close details')
|
||||
->assertDontSee('Open operation');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log', ['event' => (int) $audit->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Close details')
|
||||
->assertSee('Open operation');
|
||||
});
|
||||
|
||||
it('surfaces origin context quietly when deep-linked to a selected audit event', function (): void {
|
||||
|
||||
@ -69,10 +69,12 @@
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
|
||||
->assertSee('Focused review lane')
|
||||
->assertSee('Decision lane')
|
||||
->assertSee('Related drilldown')
|
||||
->assertDontSee('Quiet monitoring mode')
|
||||
->assertActionVisible('clear_selected_exception')
|
||||
->assertActionVisible('approve_selected_exception')
|
||||
->assertActionVisible('reject_selected_exception')
|
||||
->mountAction('approve_selected_exception')
|
||||
@ -89,3 +91,42 @@
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['rejection_reason']);
|
||||
});
|
||||
|
||||
it('falls back to quiet monitoring when the requested exception is invalid or unauthorized', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$foreignTenant = \App\Models\Tenant::factory()->create();
|
||||
[$foreignRequester] = createUserWithTenant(tenant: $foreignTenant, role: 'owner');
|
||||
|
||||
$foreignFinding = Finding::factory()->for($foreignTenant)->create();
|
||||
|
||||
$foreignException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'finding_id' => (int) $foreignFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $foreignRequester->getKey(),
|
||||
'owner_user_id' => (int) $foreignRequester->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Foreign queue exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['exception' => 999999])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null)
|
||||
->assertSee('Quiet monitoring mode')
|
||||
->assertActionHidden('clear_selected_exception');
|
||||
|
||||
Livewire::withQueryParams(['exception' => (int) $foreignException->getKey()])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null)
|
||||
->assertSee('Quiet monitoring mode')
|
||||
->assertActionHidden('clear_selected_exception');
|
||||
});
|
||||
|
||||
@ -84,20 +84,24 @@
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||
->assertActionVisible('view_tenant_register');
|
||||
|
||||
$filtersComponent = Livewire::test(FindingExceptionsQueue::class);
|
||||
$queueInstance = $filtersComponent->instance();
|
||||
session()->forget([
|
||||
$queueInstance->getTableFiltersSessionKey(),
|
||||
$queueInstance->getTableSearchSessionKey(),
|
||||
$queueInstance->getTableSortSessionKey(),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'exception' => (int) $expiring->getKey(),
|
||||
])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', (int) $expiring->getKey())
|
||||
->assertSet('showSelectedExceptionSummary', true)
|
||||
->assertActionVisible('clear_selected_exception')
|
||||
->assertActionVisible('open_selected_exception')
|
||||
->assertActionVisible('open_selected_finding')
|
||||
->assertSee('Queue visibility test')
|
||||
->assertSee('Expiring')
|
||||
->assertSee($tenantA->name);
|
||||
|
||||
Livewire::test(FindingExceptionsQueue::class)
|
||||
->mountTableAction('inspect_exception', (string) $expiring->getKey())
|
||||
->assertMountedActionModalSee('Finding exception #'.$expiring->getKey())
|
||||
->assertMountedActionModalSee('Queue visibility test')
|
||||
->assertMountedActionModalSee('Close details');
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee('Focused review lane');
|
||||
});
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Pages\Monitoring\AuditLog;
|
||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\FindingException;
|
||||
|
||||
function monitoringPageStateFieldSummary(array $contract): array
|
||||
{
|
||||
return collect($contract['stateFields'])
|
||||
->mapWithKeys(static fn (array $field): array => [
|
||||
(string) $field['stateKey'] => [
|
||||
'stateClass' => $field['stateClass'],
|
||||
'queryRole' => $field['queryRole'],
|
||||
'shareable' => $field['shareable'],
|
||||
'restorableOnRefresh' => $field['restorableOnRefresh'],
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
it('declares the bounded page-state contract for each monitoring surface', function (string $pageClass, array $expected): void {
|
||||
$contract = $pageClass::monitoringPageStateContract();
|
||||
|
||||
expect($contract['surfaceKey'])->toBe($expected['surfaceKey'])
|
||||
->and($contract['surfaceType'])->toBe($expected['surfaceType'])
|
||||
->and($contract['shareableStateKeys'])->toBe($expected['shareableStateKeys'])
|
||||
->and($contract['localOnlyStateKeys'])->toBe($expected['localOnlyStateKeys'])
|
||||
->and($contract['inspectContract'])->toMatchArray($expected['inspectContract'])
|
||||
->and(monitoringPageStateFieldSummary($contract))->toEqual($expected['stateFields']);
|
||||
})->with([
|
||||
'operations' => [
|
||||
Operations::class,
|
||||
[
|
||||
'surfaceKey' => 'operations',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'shareableStateKeys' => ['tenant_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'presentation' => 'none',
|
||||
'shareable' => false,
|
||||
],
|
||||
'stateFields' => [
|
||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant_scope' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'problemClass' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'activeTab' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'audit log' => [
|
||||
AuditLog::class,
|
||||
[
|
||||
'surfaceKey' => 'audit_log',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'shareableStateKeys' => ['event'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => AuditLogModel::class,
|
||||
'selectedStateKey' => 'selectedAuditLogId',
|
||||
'presentation' => 'inline_detail',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'event' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'finding exceptions queue' => [
|
||||
FindingExceptionsQueue::class,
|
||||
[
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'shareableStateKeys' => ['tenant', 'exception'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => FindingException::class,
|
||||
'selectedStateKey' => 'selectedFindingExceptionId',
|
||||
'presentation' => 'summary_plus_related_actions',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'exception' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'evidence overview' => [
|
||||
EvidenceOverview::class,
|
||||
[
|
||||
'surfaceKey' => 'evidence_overview',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'shareableStateKeys' => ['tenant_id', 'search'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'presentation' => 'navigate_to_canonical_detail',
|
||||
'shareable' => false,
|
||||
],
|
||||
'stateFields' => [
|
||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'search' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
'tableSort' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'baseline compare landing' => [
|
||||
BaselineCompareLanding::class,
|
||||
[
|
||||
'surfaceKey' => 'baseline_compare_landing',
|
||||
'surfaceType' => 'launch_context_support',
|
||||
'shareableStateKeys' => ['baseline_profile_id', 'subject_key', 'nav'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'presentation' => 'none',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'baseline_profile_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'subject_key' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'nav' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'baseline compare matrix' => [
|
||||
BaselineCompareMatrix::class,
|
||||
[
|
||||
'surfaceKey' => 'baseline_compare_matrix',
|
||||
'surfaceType' => 'draft_apply_analysis',
|
||||
'shareableStateKeys' => ['mode', 'policy_type', 'state', 'severity', 'tenant_sort', 'subject_sort', 'subject_key'],
|
||||
'localOnlyStateKeys' => [
|
||||
'draftSelectedPolicyTypes',
|
||||
'draftSelectedStates',
|
||||
'draftSelectedSeverities',
|
||||
'draftTenantSort',
|
||||
'draftSubjectSort',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'baseline_subject',
|
||||
'selectedStateKey' => 'focusedSubjectKey',
|
||||
'presentation' => 'focused_matrix',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'mode' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'policy_type' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'state' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'severity' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant_sort' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'subject_sort' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'subject_key' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
it('keeps the selected-record monitoring surfaces on a single inspect identifier', function (): void {
|
||||
$contracts = [
|
||||
AuditLog::monitoringPageStateContract(),
|
||||
FindingExceptionsQueue::monitoringPageStateContract(),
|
||||
];
|
||||
|
||||
expect(collect($contracts)->pluck('inspectContract.selectedStateKey')->all())
|
||||
->toEqual(['selectedAuditLogId', 'selectedFindingExceptionId'])
|
||||
->and(collect($contracts)->pluck('inspectContract.shareable')->unique()->all())
|
||||
->toEqual([true]);
|
||||
});
|
||||
|
||||
it('keeps compare matrix draft state local while applied filters stay query-driven', function (): void {
|
||||
$contract = BaselineCompareMatrix::monitoringPageStateContract();
|
||||
|
||||
expect($contract['surfaceType'])->toBe('draft_apply_analysis')
|
||||
->and($contract['localOnlyStateKeys'])->toEqual([
|
||||
'draftSelectedPolicyTypes',
|
||||
'draftSelectedStates',
|
||||
'draftSelectedSeverities',
|
||||
'draftTenantSort',
|
||||
'draftSubjectSort',
|
||||
])
|
||||
->and(collect($contract['shareableStateKeys'])->contains('subject_key'))->toBeTrue()
|
||||
->and($contract['inspectContract']['presentation'])->toBe('focused_matrix');
|
||||
});
|
||||
@ -273,3 +273,40 @@
|
||||
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
]));
|
||||
});
|
||||
|
||||
it('ignores unauthorized requested tenant filters while keeping canonical tab continuity', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$foreignTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenantA, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'tenant_id' => (string) $foreignTenant->getKey(),
|
||||
'activeTab' => 'active',
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertSet('activeTab', 'active');
|
||||
|
||||
expect(urldecode($component->instance()->tabUrl(OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)))
|
||||
->toContain('activeTab='.OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
->toContain('problemClass='.OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
->not->toContain('tenant_id='.(int) $foreignTenant->getKey());
|
||||
});
|
||||
|
||||
@ -337,7 +337,7 @@
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void {
|
||||
|
||||
@ -268,5 +268,5 @@
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
@ -10,6 +13,8 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -85,3 +90,101 @@
|
||||
->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('drops unauthorized requested tenant filters on operations instead of honoring cross-tenant query state', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$unauthorizedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenantA, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant_id' => (string) $unauthorizedTenant->getKey(),
|
||||
'activeTab' => 'active',
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertSet('activeTab', 'active');
|
||||
});
|
||||
|
||||
it('falls back to an unselected audit history when the requested event is outside the accessible scope', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$foreignTenant = Tenant::factory()->create();
|
||||
|
||||
$foreignAudit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $foreignTenant->workspace_id,
|
||||
'target_label' => 'Foreign workspace',
|
||||
'summary' => 'Foreign workspace selected',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['event' => (int) $foreignAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertActionDoesNotExist('close_selected_audit_event');
|
||||
});
|
||||
|
||||
it('falls back to an unselected queue state when the requested exception is outside the accessible tenant set', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$foreignTenant = Tenant::factory()->create();
|
||||
[$foreignRequester] = createUserWithTenant(tenant: $foreignTenant, role: 'owner');
|
||||
$foreignFinding = Finding::factory()->for($foreignTenant)->create();
|
||||
|
||||
$foreignException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'finding_id' => (int) $foreignFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $foreignRequester->getKey(),
|
||||
'owner_user_id' => (int) $foreignRequester->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Foreign exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['exception' => (int) $foreignException->getKey()])
|
||||
->actingAs($approver)
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null)
|
||||
->assertActionHidden('clear_selected_exception')
|
||||
->assertActionHidden('approve_selected_exception')
|
||||
->assertActionHidden('reject_selected_exception');
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||
@ -122,3 +123,21 @@
|
||||
], tenant: $fixture['visibleTenant']))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('drops foreign compare-context launch data instead of leaking another workspace profile', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
$foreignProfile = \App\Models\BaselineProfile::factory()->create();
|
||||
|
||||
$this->actingAs($fixture['user']);
|
||||
$fixture['visibleTenant']->makeCurrent();
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'baseline_profile_id' => (int) $foreignProfile->getKey(),
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])->test(BaselineCompareLanding::class);
|
||||
|
||||
expect($component->instance()->openCompareMatrixUrl())
|
||||
->toStartWith(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||
->not->toContain((string) $foreignProfile->getKey());
|
||||
});
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('narrows required permissions results using filters and search', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
@ -51,55 +56,71 @@
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$missingResponse = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('All required permissions are present', false);
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$missingResponse
|
||||
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
])
|
||||
->test(TenantRequiredPermissions::class)
|
||||
->assertSet('tableFilters.status.value', 'missing')
|
||||
->assertSee('All required permissions are present')
|
||||
->assertCanNotSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Beta.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
|
||||
$presentResponse = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present")
|
||||
->assertSuccessful()
|
||||
->assertSee('wire:model.live="status"', false);
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
'status' => 'present',
|
||||
])
|
||||
->test(TenantRequiredPermissions::class)
|
||||
->assertSet('tableFilters.status.value', 'present')
|
||||
->assertCanSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Beta.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
|
||||
$presentResponse
|
||||
->assertSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
'status' => 'present',
|
||||
'type' => 'delegated',
|
||||
])
|
||||
->test(TenantRequiredPermissions::class)
|
||||
->assertSet('tableFilters.status.value', 'present')
|
||||
->assertSet('tableFilters.type.value', 'delegated')
|
||||
->assertCanSeeTableRecords(['Beta.Read.All'])
|
||||
->assertCanNotSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
|
||||
$delegatedResponse = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present&type=delegated")
|
||||
->assertSuccessful();
|
||||
|
||||
$delegatedResponse
|
||||
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
|
||||
$featureQuery = http_build_query([
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
'status' => 'all',
|
||||
'features' => ['backup'],
|
||||
]);
|
||||
])
|
||||
->test(TenantRequiredPermissions::class)
|
||||
->assertSet('tableFilters.features.values', ['backup'])
|
||||
->assertCanSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
])
|
||||
->assertCanNotSeeTableRecords(['Beta.Read.All']);
|
||||
|
||||
$featureResponse = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
|
||||
->assertSuccessful();
|
||||
|
||||
$featureResponse
|
||||
->assertSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertSee('data-permission-key="Gamma.Manage.All"', false)
|
||||
->assertDontSee('data-permission-key="Beta.Read.All"', false);
|
||||
|
||||
$searchResponse = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all&search=delegated")
|
||||
->assertSuccessful();
|
||||
|
||||
$searchResponse
|
||||
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
'status' => 'all',
|
||||
'search' => 'delegated',
|
||||
])
|
||||
->test(TenantRequiredPermissions::class)
|
||||
->assertSet('tableSearch', 'delegated')
|
||||
->assertCanSeeTableRecords(['Beta.Read.All'])
|
||||
->assertCanNotSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -202,7 +202,7 @@
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
|
||||
36
specs/198-monitoring-page-state/checklists/requirements.md
Normal file
36
specs/198-monitoring-page-state/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Monitoring Page-State Contract
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-15
|
||||
**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-15.
|
||||
- Route identifiers, page class paths, and operator action labels are included because the repository template and constitution require explicit scope, action-surface, and page-contract definitions for operator-facing changes.
|
||||
- The spec intentionally introduces a bounded monitoring page-state taxonomy while rejecting a global shell refactor, a runtime state framework, and any forced flattening of Compare Matrix.
|
||||
@ -0,0 +1,284 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Monitoring Page-State Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for Spec 198 monitoring page-state behavior
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 198. The affected
|
||||
surfaces continue to render HTML through Filament and Livewire. The schemas
|
||||
below define the bounded contract for contextual prefilter state, active
|
||||
state, draft state, inspect state, shareable/restorable state, and
|
||||
deterministic hydration rules across the in-scope monitoring family.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-monitoring-page-state-consumers:
|
||||
- surface: operations
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/Operations.php
|
||||
- apps/platform/resources/views/filament/pages/monitoring/operations.blade.php
|
||||
mustDeclare:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
- shareable_restorable
|
||||
mustNotDeclare:
|
||||
- draft
|
||||
- competing_same_page_inspect_model
|
||||
- surface: audit_log
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php
|
||||
- apps/platform/resources/views/filament/pages/monitoring/partials/audit-log-inspect-event.blade.php
|
||||
mustDeclare:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
- inspect
|
||||
- shareable_restorable
|
||||
mustNotDeclare:
|
||||
- draft
|
||||
- parallel_inspect_world
|
||||
- surface: finding_exceptions_queue
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
|
||||
- apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php
|
||||
mustDeclare:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
- inspect
|
||||
- shareable_restorable
|
||||
mustNotDeclare:
|
||||
- draft
|
||||
- parallel_inspect_world
|
||||
- surface: evidence_overview
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||
- apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php
|
||||
mustDeclare:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
- shareable_restorable
|
||||
mustNotDeclare:
|
||||
- draft
|
||||
- same_page_selected_record_state
|
||||
- surface: baseline_compare_matrix
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
||||
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
||||
mustDeclare:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
- draft
|
||||
- inspect
|
||||
- shareable_restorable
|
||||
mustNotDeclare:
|
||||
- forced_direct_active_filter_model
|
||||
- surface: baseline_compare_landing
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
|
||||
- apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php
|
||||
mustDeclare:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
mustNotDeclare:
|
||||
- competing_applied_compare_owner
|
||||
paths:
|
||||
/internal/page-state/{surface}:
|
||||
get:
|
||||
summary: Return the logical page-state contract for an in-scope monitoring surface
|
||||
operationId: getMonitoringPageStateContract
|
||||
parameters:
|
||||
- name: surface
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Logical state contract and hydration rules for the requested surface
|
||||
content:
|
||||
application/vnd.tenantpilot.monitoring-page-state+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SurfaceContract'
|
||||
'404':
|
||||
description: Requested surface is not in the Spec 198 inventory
|
||||
components:
|
||||
schemas:
|
||||
SurfaceKey:
|
||||
type: string
|
||||
enum:
|
||||
- operations
|
||||
- audit_log
|
||||
- finding_exceptions_queue
|
||||
- evidence_overview
|
||||
- baseline_compare_matrix
|
||||
- baseline_compare_landing
|
||||
SurfaceType:
|
||||
type: string
|
||||
enum:
|
||||
- simple_monitoring
|
||||
- selected_record_monitoring
|
||||
- draft_apply_analysis
|
||||
- launch_context_support
|
||||
StateClass:
|
||||
type: string
|
||||
enum:
|
||||
- contextual_prefilter
|
||||
- active
|
||||
- draft
|
||||
- inspect
|
||||
- shareable_restorable
|
||||
QueryRole:
|
||||
type: string
|
||||
enum:
|
||||
- initialization_only
|
||||
- durable_restorable
|
||||
- scoped_deeplink
|
||||
- unsupported
|
||||
StateCarrier:
|
||||
type: string
|
||||
enum:
|
||||
- query_param
|
||||
- session
|
||||
- livewire_property
|
||||
- route_context
|
||||
- derived_render_state
|
||||
InvalidFallback:
|
||||
type: string
|
||||
enum:
|
||||
- discard_and_continue
|
||||
- clear_selection_and_continue
|
||||
- reset_to_default_scope
|
||||
- ignore_unapplied_draft
|
||||
- deny_not_found
|
||||
InspectPresentation:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- inline_detail
|
||||
- queue_summary
|
||||
- focused_matrix
|
||||
- navigate_to_canonical_detail
|
||||
StateFieldDescriptor:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- stateKey
|
||||
- stateClass
|
||||
- carrier
|
||||
- queryRole
|
||||
- shareable
|
||||
- restorableOnRefresh
|
||||
- invalidFallback
|
||||
properties:
|
||||
stateKey:
|
||||
type: string
|
||||
stateClass:
|
||||
$ref: '#/components/schemas/StateClass'
|
||||
carrier:
|
||||
$ref: '#/components/schemas/StateCarrier'
|
||||
queryRole:
|
||||
$ref: '#/components/schemas/QueryRole'
|
||||
shareable:
|
||||
type: boolean
|
||||
restorableOnRefresh:
|
||||
type: boolean
|
||||
tenantSensitive:
|
||||
type: boolean
|
||||
invalidFallback:
|
||||
$ref: '#/components/schemas/InvalidFallback'
|
||||
HydrationRule:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- precedenceOrder
|
||||
- appliesOnInitialMountOnly
|
||||
- activeStateBecomesAuthoritativeAfterMount
|
||||
- invalidRequestedStateFallback
|
||||
properties:
|
||||
precedenceOrder:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- query
|
||||
- session
|
||||
- default
|
||||
appliesOnInitialMountOnly:
|
||||
type: boolean
|
||||
activeStateBecomesAuthoritativeAfterMount:
|
||||
type: boolean
|
||||
clearsOnTenantSwitch:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
invalidRequestedStateFallback:
|
||||
$ref: '#/components/schemas/InvalidFallback'
|
||||
InspectContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- primaryModel
|
||||
- selectedStateKey
|
||||
- openedBy
|
||||
- presentation
|
||||
- shareable
|
||||
- invalidSelectionFallback
|
||||
properties:
|
||||
primaryModel:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- selected_record_inline
|
||||
- selected_record_workbench
|
||||
- focused_subject
|
||||
selectedStateKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
openedBy:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- query_param
|
||||
- inspect_action
|
||||
- row_selection
|
||||
- cell_focus
|
||||
- landing_context
|
||||
presentation:
|
||||
$ref: '#/components/schemas/InspectPresentation'
|
||||
shareable:
|
||||
type: boolean
|
||||
invalidSelectionFallback:
|
||||
$ref: '#/components/schemas/InvalidFallback'
|
||||
SurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surfaceKey
|
||||
- surfaceType
|
||||
- stateFields
|
||||
- hydrationRule
|
||||
- inspectContract
|
||||
- shareableStateKeys
|
||||
- localOnlyStateKeys
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
surfaceType:
|
||||
$ref: '#/components/schemas/SurfaceType'
|
||||
stateFields:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateFieldDescriptor'
|
||||
hydrationRule:
|
||||
$ref: '#/components/schemas/HydrationRule'
|
||||
inspectContract:
|
||||
$ref: '#/components/schemas/InspectContract'
|
||||
shareableStateKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
localOnlyStateKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
142
specs/198-monitoring-page-state/data-model.md
Normal file
142
specs/198-monitoring-page-state/data-model.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Data Model: Monitoring Page-State Contract
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entity, table, enum, or long-lived artifact. It standardizes the derived page-state contract of existing Filament pages so the same operator questions have the same answers across the monitoring family.
|
||||
|
||||
## Existing Source Truths Reused Without Change
|
||||
|
||||
The following truths remain authoritative and are not redefined by this feature:
|
||||
|
||||
- existing page routes and panel scope
|
||||
- existing workspace and tenant entitlement checks
|
||||
- existing page-local Livewire properties
|
||||
- existing request query parameters and navigation-context payloads
|
||||
- existing session-backed table filter, search, and sort persistence
|
||||
- existing compare-builder and matrix-rendering truth
|
||||
- existing approval, audit, and destructive-action safety behavior
|
||||
|
||||
This feature changes page-state semantics and documentation only.
|
||||
|
||||
## New Derived Planning Models
|
||||
|
||||
### MonitoringPageStateContract
|
||||
|
||||
**Type**: derived per-surface contract entry
|
||||
**Source**: explicit Spec 198 contract plus page-local declarations on the affected pages
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Stable identifier such as `operations` or `baseline_compare_matrix` |
|
||||
| `pageClass` | string | Concrete Filament page class that owns the state |
|
||||
| `surfaceType` | string | `simple_monitoring`, `selected_record_monitoring`, `draft_apply_analysis`, or `launch_context_support` |
|
||||
| `usesContextualPrefilter` | boolean | Whether external launch or query context can seed the page |
|
||||
| `usesDraftState` | boolean | True only when draft state is intentionally separate from active state |
|
||||
| `usesInspectState` | boolean | Whether the page owns a selected-record or focused inspect contract |
|
||||
| `shareableStateKeys` | array<string> | State keys intentionally restorable by reload, back, or shared link |
|
||||
| `localOnlyStateKeys` | array<string> | State keys that never restore from shared or reopened entry |
|
||||
| `invalidRequestedStateFallback` | string | Predictable fallback behavior when requested state is invalid or unauthorized |
|
||||
|
||||
### StateFieldDescriptor
|
||||
|
||||
**Type**: derived state-slice descriptor
|
||||
**Source**: explicit state-role mapping for a surface
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Links back to the owning surface |
|
||||
| `stateKey` | string | Stable field or semantic slice such as `activeTab`, `selectedAuditLogId`, or `draftSelectedPolicyTypes` |
|
||||
| `stateClass` | string | `contextual_prefilter`, `active`, `draft`, `inspect`, or `shareable_restorable` |
|
||||
| `carrier` | string | `query_param`, `session`, `livewire_property`, `route_context`, or `derived_render_state` |
|
||||
| `queryRole` | string | `initialization_only`, `durable_restorable`, `scoped_deeplink`, or `unsupported` |
|
||||
| `shareable` | boolean | Whether the state may appear in a shared or bookmarked URL |
|
||||
| `restorableOnRefresh` | boolean | Whether refresh or reopen recreates the state |
|
||||
| `tenantSensitive` | boolean | Whether tenant change must clear or recompute the state |
|
||||
| `invalidFallback` | string | What happens if the requested value is invalid or inaccessible |
|
||||
|
||||
### HydrationRule
|
||||
|
||||
**Type**: derived precedence rule
|
||||
**Source**: page mount logic plus the standardized Spec 198 precedence model
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | The owning surface |
|
||||
| `precedenceOrder` | array<string> | Ordered sources such as `query`, `session`, `default` |
|
||||
| `appliesOnInitialMountOnly` | boolean | Whether the rule applies only during initial hydration |
|
||||
| `activeStateBecomesAuthoritativeAfterMount` | boolean | True for all in-scope surfaces |
|
||||
| `clearsOnTenantSwitch` | array<string> | State keys that must be reset when tenant context changes |
|
||||
| `invalidRequestedStateFallback` | string | Surface-specific fallback behavior |
|
||||
|
||||
### InspectStateContract
|
||||
|
||||
**Type**: derived selected-record or focus contract
|
||||
**Source**: Audit Log, Finding Exceptions Queue, and Baseline Compare Matrix
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | The owning surface |
|
||||
| `primaryModel` | string | `selected_record_inline`, `selected_record_workbench`, `focused_subject`, or `none` |
|
||||
| `selectedStateKey` | string | Backing property such as `selectedAuditLogId` or `focusedSubjectKey` |
|
||||
| `openedBy` | array<string> | Supported entry modes such as `query_param`, `inspect_action`, or `cell_focus` |
|
||||
| `inlinePresentation` | string | `inline_detail`, `summary_panel`, `focused_matrix`, or `navigate_to_detail` |
|
||||
| `shareable` | boolean | Whether the selected state is intentionally bookmarkable or shareable |
|
||||
| `invalidSelectionFallback` | string | Required clear-state or fallback behavior |
|
||||
|
||||
### CompareMatrixStateSlice
|
||||
|
||||
**Type**: derived special-case state descriptor
|
||||
**Source**: Baseline Compare Matrix page-local compare logic
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `appliedFilterKeys` | array<string> | Filter and sort keys currently driving the rendered matrix |
|
||||
| `draftFilterKeys` | array<string> | Pending filter and sort keys that do not affect the rendered matrix until apply |
|
||||
| `presentationModeKey` | string | The current matrix presentation mode if present |
|
||||
| `focusKey` | string | Focused subject or cell key |
|
||||
| `shareableSlices` | array<string> | Applied filters, supported mode, and focus state only |
|
||||
| `localOnlySlices` | array<string> | Unapplied draft state |
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `MonitoringPageStateContract` owns many `StateFieldDescriptor` entries.
|
||||
- One `MonitoringPageStateContract` owns exactly one `HydrationRule`.
|
||||
- A surface that supports inspect state owns one `InspectStateContract`.
|
||||
- `BaselineCompareMatrix` owns one `CompareMatrixStateSlice` in addition to the standard surface contract.
|
||||
- `BaselineCompareLanding` participates as `launch_context_support` and seeds a separate surface rather than owning the downstream applied state.
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
### Shared rules
|
||||
|
||||
1. Supported query or deeplink input hydrates first on initial mount.
|
||||
2. Session participates only for state the page already persists intentionally.
|
||||
3. Invalid or unauthorized requested state is discarded or cleared predictably.
|
||||
4. After mount, active local state becomes authoritative.
|
||||
5. Any state not explicitly declared shareable or restorable is treated as local-only.
|
||||
|
||||
### Simple monitoring rules
|
||||
|
||||
- `operations` and `evidence_overview` follow the simple monitoring pattern: contextual prefilter plus active state, with no same-page selected-record inspect state.
|
||||
- Their shareable or restorable state is limited to explicitly supported filter, tab, and context fields.
|
||||
|
||||
### Selected-record monitoring rules
|
||||
|
||||
- `audit_log` and `finding_exceptions_queue` each have exactly one selected-record contract.
|
||||
- Inspect actions, selected summaries, and selected-record query parameters must all point to the same selected state.
|
||||
- If selection becomes invalid or filtered out, the surface returns to the unselected state without stale action or summary state.
|
||||
|
||||
### Compare matrix rules
|
||||
|
||||
- `baseline_compare_matrix` is the only in-scope surface with draft state.
|
||||
- Draft state never becomes shareable or restorable.
|
||||
- Applied compare state and supported focus state are the only durable slices.
|
||||
- `baseline_compare_landing` may seed matrix launch context but never competes with the matrix for applied state ownership.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- No derived contract may widen workspace or tenant visibility beyond existing route and policy semantics.
|
||||
- No selected-record or focus state may survive refresh, reopen, or filter change if the record or subject is invalid or unauthorized.
|
||||
- No surface may expose two competing primary inspect models for the same operator function.
|
||||
- No draft state may silently leak into shared URLs or refresh restoration unless the contract explicitly allows it.
|
||||
- No shell or global context concern may be solved inside this feature without an explicit handoff decision for Spec 199.
|
||||
279
specs/198-monitoring-page-state/plan.md
Normal file
279
specs/198-monitoring-page-state/plan.md
Normal file
@ -0,0 +1,279 @@
|
||||
# Implementation Plan: Monitoring Page-State Contract
|
||||
|
||||
**Branch**: `198-monitoring-page-state` | **Date**: 2026-04-15 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/198-monitoring-page-state/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/198-monitoring-page-state/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 page layer, current query and session helpers, and the current monitoring pages. It explicitly avoids a new global page-state framework, new persistence, or a shell-context refactor.
|
||||
|
||||
## Summary
|
||||
|
||||
Codify one bounded monitoring page-state contract for Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix, with Baseline Compare Landing remaining the compare launch-context support surface. Reuse existing Livewire page properties, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, existing session-backed table persistence, and the current compare draft/apply pattern to make contextual prefilter state, active state, inspect state, draft state, and shareable or restorable state explicit and consistent without adding a new runtime state engine.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages
|
||||
**Storage**: PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned
|
||||
**Testing**: Pest feature tests, Filament or Livewire page tests, existing table-state persistence tests, and focused Pest browser smoke tests run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application under `apps/platform`, with canonical monitoring routes under `/admin` and tenant-bound compare routes under `/admin/t/{tenant}` or active tenant context
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Keep monitoring pages DB-only at render time, preserve current session-backed table persistence, avoid new outbound HTTP or queued work during state hydration, avoid N+1 query regressions, and keep first-mount state hydration deterministic and cheap
|
||||
**Constraints**: No new global page-state framework, no new persistence, no panel or route-family changes, no shell or context refactor that belongs in Spec 199, no authorization-plane changes, no new Graph calls, no new badge taxonomy, and no forced flattening of Baseline Compare Matrix into a generic table contract
|
||||
**Scale/Scope**: 5 primary in-scope page-state surfaces, 1 compare launch-context support surface, 6 page classes, 7 existing focused test files, likely 1 new cross-surface contract test, and 1 focused browser smoke suite or extension of existing smoke 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 feature does not alter inventory, snapshot, or backup truth. It standardizes page-state semantics only. |
|
||||
| Read/write separation | PASS | PASS | Existing writes such as exception approval or rejection keep their current confirmation, audit, and authorization behavior. No new write workflow is introduced. |
|
||||
| Graph contract path | N/A | N/A | No Microsoft Graph calls or contract-registry changes are planned. |
|
||||
| Deterministic capabilities | PASS | PASS | Capability checks stay in the existing registries and page actions. The work does not add new raw capability strings or new auth planes. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Requested tenant filters, selected-record deeplinks, and compare focus state remain subject to existing workspace and tenant entitlement checks. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, members without capability remain `403`, and server-side authorization remains authoritative for mutations and restricted drilldowns. |
|
||||
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` is introduced. Monitoring pages remain DB-only at render, and no start surface behavior is changed. |
|
||||
| Data minimization | PASS | PASS | No new persistence, caches, or derived artifacts are introduced. The contract remains derived from existing page and query state. |
|
||||
| Proportionality / anti-bloat | PASS WITH JUSTIFIED TAXONOMY | PASS WITH JUSTIFIED TAXONOMY | The feature introduces a bounded page-state taxonomy because five primary monitoring surfaces plus the compare launch surface already need the same explicit contract. It avoids a runtime framework or registry unless implementation proves one tiny helper is truly unavoidable. |
|
||||
| UI semantics / few layers | PASS | PASS | The design uses direct page-state declarations and existing helpers, not a presenter or explanation framework. |
|
||||
| Filament-native UI | PASS | PASS | Existing Filament pages, tables, actions, and Blade views remain the implementation path. No custom status system or new UI framework is needed. |
|
||||
| Shell boundary / Spec 199 separation | PASS | PASS | Global workspace or tenant shell concerns remain out of scope. Any discovered shell drift is documented for Spec 199 instead of solved here. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain on Filament v5 and Livewire v4 page patterns. |
|
||||
| Provider registration location | PASS | PASS | No panel or provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No globally searchable resource is added or modified. Existing search behavior remains unchanged. |
|
||||
| Destructive action safety | PASS | PASS | Existing destructive-like governance actions in Finding Exceptions Queue keep `->requiresConfirmation()` and current authorization and audit semantics. |
|
||||
| Asset strategy | PASS | PASS | No new global or lazy-loaded assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The implementation stays on Filament v5 + Livewire v4 page, table, and action APIs. No legacy Livewire or Filament APIs are introduced.
|
||||
- **Provider registration location**: No provider or panel registration changes are planned. Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||
- **Global search**: No in-scope page is a globally searchable resource surface, and this plan does not change global search visibility for any existing resource.
|
||||
- **Destructive actions**: `Approve exception` and `Reject exception` remain the only destructive-like actions directly affected by state-contract cleanup. They continue to execute via `Action::make(...)->action(...)` with `->requiresConfirmation()`, existing server-side authorization, and existing audit behavior. Operations, Audit Log, Evidence Overview, and Baseline Compare Matrix do not gain new destructive actions.
|
||||
- **Asset strategy**: No new assets or build steps are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- **Testing plan**: Extend the current Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, Baseline Compare Matrix, `ActionSurfaceRbacSemanticsTest`, `BaselineCompareMatrixAuthorizationTest`, and table-persistence suites, and add one narrow cross-surface contract test plus focused browser smoke coverage for refresh, back, deeplink, share behavior, and unauthorized requested-state fallback.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/198-monitoring-page-state/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, existing session-backed table persistence, and page-local Livewire state instead of introducing a global page-state framework.
|
||||
- Make state precedence explicit: supported query or deeplink input hydrates first on mount, session provides baseline only for explicitly persisted filter/search/sort state, and active local state becomes authoritative after hydration.
|
||||
- Unify selected-record inspect on Audit Log and Finding Exceptions Queue so action-based inspect and deeplinked selected IDs express the same inspect state.
|
||||
- Keep Baseline Compare Matrix as the only explicit draft/apply special case, with draft state remaining local-only and unapplied draft state never being shareable or restorable.
|
||||
- Treat Baseline Compare Landing as a launch-context broker for matrix state, not as a competing owner of compare state.
|
||||
- Extend existing Pest and Livewire test seams rather than leaning on browser-only validation or adding a new guard framework.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/198-monitoring-page-state/`:
|
||||
|
||||
- `research.md`: page-state decisions, rationale, and rejected alternatives
|
||||
- `data-model.md`: derived page-state contract model, state-field descriptors, hydration rules, and inspect-state descriptors
|
||||
- `contracts/monitoring-page-state.logical.openapi.yaml`: internal logical contract for the bounded page-state model and per-surface expectations
|
||||
- `quickstart.md`: implementation and verification workflow for Spec 198
|
||||
|
||||
Design highlights:
|
||||
|
||||
- Keep the contract derived and page-local. No new persisted truth or generalized runtime registry is planned.
|
||||
- Reuse existing helpers for tenant-sensitive session state and navigation context rather than adding a second helper layer.
|
||||
- Make the role of each state field explicit per surface: contextual prefilter, active, draft, inspect, and shareable/restorable.
|
||||
- Keep Operations and Evidence Overview in the simple query or session plus active-state pattern, keep Audit Log and Finding Exceptions Queue in the unified selected-record inspect pattern, and keep Baseline Compare Matrix in the explicit draft/applied/focus special-case pattern.
|
||||
- Add cross-surface regression protection through focused feature tests instead of a new runtime state validator.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
Planned command:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
This feature does not introduce a new language or framework, but the required agent-context refresh still runs after the design artifacts are complete.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/198-monitoring-page-state/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── monitoring-page-state.logical.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ └── Pages/
|
||||
│ │ ├── Monitoring/
|
||||
│ │ │ ├── Operations.php # MODIFY
|
||||
│ │ │ ├── AuditLog.php # MODIFY
|
||||
│ │ │ ├── FindingExceptionsQueue.php # MODIFY
|
||||
│ │ │ └── EvidenceOverview.php # MODIFY
|
||||
│ │ ├── BaselineCompareLanding.php # MODIFY
|
||||
│ │ └── BaselineCompareMatrix.php # MODIFY
|
||||
│ └── Support/
|
||||
│ ├── Filament/
|
||||
│ │ └── CanonicalAdminTenantFilterState.php # REUSE / possible small extend
|
||||
│ ├── Navigation/
|
||||
│ │ └── CanonicalNavigationContext.php # REUSE / possible small extend
|
||||
│ └── OperateHub/
|
||||
│ └── OperateHubShell.php # REUSE
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── filament/
|
||||
│ └── pages/
|
||||
│ ├── monitoring/
|
||||
│ │ ├── operations.blade.php # MODIFY
|
||||
│ │ ├── audit-log.blade.php # MODIFY
|
||||
│ │ ├── partials/
|
||||
│ │ │ └── audit-log-inspect-event.blade.php # MODIFY
|
||||
│ │ ├── finding-exceptions-queue.blade.php # MODIFY
|
||||
│ │ └── evidence-overview.blade.php # MODIFY
|
||||
│ ├── baseline-compare-landing.blade.php # MODIFY
|
||||
│ └── baseline-compare-matrix.blade.php # MODIFY
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Monitoring/
|
||||
│ │ ├── OperationsDashboardDrillthroughTest.php # MODIFY
|
||||
│ │ ├── AuditLogInspectFlowTest.php # MODIFY
|
||||
│ │ ├── FindingExceptionsQueueHierarchyTest.php # MODIFY
|
||||
│ │ └── MonitoringPageStateContractTest.php # NEW
|
||||
│ ├── Evidence/
|
||||
│ │ └── EvidenceOverviewPageTest.php # MODIFY
|
||||
│ ├── Filament/
|
||||
│ │ ├── BaselineCompareMatrixPageTest.php # MODIFY
|
||||
│ │ ├── BaselineCompareLandingStartSurfaceTest.php # MODIFY
|
||||
│ │ └── TableStatePersistenceTest.php # MODIFY
|
||||
│ └── Rbac/
|
||||
│ ├── BaselineCompareMatrixAuthorizationTest.php # REUSE / possible extend
|
||||
│ └── ActionSurfaceRbacSemanticsTest.php # REUSE / possible extend
|
||||
└── Browser/
|
||||
├── Spec190BaselineCompareMatrixSmokeTest.php # REUSE / possible extend
|
||||
├── Spec194GovernanceFrictionSmokeTest.php # REUSE / possible extend
|
||||
└── Spec198MonitoringPageStateSmokeTest.php # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Modify the affected page classes, their existing Blade views, and focused test suites. Reuse current support helpers and extract a new support helper only if repeated implementation proves that at least three pages share the same normalization code in a way that cannot stay page-local.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Cross-surface monitoring page-state taxonomy and per-surface contract documentation (BLOAT-001 trigger) | Five primary monitoring surfaces plus the compare launch surface already have overlapping but divergent page-state behavior, and the product needs explicit hydration, inspect, and restore rules now. | Pure page-by-page local cleanup would reduce individual bugs but would not produce a shared operator contract or durable regression coverage across the monitoring family. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Similar monitoring pages currently give different answers about what a deeplink sets, what refresh restores, when a selected record is authoritative, and whether draft state exists.
|
||||
- **Existing structure is insufficient because**: The current page-local implementations contain the behavior, but not a consistent or documented contract. Without an explicit shared model, the same drift will continue page by page.
|
||||
- **Narrowest correct implementation**: Reuse current helpers and page-local Livewire state, add explicit per-surface state declarations and targeted tests, and keep Compare Matrix as one documented special case. Do not add persistence or a global runtime framework.
|
||||
- **Ownership cost created**: Reviewers must maintain one bounded page-state taxonomy, a small set of per-surface declarations, and focused regression coverage for deeplink, inspect, and restoration behavior.
|
||||
- **Alternative intentionally rejected**: A global page-state engine or shell-level state resolver was rejected because the affected surfaces can be standardized with existing helpers and local state.
|
||||
- **Release truth**: current-release operator predictability and monitoring-family consistency
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Declare the bounded page-state contract per surface
|
||||
|
||||
**Goal**: Make the state classes, query roles, and shareable or restorable subset explicit without adding a global framework.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `AuditLog.php`, `FindingExceptionsQueue.php`, `EvidenceOverview.php`, `BaselineCompareMatrix.php`, `BaselineCompareLanding.php` | Add or align explicit page-local contract declarations for contextual prefilter, active, draft, inspect, and shareable or restorable state, plus invalid-state fallback behavior |
|
||||
| A.2 | Existing page-local mount and hydrate methods | Make first-mount precedence explicit between query input, session state, and page-local defaults |
|
||||
| A.3 | `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php` | Add one cross-surface contract test that asserts every in-scope page exposes the expected state classes, query roles, shareable or restorable semantics, and Audit Log/Finding Exceptions inspect-vocabulary compatibility |
|
||||
|
||||
### Phase B — Standardize the simple monitoring pattern on Operations and Evidence Overview
|
||||
|
||||
**Goal**: Align the surfaces that should behave like query or session plus active-state pages, not special-case workbenches.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Filament/Pages/Monitoring/Operations.php` | Make requested dashboard prefilter, requested tenant scope, active tab, and persisted table state follow one deterministic precedence rule |
|
||||
| B.2 | `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` | Align query hydration, session persistence, clear-filter behavior, and shareable filter semantics with the shared simple monitoring contract |
|
||||
| B.3 | `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`, and `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php` | Cover query-first hydration, tenant-sensitive reset behavior, active tab restoration, persistence boundaries, and unauthorized requested-tenant fallback |
|
||||
| B.4 | `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` | Extend multi-filter hydration, clear-filter, and refresh or reopen scenarios |
|
||||
|
||||
### Phase C — Unify inspect-state semantics on Audit Log and Finding Exceptions Queue
|
||||
|
||||
**Goal**: Ensure action-based inspect and deeplinked selected-record entry use one primary inspect model on each surface.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php` and `apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php` | Make `selectedAuditLogId` the only inspect contract, with consistent open, close, refresh, and invalid-selection fallback behavior |
|
||||
| C.2 | `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` and `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php` | Make `selectedFindingExceptionId` the only inspect and decision state, with summary and action lanes deriving from that single selection |
|
||||
| C.3 | `apps/platform/resources/views/filament/pages/monitoring/partials/audit-log-inspect-event.blade.php` | Align inline inspect rendering with the single selected-event contract |
|
||||
| C.4 | `apps/platform/tests/Feature/Monitoring/AuditLogInspectFlowTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`, and `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php` | Add positive and negative cases for query-selected inspect, invalid or unauthorized IDs, close-detail behavior, refresh behavior, selection clearing when filters no longer match, and compatible selected-record vocabulary across both surfaces |
|
||||
|
||||
### Phase D — Preserve the Compare Matrix special case while making it explicit
|
||||
|
||||
**Goal**: Keep Baseline Compare Matrix powerful while making draft, applied, focus, and launch-context semantics predictable.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php` | Treat landing state as launch context only, and make matrix launch parameters explicit and non-competing |
|
||||
| D.2 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | Make applied filter state, draft filter state, presentation mode, focus state, and shareable slices explicit; keep draft state local-only until apply |
|
||||
| D.3 | `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` | Extend draft-versus-applied, focus restoration, refresh behavior, and non-shareable draft discard coverage |
|
||||
| D.4 | `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` and `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` | Cover launch-context hydration, focus handoff, and invalid or unauthorized requested compare context |
|
||||
|
||||
### Phase E — Browser verification and closure documentation
|
||||
|
||||
**Goal**: Prove the contract through UI-level flows and leave a clear handoff boundary for shell-context concerns.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php` | Add a focused browser smoke suite for deeplink, refresh, back, close-detail, and share behavior across the in-scope surfaces |
|
||||
| E.2 | Existing browser suites | Reuse or extend `Spec190BaselineCompareMatrixSmokeTest.php` and `Spec194GovernanceFrictionSmokeTest.php` where they already cover relevant state flows |
|
||||
| E.3 | `specs/198-monitoring-page-state/quickstart.md` and final implementation notes | Record the final state-class mapping, shareable/restorable subset, and any shell or context handoff that remains for Spec 199 |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — Reuse current helpers and keep the contract page-local
|
||||
|
||||
`CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, and current page-local Livewire properties already cover most of the needed mechanics. The narrowest implementation is to make the contract explicit on the pages that already own the state.
|
||||
|
||||
### D-002 — Query or deeplink input wins on first hydration, session only persists the state the page already owns
|
||||
|
||||
The consistent precedence rule is: supported query or deeplink input hydrates first, session supplies only explicitly persisted table filter, search, or sort state, and page-local active state becomes authoritative after mount.
|
||||
|
||||
### D-003 — Selected-record inspect is the authoritative inspect model on Audit Log and Finding Exceptions Queue
|
||||
|
||||
The inspect action, the selected summary, and the deeplinked selected ID must all express the same state. There must be no second inspect world beside the selected-record contract.
|
||||
|
||||
### D-004 — Baseline Compare Matrix keeps explicit draft, applied, and focus slices
|
||||
|
||||
Compare Matrix already has a genuine draft/apply interaction model. The right move is to document and tighten it, not to flatten it into a direct-active table pattern.
|
||||
|
||||
### D-005 — Regression protection belongs in focused tests, not a new runtime state engine
|
||||
|
||||
The contract is enforceable through page-local declarations and focused Pest coverage. That keeps the implementation surface small and avoids importing a new state registry or framework.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Query and session precedence regressions change what first mount restores | High | Medium | Make precedence explicit per page, extend query-param and table-persistence tests, and cover tenant-sensitive filter resets |
|
||||
| Invalid deeplinks leave phantom selected state or stale action lanes | High | Medium | Add invalid-ID fallback rules and test them on Audit Log, Finding Exceptions Queue, and Baseline Compare launch context |
|
||||
| Compare Matrix accidentally persists or shares draft state | High | Medium | Keep draft and applied state separate in code and tests, and assert that shared links restore only applied and focused state |
|
||||
| Shell-context concerns leak into Spec 198 and widen scope | Medium | Medium | Keep workspace or tenant shell concerns documented only as handoff items for Spec 199 |
|
||||
| Implementation drifts into a generic page-state framework | Medium | Medium | Keep helpers page-local by default and extract only a tiny shared helper if duplication across multiple pages proves it necessary during implementation |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, Baseline Compare Matrix, and `TableStatePersistenceTest` with deterministic query, session, refresh, back, and restore coverage.
|
||||
- Add one focused cross-surface contract test for the explicit page-state declarations on the in-scope pages.
|
||||
- Reuse current RBAC suites where requested state can become unauthorized or cross-tenant.
|
||||
- Add one browser smoke suite for the top user journeys: Operations deeplink and refresh, Audit Log selected-event open and close, Finding Exceptions selected-exception review state, Evidence Overview filter clear behavior, and Baseline Compare draft/apply plus focus restoration.
|
||||
- Run focused verification through Sail and format only touched files with Pint.
|
||||
117
specs/198-monitoring-page-state/quickstart.md
Normal file
117
specs/198-monitoring-page-state/quickstart.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Quickstart: Monitoring Page-State Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the in-scope monitoring and governance pages under one bounded page-state contract so operators can predict how deeplinks, tabs, filters, selected-record detail, draft/apply behavior, refresh, back, and shared links behave.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Declare the contract on the in-scope pages.
|
||||
- Make contextual prefilter, active, draft, inspect, and shareable/restorable state explicit on Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix.
|
||||
- Define the invalid-state fallback for each page.
|
||||
|
||||
2. Standardize the simple monitoring pages.
|
||||
- Align Operations query/session precedence for requested dashboard context, tenant prefilter, active tab, and persisted table state.
|
||||
- Align Evidence Overview query hydration, filter clearing, and restorable filter behavior.
|
||||
|
||||
3. Unify the selected-record inspect pages.
|
||||
- Ensure Audit Log uses one selected-event inspect model for action-based inspect, deeplink entry, close detail, and refresh.
|
||||
- Ensure Finding Exceptions Queue uses one selected-exception workbench model for inspect, summary, decision actions, close detail, and refresh.
|
||||
|
||||
4. Keep Compare Matrix explicitly special.
|
||||
- Treat Baseline Compare Landing as launch context only.
|
||||
- Keep Baseline Compare Matrix split into draft state, applied state, and focus state.
|
||||
- Ensure unapplied draft state never becomes shareable or restorable.
|
||||
|
||||
5. Add regression protection.
|
||||
- Add one narrow cross-surface contract test.
|
||||
- Extend the existing Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, Baseline Compare Matrix, and table-persistence suites.
|
||||
- Add one focused browser smoke suite for state restoration and navigation behavior.
|
||||
|
||||
## Suggested Source Files
|
||||
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||
- `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php`
|
||||
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/operations.blade.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/partials/audit-log-inspect-event.blade.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`
|
||||
- `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
- `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||
|
||||
## Suggested Test Files
|
||||
|
||||
- `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/AuditLogInspectFlowTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`
|
||||
- `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`
|
||||
- `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
|
||||
- `apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php`
|
||||
|
||||
## Minimum Verification Commands
|
||||
|
||||
Run all commands through Sail from `apps/platform`.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/MonitoringPageStateContractTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/AuditLogInspectFlowTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.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/Filament/TableStatePersistenceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec198MonitoringPageStateSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Acceptance Checklist
|
||||
|
||||
1. Open Operations directly and from a KPI or drillthrough link, change tabs and filters, and confirm refresh and reopen behavior matches the documented contract.
|
||||
2. Open Audit Log with and without a selected event and confirm inspect action, selected-event deeplink, close detail, and refresh all use the same selected-event model.
|
||||
3. Open Finding Exceptions Queue with and without a selected exception and confirm summary, decision actions, close detail, and refresh all use the same selected-exception model.
|
||||
4. Open Evidence Overview with prefilter state, clear filters, and confirm refresh or reopen behavior stays predictable and simple.
|
||||
5. Open Baseline Compare Landing and launch the matrix with subject context, then confirm the matrix owns applied compare state after hydration.
|
||||
6. On Baseline Compare Matrix, change draft filters without applying them, confirm applied results do not move, then apply and verify the new results and focused subject restore correctly.
|
||||
7. Use browser back on the in-scope pages and confirm selected detail, active filters, and shareable state match the documented restore or discard behavior.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No migration is expected.
|
||||
- No provider registration change is expected; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||
- No new asset registration is expected. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||
|
||||
## Final Implemented State Mapping
|
||||
|
||||
1. Operations restores the entitled `tenant_id`, the selected `activeTab`, and compatible table filters/search. It does not keep a competing same-page inspect state.
|
||||
2. Audit Log restores filters/search and the `event` query parameter only while the selected event is still visible and authorized; otherwise it falls back to the unselected history view.
|
||||
3. Finding Exceptions Queue restores queue filters and the `exception` query parameter only while the selected request is still visible and authorized; otherwise it falls back to quiet monitoring mode.
|
||||
4. Evidence Overview keeps only the simple monitoring filter/search contract. Clearing filters redirects back to the canonical overview route without hidden residual state.
|
||||
5. Baseline Compare Landing is launch-context only. It can hand valid context into the matrix, but it never owns applied compare state.
|
||||
6. Baseline Compare Matrix restores applied compare filters, presentation mode, and focused subject through the URL. Unapplied draft filters remain local and are discarded on reopen or refresh.
|
||||
|
||||
## Verification Results
|
||||
|
||||
The contract was verified with the following passing commands run through Sail:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/MonitoringPageStateContractTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Monitoring/AuditLogInspectFlowTest.php tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php tests/Feature/Monitoring/FindingExceptionsQueueTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/TableStatePersistenceTest.php tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec198MonitoringPageStateSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php tests/Browser/Spec194GovernanceFrictionSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Spec 199 Boundary
|
||||
|
||||
This spec normalizes page-owned state only. Global shell behavior, remembered workspace or tenant context before page hydration, and any future cross-shell state contract remain a Spec 199 concern.
|
||||
74
specs/198-monitoring-page-state/research.md
Normal file
74
specs/198-monitoring-page-state/research.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Research: Monitoring Page-State Contract
|
||||
|
||||
## Decision: Reuse existing query, session, and navigation helpers instead of creating a global page-state framework
|
||||
|
||||
### Rationale
|
||||
|
||||
The affected pages already express most of the required behavior through page-local Livewire properties, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, existing table-state persistence, and explicit request hydration methods. The product problem is missing contract clarity, not missing infrastructure. The narrowest correct move is to make those rules explicit and consistent without introducing a second runtime state engine.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Create a shared monitoring page-state framework or registry: rejected because it would import more runtime structure than the five primary surfaces plus the compare launch surface need.
|
||||
- Keep all fixes page-local and undocumented: rejected because it would reduce isolated bugs but would not prevent future drift.
|
||||
|
||||
## Decision: Adopt one deterministic precedence rule for supported query, session, and local state
|
||||
|
||||
### Rationale
|
||||
|
||||
The existing surfaces already imply a common precedence model. Supported query or deeplink input is the only safe way to restore bookmarked or shared state, while session should continue to back only the filter, search, and sort state a page already owns. After mount, page-local active state should become authoritative so the page does not continue to behave as if query parameters are a second hidden state source.
|
||||
|
||||
The standardized rule is:
|
||||
|
||||
1. supported query or deeplink state hydrates first on mount
|
||||
2. session provides baseline only for explicitly persisted table filters, search, or sort state
|
||||
3. invalid or unauthorized requested state is dropped with a predictable fallback
|
||||
4. page-local active state becomes authoritative after hydration
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Session-first precedence: rejected because it makes shared links and dashboard drillthroughs unpredictable.
|
||||
- Query-only restoration for every state slice: rejected because several pages intentionally persist table state in session.
|
||||
|
||||
## Decision: Make selected-record inspect the only inspect model on Audit Log and Finding Exceptions Queue
|
||||
|
||||
### Rationale
|
||||
|
||||
Both surfaces already carry selected-record properties and query-driven selected IDs. The safest way to remove ambiguity is to ensure that action-based inspect, inline detail, summary or decision state, and selected-record deeplink entry all resolve to the same selected-record contract. This makes refresh, back, and close-detail behavior testable and predictable.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep action-based inspect and query-selected inline detail as parallel models: rejected because it preserves the current ambiguity.
|
||||
- Move inspect into modal-only flows: rejected because the current pages already use inline or workbench detail as the main operator model.
|
||||
|
||||
## Decision: Keep Baseline Compare Matrix as the only explicit draft or apply special case
|
||||
|
||||
### Rationale
|
||||
|
||||
Baseline Compare Matrix already maintains separate draft and applied state and exposes a real operator need for staged filter changes before recalculating visible results. The right move is to tighten and document that behavior, not to normalize it away. Draft state remains local-only until apply, while applied compare state and supported focus state remain the only shareable or restorable slices.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Force Compare Matrix into direct-active filtering like simpler monitoring pages: rejected because it would degrade operator control and page performance semantics.
|
||||
- Create a generic draft/apply engine for all monitoring pages: rejected because only one real surface needs that behavior now.
|
||||
|
||||
## Decision: Treat Baseline Compare Landing as launch context, not as a competing owner of compare state
|
||||
|
||||
### Rationale
|
||||
|
||||
Baseline Compare Landing already passes profile, subject, and navigation context into the matrix route. The contract should make that role explicit: landing provides launch context that seeds the matrix, but the matrix owns the applied compare state and focus state after hydration. This avoids splitting authoritative state across two pages.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Persist compare landing context independently from the matrix: rejected because it would create a second state owner for the same operator flow.
|
||||
- Ignore landing context and rebuild matrix state locally only: rejected because it would break deeplink and drillthrough continuity.
|
||||
|
||||
## Decision: Extend existing Pest and Livewire suites instead of relying on browser-only verification
|
||||
|
||||
### Rationale
|
||||
|
||||
The repository already has focused tests for Operations drillthrough, Audit Log inspect flow, Finding Exceptions Queue hierarchy, Evidence Overview page behavior, Baseline Compare Landing, Baseline Compare Matrix, and generic table persistence. These suites already touch the exact seams this spec needs. Adding one narrow cross-surface contract test plus a focused browser smoke suite gives strong coverage without turning the feature into a browser-heavy effort.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Browser-test every state permutation: rejected because it is expensive and duplicates what existing feature tests can assert more cheaply.
|
||||
- Document the contract without automation: rejected because the risk here is behavioral drift, not just documentation quality.
|
||||
331
specs/198-monitoring-page-state/spec.md
Normal file
331
specs/198-monitoring-page-state/spec.md
Normal file
@ -0,0 +1,331 @@
|
||||
# Feature Specification: Monitoring Page-State Contract
|
||||
|
||||
**Feature Branch**: `198-monitoring-page-state`
|
||||
**Created**: 2026-04-15
|
||||
**Status**: Implemented
|
||||
**Input**: User description: "Spec 198 — Monitoring Page-State Contract"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Monitoring and governance pages with tabs, filters, selected-record state, inspect panels, and deeplink entry currently behave like separate local state protocols instead of one product-level contract.
|
||||
- **Today's failure**: Operators can open similar monitoring pages and get different answers to the same questions: what a link restores, whether a selected record is the real inspect state, whether a tab or filter is durable, and what refresh, back, or share will recreate. This increases drift, regressions, and review time.
|
||||
- **User-visible improvement**: Operators learn one predictable model for how monitoring pages hydrate from links, keep active state, expose inspect state, and decide what is shareable or restorable.
|
||||
- **Smallest enterprise-capable version**: Map the existing state behavior of the five named in-scope surfaces, define one shared monitoring page-state contract with one explicit special-case rule for Compare Matrix, align those surfaces to the contract, add regression coverage, and document what remains deferred to the shell/context spec.
|
||||
- **Explicit non-goals**: No global shell/context refactor, no generic Filament purity cleanup, no shared detail family redesign, no visual unification for its own sake, no generic mega-state framework, and no forced reduction of Compare Matrix into a normal table contract.
|
||||
- **Permanent complexity imported**: A bounded monitoring page-state taxonomy, a per-surface contract matrix, explicit shareable/restorable-state rules, and focused regression coverage for deeplink, inspect, tab/mode, and draft/apply behavior.
|
||||
- **Why now**: The repo already has multiple important monitoring surfaces with divergent state behavior, and adjacent specs intentionally leave this page-state layer unresolved. Leaving the gap open will keep producing drift on future monitoring pages.
|
||||
- **Why not local**: Local page-by-page fixes would reduce symptoms on individual surfaces but would not define the shared operator contract for hydration, inspect, draft/apply, or restoration semantics across the monitoring family.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Cross-surface taxonomy risk and multi-surface remediation breadth. Defense: the scope is tightly bounded to named monitoring/governance pages, introduces no new persistence or runtime framework, and explicitly preserves one justified special-case surface.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/operations`
|
||||
- `/admin/audit-log`
|
||||
- `/admin/finding-exceptions/queue`
|
||||
- `/admin/evidence/overview`
|
||||
- Existing tenant-bound Baseline Compare landing route
|
||||
- Existing Baseline Compare Matrix route under the active baseline profile
|
||||
- **Data Ownership**:
|
||||
- Operations, Audit Log, Finding Exceptions Queue, and Evidence Overview remain workspace-facing monitoring or governance views over workspace-visible records with tenant-scoped drilldowns.
|
||||
- Baseline Compare Matrix remains a tenant-bound analysis surface over tenant-visible compare evidence and profile context.
|
||||
- This feature introduces no new tables, persisted entities, or storage truth. It standardizes page-state behavior on existing surfaces.
|
||||
- **RBAC**:
|
||||
- Existing workspace membership and workspace capability rules continue to govern Operations, Audit Log, Finding Exceptions Queue, and Evidence Overview.
|
||||
- Existing tenant membership and tenant capability rules continue to govern Baseline Compare routes and drilldowns.
|
||||
- State-contract changes do not weaken authorization: inaccessible routes or records remain deny-as-not-found for non-members and capability-gated for members.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Workspace monitoring pages may accept a tenant prefilter from dashboard entry, remembered scope, or query state, but that prefilter must remain explicit and reversible. Tenant-bound compare pages stay bound to the active tenant and baseline profile; they do not silently broaden scope.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Requested tenant filters, selected-record deeplinks, compare focus state, and related drilldowns must resolve through existing entitled-tenant and record authorization checks. If a requested tenant, event, exception, or compare subject is not accessible, the page must discard that state predictably and expose no cross-tenant hints.
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Operations | Primary Decision Surface | Decide which run class or tenant scope needs inspection first | Active tab, current tenant scope, requested dashboard context, run health summary | Operation detail, related run context, deeper diagnostics | Primary because it is the canonical monitoring landing for deciding what run needs attention next | Aligns KPI-card entry and direct monitoring workflow under one tab/filter model | Removes guesswork about whether dashboard entry state, local tab state, and persisted filters are competing truths |
|
||||
| Audit Log | Secondary Context Surface | Validate what happened after an operational or governance event | Active filters, whether an event is selected, concise actor/target summary | Full event body, related target navigation, evidence of what changed | Secondary because it explains and verifies decisions made elsewhere rather than being the first decision queue | Supports investigation after the operator already has a question | Removes ambiguity between action inspect, inline detail, and query-selected event state |
|
||||
| Finding Exceptions Queue | Primary Decision Surface | Review one queued exception request and decide whether to approve or reject it | Queue scope, selected exception state, decision readiness, summary of what is under review | Full exception evidence, related finding detail, tenant context | Primary because it is the queue where governance work is actively decided and cleared | Aligns row selection, selected summary, and decision actions into one workbench flow | Prevents the queue from behaving like two inspect systems glued together |
|
||||
| Evidence Overview | Primary Decision Surface | Decide whether evidence is current enough and which tenant needs follow-up | Current filters, evidence freshness, default-visible next-step signals | Snapshot detail after drilldown | Primary because it is the monitoring overview that tells operators where evidence is missing or stale | Aligns direct page use and prefiltered entry under one simple monitoring contract | Keeps the surface simple without a one-off hidden prefilter protocol |
|
||||
| Baseline Compare Landing | Secondary Context Surface | Confirm which tenant/profile context should open the matrix next | Active tenant, baseline profile, launch context, and compare readiness cues | Matrix state, compare evidence, and downstream drilldowns after handoff | Secondary because it frames the compare launch and hands off to the matrix instead of owning the compare result state | Aligns tenant-bound entry and matrix launch without creating a second compare owner | Keeps launch context explicit so the matrix does not inherit hidden state |
|
||||
| Baseline Compare Matrix | Primary Decision Surface | Decide what the current compare result means for one profile and subject | Applied compare state, visible distinction between draft and applied filters, focused subject if present | Cell-level evidence, finding drilldowns, supporting compare context | Primary because the matrix itself is the focused compare decision surface | Aligns landing deeplinks, focused subject entry, and in-page refinement without flattening compare behavior | Reduces hidden state shifts between draft controls, applied results, and shared links |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | 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 / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations | List / Table / Monitoring | Monitoring landing | Open the next run that needs inspection | Full-row click opens the canonical operation detail surface | required | Header utility and context strip only | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, requested tenant prefilter, active tab | Operations / operation run | Whether the view is prefiltered and which run class is active | none |
|
||||
| Audit Log | List / Table / History | History with inline inspect | Inspect an event and verify what happened | Explicit inspect action or matching deeplink opens the same inline event detail state | forbidden | Page utilities and selected-event detail header | none | `/admin/audit-log` | Same page with selected event state | Workspace or tenant prefilter, active audit filters, selected event | Audit log / audit event | Which event is selected and what filters define the visible history | none |
|
||||
| Finding Exceptions Queue | Queue / Workbench / Review | Decision queue | Review the selected exception request and decide it | Explicit inspect or matching deeplink opens the same selected-exception workbench state | forbidden | Queue utility lane and related-links area | Selected-exception decision lane only | `/admin/finding-exceptions/queue` | Same page with selected exception state | Workspace scope, queue filters, selected exception | Finding exceptions queue / exception request | Whether a decision-ready exception is selected right now | none |
|
||||
| Evidence Overview | Report / Monitoring / Registry | Read-only monitoring overview | Open the most relevant evidence snapshot | Full-row click opens the canonical evidence snapshot detail | required | Minimal page utility only | none | `/admin/evidence/overview` | Existing evidence snapshot detail route | Optional tenant prefilter and search state | Evidence overview / evidence snapshot | Evidence freshness and next step per tenant | none |
|
||||
| Baseline Compare Landing | Report / Monitoring / Launch context | Tenant-bound compare launch surface | Open the compare matrix with the current tenant/profile context | Explicit open-matrix action or deeplink handoff is the only launch model | forbidden | Context strip and page header only | none | Existing Baseline Compare landing route | Existing Baseline Compare Matrix route under the active baseline profile | Active tenant, baseline profile, and launch context | Baseline compare landing / compare launch context | Which tenant/profile context will be used when the matrix opens | allowed support-surface exception: initialization-only launch context, with applied compare state owned by the matrix |
|
||||
| Baseline Compare Matrix | Analysis / Focused Compare / Matrix | Tenant-bound compare analysis | Apply compare changes or drill into a focused subject | Focused subject/cell state on the current matrix page is the only primary inspect model | forbidden | Local matrix toolbar and downstream drilldown lane | none | Existing Baseline Compare landing route | Existing Baseline Compare Matrix route under the active baseline profile | Active tenant, baseline profile, applied compare state, focused subject | Baseline compare matrix / compare subject | Applied compare result for the current profile and subject | allowed special case for draft/applied state |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations | Workspace operator | Decide what run needs inspection next | Monitoring landing | Which run class needs attention, and am I looking at one tenant or all tenants? | KPI summary, active tab, current scope, filtered run list | Full run detail after drilldown, related context, deeper failure evidence | execution status, outcome, scope, freshness | read-only landing | Tab switch and open detail | none |
|
||||
| Audit Log | Workspace operator or auditor | Inspect one event and understand what changed | History with inline inspect | What happened, in which scope, and which event should I inspect? | History list, current filters, selected-event summary when open | Event body, related target navigation, full change evidence | actor, target type, outcome, scope, time | read-only history | Inspect event and close detail | none |
|
||||
| Finding Exceptions Queue | Workspace approver | Review one exception request and decide it | Decision queue | Is there a pending exception selected, and what decision is justified? | Queue filters, selected-exception summary, decision readiness | Full exception detail, related finding context, tenant drilldown | queue state, exception validity, selection state | `TenantPilot only` | Inspect exception, approve exception, reject exception | Approve exception and reject exception remain confirmation-gated |
|
||||
| Evidence Overview | Workspace operator | Decide whether evidence is usable and where to drill down | Monitoring overview | Which tenants lack current evidence and what should I inspect next? | Tenant rows, freshness, completeness, next-step cues | Downstream evidence snapshot detail | freshness, completeness, scope | read-only landing | Clear filters and open snapshot | none |
|
||||
| Baseline Compare Landing | Tenant operator | Confirm which compare context should open the matrix | Tenant-bound launch context | Which tenant/profile context am I about to compare, and what launch state is being handed off? | Active tenant, baseline profile, compare readiness, launch cues | Matrix-only evidence and focused compare analysis after handoff | tenant scope, profile readiness, launch context | read-only launch surface | Open compare matrix and return | none |
|
||||
| Baseline Compare Matrix | Tenant operator | Decide how one baseline profile compares across subjects | Focused compare analysis | What does the current compare result show, and is my visible matrix based on draft or applied state? | Applied compare state, focused subject, matrix result summary | Cell-level evidence and follow-up drilldowns | compare coverage, state, severity, focus | read-only analysis | Apply filters, reset filters, focus subject | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: yes
|
||||
- **New cross-domain UI framework/taxonomy?**: yes
|
||||
- **Current operator problem**: Similar monitoring pages currently hide different local rules for URL hydration, active state, inspect state, and restoration behavior, so operators cannot reliably predict how links, refresh, or selection will behave.
|
||||
- **Existing structure is insufficient because**: Surface-local cleanup can fix a symptom on one page, but it cannot define whether query state is initialization-only or durable, whether selected record is the real inspect contract, or whether draft/apply is a deliberate exception instead of a private implementation accident.
|
||||
- **Narrowest correct implementation**: Introduce only a bounded monitoring page-state taxonomy, a per-surface contract summary for the five primary monitoring surfaces plus Baseline Compare Landing as the compare launch-context support surface, and regression tests that enforce those rules. Do not add persistence, a runtime registry, or a generic global state framework.
|
||||
- **Ownership cost**: The repo gains a contract reviewers must maintain, per-surface documentation/tests for shareable and restorable state, and explicit exception discipline for Compare Matrix and future monitoring surfaces.
|
||||
- **Alternative intentionally rejected**: One-off local fixes were rejected because they would keep the operator mental model fragmented. A global shell state framework was rejected because it would import more complexity than the named surfaces require.
|
||||
- **Release truth**: current-release operator clarity and monitoring-state consistency
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Enter Operations Without Tab Drift (Priority: P1)
|
||||
|
||||
As a workspace operator, I want Operations to treat dashboard entry state, direct navigation, tabs, and filters as one coherent contract so I can predict what the page will show after navigation, refresh, or sharing.
|
||||
|
||||
**Why this priority**: Operations is the canonical monitoring landing. If its tab and prefilter behavior remains ambiguous, the monitoring family still lacks a reliable state model.
|
||||
|
||||
**Independent Test**: Open Operations directly and from a KPI/deeplink context, change tabs and filters, then verify that the documented active and shareable state behaves the same way after refresh and reopen.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator opens Operations from a KPI link with requested tenant or problem-class context, **When** the page hydrates, **Then** the documented initial tab and filter state becomes the active view without creating a second hidden local model.
|
||||
2. **Given** an operator changes tabs or filters on Operations, **When** they refresh or reopen a shared link, **Then** only the documented restorable state is recreated and no stale local-only state reappears.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Inspect Audit Events Through One Model (Priority: P1)
|
||||
|
||||
As an operator investigating history, I want action-based inspect and deeplinked event selection on Audit Log to resolve to one inspect model so I do not have to infer which event state is authoritative.
|
||||
|
||||
**Why this priority**: Audit Log already mixes history browsing and selected-event detail. If inspect state is not unified here, similar history surfaces will continue to drift.
|
||||
|
||||
**Independent Test**: Open Audit Log with and without a selected event, inspect an event via the visible affordance, then refresh and back-navigate to verify that selected-event behavior follows one documented contract.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** Audit Log is opened with a valid selected event entry state, **When** the page loads, **Then** the same inline detail state opens that would have opened from the normal inspect affordance.
|
||||
2. **Given** the selected event becomes unavailable or inaccessible, **When** the page reloads, **Then** Audit Log falls back predictably to the unselected history state without leaving a phantom detail contract behind.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Review Finding Exceptions Through One Workbench State (Priority: P1)
|
||||
|
||||
As a workspace approver, I want row inspection, selected summary, header actions, and deeplink entry on Finding Exceptions Queue to behave as one selected-exception workbench state so I can review and decide requests without ambiguity.
|
||||
|
||||
**Why this priority**: Finding Exceptions Queue is the clearest governance decision surface in scope. If it still behaves like parallel inspect systems, the spec misses its highest-value target.
|
||||
|
||||
**Independent Test**: Open the queue with and without a selected exception, inspect one request, and confirm that decision actions, summary content, and shareable selected state all track the same selected-exception contract.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid exception is selected by deeplink or normal page interaction, **When** the queue renders, **Then** the same selected-exception state drives the summary, decision affordances, and refresh behavior.
|
||||
2. **Given** no decision-ready exception is selected, **When** the queue renders, **Then** destructive-like decision actions are not promoted and the page returns to a calm queue state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Keep Compare Matrix Explicitly Special (Priority: P1)
|
||||
|
||||
As a tenant operator using Compare Matrix, I want draft filters, applied results, focused subject state, and shared links to be explicitly separated so the page stays powerful without becoming unpredictable.
|
||||
|
||||
**Why this priority**: Compare Matrix is the intentional special case in scope. If its exception is not explicit, the rest of the contract will either over-normalize it or leave it undocumented.
|
||||
|
||||
**Independent Test**: Change draft filters without applying them, apply the new state, focus a subject, and validate what refresh, back, and a shared link restore.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** draft compare controls differ from the applied matrix state, **When** the operator has not applied them yet, **Then** visible results remain tied to the applied state and the draft remains clearly local and pending.
|
||||
2. **Given** a focused subject is part of the documented shareable state, **When** the matrix is reopened from a link, **Then** the same applied compare state and focused subject are restored without restoring abandoned draft state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Preserve Simple Monitoring Pages Without One-Off Protocols (Priority: P2)
|
||||
|
||||
As a product reviewer, I want Evidence Overview and other simple monitoring pages to use the shared monitoring contract without unnecessary local exceptions so the product family feels consistent.
|
||||
|
||||
**Why this priority**: Simpler pages must benefit from the contract too; otherwise future monitoring pages will keep inventing unnecessary prefilter and restore behavior.
|
||||
|
||||
**Independent Test**: Apply and clear Evidence Overview filters, drill down into detail, and verify that refresh and shared-link behavior follow the documented simple monitoring pattern.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** Evidence Overview is entered with a tenant prefilter or search state, **When** the page loads, **Then** the page clearly reflects whether that state is active and shareable.
|
||||
2. **Given** filters are cleared on Evidence Overview, **When** the page refreshes or is reopened, **Then** the cleared or retained state matches the documented contract and no hidden second-layer filter state returns.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A deeplink requests a tenant, audit event, exception, baseline profile, or focused subject that is no longer accessible.
|
||||
- Session-persisted filter state and query-provided entry state disagree on first load.
|
||||
- A user refreshes a page after changing local state that is intentionally not shareable or restorable.
|
||||
- An operator uses browser back after clearing filters or closing inline inspect state.
|
||||
- Compare Matrix has unapplied draft values when the page is refreshed, shared, or left and reopened.
|
||||
- A selected audit event or exception disappears because the current filters no longer include it.
|
||||
- A monitoring page is opened without tenant context, with remembered tenant context, and with explicit tenant-prefilter entry, and each path must stay distinguishable.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new persistence, and no new long-running or scheduled work. It standardizes operator-facing page-state semantics only. Existing destructive or approval mutations on in-scope surfaces retain their current confirmation, audit, and authorization obligations.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one bounded semantic taxonomy for monitoring page state. It does not create new storage truth or a runtime framework. The proportionality review above explains why one-off local cleanup is insufficient and why a broader global state layer is intentionally rejected.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` behavior remains unchanged. This spec does not introduce or rename run types, does not change service-owned run status transitions, and does not alter toast, progress-surface, or terminal-notification rules.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature spans workspace monitoring surfaces on `/admin` and tenant-bound compare surfaces under tenant context. Authorization behavior does not change: non-members remain deny-as-not-found, members still require existing capabilities for any mutation or restricted drilldown, and destructive-like actions keep confirmation. At least one positive and one negative authorization regression must confirm that state-contract cleanup does not loosen access.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Monitoring and operations pages in scope do not gain synchronous authentication-handshake behavior.
|
||||
|
||||
**Constitution alignment (BADGE-001):** The feature does not add a new badge language. Existing status, outcome, severity, and readiness badges remain centrally defined and must not be remapped ad hoc as part of the state-contract work.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature must use native Filament pages, tables, actions, form state, and existing shared page primitives already present in the repo. It must avoid introducing page-local badge, button, or status frameworks. The approved exception is that Compare Matrix may keep richer local analysis controls because the contract is standardizing state semantics, not flattening the surface into generic Filament tables.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator vocabulary must stay domain-first and consistent across buttons, headers, notifications, and audit prose. Canonical terms include `Operations`, `Audit log`, `Finding exceptions queue`, `Evidence overview`, `Baseline compare matrix`, `Inspect event`, `Approve exception`, `Reject exception`, `Apply filters`, `Reset filters`, and `Show all tenants`. Implementation-first state labels must not leak into primary operator copy.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** This spec classifies Operations, Finding Exceptions Queue, Evidence Overview, and Baseline Compare Matrix as primary decision surfaces and Audit Log as a secondary context surface. The tables above define the human-in-the-loop moment, what must be immediately visible, what stays on demand, and how the default experience becomes calmer and more predictable.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Each in-scope surface has one broad action-surface class, one detailed surface type, one primary inspect/open model, explicit row-click rules, explicit secondary and destructive action placement, canonical routes, scope signals, and critical default-visible truth. Compare Matrix is the only explicit special-case exception and is justified by draft/applied analysis semantics rather than convenience.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Navigation, context signals, mutation, and selection-bound work must remain separated on the affected pages. Queue decisions remain in the selected-object action lane, navigation and filter resets remain secondary, and no mixed catch-all action group should hide the contract.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Scope, active state, and the next meaningful inspect or review action must be visible without opening raw diagnostic detail. When a mutating action exists, its mutation scope stays explicit and its safety behavior remains unchanged.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature does not add a presenter or explanation layer. It documents and aligns direct page-state behavior on real surfaces. Tests must validate user-visible consequences such as restored filters, selected inspect state, and draft/apply separation rather than thin indirection alone.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied when each in-scope surface exposes exactly one primary inspect/open model, keeps redundant peer inspect actions out of the header, avoids empty `ActionGroup` or `BulkActionGroup` placeholders, and places destructive actions only where the chosen surface type permits them. Compare Matrix remains a documented exception because its primary interaction is focused in-page analysis rather than row-level list inspection.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes page-state and interaction semantics, not form or infolist architecture. Existing tables and reports must keep meaningful empty states, filter/search affordances, and calm layout discipline while the contract becomes more predictable.
|
||||
|
||||
### Monitoring Page-State Taxonomy
|
||||
|
||||
- **Contextual Prefilter State**: State passed into a page from outside the page itself, such as requested tenant scope, requested tab, requested problem class, requested audit event, requested exception, or compare launch context.
|
||||
- **Active Page State**: The current operative state of the page, such as active tab, active filters, applied compare state, presentation mode, or the current focused subject.
|
||||
- **Draft Page State**: Local but not yet applied changes that intentionally differ from the active state. This is allowed only where the surface explicitly supports staged changes.
|
||||
- **Inspect State**: The current selected record or focused subject that drives inline detail, decision controls, or focused analysis.
|
||||
- **Shareable/Restorable State**: The subset of state the product intentionally recreates by reload, browser navigation, bookmark, or shared link.
|
||||
|
||||
### Per-Surface Contract Summary
|
||||
|
||||
| Surface | Contextual Prefilter State | Active Page State | Draft Page State | Inspect State | Shareable/Restorable State |
|
||||
|---|---|---|---|---|---|
|
||||
| Operations | Requested tenant scope, requested dashboard prefilter, requested navigation context | Active tab, active filters/search, visible run list scope | none | Inspect happens by leaving the page for canonical run detail, not by a second same-page inspect model | Supported tenant prefilter, active tab, compatible filter state, and any explicitly supported navigation context |
|
||||
| Audit Log | Requested tenant context, requested navigation context, requested event id | Active filters/search and selected event when present | none | `selected event` is the only inline inspect model | Supported filters plus selected event when valid and accessible |
|
||||
| Finding Exceptions Queue | Requested exception id and any allowed entry context | Active filters plus selected exception workbench state | none | `selected exception` is the only inspect and review model | Supported queue filters plus selected exception when valid and accessible |
|
||||
| Evidence Overview | Requested tenant filter and search state | Active filters/search and computed overview rows | none | Inspect happens by opening snapshot detail, not by keeping a second same-page selected row state | Supported tenant/search filters only |
|
||||
| Baseline Compare Landing | Active tenant, baseline profile, requested launch context, navigation context | Visible launch parameters and compare-readiness cues on the landing page | none | Inspect happens by opening the matrix, not by a second same-page compare model | Supported launch context only when valid and accessible |
|
||||
| Baseline Compare Matrix | Baseline profile launch context, requested focus subject, navigation context, requested compare state from link | Applied compare filters, presentation mode, focused subject, visible matrix results | Pending filter or sort changes before apply | Focused subject or cell state on the matrix page | Applied compare state and documented focus state; unapplied draft state is never shareable |
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-198-001 Surface inventory**: The repo MUST maintain one explicit page-state contract for Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix.
|
||||
- **FR-198-002 State-class declaration**: Each in-scope surface MUST declare which of the five state classes it uses.
|
||||
- **FR-198-003 Query-role declaration**: Every state that can be provided by route or query input MUST be classified as initialization-only, durable/restorable, scoped deeplink state, or unsupported.
|
||||
- **FR-198-004 Deterministic hydration**: Entry state from a deeplink, dashboard card, or upstream page MUST hydrate into page state by deterministic precedence rules when remembered or session state also exists.
|
||||
- **FR-198-005 No competing primary models**: No in-scope surface MAY keep two competing primary models for the same operator function.
|
||||
- **FR-198-006 Shared monitoring pattern**: Surfaces with comparable interaction shape MUST use compatible inspect, filter, and restoration semantics unless an explicit justified exception is documented.
|
||||
- **FR-198-007 Operations tab contract**: Operations MUST use one canonical tab and prefilter contract across direct navigation, KPI entry, refresh, back, and share behavior.
|
||||
- **FR-198-008 Operations entry-state continuity**: Requested tenant or problem-class state on Operations MUST either become the active state or be explicitly discarded; it may not silently drift into an undocumented local-only model.
|
||||
- **FR-198-009 Audit Log inspect contract**: Audit Log MUST route explicit inspect and `event` deeplink entry through the same selected-event source of truth for open, close, refresh, restoration, and authorization fallback behavior.
|
||||
- **FR-198-010 Finding Exceptions inspect contract**: Finding Exceptions Queue MUST route row inspection, selected summary, header decision state, and `exception` deeplink entry through one selected-exception source of truth for review, close, refresh, restoration, and authorization fallback behavior.
|
||||
- **FR-198-011 Inspect-state hierarchy**: Inline detail, summary, or sidebar views MAY coexist with action-triggered inspect only when they are subordinate presentations of the same selected-record inspect state rather than disconnected parallel models.
|
||||
- **FR-198-012 Inspect-state fallback**: If selected-record inspect state becomes invalid or inaccessible, the surface MUST fall back predictably to the unselected state and clear stale summary, decision, or action state.
|
||||
- **FR-198-013 Compatible inspect surfaces**: Audit Log and Finding Exceptions Queue MUST share the same or an explicitly compatible inspect vocabulary and operator behavior for selected record, open detail, close detail, refresh, and share behavior.
|
||||
- **FR-198-014 Filter-mode declaration**: Every in-scope surface MUST classify its filter behavior as directly active, draft-then-apply, or initial-prefilter-only.
|
||||
- **FR-198-015 Simple monitoring filter contract**: Evidence Overview and comparable simple monitoring pages MUST use the shared prefilter and active-filter contract rather than inventing a page-specific exception.
|
||||
- **FR-198-016 Reset, clear, and apply semantics**: Reset, clear, and apply actions MUST behave consistently with the documented shareable/restorable state for each surface.
|
||||
- **FR-198-017 Restorable-state declaration**: Each in-scope surface MUST explicitly define which subset of its page state is recreated by reload, browser back, bookmark, or shared link.
|
||||
- **FR-198-018 Non-shareable local state**: Any state intentionally kept local-only MUST be explicitly documented and MUST NOT reappear after the page is reopened from a link.
|
||||
- **FR-198-019 Compare Matrix special case**: Baseline Compare Matrix MAY remain a special-case monitoring surface, but it MUST explicitly model draft compare state, applied compare state, presentation mode, focus/deeplink state, and drilldown semantics.
|
||||
- **FR-198-020 Compare Matrix draft/apply separation**: Compare Matrix MUST visibly and behaviorally separate draft values from applied results.
|
||||
- **FR-198-021 Compare Matrix restoration**: Compare Matrix MUST document and test refresh, back, and shared-link behavior separately for applied state, focus state, and non-shareable draft state.
|
||||
- **FR-198-022 Surface-first implementation**: The implementation MUST remain surface-first and MUST NOT introduce a generic global page-state framework to satisfy this spec.
|
||||
- **FR-198-023 Shell boundary**: The page-state contract MUST NOT take ownership of workspace-shell or global context behavior. Unresolved shell/context concerns MUST be handed off to Spec 199.
|
||||
- **FR-198-024 Regression coverage**: Automated tests MUST cover deeplink entry, selected inspect restoration, tab or mode behavior, and filter clear/apply behavior for every in-scope surface.
|
||||
- **FR-198-025 Manual monitoring-family smoke checks**: Manual smoke checks MUST confirm that deeplink, refresh, back, and share behavior feels compatible across the in-scope surfaces.
|
||||
- **FR-198-026 Closure documentation**: Final documentation MUST record the state classes, the shareable/restorable subset, the allowed exception if any, and any shell/context handoff for each in-scope surface.
|
||||
- **FR-198-027 No forced flattening**: Baseline Compare Matrix MUST NOT be reduced to a generic table-style contract purely for uniformity.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Rebuilding the global workspace or tenant shell
|
||||
- Reworking shared detail micro-UI families already covered elsewhere
|
||||
- Repeating general Filament nativity cleanup from adjacent specs
|
||||
- Forcing every monitoring page into the same visual layout type
|
||||
- Changing existing authorization, audit, or run-lifecycle semantics beyond what state-contract clarity requires
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Existing authorization, audit logging, and underlying mutation safety on the affected pages are already correct and will be preserved.
|
||||
- The listed routes and pages remain the canonical monitoring entry points during this spec.
|
||||
- Additional monitoring surfaces join only if they meet the same page-state criteria and do not pull shell/context ownership into this spec.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing monitoring pages and route structure in the admin panel remain available.
|
||||
- Existing tenant-entitlement and record-authorization checks continue to govern drilldowns and selected-record access.
|
||||
- Spec 199 will absorb any unresolved global shell/context contract issues discovered during implementation.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations | `app/Filament/Pages/Monitoring/Operations.php` | `Return`, `Show all tenants`, `Clear filters` only when applicable | `recordUrl()` via full-row click into canonical run detail | none beyond row open | none | `Show all operations` when prefilter causes an empty result | Run detail page owns its own header actions; Operations stays landing-focused | n/a | no new write in this spec | One inspect model only: leave page for run detail |
|
||||
| Audit Log | `app/Filament/Pages/Monitoring/AuditLog.php` | `Return`, `Clear filters`, `Close details` when applicable | Explicit `Inspect event` action and matching selected-event deeplink open the same inline detail state | `Inspect event` | none | `Clear filters` | Selected-event detail header may include related navigation, but no second inspect model | n/a | no | Inline detail is allowed because it is the only inspect contract |
|
||||
| Finding Exceptions Queue | `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` | `Return`, `Clear filters`, `Close details` when applicable | Explicit `Inspect exception` or matching selected-exception deeplink open the same workbench state | `Inspect exception` | none | `Back to findings` or `Clear filters`, depending on empty state reason | `Approve exception`, `Reject exception`, and related open actions only when a valid selected exception exists | n/a | yes, existing approval or rejection audit behavior remains | Destructive-like decisions stay in the selected-exception lane and require confirmation |
|
||||
| Evidence Overview | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | `Clear filters` only when applicable | Full-row click opens snapshot detail | none beyond row open | none | `Clear filters` | Snapshot detail page owns its own header semantics | n/a | no | Simple monitoring surface; no separate selected-row inspect state |
|
||||
| Baseline Compare Landing | `app/Filament/Pages/BaselineCompareLanding.php` | `Return`, `Open compare matrix`, `Clear launch context` only when applicable | Explicit `Open compare matrix` action or deeplink hands off the same launch context | none | none | `Back to tenant overview` or `Clear launch context`, depending on empty-state reason | Matrix page owns compare actions after handoff | n/a | no | Launch context is initialization-only; landing never becomes the applied compare-state owner |
|
||||
| Baseline Compare Matrix | `app/Filament/Pages/BaselineCompareMatrix.php` | `Return to compare landing`, `Apply filters`, `Reset filters`, `Clear focus` when applicable | Focused subject or cell on the current matrix page is the only primary inspect model | none | none | `Reset filters` or `Back to compare landing`, depending on empty-state reason | Same page owns apply, reset, focus, and downstream drilldown affordances | n/a | no | Approved exception: rich local analysis controls with explicit draft/applied contract |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Monitoring Page-State Contract**: The per-surface declaration of contextual prefilter state, active state, draft state, inspect state, shareable/restorable state, and precedence rules.
|
||||
- **Inspect State**: The selected event, selected exception, or focused compare subject that controls inline detail or focused analysis on a surface.
|
||||
- **Shareable/Restorable State**: The subset of state intentionally recreated by refresh, browser navigation, bookmark, or shared link.
|
||||
- **Draft Page State**: A local pending state that is intentionally separate from the currently applied result state.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All six in-scope surfaces have a documented contract that identifies every supported state class and whether it is shareable/restorable, with zero undocumented state categories remaining.
|
||||
- **SC-002**: 100% of scripted deeplink-entry scenarios for the six in-scope surfaces recreate the documented initial state on first load.
|
||||
- **SC-003**: 100% of scripted refresh, back, and shared-link scenarios across the six in-scope surfaces match the documented restore or discard behavior with no stale selected-record state.
|
||||
- **SC-004**: Audit Log and Finding Exceptions Queue each expose exactly one operator-understandable inspect model in automated and manual verification, with no competing primary inspect behavior remaining.
|
||||
- **SC-005**: 100% of scripted Operations tab or prefilter scenarios and Compare Matrix draft/apply scenarios behave according to the documented contract without silent state drift.
|
||||
|
||||
## Closure Notes
|
||||
|
||||
### Final State Mapping
|
||||
|
||||
| Surface | Implemented shareable/restorable state | Implemented local-only or discarded state |
|
||||
|---|---|---|
|
||||
| Operations | Entitled `tenant_id`, `activeTab`, supported navigation context, compatible table filters/search restored from session | Unsupported or unauthorized tenant prefilters are discarded to the active entitled scope; no same-page inspect state exists |
|
||||
| Audit Log | Supported table filters/search and `event` when the selected record remains visible and authorized | Invalid, unauthorized, or filtered-out `event` state is discarded to the unselected history view |
|
||||
| Finding Exceptions Queue | Supported queue filters and `exception` when the selected request remains visible and authorized | Invalid, unauthorized, or filtered-out `exception` state is discarded to quiet monitoring mode |
|
||||
| Evidence Overview | Supported tenant/search filters on the canonical overview route | Cleared filters do not repopulate hidden local state after redirect back to the canonical overview route |
|
||||
| Baseline Compare Landing | Launch-context handoff into the compare matrix when the profile and tenant context are valid | Landing-page context never becomes the owner of applied compare results |
|
||||
| Baseline Compare Matrix | Applied compare filters, requested presentation mode, and focused subject carried by the matrix URL | Unapplied draft filter changes remain local and are discarded on reopen or refresh |
|
||||
|
||||
### Verification Summary
|
||||
|
||||
- Focused feature pack passed: `77` tests, `468` assertions.
|
||||
- Spec 198 browser smoke passed: `2` tests, `25` assertions.
|
||||
- No-regression browser smokes passed: `5` tests, `80` assertions across Spec 190 and Spec 194.
|
||||
|
||||
### Spec 199 Handoff Notes
|
||||
|
||||
- This implementation intentionally stops at page-owned hydration and restore rules. Workspace-shell and global tenant-context ownership remain outside this spec.
|
||||
- Remembered workspace or tenant context may still influence entry state before a page hydrates, but this spec does not redefine that shell-level contract.
|
||||
- No global page-state framework, provider registration change, or new persistence layer was introduced. Any future cross-shell normalization belongs to Spec 199.
|
||||
284
specs/198-monitoring-page-state/tasks.md
Normal file
284
specs/198-monitoring-page-state/tasks.md
Normal file
@ -0,0 +1,284 @@
|
||||
# Tasks: Monitoring Page-State Contract
|
||||
|
||||
**Input**: Design documents from `/specs/198-monitoring-page-state/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest).
|
||||
**Operations**: This feature does not create or reuse a new `OperationRun`; existing Monitoring and governance writes keep their current run, audit, and confirmation behavior.
|
||||
**RBAC**: Authorization semantics remain unchanged, but tasks include negative-path coverage where requested query or selected-record state becomes unauthorized or invalid.
|
||||
**UI Naming**: Operator-facing labels and helper copy must stay aligned with the spec's canonical vocabulary (`Operations`, `Audit log`, `Finding exceptions queue`, `Evidence overview`, `Baseline compare matrix`, `Inspect event`, `Approve exception`, `Reject exception`, `Apply filters`, `Reset filters`).
|
||||
**Operator Surfaces**: Each changed page keeps the spec's declared surface role and single inspect/open model.
|
||||
**Filament UI Action Surfaces**: All changed pages must preserve one primary inspect model, keep destructive-like actions confirmation-gated, and avoid introducing new page-local status or action frameworks.
|
||||
**Proportionality / Anti-Bloat**: Keep the implementation surface-first and page-local; do not introduce a global page-state engine or new persistence.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare shared verification scaffolding for Spec 198 without changing runtime behavior yet.
|
||||
|
||||
- [X] T001 [P] Create the cross-surface contract test scaffold in `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php`
|
||||
- [X] T002 [P] Create the Spec 198 browser smoke scaffold in `apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php`
|
||||
- [X] T003 [P] Extend the shared state-persistence harness in `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared page-state contract structure and helper touchpoints before any surface-specific work begins.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 [P] Add explicit page-state contract declarations to `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- [X] T005 [P] Add explicit page-state contract declarations to `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||
- [X] T006 [P] Align shared query/session/navigation normalization touchpoints in `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php` and `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
|
||||
- [X] T007 Update cross-surface state-class, query-role, invalid-fallback, and shared inspect-vocabulary assertions in `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php`
|
||||
- [X] T008 Update tenant-sensitive persistence and restore-boundary assertions in `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`
|
||||
|
||||
**Checkpoint**: Foundation ready. All user stories can now proceed independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Enter Operations Without Tab Drift (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make Operations use one deterministic contract for requested dashboard state, tenant prefilter, tabs, and persisted table state.
|
||||
|
||||
**Independent Test**: Open Operations directly and from KPI/drillthrough context, change tabs and filters, and confirm refresh or reopen behavior restores only the documented shareable state.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
> **NOTE**: Write these tests first, ensure they fail before implementation.
|
||||
|
||||
- [X] T009 [P] [US1] Extend deeplink, dashboard-prefilter, and unauthorized requested-tenant fallback coverage in `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` and `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php`
|
||||
- [X] T010 [P] [US1] Extend Operations restorable tab/filter coverage in `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Align requested tenant, requested dashboard prefilter, and `activeTab` precedence in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
|
||||
- [X] T012 [US1] Update explicit shareable/restorable tab and filter cues in `apps/platform/resources/views/filament/pages/monitoring/operations.blade.php`
|
||||
|
||||
**Checkpoint**: Operations behaves as a predictable simple monitoring surface and is testable independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Inspect Audit Events Through One Model (Priority: P1)
|
||||
|
||||
**Goal**: Make Audit Log use one selected-event inspect contract for action-based inspect, deeplink entry, close detail, and refresh.
|
||||
|
||||
**Independent Test**: Open Audit Log with and without a selected event, inspect an event, then refresh and back-navigate to confirm there is only one selected-event model.
|
||||
|
||||
### Tests for User Story 2 ⚠️
|
||||
|
||||
- [X] T013 [P] [US2] Extend selected-event hydration, invalid or unauthorized event fallback, and close-detail restoration coverage in `apps/platform/tests/Feature/Monitoring/AuditLogInspectFlowTest.php` and `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php`
|
||||
- [X] T014 [P] [US2] Extend persisted filter and selected-event clear-state coverage in `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T015 [US2] Make `selectedAuditLogId` the single inspect source in `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- [X] T016 [US2] Align inline event-detail rendering and close-detail controls in `apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php` and `apps/platform/resources/views/filament/pages/monitoring/partials/audit-log-inspect-event.blade.php`
|
||||
|
||||
**Checkpoint**: Audit Log is independently testable with one selected-event inspect model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Review Finding Exceptions Through One Workbench State (Priority: P1)
|
||||
|
||||
**Goal**: Make Finding Exceptions Queue use one selected-exception workbench model for inspect, summary, decision actions, and refresh.
|
||||
|
||||
**Independent Test**: Open the queue with and without a selected exception and verify that summary, decision actions, and refresh behavior all derive from the same selected-exception state.
|
||||
|
||||
### Tests for User Story 3 ⚠️
|
||||
|
||||
- [X] T017 [P] [US3] Extend selected-exception hydration, invalid or unauthorized fallback, and selection-clearing coverage in `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php` and `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php`
|
||||
- [X] T018 [P] [US3] Extend calm-queue vs decision-ready state coverage in `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T019 [US3] Make `selectedFindingExceptionId` the sole inspect/review state in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- [X] T020 [US3] Align selected-exception summary and decision-lane rendering in `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
|
||||
|
||||
**Checkpoint**: Finding Exceptions Queue is independently testable as one selected-exception workbench flow.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Keep Compare Matrix Explicitly Special (Priority: P1)
|
||||
|
||||
**Goal**: Preserve Baseline Compare Matrix as an explicit draft/apply special case while making launch-context, applied state, and focus state predictable.
|
||||
|
||||
**Independent Test**: Launch the matrix from the landing page, change draft filters without applying them, apply the new state, focus a subject, and verify what refresh, back, and a shared link restore.
|
||||
|
||||
### Tests for User Story 4 ⚠️
|
||||
|
||||
- [X] T021 [P] [US4] Extend launch-context hydration and focus handoff coverage in `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||
- [X] T022 [P] [US4] Extend draft/apply, focus restoration, and draft discard coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||
- [X] T023 [P] [US4] Extend invalid and unauthorized compare-context fallback coverage in `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T024 [US4] Treat compare landing context as initialization-only state in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
- [X] T025 [US4] Separate draft, applied, focus, and shareable slices in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||
- [X] T026 [US4] Surface draft/apply/focus semantics in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||
|
||||
**Checkpoint**: Compare Matrix remains powerful but independently testable with explicit launch, draft/apply, and focus semantics.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - Preserve Simple Monitoring Pages Without One-Off Protocols (Priority: P2)
|
||||
|
||||
**Goal**: Align Evidence Overview with the shared simple monitoring contract for query hydration, clear filters, and restorable filter behavior.
|
||||
|
||||
**Independent Test**: Enter Evidence Overview with prefilter state, clear filters, reopen or refresh the page, and confirm the page behaves like a simple monitoring surface without hidden local exceptions.
|
||||
|
||||
### Tests for User Story 5 ⚠️
|
||||
|
||||
- [X] T027 [P] [US5] Extend tenant/search hydration, clear-filter, and refresh coverage in `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T028 [US5] Align query hydration, session persistence, and simple-monitoring filter semantics in `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- [X] T029 [US5] Update empty-state and clear-filter rendering for the simple monitoring contract in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`
|
||||
|
||||
**Checkpoint**: Evidence Overview is independently testable as a simple monitoring surface.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final verification, browser coverage, closure documentation, and formatting across all stories.
|
||||
|
||||
- [X] T030 [P] Add cross-surface deeplink, refresh, back, and share smoke coverage in `apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php`
|
||||
- [X] T031 [P] Extend no-regression browser flows in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` and `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
|
||||
- [X] T032 Run the focused Sail feature-test pack for `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Monitoring/AuditLogInspectFlowTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php`, `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php`, and `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
|
||||
- [X] T033 Execute the manual smoke checklist in `specs/198-monitoring-page-state/quickstart.md` against `/admin/operations`, `/admin/audit-log`, `/admin/finding-exceptions/queue`, `/admin/evidence/overview`, and the Baseline Compare routes
|
||||
- [X] T034 [P] Update closure documentation in `specs/198-monitoring-page-state/spec.md` and `specs/198-monitoring-page-state/quickstart.md` with the final shareable/restorable-state mapping and any Spec 199 handoff notes
|
||||
- [X] T035 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` over touched files under `apps/platform/app/Filament/Pages/`, `apps/platform/app/Support/`, `apps/platform/resources/views/filament/pages/`, and `apps/platform/tests/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies. Can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion. Blocks all user stories.
|
||||
- **User Stories (Phases 3-7)**: All depend on Foundational completion.
|
||||
- User Stories 1-4 are all Priority P1 and can proceed in parallel after Phase 2.
|
||||
- User Story 5 is Priority P2 and can also proceed after Phase 2, but it is lower delivery priority.
|
||||
- **Polish (Phase 8)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational. No dependency on other stories.
|
||||
- **User Story 2 (P1)**: Can start after Foundational. No dependency on other stories.
|
||||
- **User Story 3 (P1)**: Can start after Foundational. No dependency on other stories.
|
||||
- **User Story 4 (P1)**: Can start after Foundational. No dependency on other stories; landing and matrix work stay inside the same story.
|
||||
- **User Story 5 (P2)**: Can start after Foundational. No dependency on other stories.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and fail before implementation.
|
||||
- Page class state logic before Blade rendering updates.
|
||||
- Query/session precedence before browser smoke validation.
|
||||
- Story-specific verification before moving on to another story.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked `[P]` can run in parallel.
|
||||
- Foundational tasks `T004`, `T005`, and `T006` can run in parallel.
|
||||
- Once Foundational is complete, the test tasks for US1-US5 can run in parallel.
|
||||
- User Stories 1-4 can be implemented in parallel by different developers after Phase 2.
|
||||
- Browser smoke additions `T030` and `T031` can run in parallel once story behavior is stable.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch both Operations test tracks together:
|
||||
Task T009 - Extend deeplink and dashboard-prefilter hydration coverage in apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
|
||||
Task T010 - Extend Operations restorable tab/filter coverage in apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch both Audit Log test tracks together:
|
||||
Task T013 - Extend selected-event hydration, invalid event fallback, and close-detail restoration coverage in apps/platform/tests/Feature/Monitoring/AuditLogInspectFlowTest.php
|
||||
Task T014 - Extend persisted filter and selected-event clear-state coverage in apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch both Finding Exceptions Queue test tracks together:
|
||||
Task T017 - Extend selected-exception hydration, invalid fallback, and selection-clearing coverage in apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php
|
||||
Task T018 - Extend calm-queue vs decision-ready state coverage in apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Launch all Compare Matrix verification tasks together:
|
||||
Task T021 - Extend launch-context hydration and focus handoff coverage in apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||
Task T022 - Extend draft/apply, focus restoration, and draft discard coverage in apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
||||
Task T023 - Extend invalid and unauthorized compare-context fallback coverage in apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 5
|
||||
|
||||
```bash
|
||||
# Evidence Overview can start alongside any P1 story once Phase 2 is complete:
|
||||
Task T027 - Extend tenant/search hydration, clear-filter, and refresh coverage in apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently.
|
||||
5. Demo or ship the Operations state contract before moving to additional surfaces.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → foundation ready.
|
||||
2. Add User Story 1 → test independently → deploy/demo.
|
||||
3. Add User Story 2 → test independently → deploy/demo.
|
||||
4. Add User Story 3 → test independently → deploy/demo.
|
||||
5. Add User Story 4 → test independently → deploy/demo.
|
||||
6. Add User Story 5 → test independently → deploy/demo.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together.
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 2
|
||||
- Developer C: User Story 3
|
||||
- Developer D: User Story 4
|
||||
- Developer E: User Story 5
|
||||
3. Finish with the shared browser, documentation, and verification tasks in Phase 8.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch different files and have no direct dependency on incomplete tasks.
|
||||
- `[US#]` labels map tasks back to the five user stories in `spec.md`.
|
||||
- Every story remains independently completable and testable after Phase 2.
|
||||
- Verify tests fail before implementing each story.
|
||||
- Avoid adding a new global page-state framework, shell-level context layer, or new persistence while implementing these tasks.
|
||||
Loading…
Reference in New Issue
Block a user