Compare commits
2 Commits
197-shared
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e02799b383 | |||
| c0f4587d90 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -186,6 +186,10 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
|
||||
- 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 (205-compare-job-cleanup)
|
||||
- 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)
|
||||
|
||||
@ -220,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
|
||||
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||
<!-- 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1760,6 +1760,8 @@ private function verificationReportViewData(): array
|
||||
'previousRunUrl' => null,
|
||||
'canAcknowledge' => false,
|
||||
'acknowledgements' => [],
|
||||
'surface' => [],
|
||||
'redactionNotes' => [],
|
||||
'assistVisibility' => $assistVisibility,
|
||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||
@ -1809,7 +1811,28 @@ private function verificationReportViewData(): array
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||
$verificationReport = VerificationReportViewer::report($run);
|
||||
$surface = VerificationReportViewer::surface($run, $acknowledgements, [
|
||||
'hostKind' => 'onboarding_wizard',
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'nextStepPlacement' => ($assistVisibility['is_visible'] ?? false) ? 'host_action_zone' : 'shared_zone',
|
||||
'hostActions' => array_values(array_filter([
|
||||
($assistVisibility['is_visible'] ?? false)
|
||||
? ['kind' => 'assist', 'label' => 'View required permissions', 'ownedByHost' => true]
|
||||
: null,
|
||||
['kind' => 'technical_details', 'label' => 'Technical details', 'ownedByHost' => true],
|
||||
$canAcknowledge
|
||||
? ['kind' => 'acknowledge', 'label' => 'Acknowledge', 'ownedByHost' => true]
|
||||
: null,
|
||||
])),
|
||||
'hostVariation' => [
|
||||
'ownsNoRunState' => true,
|
||||
'ownsActiveState' => true,
|
||||
'supportsAssist' => (bool) ($assistVisibility['is_visible'] ?? false),
|
||||
'supportsAcknowledge' => $canAcknowledge,
|
||||
'supportsTechnicalDetailsTrigger' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
@ -1832,6 +1855,8 @@ private function verificationReportViewData(): array
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
'surface' => $surface,
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
'assistVisibility' => $assistVisibility,
|
||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Filament\Support\NormalizedDiffSurface;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
@ -412,11 +413,6 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Diff')
|
||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||
->schema([
|
||||
TextEntry::make('diff_unavailable')
|
||||
->label('')
|
||||
->state(fn (Finding $record): string => static::driftDiffUnavailableMessage($record))
|
||||
->visible(fn (Finding $record): bool => ! static::canRenderDriftDiff($record))
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('rbac_role_definition_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.rbac-role-definition-diff')
|
||||
@ -429,13 +425,13 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
return NormalizedDiffSurface::build(static::unavailableDiffState('No tenant context'), 'finding');
|
||||
}
|
||||
|
||||
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
||||
|
||||
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
||||
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
|
||||
return NormalizedDiffSurface::build(static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'), 'finding');
|
||||
}
|
||||
|
||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||
@ -452,9 +448,9 @@ public static function infolist(Schema $schema): Schema
|
||||
);
|
||||
}
|
||||
|
||||
return $diff;
|
||||
return NormalizedDiffSurface::build($diff, 'finding');
|
||||
})
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('scope_tags_diff')
|
||||
|
||||
@ -1178,6 +1178,18 @@ private static function verificationReportViewData(OperationRun $record): array
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
'surface' => VerificationReportViewer::surface($record, $acknowledgements, [
|
||||
'hostKind' => 'operation_run_detail',
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'hostVariation' => [
|
||||
'ownsNoRunState' => false,
|
||||
'ownsActiveState' => false,
|
||||
'supportsAssist' => false,
|
||||
'supportsAcknowledge' => false,
|
||||
'supportsTechnicalDetailsTrigger' => false,
|
||||
],
|
||||
]),
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
];
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\PolicyResource\Pages;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Filament\Support\NormalizedSettingsSurface;
|
||||
use App\Jobs\BulkPolicyDeleteJob;
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Jobs\BulkPolicyUnignoreJob;
|
||||
@ -238,25 +239,13 @@ public static function infolist(Schema $schema): Schema
|
||||
Tab::make('Settings')
|
||||
->id('settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings_catalog')
|
||||
ViewEntry::make('settings')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (Policy $record) {
|
||||
return static::settingsTabState($record);
|
||||
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
|
||||
})
|
||||
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
|
||||
$record->versions()->exists()
|
||||
),
|
||||
|
||||
ViewEntry::make('settings_standard')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (Policy $record) {
|
||||
return static::settingsTabState($record);
|
||||
})
|
||||
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
|
||||
$record->versions()->exists()
|
||||
),
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
|
||||
TextEntry::make('no_settings_available')
|
||||
->label('Settings')
|
||||
@ -301,16 +290,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (Policy $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
static::latestSnapshot($record),
|
||||
$record->policy_type ?? '',
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'policy';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
|
||||
}),
|
||||
])
|
||||
->columnSpanFull()
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
use App\Filament\Support\NormalizedDiffSurface;
|
||||
use App\Filament\Support\NormalizedSettingsSurface;
|
||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||
@ -180,7 +182,7 @@ public static function infolist(Schema $schema): Schema
|
||||
Tab::make('Normalized settings')
|
||||
->id('normalized-settings')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_catalog')
|
||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
@ -189,29 +191,12 @@ public static function infolist(Schema $schema): Schema
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'version';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_standard')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
is_array($record->snapshot) ? $record->snapshot : [],
|
||||
$record->policy_type ?? '',
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'version';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
$normalized['policy_type'] = $record->policy_type;
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
||||
}),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
->id('raw-json')
|
||||
@ -238,7 +223,7 @@ public static function infolist(Schema $schema): Schema
|
||||
$result = $diff->compare($from, $to);
|
||||
$result['policy_type'] = $record->policy_type;
|
||||
|
||||
return $result;
|
||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
||||
}),
|
||||
Infolists\Components\ViewEntry::make('diff_json')
|
||||
->label('Raw diff (advanced)')
|
||||
|
||||
155
apps/platform/app/Filament/Support/NormalizedDiffSurface.php
Normal file
155
apps/platform/app/Filament/Support/NormalizedDiffSurface.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
final class NormalizedDiffSurface
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $diff
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(array $diff, string $hostKind): array
|
||||
{
|
||||
$summary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
|
||||
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||
$message = is_string($summary['message'] ?? null) && trim((string) $summary['message']) !== ''
|
||||
? trim((string) $summary['message'])
|
||||
: null;
|
||||
|
||||
$addedCount = is_numeric($summary['added'] ?? null) ? (int) $summary['added'] : count($added);
|
||||
$removedCount = is_numeric($summary['removed'] ?? null) ? (int) $summary['removed'] : count($removed);
|
||||
$changedCount = is_numeric($summary['changed'] ?? null) ? (int) $summary['changed'] : count($changed);
|
||||
$availabilityState = self::availabilityState($message, $addedCount, $removedCount, $changedCount);
|
||||
|
||||
return [
|
||||
'hostKind' => $hostKind,
|
||||
'availabilityState' => $availabilityState,
|
||||
'summary' => [
|
||||
'added' => $addedCount,
|
||||
'removed' => $removedCount,
|
||||
'changed' => $changedCount,
|
||||
'message' => $message,
|
||||
],
|
||||
'viewModes' => [
|
||||
['key' => 'grouped', 'label' => 'Grouped diff', 'default' => true],
|
||||
],
|
||||
'sectionBehavior' => [
|
||||
'preservesGroupOrder' => true,
|
||||
'supportsExpansion' => true,
|
||||
'supportsFullscreen' => true,
|
||||
],
|
||||
'renderExpectations' => [
|
||||
'ownsAvailabilityState' => true,
|
||||
'ownsZeroDiffMessaging' => true,
|
||||
'keepsHostFramingOutsideCore' => true,
|
||||
],
|
||||
'groups' => [
|
||||
self::group('changed', 'Changed', $changed, false),
|
||||
self::group('added', 'Added', $added, true),
|
||||
self::group('removed', 'Removed', $removed, true),
|
||||
],
|
||||
'scriptRendering' => [
|
||||
'policyType' => $diff['policy_type'] ?? null,
|
||||
'showScriptContent' => (bool) config('tenantpilot.display.show_script_content', false),
|
||||
],
|
||||
'emptyState' => self::emptyState($availabilityState, $message, $addedCount, $removedCount, $changedCount),
|
||||
'raw' => [
|
||||
'added' => $added,
|
||||
'removed' => $removed,
|
||||
'changed' => $changed,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function group(string $key, string $label, array $items, bool $collapsed): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'collapsed' => $collapsed,
|
||||
'count' => count($items),
|
||||
'items' => self::groupByBlock($items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function groupByBlock(array $items): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
$group = count($parts) === 2 ? $parts[0] : 'Other';
|
||||
$label = count($parts) === 2 ? $parts[1] : $path;
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
private static function availabilityState(?string $message, int $addedCount, int $removedCount, int $changedCount): string
|
||||
{
|
||||
if ($message !== null && str_contains(strtolower($message), 'unavailable')) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
if ($message !== null && str_contains(strtolower($message), 'partial')) {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'available';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, message: string}|null
|
||||
*/
|
||||
private static function emptyState(
|
||||
string $availabilityState,
|
||||
?string $message,
|
||||
int $addedCount,
|
||||
int $removedCount,
|
||||
int $changedCount,
|
||||
): ?array
|
||||
{
|
||||
if ($availabilityState === 'unavailable' && $message !== null) {
|
||||
return [
|
||||
'title' => 'Diff unavailable',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
if ($availabilityState === 'partial' && $message !== null) {
|
||||
return [
|
||||
'title' => 'Diff partially available',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
if ($availabilityState === 'available' && ($addedCount + $removedCount + $changedCount) === 0) {
|
||||
return [
|
||||
'title' => 'No normalized changes',
|
||||
'message' => $message ?? 'No normalized changes were found.',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
final class NormalizedSettingsSurface
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $normalized
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(array $normalized, string $hostKind): array
|
||||
{
|
||||
$warnings = collect($normalized['warnings'] ?? [])
|
||||
->filter(static fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
|
||||
->map(static fn (string $warning): string => trim($warning))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$settingsTable = is_array($normalized['settings_table'] ?? null) ? $normalized['settings_table'] : null;
|
||||
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
|
||||
$blocks = collect($normalized['settings'] ?? [])
|
||||
->filter(static fn (mixed $block): bool => is_array($block))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$context = is_string($normalized['context'] ?? null) && $normalized['context'] !== ''
|
||||
? (string) $normalized['context']
|
||||
: 'policy';
|
||||
|
||||
$variant = $settingsTableRows !== [] ? 'settings_catalog_table' : 'standard_blocks';
|
||||
|
||||
return [
|
||||
'hostKind' => $hostKind,
|
||||
'context' => $context,
|
||||
'variant' => $variant,
|
||||
'warnings' => $warnings,
|
||||
'settingsTable' => $settingsTableRows !== [] ? $settingsTable : null,
|
||||
'blocks' => $blocks,
|
||||
'sectionBehavior' => [
|
||||
'preservesSectionOrder' => true,
|
||||
'supportsExpansion' => true,
|
||||
'ownsEmptyState' => true,
|
||||
],
|
||||
'renderExpectations' => [
|
||||
'ownsWarningsInWrapper' => true,
|
||||
'ownsSubtypeDelegation' => true,
|
||||
'keepsHostFramingOutsideCore' => true,
|
||||
],
|
||||
'emptyState' => $settingsTableRows === [] && $blocks === []
|
||||
? [
|
||||
'title' => 'No settings available.',
|
||||
'message' => 'No normalized settings payload is available for this host.',
|
||||
]
|
||||
: null,
|
||||
'titlePolicy' => [
|
||||
'showWrapperTitle' => false,
|
||||
],
|
||||
'recordId' => $normalized['record_id'] ?? null,
|
||||
'policyType' => $normalized['policy_type'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
@ -91,6 +93,276 @@ public static function shouldRenderForRun(OperationRun $run): bool
|
||||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $acknowledgements
|
||||
* @param array{
|
||||
* hostKind?: string,
|
||||
* changeIndicator?: array{state: string, previous_report_id: int}|null,
|
||||
* previousRunUrl?: string|null,
|
||||
* nextStepPlacement?: 'shared_zone'|'host_action_zone',
|
||||
* hostActions?: array<int, array{kind: string, label: string, ownedByHost: bool}>,
|
||||
* hostVariation?: array{
|
||||
* ownsNoRunState?: bool,
|
||||
* ownsActiveState?: bool,
|
||||
* supportsAssist?: bool,
|
||||
* supportsAcknowledge?: bool,
|
||||
* supportsTechnicalDetailsTrigger?: bool
|
||||
* },
|
||||
* optionalZones?: array<int, string>
|
||||
* } $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function surface(OperationRun $run, array $acknowledgements = [], array $options = []): array
|
||||
{
|
||||
$report = self::report($run);
|
||||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
$groupedChecks = self::groupedChecks($report, $acknowledgements);
|
||||
$changeIndicator = $options['changeIndicator'] ?? null;
|
||||
$hostKind = is_string($options['hostKind'] ?? null) && $options['hostKind'] !== ''
|
||||
? (string) $options['hostKind']
|
||||
: 'operation_run_detail';
|
||||
$nextStepPlacement = ($options['nextStepPlacement'] ?? 'shared_zone') === 'host_action_zone'
|
||||
? 'host_action_zone'
|
||||
: 'shared_zone';
|
||||
|
||||
$hostActions = collect($options['hostActions'] ?? [])
|
||||
->filter(static fn (mixed $action): bool => is_array($action))
|
||||
->map(static function (array $action): array {
|
||||
$kind = is_string($action['kind'] ?? null) ? trim((string) $action['kind']) : 'navigation';
|
||||
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : 'Action';
|
||||
|
||||
return [
|
||||
'kind' => $kind !== '' ? $kind : 'navigation',
|
||||
'label' => $label !== '' ? $label : 'Action',
|
||||
'ownedByHost' => (bool) ($action['ownedByHost'] ?? true),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$hostVariation = [
|
||||
'ownsNoRunState' => (bool) (($options['hostVariation']['ownsNoRunState'] ?? false)),
|
||||
'ownsActiveState' => (bool) (($options['hostVariation']['ownsActiveState'] ?? false)),
|
||||
'supportsAssist' => (bool) (($options['hostVariation']['supportsAssist'] ?? false)),
|
||||
'supportsAcknowledge' => (bool) (($options['hostVariation']['supportsAcknowledge'] ?? false)),
|
||||
'supportsTechnicalDetailsTrigger' => (bool) (($options['hostVariation']['supportsTechnicalDetailsTrigger'] ?? false)),
|
||||
];
|
||||
|
||||
$optionalZones = collect($options['optionalZones'] ?? ['technical_details', 'change_indicator', 'previous_run_context'])
|
||||
->filter(static fn (mixed $zone): bool => is_string($zone) && trim($zone) !== '')
|
||||
->map(static fn (string $zone): string => trim($zone))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$overall = $summary['overall'] ?? null;
|
||||
$overallSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall);
|
||||
|
||||
return [
|
||||
'hostKind' => $hostKind,
|
||||
'coreState' => $report === null ? 'unavailable' : 'completed',
|
||||
'summary' => [
|
||||
'overall' => $overall,
|
||||
'overallLabel' => $overallSpec->label,
|
||||
'counts' => [
|
||||
'total' => (int) ($counts['total'] ?? 0),
|
||||
'pass' => (int) ($counts['pass'] ?? 0),
|
||||
'fail' => (int) ($counts['fail'] ?? 0),
|
||||
'warn' => (int) ($counts['warn'] ?? 0),
|
||||
'skip' => (int) ($counts['skip'] ?? 0),
|
||||
'running' => (int) ($counts['running'] ?? 0),
|
||||
],
|
||||
'changeIndicator' => is_array($changeIndicator) ? $changeIndicator : null,
|
||||
],
|
||||
'issueGroups' => $groupedChecks['issueGroups'],
|
||||
'passedChecks' => $groupedChecks['passedChecks'],
|
||||
'diagnostics' => [
|
||||
'hasTechnicalZone' => true,
|
||||
'fingerprint' => is_array($report) ? self::fingerprint($report) : null,
|
||||
'previousRunUrl' => is_string($options['previousRunUrl'] ?? null) && $options['previousRunUrl'] !== ''
|
||||
? (string) $options['previousRunUrl']
|
||||
: null,
|
||||
'operationRunId' => (int) $run->getKey(),
|
||||
'flow' => (string) $run->type,
|
||||
'completedAt' => $run->completed_at?->toJSON(),
|
||||
],
|
||||
'viewZones' => [
|
||||
['key' => 'issues', 'label' => 'Issues', 'defaultVisible' => true],
|
||||
['key' => 'passed', 'label' => 'Passed', 'defaultVisible' => false],
|
||||
],
|
||||
'nextSteps' => self::nextSteps($groupedChecks['issueGroups'], $nextStepPlacement),
|
||||
'hostActions' => $hostActions,
|
||||
'hostVariation' => $hostVariation,
|
||||
'optionalZones' => $optionalZones,
|
||||
'emptyState' => $report === null
|
||||
? [
|
||||
'title' => 'Verification report unavailable',
|
||||
'message' => 'This operation doesn’t have a report yet. If it is still running, refresh in a moment. If it already completed, start verification again.',
|
||||
]
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $report
|
||||
* @param array<string, array<string, mixed>> $acknowledgements
|
||||
* @return array{issueGroups: array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}>, passedChecks: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private static function groupedChecks(?array $report, array $acknowledgements): array
|
||||
{
|
||||
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $acknowledgement) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($acknowledgement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $acknowledgement;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = is_string($check['status'] ?? null) ? strtolower(trim((string) $check['status'])) : '';
|
||||
$blocking = (bool) ($check['blocking'] ?? false);
|
||||
$normalizedCheck = self::normalizeCheck($check, $ackByKey[$key] ?? null);
|
||||
|
||||
if ($normalizedCheck['acknowledgement'] !== null) {
|
||||
$acknowledgedIssues[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'pass') {
|
||||
$passed[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'fail' && $blocking) {
|
||||
$blockers[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'fail') {
|
||||
$failures[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'warn') {
|
||||
$warnings[] = $normalizedCheck;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static fn (array $left, array $right): int => strcmp((string) ($left['key'] ?? ''), (string) ($right['key'] ?? ''));
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
|
||||
return [
|
||||
'issueGroups' => array_values(array_filter([
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
['label' => 'Acknowledged issues', 'checks' => $acknowledgedIssues, 'acknowledged' => true],
|
||||
], static fn (array $group): bool => ($group['checks'] ?? []) !== [])),
|
||||
'passedChecks' => $passed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
* @param array<string, mixed>|null $acknowledgement
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeCheck(array $check, ?array $acknowledgement): array
|
||||
{
|
||||
$nextSteps = collect($check['next_steps'] ?? [])
|
||||
->filter(static fn (mixed $step): bool => is_array($step))
|
||||
->map(static function (array $step): array {
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
})
|
||||
->filter(static fn (array $step): bool => $step['label'] !== '' && $step['url'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'key' => is_string($check['key'] ?? null) ? trim((string) $check['key']) : '',
|
||||
'title' => is_string($check['title'] ?? null) && trim((string) $check['title']) !== ''
|
||||
? trim((string) $check['title'])
|
||||
: 'Check',
|
||||
'message' => is_string($check['message'] ?? null) && trim((string) $check['message']) !== ''
|
||||
? trim((string) $check['message'])
|
||||
: null,
|
||||
'status' => is_string($check['status'] ?? null) ? trim((string) $check['status']) : null,
|
||||
'severity' => is_string($check['severity'] ?? null) ? trim((string) $check['severity']) : null,
|
||||
'reason_code' => is_string($check['reason_code'] ?? null) ? trim((string) $check['reason_code']) : null,
|
||||
'blocking' => (bool) ($check['blocking'] ?? false),
|
||||
'next_steps' => $nextSteps,
|
||||
'acknowledgement' => is_array($acknowledgement) ? $acknowledgement : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}> $issueGroups
|
||||
* @return array<int, array{label: string, placement: string, ownedByHost: bool, actionKind: string|null}>
|
||||
*/
|
||||
private static function nextSteps(array $issueGroups, string $placement): array
|
||||
{
|
||||
$steps = [];
|
||||
|
||||
foreach ($issueGroups as $group) {
|
||||
foreach ($group['checks'] as $check) {
|
||||
foreach ($check['next_steps'] ?? [] as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
|
||||
if ($label === '' || array_key_exists($label, $steps)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$steps[$label] = [
|
||||
'label' => $label,
|
||||
'placement' => $placement,
|
||||
'ownedByHost' => $placement === 'host_action_zone',
|
||||
'actionKind' => $placement === 'host_action_zone' ? 'assist' : 'navigation',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $report
|
||||
* @return array<int, string>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -189,6 +190,12 @@ protected function getViewData(): array
|
||||
$report = $run instanceof OperationRun
|
||||
? VerificationReportViewer::report($run)
|
||||
: null;
|
||||
$changeIndicator = $run instanceof OperationRun
|
||||
? VerificationReportChangeIndicator::forRun($run)
|
||||
: null;
|
||||
$previousRunUrl = is_array($changeIndicator) && is_numeric($changeIndicator['previous_report_id'] ?? null)
|
||||
? OperationRunLinks::tenantlessView((int) $changeIndicator['previous_report_id'])
|
||||
: null;
|
||||
|
||||
$isInProgress = $run instanceof OperationRun
|
||||
&& (string) $run->status !== OperationRunStatus::Completed->value;
|
||||
@ -230,6 +237,20 @@ protected function getViewData(): array
|
||||
'runData' => $runData,
|
||||
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
|
||||
'report' => $report,
|
||||
'surface' => $run instanceof OperationRun
|
||||
? VerificationReportViewer::surface($run, [], [
|
||||
'hostKind' => 'tenant_widget',
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'hostVariation' => [
|
||||
'ownsNoRunState' => true,
|
||||
'ownsActiveState' => true,
|
||||
'supportsAssist' => false,
|
||||
'supportsAcknowledge' => false,
|
||||
'supportsTechnicalDetailsTrigger' => false,
|
||||
],
|
||||
])
|
||||
: [],
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
'isInProgress' => $isInProgress,
|
||||
'showStartAction' => ! ($run instanceof OperationRun) && $isTenantMember && $canOperate,
|
||||
|
||||
@ -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,108 +1,38 @@
|
||||
@php
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
$redactionNotes = $redactionNotes ?? [];
|
||||
$redactionNotes = is_array($redactionNotes) ? array_values(array_filter($redactionNotes, 'is_string')) : [];
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
$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'))
|
||||
: [];
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
$ackAction = $ackAction ?? null;
|
||||
$showAssist = (bool) ($showAssist ?? false);
|
||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||
? trim((string) $assistActionName)
|
||||
: 'wizardVerificationRequiredPermissionsAssist';
|
||||
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
|
||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div
|
||||
data-shared-detail-family="verification-report"
|
||||
data-host-kind="{{ (string) ($surface['hostKind'] ?? 'operation_run_detail') }}"
|
||||
class="space-y-4"
|
||||
>
|
||||
@if ($coreState === 'unavailable')
|
||||
<div
|
||||
data-shared-zone="unavailable"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
{{ $emptyState['title'] ?? 'Verification report unavailable' }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This operation doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||
{{ $emptyState['message'] ?? 'This operation does not have a report yet.' }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
@ -117,68 +47,10 @@
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
|
||||
@if ($redactionNotes !== [])
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
@foreach ($redactionNotes as $note)
|
||||
<div>{{ $note }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@include('filament.components.verification-report.summary', [
|
||||
'surface' => $surface,
|
||||
'redactionNotes' => $redactionNotes,
|
||||
])
|
||||
|
||||
<div x-data="{ tab: 'issues' }" class="space-y-4">
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
@ -196,313 +68,30 @@
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'technical'"
|
||||
x-on:click="tab = 'technical'"
|
||||
>
|
||||
Technical details
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@include('filament.components.verification-report.issues', [
|
||||
'surface' => $surface,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'ackAction' => $ackAction,
|
||||
'showAssist' => $showAssist,
|
||||
'assistActionName' => $assistActionName,
|
||||
'linkBehavior' => $linkBehavior,
|
||||
])
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'technical'" style="display: none;">
|
||||
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Identifiers
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@if ($run !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Operation ID:</span>
|
||||
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($fingerprint)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($previousRunUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $previousRunUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous operation
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@include('filament.components.verification-report.passed', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showDiagnosticsZone)
|
||||
@include('filament.components.verification-report.diagnostics', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
@php
|
||||
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-shared-zone="diagnostics"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Diagnostics
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Operation ID:</span>
|
||||
<span class="font-mono">{{ (int) ($diagnostics['operationRunId'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($diagnostics['flow'] ?? '') }}</span>
|
||||
</div>
|
||||
@if (filled($diagnostics['completedAt'] ?? null))
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
|
||||
<span>{{ $diagnostics['completedAt'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($diagnostics['fingerprint'] ?? null))
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $diagnostics['fingerprint'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($diagnostics['previousRunUrl'] ?? null))
|
||||
<div>
|
||||
<a
|
||||
href="{{ $diagnostics['previousRunUrl'] }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous operation
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,226 @@
|
||||
@php
|
||||
$issueGroups = collect($surface['issueGroups'] ?? [])
|
||||
->filter(static fn (mixed $group): bool => is_array($group))
|
||||
->values();
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
$ackAction = $ackAction ?? null;
|
||||
$showAssist = (bool) ($showAssist ?? false);
|
||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||
? trim((string) $assistActionName)
|
||||
: 'wizardVerificationRequiredPermissionsAssist';
|
||||
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="issues">
|
||||
@if ($issueGroups->isEmpty())
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = is_string($group['label'] ?? null) ? (string) $group['label'] : 'Issues';
|
||||
$checks = collect($group['checks'] ?? [])->filter(static fn (mixed $check): bool => is_array($check))->values();
|
||||
$acknowledged = (bool) ($group['acknowledged'] ?? false);
|
||||
@endphp
|
||||
|
||||
@if ($acknowledged)
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
|
||||
$message = is_string($check['message'] ?? null) && trim((string) $check['message']) !== '' ? trim((string) $check['message']) : null;
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
$acknowledgement = is_array($check['acknowledgement'] ?? null) ? $check['acknowledgement'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($acknowledgement)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if (filled($acknowledgement['ack_reason'] ?? null))
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $acknowledgement['ack_reason'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($acknowledgement['acknowledged_by']['name'] ?? null) || filled($acknowledgement['acknowledged_at'] ?? null))
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if (filled($acknowledgement['acknowledged_by']['name'] ?? null))
|
||||
{{ $acknowledgement['acknowledged_by']['name'] }}
|
||||
@endif
|
||||
@if (filled($acknowledgement['acknowledged_at'] ?? null))
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $acknowledgement['acknowledged_at'] }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($acknowledgement['expires_at'] ?? null))
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $acknowledgement['expires_at'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
|
||||
$message = is_string($check['message'] ?? null) && trim((string) $check['message']) !== '' ? trim((string) $check['message']) : null;
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
$nextSteps = collect($check['next_steps'] ?? [])->filter(static fn (mixed $step): bool => is_array($step))->take(2)->values();
|
||||
$blocking = (bool) ($check['blocking'] ?? false);
|
||||
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
|
||||
{{ ($ackAction)(['check_key' => $checkKey]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps->isNotEmpty())
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
$testId = $label !== '' ? 'verification-next-step-'.\Illuminate\Support\Str::slug($label) : null;
|
||||
$behavior = $routeNextStepsThroughAssist
|
||||
? null
|
||||
: $linkBehavior->describe($label, $url);
|
||||
@endphp
|
||||
|
||||
@if ($label !== '' && $url !== '')
|
||||
<li>
|
||||
@if ($routeNextStepsThroughAssist)
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
wire:click="mountAction('{{ $assistActionName }}')"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Open in assist
|
||||
</span>
|
||||
</button>
|
||||
@else
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
@if ((bool) ($behavior['opens_in_new_tab'] ?? false))
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
@if ((bool) ($behavior['show_new_tab_hint'] ?? false))
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Opens in new tab
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,34 @@
|
||||
@php
|
||||
$passedChecks = collect($surface['passedChecks'] ?? [])
|
||||
->filter(static fn (mixed $check): bool => is_array($check))
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="passed">
|
||||
@if ($passedChecks->isEmpty())
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passedChecks as $check)
|
||||
@php
|
||||
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,64 @@
|
||||
@php
|
||||
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
$changeIndicator = is_array($summary['changeIndicator'] ?? null) ? $summary['changeIndicator'] : null;
|
||||
$redactionNotes = is_array($redactionNotes ?? null)
|
||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-shared-zone="summary"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if (($changeIndicator['state'] ?? null) === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif (($changeIndicator['state'] ?? null) === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
|
||||
@if ($redactionNotes !== [])
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
@foreach ($redactionNotes as $note)
|
||||
<div>{{ $note }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,70 +1,26 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$runUrl = $runUrl ?? null;
|
||||
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$advancedRunUrl = $advancedRunUrl ?? null;
|
||||
$advancedRunUrl = is_string($advancedRunUrl) && $advancedRunUrl !== '' ? $advancedRunUrl : null;
|
||||
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
|
||||
$assistVisibility = $assistVisibility ?? [];
|
||||
$assistVisibility = is_array($assistVisibility) ? $assistVisibility : [];
|
||||
|
||||
$assistActionName = $assistActionName ?? 'wizardVerificationRequiredPermissionsAssist';
|
||||
$assistActionName = is_string($assistActionName) && trim($assistActionName) !== ''
|
||||
? trim($assistActionName)
|
||||
$run = is_array($run ?? null) ? $run : null;
|
||||
$runUrl = is_string($runUrl ?? null) && trim((string) $runUrl) !== '' ? trim((string) $runUrl) : null;
|
||||
$surface = is_array($surface ?? null) ? $surface : [];
|
||||
$redactionNotes = is_array($redactionNotes ?? null)
|
||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||
: [];
|
||||
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||
? trim((string) $assistActionName)
|
||||
: 'wizardVerificationRequiredPermissionsAssist';
|
||||
|
||||
$technicalDetailsActionName = $technicalDetailsActionName ?? 'wizardVerificationTechnicalDetails';
|
||||
$technicalDetailsActionName = is_string($technicalDetailsActionName) && trim($technicalDetailsActionName) !== ''
|
||||
? trim($technicalDetailsActionName)
|
||||
$technicalDetailsActionName = is_string($technicalDetailsActionName ?? null) && trim((string) $technicalDetailsActionName) !== ''
|
||||
? trim((string) $technicalDetailsActionName)
|
||||
: 'wizardVerificationTechnicalDetails';
|
||||
|
||||
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
||||
$assistReason = $assistVisibility['reason'] ?? 'hidden_irrelevant';
|
||||
$assistReason = is_string($assistReason) ? $assistReason : 'hidden_irrelevant';
|
||||
|
||||
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
|
||||
$assistDescription = match ($assistReason) {
|
||||
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
|
||||
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
|
||||
default => 'Review required permissions without leaving onboarding.',
|
||||
};
|
||||
|
||||
$status = $run['status'] ?? null;
|
||||
$status = is_string($status) ? $status : null;
|
||||
|
||||
$outcome = $run['outcome'] ?? null;
|
||||
$outcome = is_string($outcome) ? $outcome : null;
|
||||
|
||||
$targetScope = $run['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = $run['failures'] ?? [];
|
||||
$failures = is_array($failures) ? $failures : [];
|
||||
|
||||
$completedAt = $run['completed_at'] ?? null;
|
||||
$completedAt = is_string($completedAt) && $completedAt !== '' ? $completedAt : null;
|
||||
|
||||
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
||||
$completedAtLabel = null;
|
||||
|
||||
if ($completedAt !== null) {
|
||||
@ -75,81 +31,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
$status = is_string($run['status'] ?? null) ? (string) $run['status'] : null;
|
||||
$runState = is_string($runState ?? null) ? (string) $runState : null;
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
|
||||
$runState = $run === null
|
||||
? 'no_run'
|
||||
: ($status === 'completed' ? 'completed' : 'active');
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
|
||||
$ackAction = null;
|
||||
|
||||
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
|
||||
@ -157,15 +47,6 @@
|
||||
}
|
||||
|
||||
$linkBehavior = app(\App\Support\Verification\VerificationLinkBehavior::class);
|
||||
|
||||
$runState = $runState ?? null;
|
||||
$runState = is_string($runState) ? $runState : null;
|
||||
|
||||
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
|
||||
$runState = $run === null
|
||||
? 'no_run'
|
||||
: ($status === 'completed' ? 'completed' : 'active');
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@ -211,65 +92,6 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
@php
|
||||
$overallSpec = $summary === null
|
||||
? null
|
||||
: \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($overallSpec)
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($runUrl)
|
||||
<x-filament::button
|
||||
@ -315,338 +137,15 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This operation doesn’t have a report yet. If it already completed, start verification again.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="{ tab: 'issues' }"
|
||||
class="space-y-4"
|
||||
>
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
alpine-active="tab === 'issues'"
|
||||
x-on:click="tab = 'issues'"
|
||||
>
|
||||
Issues
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'passed'"
|
||||
x-on:click="tab = 'passed'"
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
|
||||
{{ ($ackAction)(['check_key' => $checkKey]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$testId = is_string($label) && $label !== ''
|
||||
? 'verification-next-step-'.\Illuminate\Support\Str::slug($label)
|
||||
: null;
|
||||
$behavior = $routeNextStepsThroughAssist
|
||||
? null
|
||||
: $linkBehavior->describe(
|
||||
is_string($label) ? $label : null,
|
||||
is_string($url) ? $url : null,
|
||||
);
|
||||
$opensInNewTab = (bool) ($behavior['opens_in_new_tab'] ?? false);
|
||||
$showNewTabHint = (bool) ($behavior['show_new_tab_hint'] ?? false);
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
@if ($routeNextStepsThroughAssist)
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
wire:click="mountAction('{{ $assistActionName }}')"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Open in assist
|
||||
</span>
|
||||
</button>
|
||||
@else
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
@if ($opensInNewTab)
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
@if ($showNewTabHint)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Opens in new tab
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('filament.components.verification-report-viewer', [
|
||||
'surface' => $surface,
|
||||
'redactionNotes' => $redactionNotes,
|
||||
'canAcknowledge' => (bool) ($canAcknowledge ?? false),
|
||||
'ackAction' => $ackAction,
|
||||
'showAssist' => $showAssist,
|
||||
'assistActionName' => $assistActionName,
|
||||
'linkBehavior' => $linkBehavior,
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
@ -1,787 +1,2 @@
|
||||
@php
|
||||
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
$summary = $diff['summary'] ?? [];
|
||||
$policyType = $diff['policy_type'] ?? null;
|
||||
|
||||
$groupByBlock = static function (array $items): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
[$group, $label] = $parts;
|
||||
} else {
|
||||
$group = 'Other';
|
||||
$label = $path;
|
||||
}
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
};
|
||||
|
||||
$stringify = static function (mixed $value): string {
|
||||
if ($value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||
};
|
||||
|
||||
$isExpandable = static function (mixed $value): bool {
|
||||
if (is_array($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && strlen($value) > 160;
|
||||
};
|
||||
|
||||
$isScriptKey = static function (mixed $name): bool {
|
||||
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
|
||||
};
|
||||
|
||||
$canHighlightScripts = static function (?string $policyType): bool {
|
||||
return (bool) config('tenantpilot.display.show_script_content', false)
|
||||
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
|
||||
};
|
||||
|
||||
$selectGrammar = static function (?string $policyType, string $code): string {
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
return 'zsh';
|
||||
}
|
||||
|
||||
if (str_contains($shebang, 'bash')) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'powershell';
|
||||
};
|
||||
|
||||
$highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: false,
|
||||
);
|
||||
|
||||
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
|
||||
|
||||
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[0] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$splitLines = static function (string $text): array {
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
return $text === '' ? [] : explode("\n", $text);
|
||||
};
|
||||
|
||||
$myersLineDiff = static function (array $a, array $b): array {
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
$max = $n + $m;
|
||||
|
||||
$v = [1 => 0];
|
||||
$trace = [];
|
||||
|
||||
for ($d = 0; $d <= $max; $d++) {
|
||||
$trace[$d] = $v;
|
||||
|
||||
for ($k = -$d; $k <= $d; $k += 2) {
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$x = $kPlus;
|
||||
} else {
|
||||
$x = $kMinus + 1;
|
||||
}
|
||||
|
||||
$y = $x - $k;
|
||||
|
||||
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
|
||||
$x++;
|
||||
$y++;
|
||||
}
|
||||
|
||||
$v[$k] = $x;
|
||||
|
||||
if ($x >= $n && $y >= $m) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ops = [];
|
||||
$x = $n;
|
||||
$y = $m;
|
||||
|
||||
for ($d = count($trace) - 1; $d >= 0; $d--) {
|
||||
$v = $trace[$d];
|
||||
$k = $x - $y;
|
||||
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$prevK = $k + 1;
|
||||
} else {
|
||||
$prevK = $k - 1;
|
||||
}
|
||||
|
||||
$prevX = $v[$prevK] ?? 0;
|
||||
$prevY = $prevX - $prevK;
|
||||
|
||||
while ($x > $prevX && $y > $prevY) {
|
||||
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
|
||||
$x--;
|
||||
$y--;
|
||||
}
|
||||
|
||||
if ($d === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($x === $prevX) {
|
||||
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
|
||||
$y--;
|
||||
} else {
|
||||
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
|
||||
$x--;
|
||||
}
|
||||
}
|
||||
|
||||
return array_reverse($ops);
|
||||
};
|
||||
|
||||
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
|
||||
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Normalized diff"
|
||||
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@foreach (['changed' => ['label' => 'Changed', 'collapsed' => false], 'added' => ['label' => 'Added', 'collapsed' => true], 'removed' => ['label' => 'Removed', 'collapsed' => true]] as $key => $meta)
|
||||
@php
|
||||
$items = $diff[$key] ?? [];
|
||||
$groups = $groupByBlock(is_array($items) ? $items : []);
|
||||
@endphp
|
||||
|
||||
@if ($groups !== [])
|
||||
<x-filament::section
|
||||
:heading="$meta['label']"
|
||||
collapsible
|
||||
:collapsed="$meta['collapsed']"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
@foreach ($groups as $group => $groupItems)
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $group }}
|
||||
</div>
|
||||
<x-filament::badge size="sm" color="gray">
|
||||
{{ count($groupItems) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
@foreach ($groupItems as $name => $value)
|
||||
<div class="px-4 py-3">
|
||||
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
|
||||
@php
|
||||
$from = $value['from'];
|
||||
$to = $value['to'];
|
||||
$fromText = $stringify($from);
|
||||
$toText = $stringify($to);
|
||||
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
|
||||
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
|
||||
|
||||
$rows = [];
|
||||
if ($isScriptContent) {
|
||||
$count = count($ops);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$op = $ops[$i];
|
||||
$next = $ops[$i + 1] ?? null;
|
||||
$type = $op['type'] ?? null;
|
||||
$line = (string) ($op['line'] ?? '');
|
||||
|
||||
if ($type === 'equal') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'equal', 'line' => $line],
|
||||
'right' => ['type' => 'equal', 'line' => $line],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
|
||||
];
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'blank', 'line' => ''],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'blank', 'line' => ''],
|
||||
'right' => ['type' => 'insert', 'line' => $line],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
|
||||
@if ($isScriptContent)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
|
||||
<details class="mt-1" x-data="{ fullscreenOpen: false }">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
|
||||
⤢ Fullscreen
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="fullscreenOpen"
|
||||
x-cloak
|
||||
x-on:keydown.escape.window="fullscreenOpen = false"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50"></div>
|
||||
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
|
||||
Close
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
<div
|
||||
x-data="{
|
||||
tab: 'diff',
|
||||
syncing: false,
|
||||
syncHorizontal: true,
|
||||
sync(from, to) {
|
||||
if (this.syncing) return;
|
||||
this.syncing = true;
|
||||
|
||||
to.scrollTop = from.scrollTop;
|
||||
|
||||
const bothHorizontal = this.syncHorizontal
|
||||
&& from.scrollWidth > from.clientWidth
|
||||
&& to.scrollWidth > to.clientWidth;
|
||||
|
||||
if (bothHorizontal) {
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => { this.syncing = false; });
|
||||
},
|
||||
}"
|
||||
x-init="$nextTick(() => {
|
||||
const left = $refs.left;
|
||||
const right = $refs.right;
|
||||
|
||||
if (!left || !right) return;
|
||||
|
||||
left.addEventListener('scroll', () => sync(left, right), { passive: true });
|
||||
right.addEventListener('scroll', () => sync(right, left), { passive: true });
|
||||
})"
|
||||
class="h-full space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre x-ref="left" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre x-ref="right" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
|
||||
@if ($isExpandable($from))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
|
||||
@if ($isExpandable($to))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $toText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$text = $stringify($value);
|
||||
@endphp
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
|
||||
@if ($isExpandable($value))
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
@php
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlighted) && $highlighted !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
|
||||
@endif
|
||||
</details>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- NormalizedDiffSurface normalized-diff wrapper --}}
|
||||
@include('filament.infolists.entries.normalized-diff.wrapper', ['state' => $getState() ?? []])
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
@php
|
||||
$emptyState = is_array($emptyState ?? null) ? $emptyState : [];
|
||||
$availabilityState = is_string($availabilityState ?? null) ? (string) $availabilityState : 'available';
|
||||
$toneClasses = match ($availabilityState) {
|
||||
'unavailable' => 'border-danger-200 bg-danger-50 text-danger-900 dark:border-danger-500/30 dark:bg-danger-500/10 dark:text-danger-100',
|
||||
'partial' => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
|
||||
default => 'border-gray-200 bg-white text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-200',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="empty" class="rounded-lg border px-4 py-3 text-sm {{ $toneClasses }}">
|
||||
<div class="font-medium">
|
||||
{{ $emptyState['title'] ?? 'No normalized changes' }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ $emptyState['message'] ?? 'No normalized changes were found.' }}
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,772 @@
|
||||
@php
|
||||
$diff = is_array($surface['raw'] ?? null) ? $surface['raw'] : ['changed' => [], 'added' => [], 'removed' => []];
|
||||
$groupMeta = collect($surface['groups'] ?? [])
|
||||
->filter(static fn (mixed $group): bool => is_array($group))
|
||||
->values();
|
||||
$policyType = is_string($surface['scriptRendering']['policyType'] ?? null) ? (string) $surface['scriptRendering']['policyType'] : null;
|
||||
|
||||
$groupByBlock = static function (array $items): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
[$group, $label] = $parts;
|
||||
} else {
|
||||
$group = 'Other';
|
||||
$label = $path;
|
||||
}
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
};
|
||||
|
||||
$stringify = static function (mixed $value): string {
|
||||
if ($value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||
};
|
||||
|
||||
$isExpandable = static function (mixed $value): bool {
|
||||
if (is_array($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && strlen($value) > 160;
|
||||
};
|
||||
|
||||
$isScriptKey = static function (mixed $name): bool {
|
||||
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
|
||||
};
|
||||
|
||||
$canHighlightScripts = static function (?string $policyType): bool {
|
||||
return (bool) config('tenantpilot.display.show_script_content', false)
|
||||
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
|
||||
};
|
||||
|
||||
$selectGrammar = static function (?string $policyType, string $code): string {
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
return 'zsh';
|
||||
}
|
||||
|
||||
if (str_contains($shebang, 'bash')) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'powershell';
|
||||
};
|
||||
|
||||
$highlight = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: false,
|
||||
);
|
||||
|
||||
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
|
||||
|
||||
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[0] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$splitLines = static function (string $text): array {
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
return $text === '' ? [] : explode("\n", $text);
|
||||
};
|
||||
|
||||
$myersLineDiff = static function (array $a, array $b): array {
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
$max = $n + $m;
|
||||
$v = [1 => 0];
|
||||
$trace = [];
|
||||
|
||||
for ($d = 0; $d <= $max; $d++) {
|
||||
$trace[$d] = $v;
|
||||
|
||||
for ($k = -$d; $k <= $d; $k += 2) {
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$x = $kPlus;
|
||||
} else {
|
||||
$x = $kMinus + 1;
|
||||
}
|
||||
|
||||
$y = $x - $k;
|
||||
|
||||
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
|
||||
$x++;
|
||||
$y++;
|
||||
}
|
||||
|
||||
$v[$k] = $x;
|
||||
|
||||
if ($x >= $n && $y >= $m) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ops = [];
|
||||
$x = $n;
|
||||
$y = $m;
|
||||
|
||||
for ($d = count($trace) - 1; $d >= 0; $d--) {
|
||||
$v = $trace[$d];
|
||||
$k = $x - $y;
|
||||
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$prevK = $k + 1;
|
||||
} else {
|
||||
$prevK = $k - 1;
|
||||
}
|
||||
|
||||
$prevX = $v[$prevK] ?? 0;
|
||||
$prevY = $prevX - $prevK;
|
||||
|
||||
while ($x > $prevX && $y > $prevY) {
|
||||
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
|
||||
$x--;
|
||||
$y--;
|
||||
}
|
||||
|
||||
if ($d === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($x === $prevX) {
|
||||
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
|
||||
$y--;
|
||||
} else {
|
||||
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
|
||||
$x--;
|
||||
}
|
||||
}
|
||||
|
||||
return array_reverse($ops);
|
||||
};
|
||||
|
||||
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
|
||||
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="groups" class="space-y-4">
|
||||
@foreach ($groupMeta as $group)
|
||||
@php
|
||||
$key = is_string($group['key'] ?? null) ? (string) $group['key'] : 'changed';
|
||||
$label = is_string($group['label'] ?? null) ? (string) $group['label'] : ucfirst($key);
|
||||
$collapsed = (bool) ($group['collapsed'] ?? false);
|
||||
$items = is_array($diff[$key] ?? null) ? $diff[$key] : [];
|
||||
$groups = $groupByBlock($items);
|
||||
@endphp
|
||||
|
||||
@if ($groups !== [])
|
||||
<x-filament::section
|
||||
:heading="$label"
|
||||
collapsible
|
||||
:collapsed="$collapsed"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
@foreach ($groups as $groupLabel => $groupItems)
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $groupLabel }}
|
||||
</div>
|
||||
<x-filament::badge size="sm" color="gray">
|
||||
{{ count($groupItems) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
@foreach ($groupItems as $name => $value)
|
||||
<div class="px-4 py-3">
|
||||
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
|
||||
@php
|
||||
$from = $value['from'];
|
||||
$to = $value['to'];
|
||||
$fromText = $stringify($from);
|
||||
$toText = $stringify($to);
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
|
||||
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
|
||||
|
||||
$rows = [];
|
||||
if ($isScriptContent) {
|
||||
$count = count($ops);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$op = $ops[$i];
|
||||
$next = $ops[$i + 1] ?? null;
|
||||
$type = $op['type'] ?? null;
|
||||
$line = (string) ($op['line'] ?? '');
|
||||
|
||||
if ($type === 'equal') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'equal', 'line' => $line],
|
||||
'right' => ['type' => 'equal', 'line' => $line],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
|
||||
];
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'blank', 'line' => ''],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'blank', 'line' => ''],
|
||||
'right' => ['type' => 'insert', 'line' => $line],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
|
||||
@if ($isScriptContent)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
|
||||
<details class="mt-1" x-data="{ fullscreenOpen: false }">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
|
||||
⤢ Fullscreen
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="fullscreenOpen"
|
||||
x-cloak
|
||||
x-on:keydown.escape.window="fullscreenOpen = false"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50"></div>
|
||||
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
|
||||
Close
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
<div
|
||||
x-data="{
|
||||
tab: 'diff',
|
||||
syncing: false,
|
||||
syncHorizontal: true,
|
||||
sync(from, to) {
|
||||
if (this.syncing) return;
|
||||
this.syncing = true;
|
||||
to.scrollTop = from.scrollTop;
|
||||
|
||||
const bothHorizontal = this.syncHorizontal
|
||||
&& from.scrollWidth > from.clientWidth
|
||||
&& to.scrollWidth > to.clientWidth;
|
||||
|
||||
if (bothHorizontal) {
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => { this.syncing = false; });
|
||||
},
|
||||
}"
|
||||
x-init="$nextTick(() => {
|
||||
const left = $refs.left;
|
||||
const right = $refs.right;
|
||||
|
||||
if (! left || ! right) {
|
||||
return;
|
||||
}
|
||||
|
||||
left.addEventListener('scroll', () => sync(left, right), { passive: true });
|
||||
right.addEventListener('scroll', () => sync(right, left), { passive: true });
|
||||
})"
|
||||
class="h-full space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre x-ref="left" class="mt-2 flex-1 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre x-ref="right" class="mt-2 flex-1 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
|
||||
@if ($isExpandable($from))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
|
||||
@if ($isExpandable($to))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $toText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$text = $stringify($value);
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
|
||||
@if ($isExpandable($value))
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
@if (is_string($highlighted) && $highlighted !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
|
||||
@endif
|
||||
</details>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@ -0,0 +1,30 @@
|
||||
@php
|
||||
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
|
||||
$availabilityState = is_string($surface['availabilityState'] ?? null) ? (string) $surface['availabilityState'] : 'available';
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="summary">
|
||||
<x-filament::section heading="Normalized diff">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($availabilityState === 'unavailable')
|
||||
<x-filament::badge color="danger">
|
||||
Unavailable
|
||||
</x-filament::badge>
|
||||
@elseif ($availabilityState === 'partial')
|
||||
<x-filament::badge color="warning">
|
||||
Partial
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@ -0,0 +1,47 @@
|
||||
@php
|
||||
$state = is_array($state ?? null) ? $state : [];
|
||||
|
||||
$surface = array_key_exists('renderExpectations', $state)
|
||||
? $state
|
||||
: \App\Filament\Support\NormalizedDiffSurface::build($state, 'unknown');
|
||||
|
||||
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
|
||||
$availabilityState = is_string($surface['availabilityState'] ?? null) ? (string) $surface['availabilityState'] : 'available';
|
||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
||||
$hasChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
|
||||
|
||||
if ($emptyState === null && ! $hasChanges && $availabilityState === 'available') {
|
||||
$message = is_string($summary['message'] ?? null) && trim((string) $summary['message']) !== ''
|
||||
? trim((string) $summary['message'])
|
||||
: 'No normalized changes were found.';
|
||||
|
||||
$emptyState = [
|
||||
'title' => 'No normalized changes',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="space-y-4"
|
||||
data-shared-detail-family="normalized-diff"
|
||||
data-shared-normalized-diff-host="{{ $surface['hostKind'] ?? 'unknown' }}"
|
||||
data-shared-normalized-diff-state="{{ $availabilityState }}"
|
||||
>
|
||||
@include('filament.infolists.entries.normalized-diff.summary', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
|
||||
@if ($emptyState !== null)
|
||||
@include('filament.infolists.entries.normalized-diff.empty-state', [
|
||||
'emptyState' => $emptyState,
|
||||
'availabilityState' => $availabilityState,
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($hasChanges)
|
||||
@include('filament.infolists.entries.normalized-diff.groups', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
@ -1,233 +1,2 @@
|
||||
@php
|
||||
$normalized = $getState() ?? [];
|
||||
$warnings = $normalized['warnings'] ?? [];
|
||||
$settings = $normalized['settings'] ?? [];
|
||||
$settingsTable = $normalized['settings_table'] ?? null;
|
||||
$settingsTableRows = is_array($settingsTable) ? ($settingsTable['rows'] ?? []) : [];
|
||||
$context = $normalized['context'] ?? 'policy';
|
||||
$recordId = $normalized['record_id'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@if (! empty($warnings))
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<div class="font-semibold">Warnings</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
@foreach ($warnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (empty($settings) && empty($settingsTableRows))
|
||||
<p class="text-sm text-gray-600">No settings available.</p>
|
||||
@endif
|
||||
|
||||
@if (! empty($settingsTableRows))
|
||||
@php
|
||||
$settingsTableTitle = is_array($settingsTable) ? ($settingsTable['title'] ?? null) : null;
|
||||
$shouldShowTitle = is_string($settingsTableTitle)
|
||||
&& $settingsTableTitle !== ''
|
||||
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2">
|
||||
@if ($shouldShowTitle)
|
||||
<div class="text-sm font-semibold text-gray-800">{{ $settingsTableTitle }}</div>
|
||||
@endif
|
||||
|
||||
<livewire:settings-catalog-settings-table
|
||||
:settings-rows="$settingsTableRows"
|
||||
:context="$context"
|
||||
:key="$recordId ? ('sc-settings-'.$context.'-'.$recordId) : ('sc-settings-'.$context)"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach ($settings as $block)
|
||||
@php
|
||||
$title = $block['title'] ?? 'Settings';
|
||||
$isGeneral = is_string($title) && strtolower($title) === 'general';
|
||||
@endphp
|
||||
|
||||
@if ($isGeneral)
|
||||
<x-filament::section
|
||||
:heading="$title"
|
||||
collapsible
|
||||
:collapsed="true"
|
||||
data-block="general"
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['entries'] ?? []) }} fields
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
@if (($block['type'] ?? 'keyValue') === 'table')
|
||||
@php
|
||||
$columns = $block['columns'] ?? null;
|
||||
$hasColumns = is_array($columns) && ! empty($columns);
|
||||
$columnMeta = [
|
||||
'definitionId' => ['width' => 'w-[35%]', 'style' => 'width: 35%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'instanceType' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'value' => ['width' => 'w-[25%]', 'style' => 'width: 25%;', 'cell' => 'break-words whitespace-pre-wrap', 'cellStyle' => 'overflow-wrap: anywhere; white-space: pre-wrap;'],
|
||||
'path' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
];
|
||||
@endphp
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200" style="overflow-x: auto;">
|
||||
<table class="min-w-[900px] w-full table-fixed text-left text-sm" style="table-layout: fixed; width: 100%; min-width: 900px;">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<th class="px-3 py-2 {{ $meta['width'] ?? '' }}" style="{{ $meta['style'] ?? '' }}">{{ $column['label'] ?? $column['key'] ?? '-' }}</th>
|
||||
@endforeach
|
||||
@else
|
||||
<th class="px-3 py-2">Path</th>
|
||||
<th class="px-3 py-2">Value</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($block['rows'] ?? [] as $row)
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$cell = is_string($key) ? ($row[$key] ?? null) : null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<td class="px-3 py-2 align-top text-gray-800 {{ $meta['cell'] ?? 'whitespace-pre-wrap' }}" style="{{ $meta['cellStyle'] ?? '' }}">
|
||||
@if (is_array($cell))
|
||||
<pre class="overflow-x-auto text-xs">{{ json_encode($cell, JSON_PRETTY_PRINT) }}</pre>
|
||||
@elseif (is_bool($cell))
|
||||
<span>{{ $cell ? 'true' : 'false' }}</span>
|
||||
@else
|
||||
<span title="{{ is_string($cell) ? $cell : '' }}">{{ $cell ?? '-' }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@else
|
||||
<td class="px-3 py-2 align-top">
|
||||
<div class="font-mono text-xs font-medium text-gray-800 break-all whitespace-normal" style="word-break: break-all; overflow-wrap: anywhere; white-space: normal;">{{ $row['path'] ?? '-' }}</div>
|
||||
@if (! empty($row['label']))
|
||||
<div class="text-xs text-gray-600">{{ $row['label'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top text-gray-800 break-words whitespace-pre-wrap" style="overflow-wrap: anywhere; white-space: pre-wrap;">
|
||||
{{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($block['entries'] ?? [] as $entry)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] ?? '-' }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words">
|
||||
{{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@else
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="text-sm font-semibold text-gray-800">{{ $title }}</div>
|
||||
|
||||
@if (($block['type'] ?? 'keyValue') === 'table')
|
||||
@php
|
||||
$columns = $block['columns'] ?? null;
|
||||
$hasColumns = is_array($columns) && ! empty($columns);
|
||||
$columnMeta = [
|
||||
'definitionId' => ['width' => 'w-[35%]', 'style' => 'width: 35%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'instanceType' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'value' => ['width' => 'w-[25%]', 'style' => 'width: 25%;', 'cell' => 'break-words whitespace-pre-wrap', 'cellStyle' => 'overflow-wrap: anywhere; white-space: pre-wrap;'],
|
||||
'path' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
];
|
||||
@endphp
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200" style="overflow-x: auto;">
|
||||
<table class="min-w-[900px] w-full table-fixed text-left text-sm" style="table-layout: fixed; width: 100%; min-width: 900px;">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<th class="px-3 py-2 {{ $meta['width'] ?? '' }}" style="{{ $meta['style'] ?? '' }}">{{ $column['label'] ?? $column['key'] ?? '-' }}</th>
|
||||
@endforeach
|
||||
@else
|
||||
<th class="px-3 py-2">Path</th>
|
||||
<th class="px-3 py-2">Value</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($block['rows'] ?? [] as $row)
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$cell = is_string($key) ? ($row[$key] ?? null) : null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<td class="px-3 py-2 align-top text-gray-800 {{ $meta['cell'] ?? 'whitespace-pre-wrap' }}" style="{{ $meta['cellStyle'] ?? '' }}">
|
||||
@if (is_array($cell))
|
||||
<pre class="overflow-x-auto text-xs">{{ json_encode($cell, JSON_PRETTY_PRINT) }}</pre>
|
||||
@elseif (is_bool($cell))
|
||||
<span>{{ $cell ? 'true' : 'false' }}</span>
|
||||
@else
|
||||
<span title="{{ is_string($cell) ? $cell : '' }}">{{ $cell ?? '-' }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@else
|
||||
<td class="px-3 py-2 align-top">
|
||||
<div class="font-mono text-xs font-medium text-gray-800 break-all whitespace-normal" style="word-break: break-all; overflow-wrap: anywhere; white-space: normal;">{{ $row['path'] ?? '-' }}</div>
|
||||
@if (! empty($row['label']))
|
||||
<div class="text-xs text-gray-600">{{ $row['label'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top text-gray-800 break-words whitespace-pre-wrap" style="overflow-wrap: anywhere; white-space: pre-wrap;">
|
||||
{{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
@foreach ($block['entries'] ?? [] as $entry)
|
||||
<div class="rounded border border-gray-100 bg-gray-50 p-3">
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">{{ $entry['key'] ?? '-' }}</dt>
|
||||
<dd class="whitespace-pre-wrap text-sm text-gray-800">
|
||||
{{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- NormalizedSettingsSurface normalized-settings wrapper --}}
|
||||
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
@php
|
||||
$settingsTable = is_array($settingsTable ?? null) ? $settingsTable : null;
|
||||
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
|
||||
$context = is_string($context ?? null) && $context !== '' ? $context : 'policy';
|
||||
$recordId = is_scalar($recordId ?? null) ? (string) $recordId : null;
|
||||
$settingsTableTitle = is_string($settingsTable['title'] ?? null) ? $settingsTable['title'] : null;
|
||||
$shouldShowTitle = is_string($settingsTableTitle)
|
||||
&& $settingsTableTitle !== ''
|
||||
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2" data-shared-zone="settings-table">
|
||||
@if ($shouldShowTitle)
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $settingsTableTitle }}</div>
|
||||
@endif
|
||||
|
||||
<livewire:settings-catalog-settings-table
|
||||
:settings-rows="$settingsTableRows"
|
||||
:context="$context"
|
||||
:key="$recordId ? ('sc-settings-'.$context.'-'.$recordId) : ('sc-settings-'.$context)"
|
||||
/>
|
||||
</div>
|
||||
@ -0,0 +1,273 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$blocks = is_array($blocks ?? null) ? $blocks : [];
|
||||
$policyType = is_string($policyType ?? null) ? $policyType : null;
|
||||
|
||||
$stringifyValue = function (mixed $value): string {
|
||||
if (is_null($value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (method_exists($value, '__toString')) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
$shouldRenderBadges = function (mixed $value): bool {
|
||||
if (! is_array($value) || $value === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (! is_scalar($item) && ! is_null($item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
|
||||
return $spec->label === 'Unknown' ? null : $spec;
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4" data-shared-zone="blocks">
|
||||
@foreach ($blocks as $block)
|
||||
@php
|
||||
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@if ($blockType === 'table')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['rows'] ?? []) }} {{ Str::plural('item', count($block['rows'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($block['rows'] ?? [] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
|
||||
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
|
||||
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
|
||||
<p class="mt-0.5 break-all text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
{{ (string) $row['path'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (! empty($row['description']))
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ Str::limit($row['description'], 80) }}</p>
|
||||
@endif
|
||||
</dt>
|
||||
<dd class="mt-1 sm:col-span-2 sm:mt-0">
|
||||
@php
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if ($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif (is_numeric($row['value'] ?? null))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
@elseif ($shouldRenderBadges($row['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach (($row['value'] ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if ($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="break-words text-sm text-gray-900 dark:text-white">
|
||||
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@elseif ($blockType === 'keyValue')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['entries'] ?? []) }} {{ Str::plural('entry', count($block['entries'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($block['entries'] ?? [] as $entry)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] ?? 'Setting' }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:col-span-2 sm:mt-0">
|
||||
@php
|
||||
$rawValue = $entry['value'] ?? null;
|
||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
|
||||
@endphp
|
||||
|
||||
@if ($isScriptContent)
|
||||
@php
|
||||
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
$grammar = 'powershell';
|
||||
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
$grammar = 'zsh';
|
||||
} elseif (str_contains($shebang, 'bash')) {
|
||||
$grammar = 'bash';
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
|
||||
$grammar = 'powershell';
|
||||
}
|
||||
|
||||
$highlightedHtml = null;
|
||||
|
||||
if (class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
try {
|
||||
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $grammar,
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$highlightedHtml = null;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div x-data="{ open: false }" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button
|
||||
size="xs"
|
||||
color="gray"
|
||||
type="button"
|
||||
x-on:click="open = !open"
|
||||
>
|
||||
<span x-cloak x-show="!open">Show</span>
|
||||
<span x-cloak x-show="open">Hide</span>
|
||||
</x-filament::button>
|
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ number_format(Str::length($code)) }} chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="open">
|
||||
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
|
||||
@else
|
||||
<pre class="whitespace-pre-wrap break-words text-xs font-mono text-gray-900 dark:text-white">{{ $code }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($shouldRenderBadges($rawValue))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach (($rawValue ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if ($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="break-words text-sm text-gray-900 dark:text-white">
|
||||
{{ Str::limit($stringifyValue($rawValue), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@ -0,0 +1,56 @@
|
||||
@php
|
||||
$state = is_array($state ?? null) ? $state : [];
|
||||
|
||||
$surface = array_key_exists('renderExpectations', $state)
|
||||
? $state
|
||||
: \App\Filament\Support\NormalizedSettingsSurface::build(
|
||||
$state,
|
||||
($state['context'] ?? 'policy') === 'policy' ? 'policy' : 'policy_version'
|
||||
);
|
||||
|
||||
$warnings = is_array($surface['warnings'] ?? null) ? $surface['warnings'] : [];
|
||||
$settingsTable = is_array($surface['settingsTable'] ?? null) ? $surface['settingsTable'] : null;
|
||||
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
|
||||
$blocks = is_array($surface['blocks'] ?? null) ? $surface['blocks'] : [];
|
||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="space-y-4"
|
||||
data-shared-detail-family="normalized-settings"
|
||||
data-shared-normalized-settings-host="{{ $surface['hostKind'] ?? 'unknown' }}"
|
||||
data-shared-normalized-settings-variant="{{ $surface['variant'] ?? 'standard_blocks' }}"
|
||||
>
|
||||
@if ($warnings !== [])
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800" data-shared-zone="warnings">
|
||||
<div class="font-semibold">Warnings</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
@foreach ($warnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($settingsTableRows !== [])
|
||||
@include('filament.infolists.entries.normalized-settings.catalog-table', [
|
||||
'settingsTable' => $settingsTable,
|
||||
'context' => $surface['context'] ?? 'policy',
|
||||
'recordId' => $surface['recordId'] ?? null,
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($blocks !== [])
|
||||
@include('filament.infolists.entries.normalized-settings.standard-blocks', [
|
||||
'blocks' => $blocks,
|
||||
'policyType' => $surface['policyType'] ?? null,
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($settingsTableRows === [] && $blocks === [] && $emptyState !== null)
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-8 text-center dark:border-white/15 dark:bg-gray-900/40" data-shared-zone="empty">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $emptyState['title'] ?? 'No settings available.' }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['message'] ?? 'No normalized settings payload is available for this host.' }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,355 +1,2 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// Extract state from Filament ViewEntry
|
||||
$state = $getState();
|
||||
$status = $state['status'] ?? 'success';
|
||||
$warnings = $state['warnings'] ?? [];
|
||||
$settings = $state['settings'] ?? [];
|
||||
$settingsTable = $state['settings_table'] ?? null;
|
||||
|
||||
$policyType = $state['policy_type'] ?? null;
|
||||
|
||||
$stringifyValue = function (mixed $value): string {
|
||||
if (is_null($value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (method_exists($value, '__toString')) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
$shouldRenderBadges = function (mixed $value): bool {
|
||||
if (! is_array($value) || $value === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (! is_scalar($item) && ! is_null($item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
|
||||
return $spec->label === 'Unknown' ? null : $spec;
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Warnings --}}
|
||||
@if(!empty($warnings))
|
||||
<x-filament::section>
|
||||
<div class="space-y-2">
|
||||
@foreach($warnings as $warning)
|
||||
<div class="flex items-start gap-2 text-sm text-warning-600 dark:text-warning-400">
|
||||
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<span>{{ $warning }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Settings Table (for Settings Catalog legacy format) --}}
|
||||
@if($settingsTable && !empty($settingsTable['rows']))
|
||||
<x-filament::section
|
||||
:heading="$settingsTable['title'] ?? 'Settings'"
|
||||
:description="$settingsTable['description'] ?? null"
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($settingsTable['rows']) }} {{ Str::plural('setting', count($settingsTable['rows'])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($settingsTable['rows'] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $row['definition'] ?? $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
@php
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
|
||||
@else
|
||||
{{ $row['value'] ?? 'N/A' }}
|
||||
@endif
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}}
|
||||
@foreach($settings as $block)
|
||||
@php
|
||||
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@if($blockType === 'table')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['rows'] ?? []) }} {{ Str::plural('item', count($block['rows'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($block['rows'] ?? [] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
|
||||
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
|
||||
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
|
||||
<p class="mt-0.5 text-xs font-mono text-gray-400 dark:text-gray-500 break-all">
|
||||
{{ (string) $row['path'] }}
|
||||
</p>
|
||||
@endif
|
||||
@if(!empty($row['description']))
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
|
||||
@endif
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@php
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
@elseif($shouldRenderBadges($row['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($row['value'] ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@elseif($blockType === 'keyValue')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['entries'] ?? []) }} {{ Str::plural('entry', count($block['entries'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($block['entries'] ?? [] as $entry)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@php
|
||||
$rawValue = $entry['value'] ?? null;
|
||||
|
||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
|
||||
@endphp
|
||||
|
||||
@if($isScriptContent)
|
||||
@php
|
||||
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
|
||||
$grammar = 'powershell';
|
||||
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
$grammar = 'zsh';
|
||||
} elseif (str_contains($shebang, 'bash')) {
|
||||
$grammar = 'bash';
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
|
||||
$grammar = 'powershell';
|
||||
}
|
||||
|
||||
$highlightedHtml = null;
|
||||
|
||||
if (class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
try {
|
||||
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $grammar,
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$highlightedHtml = null;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div x-data="{ open: false }" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button
|
||||
size="xs"
|
||||
color="gray"
|
||||
type="button"
|
||||
x-on:click="open = !open"
|
||||
>
|
||||
<span x-show="!open" x-cloak>Show</span>
|
||||
<span x-show="open" x-cloak>Hide</span>
|
||||
</x-filament::button>
|
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ number_format(Str::length($code)) }} chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-cloak>
|
||||
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
|
||||
@else
|
||||
<pre class="text-xs font-mono text-gray-900 dark:text-white whitespace-pre-wrap break-words">{{ $code }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif($shouldRenderBadges($rawValue))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($rawValue ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($rawValue), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Empty state --}}
|
||||
@if(empty($settings) && (!$settingsTable || empty($settingsTable['rows'])))
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
No settings data available
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
This policy may not contain settings, or they are in an unsupported format
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
{{-- NormalizedSettingsSurface policy-settings-standard compatibility wrapper --}}
|
||||
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -94,8 +94,7 @@
|
||||
@endif
|
||||
@else
|
||||
@include('filament.components.verification-report-viewer', [
|
||||
'run' => $runData,
|
||||
'report' => $report,
|
||||
'surface' => $surface ?? [],
|
||||
'redactionNotes' => $redactionNotes ?? [],
|
||||
])
|
||||
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
'display_name' => 'My Policy 123',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Normalized diff')
|
||||
@ -96,4 +96,10 @@
|
||||
->assertSee('To')
|
||||
->assertSee('Old value')
|
||||
->assertSee('New value');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="groups"');
|
||||
});
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
@ -45,6 +45,11 @@
|
||||
->assertOk()
|
||||
->assertSee('Diff unavailable')
|
||||
->assertDontSee('No normalized changes were found');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-normalized-diff-state="unavailable"');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty baseline for unexpected_policy findings with a current policy version reference', function (): void {
|
||||
@ -101,7 +106,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
@ -110,6 +115,11 @@
|
||||
->assertDontSee('Diff unavailable')
|
||||
->assertSee('1 added')
|
||||
->assertSee('Password required');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-normalized-diff-state="available"');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty current side for missing_policy findings with a baseline policy version reference', function (): void {
|
||||
@ -166,7 +176,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
@ -175,4 +185,9 @@
|
||||
->assertDontSee('Diff unavailable')
|
||||
->assertSee('1 removed')
|
||||
->assertSee('Password required');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-normalized-diff-state="available"');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('group policy configuration normalized diff keys use definition display names', function () {
|
||||
$flat = app(PolicyNormalizer::class)->flattenForDiff(
|
||||
@ -27,3 +35,77 @@
|
||||
expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)');
|
||||
expect(implode("\n", $keys))->not->toContain('graph.microsoft.com');
|
||||
});
|
||||
|
||||
test('group policy configuration policy-version detail renders the shared normalized diff family', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'gpo-policy-1',
|
||||
'policy_type' => 'groupPolicyConfiguration',
|
||||
'display_name' => 'Admin Templates Alpha',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'id' => 'gpo-1',
|
||||
'displayName' => 'Admin Templates Alpha',
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => [
|
||||
[
|
||||
'enabled' => false,
|
||||
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
|
||||
'#Definition_Id' => 'def-1',
|
||||
'#Definition_displayName' => 'Block legacy auth',
|
||||
'#Definition_categoryPath' => 'Windows Components\\Security Options',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'id' => 'gpo-1',
|
||||
'displayName' => 'Admin Templates Alpha',
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => [
|
||||
[
|
||||
'enabled' => true,
|
||||
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
|
||||
'#Definition_Id' => 'def-1',
|
||||
'#Definition_displayName' => 'Block legacy auth',
|
||||
'#Definition_categoryPath' => 'Windows Components\\Security Options',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Block legacy auth');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"')
|
||||
->toContain('data-shared-zone="groups"');
|
||||
});
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders shared normalized settings and diff families on policy and policy-version detail hosts', function (): void {
|
||||
$tenant = \App\Models\Tenant::factory()->create([
|
||||
'name' => 'Tenant One',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Policy A',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Policy A',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$policyResponse = $this->actingAs($user)
|
||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||
|
||||
$policyResponse->assertSuccessful()->assertSee('Enable feature');
|
||||
|
||||
expect($policyResponse->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy"');
|
||||
|
||||
$versionResponse = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||
|
||||
$versionResponse->assertSuccessful()->assertSee('Normalized diff');
|
||||
|
||||
expect($versionResponse->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"');
|
||||
});
|
||||
|
||||
it('renders the shared normalized diff family on finding detail hosts', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'shared-detail-contract'),
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-123',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'My Policy',
|
||||
'customSettingFoo' => 'Old value',
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'My Policy',
|
||||
'customSettingFoo' => 'New value',
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'snapshot_hash' => 'baseline-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'snapshot_hash' => 'current-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'My Policy 123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Normalized diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"');
|
||||
});
|
||||
@ -4,8 +4,10 @@
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -35,3 +37,68 @@
|
||||
->assertCanSeeTableRecords([$policyA])
|
||||
->assertCanNotSeeTableRecords([$policyB]);
|
||||
});
|
||||
|
||||
it('renders remembered canonical tenant policy detail with shared normalized settings markers', 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');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered tenant policy']);
|
||||
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other tenant policy']);
|
||||
|
||||
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
|
||||
'omaSettings' => [
|
||||
[
|
||||
'displayName' => 'Setting A',
|
||||
'omaUri' => './Vendor/MSFT/SettingA',
|
||||
'value' => 'Enabled',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyB->policy_type,
|
||||
'platform' => $policyB->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
|
||||
'omaSettings' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withSession($session)
|
||||
->get(\App\Filament\Resources\PolicyResource::getUrl('view', ['record' => $policyA], panel: 'admin'));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Setting A');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy"');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(\App\Filament\Resources\PolicyResource::getUrl('view', ['record' => $policyB], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -73,3 +74,78 @@
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $versionB], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders remembered canonical tenant policy-version detail with shared normalized detail markers', 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');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered policy']);
|
||||
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other policy']);
|
||||
|
||||
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Remembered policy',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$versionA = PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Remembered policy',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyB->policy_type,
|
||||
'platform' => $policyB->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Other policy',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withSession($session)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $versionA], panel: 'admin'));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Enable feature');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"');
|
||||
});
|
||||
|
||||
@ -50,6 +50,12 @@
|
||||
$response->assertSee('Normalized settings');
|
||||
$response->assertSee('Enable feature');
|
||||
$response->assertSee('Normalized diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"');
|
||||
});
|
||||
|
||||
test('policy version detail shows enrollment notification template settings', function () {
|
||||
@ -139,4 +145,8 @@
|
||||
$response->assertSee('Push Subject');
|
||||
$response->assertSee('Push (en-us) Message');
|
||||
$response->assertSee('Push Body');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"');
|
||||
});
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\SettingsCatalogCategory;
|
||||
use App\Models\SettingsCatalogDefinition;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -53,3 +58,69 @@
|
||||
expect($keys)->toContain('Settings > Account Management > Deletion Policy');
|
||||
expect(implode("\n", $keys))->not->toContain('device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy');
|
||||
});
|
||||
|
||||
test('settings catalog policy version detail renders the shared normalized settings family', function () {
|
||||
SettingsCatalogCategory::create([
|
||||
'category_id' => 'cat-1',
|
||||
'display_name' => 'Account Management',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
SettingsCatalogDefinition::create([
|
||||
'definition_id' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
|
||||
'display_name' => 'Deletion Policy',
|
||||
'description' => null,
|
||||
'help_text' => null,
|
||||
'category_id' => 'cat-1',
|
||||
'ux_behavior' => null,
|
||||
'raw' => [],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'settings-catalog-policy-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 's1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
|
||||
'settingDefinitionId' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
|
||||
'choiceSettingValue' => [
|
||||
'value' => 'enabled',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Deletion Policy');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"');
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders the shared verification family on central operation detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'provider_connection',
|
||||
'title' => 'Provider connection preflight',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'provider_connection_missing',
|
||||
'message' => 'No provider connection configured.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Verification report');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="operation_run_detail"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('renders the shared verification family on onboarding verification', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'onboarding_permissions',
|
||||
'title' => 'Graph permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding');
|
||||
|
||||
$response->assertSuccessful()->assertSee('Graph permissions');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('renders the shared verification family on the tenant widget host', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'tenant_widget_report',
|
||||
'title' => 'Tenant widget verification',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'provider_permission_denied',
|
||||
'message' => 'Insufficient permission — ask a tenant Owner.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
],
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(TenantVerificationReport::class, ['record' => $tenant])
|
||||
->assertSee('Tenant widget verification');
|
||||
|
||||
expect($component->html())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="tenant_widget"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -108,7 +108,7 @@
|
||||
bindFailHardGraphClient();
|
||||
|
||||
assertNoOutboundHttp(function () use ($user): void {
|
||||
Livewire::actingAs($user)
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(TenantVerificationReport::class)
|
||||
->assertSee('Provider connection preflight')
|
||||
->assertSee(OperationRunLinks::openLabel())
|
||||
@ -116,6 +116,13 @@
|
||||
->assertSee(OperationRunLinks::identifierLabel().':')
|
||||
->assertSee('Read-only:')
|
||||
->assertSee('Insufficient permission — ask a tenant Owner.');
|
||||
|
||||
expect($component->html())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="tenant_widget"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -116,3 +119,89 @@
|
||||
expect($exception->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders finding detail with shared normalized diff markers for entitled members', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'finding-rbac-shared-diff'),
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-finding-rbac',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'snapshot' => [
|
||||
'displayName' => 'RBAC Policy',
|
||||
'customSettingFoo' => 'Old value',
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'snapshot' => [
|
||||
'displayName' => 'RBAC Policy',
|
||||
'customSettingFoo' => 'New value',
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'snapshot_hash' => 'baseline-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'snapshot_hash' => 'current-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'RBAC Policy',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Normalized diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"');
|
||||
});
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
it('keeps verification tab ownership inside the shared viewer', function (): void {
|
||||
$sharedViewer = (string) file_get_contents(resource_path('views/filament/components/verification-report-viewer.blade.php'));
|
||||
|
||||
expect($sharedViewer)
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('Verification report tabs');
|
||||
|
||||
$hostViews = [
|
||||
resource_path('views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php'),
|
||||
resource_path('views/filament/widgets/tenant/tenant-verification-report.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($hostViews as $path) {
|
||||
expect((string) file_get_contents($path))->not->toContain('Verification report tabs');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps policy-settings-standard as a compatibility wrapper only', function (): void {
|
||||
$compatibilityView = (string) file_get_contents(resource_path('views/filament/infolists/entries/policy-settings-standard.blade.php'));
|
||||
|
||||
expect($compatibilityView)->toContain('normalized-settings.wrapper');
|
||||
|
||||
$directUsages = collect(File::allFiles(resource_path('views/filament')))
|
||||
->reject(static fn (\SplFileInfo $file): bool => $file->getPathname() === resource_path('views/filament/infolists/entries/policy-settings-standard.blade.php'))
|
||||
->filter(static fn (\SplFileInfo $file): bool => str_contains((string) file_get_contents($file->getPathname()), 'policy-settings-standard'))
|
||||
->map(static fn (\SplFileInfo $file): string => str_replace(resource_path('views/'), '', $file->getPathname()))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($directUsages)->toBe([]);
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
@ -142,3 +143,45 @@
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders shared verification family markers on monitoring operation detail for workspace members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'initiator_name' => 'System',
|
||||
'run_identity_hash' => 'hash123',
|
||||
'context' => [
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'provider_connection',
|
||||
'title' => 'Provider connection preflight',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'provider_connection_missing',
|
||||
'message' => 'No provider connection configured.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Provider connection preflight');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="operation_run_detail"');
|
||||
});
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -148,6 +150,81 @@
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('renders shared verification family markers for an entitled requested verify draft', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Requested verify connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'requested_verify_draft',
|
||||
'title' => 'Requested verify draft check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'missing_configuration',
|
||||
'message' => 'Draft needs attention.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->followingRedirects()
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Requested verify draft check');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"');
|
||||
});
|
||||
|
||||
it('mounts the requested draft with canonical persisted continuity state even when other drafts exist', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
|
||||
@ -319,7 +319,7 @@
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
@ -331,6 +331,13 @@
|
||||
->assertSee('Missing required Graph permissions.')
|
||||
->assertSee('Graph permissions')
|
||||
->assertSee($entraTenantId);
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void {
|
||||
|
||||
@ -246,7 +246,7 @@
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
@ -262,4 +262,11 @@
|
||||
->assertSee('First step')
|
||||
->assertSee('Second step')
|
||||
->assertDontSee('Third step');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
@ -26,5 +26,6 @@
|
||||
|
||||
expect($html)->toContain('Enabled')
|
||||
->and($html)->toContain('Disabled')
|
||||
->and($html)->toContain('fi-badge');
|
||||
->and($html)->toContain('fi-badge')
|
||||
->and($html)->toContain('data-shared-detail-family="normalized-settings"');
|
||||
});
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@ -39,6 +40,21 @@
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'provider_connection',
|
||||
'title' => 'Provider connection preflight',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'provider_connection_missing',
|
||||
'message' => 'No provider connection configured.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
|
||||
@ -50,6 +50,13 @@
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Token acquisition works');
|
||||
|
||||
expect($component->html())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="operation_run_detail"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
|
||||
$component
|
||||
->call('$refresh')
|
||||
->assertSee('Token acquisition works');
|
||||
@ -184,11 +191,18 @@
|
||||
]);
|
||||
|
||||
assertNoOutboundHttp(function () use ($user): void {
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Onboarding check');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
|
||||
36
specs/197-shared-detail-contract/checklists/requirements.md
Normal file
36
specs/197-shared-detail-contract/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Shared Detail Micro-UI 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 completed in one pass.
|
||||
- The spec stays bounded to two already proven shared detail families and explicitly excludes shell, monitoring-state, and broad custom-view consolidation themes.
|
||||
- Repository truth from current host usage informed the in-scope family definitions without embedding file-level implementation detail into the spec body.
|
||||
@ -0,0 +1,299 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Normalized Detail Family Internal Surface Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for shared normalized settings and normalized diff families
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 197. It documents the
|
||||
family-owned normalized settings and normalized diff surfaces used across
|
||||
policy, policy version, and finding detail hosts. Rendered routes still
|
||||
return HTML. The structured schemas below describe the internal surface
|
||||
contract and approved host-variation boundaries. This does not add a public
|
||||
HTTP API.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-family-consumers:
|
||||
- surface: policy_settings
|
||||
source: normalized_settings_surface
|
||||
accessPattern: infolist_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Resources/PolicyResource.php
|
||||
- apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php
|
||||
requiredMarkers:
|
||||
- NormalizedSettingsSurface::build
|
||||
- data-shared-detail-family="normalized-settings"
|
||||
- data-shared-normalized-settings-host="policy"
|
||||
- surface: policy_version_settings
|
||||
source: normalized_settings_surface
|
||||
accessPattern: infolist_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Resources/PolicyVersionResource.php
|
||||
- apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php
|
||||
requiredMarkers:
|
||||
- NormalizedSettingsSurface::build
|
||||
- data-shared-detail-family="normalized-settings"
|
||||
- data-shared-normalized-settings-host="policy_version"
|
||||
x-forbiddenMarkers:
|
||||
- needle: "->view('filament.infolists.entries.policy-settings-standard')"
|
||||
max: 0
|
||||
- surface: policy_version_diff
|
||||
source: normalized_diff_surface
|
||||
accessPattern: infolist_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Resources/PolicyVersionResource.php
|
||||
- apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php
|
||||
requiredMarkers:
|
||||
- NormalizedDiffSurface::build
|
||||
- data-shared-detail-family="normalized-diff"
|
||||
- data-shared-normalized-diff-host="policy_version"
|
||||
- data-shared-zone="summary"
|
||||
- data-shared-zone="groups"
|
||||
- surface: finding_diff
|
||||
source: normalized_diff_surface
|
||||
accessPattern: infolist_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Resources/FindingResource.php
|
||||
- apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php
|
||||
requiredMarkers:
|
||||
- NormalizedDiffSurface::build
|
||||
- data-shared-detail-family="normalized-diff"
|
||||
- data-shared-normalized-diff-host="finding"
|
||||
- data-shared-normalized-diff-state
|
||||
- data-shared-zone="summary"
|
||||
paths:
|
||||
/ui-contracts/normalized-settings/resolve:
|
||||
get:
|
||||
summary: Resolve the shared normalized settings family contract for one host
|
||||
operationId: resolveNormalizedSettingsSurface
|
||||
parameters:
|
||||
- name: host
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/NormalizedSettingsHostKind'
|
||||
responses:
|
||||
'200':
|
||||
description: Shared normalized settings family contract resolved for the current host context
|
||||
content:
|
||||
application/vnd.tenantpilot.normalized-settings-surface+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NormalizedSettingsSurfaceContract'
|
||||
'403':
|
||||
description: Actor is in scope but lacks the capability required to view the host
|
||||
'404':
|
||||
description: Host or tenant scope is not visible in the current workspace or tenant context
|
||||
/ui-contracts/normalized-diff/resolve:
|
||||
get:
|
||||
summary: Resolve the shared normalized diff family contract for one host
|
||||
operationId: resolveNormalizedDiffSurface
|
||||
parameters:
|
||||
- name: host
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/NormalizedDiffHostKind'
|
||||
responses:
|
||||
'200':
|
||||
description: Shared normalized diff family contract resolved for the current host context
|
||||
content:
|
||||
application/vnd.tenantpilot.normalized-diff-surface+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NormalizedDiffSurfaceContract'
|
||||
'403':
|
||||
description: Actor is in scope but lacks the capability required to view the host
|
||||
'404':
|
||||
description: Host or tenant scope is not visible in the current workspace or tenant context
|
||||
components:
|
||||
schemas:
|
||||
NormalizedSettingsHostKind:
|
||||
type: string
|
||||
enum:
|
||||
- policy
|
||||
- policy_version
|
||||
NormalizedDiffHostKind:
|
||||
type: string
|
||||
enum:
|
||||
- policy_version
|
||||
- finding
|
||||
NormalizedSettingsVariant:
|
||||
type: string
|
||||
enum:
|
||||
- settings_catalog_table
|
||||
- standard_blocks
|
||||
AvailabilityState:
|
||||
type: string
|
||||
enum:
|
||||
- available
|
||||
- unavailable
|
||||
- partial
|
||||
NormalizedSettingsSectionBehavior:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- preservesSectionOrder
|
||||
- supportsExpansion
|
||||
- ownsEmptyState
|
||||
properties:
|
||||
preservesSectionOrder:
|
||||
type: boolean
|
||||
supportsExpansion:
|
||||
type: boolean
|
||||
ownsEmptyState:
|
||||
type: boolean
|
||||
NormalizedSettingsRenderExpectations:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- ownsWarningsInWrapper
|
||||
- ownsSubtypeDelegation
|
||||
- keepsHostFramingOutsideCore
|
||||
properties:
|
||||
ownsWarningsInWrapper:
|
||||
type: boolean
|
||||
ownsSubtypeDelegation:
|
||||
type: boolean
|
||||
keepsHostFramingOutsideCore:
|
||||
type: boolean
|
||||
NormalizedDiffViewMode:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- default
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
default:
|
||||
type: boolean
|
||||
NormalizedDiffSectionBehavior:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- preservesGroupOrder
|
||||
- supportsExpansion
|
||||
- supportsFullscreen
|
||||
properties:
|
||||
preservesGroupOrder:
|
||||
type: boolean
|
||||
supportsExpansion:
|
||||
type: boolean
|
||||
supportsFullscreen:
|
||||
type: boolean
|
||||
NormalizedDiffRenderExpectations:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- ownsAvailabilityState
|
||||
- ownsZeroDiffMessaging
|
||||
- keepsHostFramingOutsideCore
|
||||
properties:
|
||||
ownsAvailabilityState:
|
||||
type: boolean
|
||||
ownsZeroDiffMessaging:
|
||||
type: boolean
|
||||
keepsHostFramingOutsideCore:
|
||||
type: boolean
|
||||
NormalizedSettingsSurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- hostKind
|
||||
- context
|
||||
- variant
|
||||
- sectionBehavior
|
||||
- renderExpectations
|
||||
- titlePolicy
|
||||
properties:
|
||||
hostKind:
|
||||
$ref: '#/components/schemas/NormalizedSettingsHostKind'
|
||||
context:
|
||||
type: string
|
||||
enum:
|
||||
- policy
|
||||
- version
|
||||
variant:
|
||||
$ref: '#/components/schemas/NormalizedSettingsVariant'
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
settingsTable:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
blocks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
sectionBehavior:
|
||||
$ref: '#/components/schemas/NormalizedSettingsSectionBehavior'
|
||||
renderExpectations:
|
||||
$ref: '#/components/schemas/NormalizedSettingsRenderExpectations'
|
||||
emptyState:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
titlePolicy:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- showWrapperTitle
|
||||
properties:
|
||||
showWrapperTitle:
|
||||
type: boolean
|
||||
NormalizedDiffSurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- hostKind
|
||||
- availabilityState
|
||||
- summary
|
||||
- viewModes
|
||||
- sectionBehavior
|
||||
- renderExpectations
|
||||
properties:
|
||||
hostKind:
|
||||
$ref: '#/components/schemas/NormalizedDiffHostKind'
|
||||
availabilityState:
|
||||
$ref: '#/components/schemas/AvailabilityState'
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- added
|
||||
- removed
|
||||
- changed
|
||||
properties:
|
||||
added:
|
||||
type: integer
|
||||
removed:
|
||||
type: integer
|
||||
changed:
|
||||
type: integer
|
||||
message:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
viewModes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NormalizedDiffViewMode'
|
||||
sectionBehavior:
|
||||
$ref: '#/components/schemas/NormalizedDiffSectionBehavior'
|
||||
renderExpectations:
|
||||
$ref: '#/components/schemas/NormalizedDiffRenderExpectations'
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
scriptRendering:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
emptyState:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
@ -0,0 +1,304 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Verification Report Family Internal Surface Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for the shared Verification Report family
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 197. It documents the
|
||||
family-owned verification surface used across operation detail, onboarding,
|
||||
and tenant verification widget hosts. Rendered routes still return HTML.
|
||||
The structured schemas below describe the internal surface contract and the
|
||||
allowed host-variation boundaries. This does not add a public HTTP API.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-family-consumers:
|
||||
- surface: operation_run_detail
|
||||
source: verification_report_surface
|
||||
accessPattern: detail_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Resources/OperationRunResource.php
|
||||
- apps/platform/resources/views/filament/components/verification-report-viewer.blade.php
|
||||
requiredMarkers:
|
||||
- VerificationReportViewer::surface
|
||||
- data-shared-detail-family="verification-report"
|
||||
- data-host-kind="operation_run_detail"
|
||||
- data-shared-zone="summary"
|
||||
- data-shared-zone="diagnostics"
|
||||
- surface: onboarding_verification_step
|
||||
source: verification_report_surface
|
||||
accessPattern: form_embedded_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
requiredMarkers:
|
||||
- VerificationReportViewer::surface
|
||||
- data-shared-detail-family="verification-report"
|
||||
- data-host-kind="onboarding_wizard"
|
||||
- assistActionName
|
||||
- technicalDetailsActionName
|
||||
x-forbiddenMarkers:
|
||||
- needle: "x-data=\"{ tab: 'issues' }\""
|
||||
max: 0
|
||||
- surface: tenant_verification_widget
|
||||
source: verification_report_surface
|
||||
accessPattern: widget_safe
|
||||
guardScope:
|
||||
- apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php
|
||||
- apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php
|
||||
requiredMarkers:
|
||||
- VerificationReportViewer::surface
|
||||
- data-shared-detail-family="verification-report"
|
||||
- data-host-kind="tenant_widget"
|
||||
- data-shared-zone="summary"
|
||||
- data-shared-zone="diagnostics"
|
||||
paths:
|
||||
/ui-contracts/verification-report/resolve:
|
||||
get:
|
||||
summary: Resolve the shared Verification Report family contract for one host
|
||||
operationId: resolveVerificationReportSurface
|
||||
parameters:
|
||||
- name: host
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationHostKind'
|
||||
- name: runId
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Shared Verification Report surface contract resolved for the current host context
|
||||
content:
|
||||
application/vnd.tenantpilot.verification-report-surface+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationReportSurfaceContract'
|
||||
'403':
|
||||
description: Actor is in scope but lacks the capability required for host-owned actions
|
||||
'404':
|
||||
description: Run or tenant scope is not visible in the current workspace or tenant context
|
||||
/admin/onboarding:
|
||||
get:
|
||||
summary: Onboarding verification host using the shared Verification Report family
|
||||
operationId: viewOnboardingVerificationHost
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered onboarding verification step with shared verification family core
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
summary: Tenant verification widget host using the shared Verification Report family
|
||||
operationId: viewTenantVerificationWidgetHost
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered tenant detail page containing the shared verification family widget
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
components:
|
||||
schemas:
|
||||
VerificationHostKind:
|
||||
type: string
|
||||
enum:
|
||||
- operation_run_detail
|
||||
- onboarding_wizard
|
||||
- tenant_widget
|
||||
VerificationCoreState:
|
||||
type: string
|
||||
enum:
|
||||
- unavailable
|
||||
- completed
|
||||
VerificationActionKind:
|
||||
type: string
|
||||
enum:
|
||||
- navigation
|
||||
- assist
|
||||
- acknowledge
|
||||
- refresh
|
||||
- technical_details
|
||||
VerificationHostVariation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- ownsNoRunState
|
||||
- ownsActiveState
|
||||
- supportsAssist
|
||||
- supportsAcknowledge
|
||||
- supportsTechnicalDetailsTrigger
|
||||
properties:
|
||||
ownsNoRunState:
|
||||
type: boolean
|
||||
ownsActiveState:
|
||||
type: boolean
|
||||
supportsAssist:
|
||||
type: boolean
|
||||
supportsAcknowledge:
|
||||
type: boolean
|
||||
supportsTechnicalDetailsTrigger:
|
||||
type: boolean
|
||||
VerificationAction:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- label
|
||||
- ownedByHost
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/VerificationActionKind'
|
||||
label:
|
||||
type: string
|
||||
ownedByHost:
|
||||
type: boolean
|
||||
VerificationViewZone:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- defaultVisible
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
defaultVisible:
|
||||
type: boolean
|
||||
optional:
|
||||
type: boolean
|
||||
VerificationNextStepPlacement:
|
||||
type: string
|
||||
enum:
|
||||
- shared_zone
|
||||
- host_action_zone
|
||||
VerificationNextStep:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- label
|
||||
- placement
|
||||
- ownedByHost
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
placement:
|
||||
$ref: '#/components/schemas/VerificationNextStepPlacement'
|
||||
ownedByHost:
|
||||
type: boolean
|
||||
actionKind:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/VerificationActionKind'
|
||||
- type: 'null'
|
||||
VerificationOptionalZone:
|
||||
type: string
|
||||
enum:
|
||||
- technical_details
|
||||
- change_indicator
|
||||
- previous_run_context
|
||||
VerificationIssueGroup:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- label
|
||||
- checks
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
checks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
VerificationDiagnostics:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- hasTechnicalZone
|
||||
properties:
|
||||
hasTechnicalZone:
|
||||
type: boolean
|
||||
fingerprint:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
previousRunUrl:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
operationRunId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
VerificationReportSurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- hostKind
|
||||
- coreState
|
||||
- summary
|
||||
- issueGroups
|
||||
- passedChecks
|
||||
- diagnostics
|
||||
- viewZones
|
||||
- nextSteps
|
||||
- hostVariation
|
||||
properties:
|
||||
hostKind:
|
||||
$ref: '#/components/schemas/VerificationHostKind'
|
||||
coreState:
|
||||
$ref: '#/components/schemas/VerificationCoreState'
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- overallLabel
|
||||
- counts
|
||||
properties:
|
||||
overallLabel:
|
||||
type: string
|
||||
counts:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
issueGroups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VerificationIssueGroup'
|
||||
passedChecks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
diagnostics:
|
||||
$ref: '#/components/schemas/VerificationDiagnostics'
|
||||
viewZones:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VerificationViewZone'
|
||||
nextSteps:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VerificationNextStep'
|
||||
hostActions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VerificationAction'
|
||||
hostVariation:
|
||||
$ref: '#/components/schemas/VerificationHostVariation'
|
||||
optionalZones:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VerificationOptionalZone'
|
||||
emptyState:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
232
specs/197-shared-detail-contract/data-model.md
Normal file
232
specs/197-shared-detail-contract/data-model.md
Normal file
@ -0,0 +1,232 @@
|
||||
# Phase 1 Data Model: Shared Detail Micro-UI Contract
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds no database table, no persisted UI artifact, and no new state family. It formalizes two existing shared detail families as runtime contracts with explicit host-variation boundaries.
|
||||
|
||||
## Persistent Source Truths
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Supplies stored verification status, timing, target scope, failure summary, and the raw verification report payload already used by the current verification hosts.
|
||||
|
||||
**Key fields**:
|
||||
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
- `context.verification_report`
|
||||
- `context.target_scope`
|
||||
- `failure_summary`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Verification family rendering remains DB-only and derives only from stored run data.
|
||||
- No host may introduce a second source of verification truth outside the run payload and existing support helpers.
|
||||
|
||||
### Policy and PolicyVersion snapshot truth
|
||||
|
||||
**Purpose**: Supplies the source payloads for normalized settings and version-to-version diffs.
|
||||
|
||||
**Key fields**:
|
||||
|
||||
- `policies.id`
|
||||
- `policies.tenant_id`
|
||||
- `policies.policy_type`
|
||||
- `policies.platform`
|
||||
- `policy_versions.id`
|
||||
- `policy_versions.policy_id`
|
||||
- `policy_versions.snapshot`
|
||||
- `policy_versions.policy_type`
|
||||
- `policy_versions.platform`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- The settings and diff families remain derived from current normalized snapshot truth.
|
||||
- No new persisted “surface state” may be added to remember tabs, expanded sections, or unavailable-state decisions.
|
||||
|
||||
### Finding drift evidence
|
||||
|
||||
**Purpose**: Supplies the source references and availability reasons for diff rendering on drift findings.
|
||||
|
||||
**Key fields**:
|
||||
|
||||
- `findings.id`
|
||||
- `findings.tenant_id`
|
||||
- `findings.finding_type`
|
||||
- `findings.evidence_jsonb.summary.kind`
|
||||
- `findings.evidence_jsonb.baseline.policy_version_id`
|
||||
- `findings.evidence_jsonb.current.policy_version_id`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Diff availability remains derived from referenced versions and current tenant context.
|
||||
- The shared diff family must express unavailable or partial state from these sources instead of letting each host improvise new rules.
|
||||
|
||||
## Existing Runtime Source Objects
|
||||
|
||||
### VerificationReportViewer
|
||||
|
||||
**Purpose**: Existing support seam that extracts and validates the verification report, computes fingerprints, and resolves previous runs.
|
||||
|
||||
**Current responsibilities already present**:
|
||||
|
||||
- `report()`
|
||||
- `fingerprint()`
|
||||
- `previousRun()`
|
||||
- `shouldRenderForRun()`
|
||||
- `redactionNotes()`
|
||||
|
||||
**Relationship to this feature**:
|
||||
|
||||
- It becomes the narrow verification-family contract seam rather than being replaced.
|
||||
- It should grow family-owned shaping helpers instead of remaining only a payload extractor.
|
||||
|
||||
### PolicyNormalizer, VersionDiff, and DriftFindingDiffBuilder
|
||||
|
||||
**Purpose**: Existing domain truth builders that already normalize settings and derive diffs.
|
||||
|
||||
**Relationship to this feature**:
|
||||
|
||||
- They remain the domain truth source.
|
||||
- The new family contracts sit after these builders and before host rendering.
|
||||
- The feature must not create a second normalization pipeline.
|
||||
|
||||
### SettingsCatalogSettingsTable
|
||||
|
||||
**Purpose**: Existing Livewire renderer for the settings-catalog table subtype.
|
||||
|
||||
**Relationship to this feature**:
|
||||
|
||||
- It remains the renderer for one settings subtype.
|
||||
- The settings family wrapper decides when it appears and with what shared wrapper semantics.
|
||||
|
||||
## New Derived Runtime Contracts
|
||||
|
||||
### VerificationReportSurfaceContract
|
||||
|
||||
**Purpose**: One runtime contract that defines the verification family’s shared zones and allowed host variations.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `host_kind` | enum(`operation_run_detail`,`onboarding_wizard`,`tenant_widget`) | yes | Current host family consumer |
|
||||
| `core_state` | enum(`unavailable`,`completed`) | yes | Shared family core state after host-owned no-run or in-progress framing |
|
||||
| `summary` | object | yes | Overall badge, counts, and change-indicator data |
|
||||
| `issue_groups` | array | yes | Grouped blockers, failures, warnings, and acknowledged issues |
|
||||
| `passed_checks` | array | yes | Checks rendered under the passed zone |
|
||||
| `diagnostics` | object | yes | Fingerprint, run identity, previous-run link, and related technical details |
|
||||
| `view_zones` | array | yes | Ordered family view or tab definitions |
|
||||
| `next_steps` | array | yes | Shared next-step items derived from report checks |
|
||||
| `host_actions` | array | no | Host-owned actions such as assist, acknowledge, refresh, or open operation |
|
||||
| `optional_zones` | array | no | Family-recognized zones that may be absent, such as technical details |
|
||||
| `empty_state` | object nullable | no | Family-owned unavailable-state explanation when no valid report payload exists |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- The contract must own one tab or view model across all completed or unavailable verification surfaces.
|
||||
- Hosts may append actions and framing, but may not redefine issue grouping, passed-check grouping, or diagnostics ownership.
|
||||
- Host actions must declare whether they are shared navigation, host assist, or host mutation.
|
||||
|
||||
### VerificationReportHostVariation
|
||||
|
||||
**Purpose**: Declares the bounded host-owned differences for a verification-family consumer.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `owns_no_run_state` | bool | yes | Whether the host frames its own pre-run state |
|
||||
| `owns_active_state` | bool | yes | Whether the host frames its own in-progress state |
|
||||
| `supports_acknowledge` | bool | yes | Whether the host may surface acknowledgement mutations |
|
||||
| `supports_assist` | bool | yes | Whether the host may route next steps through assist actions |
|
||||
| `supports_technical_details_trigger` | bool | yes | Whether the host exposes a dedicated technical-details trigger outside the family core |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Host variation may extend actions and framing only.
|
||||
- Host variation must never add a second structural tab system or redefine the diagnostics contract.
|
||||
|
||||
### NormalizedSettingsSurfaceContract
|
||||
|
||||
**Purpose**: One runtime contract that defines the normalized settings family’s wrapper ownership, subtype selection, warnings, and empty-state behavior.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `host_kind` | enum(`policy`,`policy_version`) | yes | Host context for the settings family |
|
||||
| `context` | enum(`policy`,`version`) | yes | Existing context string used for table query-string isolation and titles |
|
||||
| `variant` | enum(`settings_catalog_table`,`standard_blocks`) | yes | Explicit subtype used by the shared family |
|
||||
| `warnings` | array | no | Warning list displayed by the family wrapper |
|
||||
| `settings_table` | object nullable | no | Table subtype payload when the settings-catalog subtype is active |
|
||||
| `blocks` | array | no | Standard block payload for general, table, or key-value sections |
|
||||
| `section_behavior` | object | yes | Shared section-order, expansion, and empty-state ownership rules for the settings family |
|
||||
| `render_expectations` | object | yes | Explicit wrapper expectations for warnings, subtype delegation, and host-framing boundaries |
|
||||
| `empty_state` | object nullable | no | Family-owned empty or unavailable message when no settings payload exists |
|
||||
| `title_policy` | object | yes | Rules for whether wrapper or subtype titles are shown |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Hosts may not choose sibling top-level settings blades once the family contract exists.
|
||||
- Subtypes must be explicit family variations, not host-selected main variants.
|
||||
- Warning and empty-state behavior must come from the family wrapper, not from host-specific text entries.
|
||||
- Section order, expansion behavior, and wrapper expectations must be explicit so hosts do not silently redefine how the family reads.
|
||||
|
||||
### NormalizedDiffSurfaceContract
|
||||
|
||||
**Purpose**: One runtime contract that defines normalized diff summary, grouped rendering, unavailable behavior, and zero-diff messaging.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `host_kind` | enum(`policy_version`,`finding`) | yes | Host context for the diff family |
|
||||
| `availability_state` | enum(`available`,`unavailable`,`partial`) | yes | Family-owned availability state |
|
||||
| `summary` | object | yes | Added, removed, changed counts and summary message |
|
||||
| `view_modes` | array | yes | Explicit diff-family modes or views that the host may expose without redefining the family |
|
||||
| `section_behavior` | object | yes | Shared grouped-section order, expansion, and fullscreen rules |
|
||||
| `render_expectations` | object | yes | Explicit ownership of availability, zero-diff messaging, and host-framing boundaries |
|
||||
| `groups` | array | no | Changed, added, and removed groups with ordered row payloads |
|
||||
| `script_rendering` | object | no | Script-highlighting flags and grammar hints |
|
||||
| `empty_state` | object nullable | no | Family-owned zero-diff or unavailable-state explanation |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Hosts may not prepend separate unavailable-state entries for the same normalized diff concept.
|
||||
- The family must own both true unavailable and zero-diff informational states.
|
||||
- Rich script rendering remains allowed, but it must live inside the family contract.
|
||||
- View modes, grouped-section behavior, and render expectations must be explicit so a policy-version diff and a finding diff cannot drift into separate interaction models.
|
||||
|
||||
## Consumer Mapping
|
||||
|
||||
| Consumer | Family | Shared-family responsibility | Local host responsibility |
|
||||
|---|---|---|---|
|
||||
| `OperationRunResource` verification section | Verification report | Completed and unavailable verification core, tab contract, diagnostics, grouped issues | Section placement inside enterprise detail, record authorization, no new mutation behavior |
|
||||
| `ManagedTenantOnboardingWizard` verify step | Verification report | Completed and unavailable verification core, grouped issues, diagnostics, shared view zones | No-run and active-state framing, assist routing, acknowledge action, verify-step workflow actions |
|
||||
| `TenantVerificationReport` widget | Verification report | Completed and unavailable verification core | Widget no-run and active-state framing, start-verification CTA, tenant operability copy |
|
||||
| `PolicyResource` settings tab or fallback section | Normalized settings | Warnings, empty state, subtype ownership, wrapper structure | Tab framing, route context, surrounding policy detail layout |
|
||||
| `PolicyVersionResource` normalized settings tab | Normalized settings | Same settings family contract as policy detail | Tab framing and version-specific surrounding detail layout |
|
||||
| `PolicyVersionResource` diff tab | Normalized diff | Summary, grouped diff rendering, zero-diff and unavailable semantics | Tab framing and raw diff sidecar JSON |
|
||||
| `FindingResource` diff section | Normalized diff | Same diff family contract as policy-version diff | Surrounding drift-specific sections, version resolution, and domain-specific sibling diff viewers kept out of scope |
|
||||
|
||||
## Derived Lifecycle
|
||||
|
||||
1. A host resolves current domain truth using existing run, normalization, or diff builders.
|
||||
2. The host passes that truth through the appropriate shared family contract seam.
|
||||
3. The family contract shapes shared zones, states, and subtype markers.
|
||||
4. The host renders the shared family core and attaches only its allowed host-owned variations.
|
||||
5. Tests assert that a new host cannot redefine family structure outside those variations.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- No database migration is required.
|
||||
- `policy-settings-standard.blade.php` must stop acting as a sibling top-level host choice and become an internal subtype or be absorbed.
|
||||
- Existing verification DB-only and tenant widget tests remain the primary regression base for the verification family.
|
||||
- Existing policy-version and drift-diff tests remain the primary regression base for normalized detail families.
|
||||
46
specs/197-shared-detail-contract/migration-note.md
Normal file
46
specs/197-shared-detail-contract/migration-note.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Closing Migration Note: Shared Detail Micro-UI Contract
|
||||
|
||||
This file is the release-acceptance artifact for Spec 197. Update it as implementation closes so FR-197-015 and D-197-004 are satisfied without overloading `quickstart.md`.
|
||||
|
||||
## Migrated Hosts
|
||||
|
||||
### Verification Report family
|
||||
|
||||
- `OperationRunResource` verification detail surface via `VerificationReportViewer::surface(...)` and shared summary, issues, passed, diagnostics, and unavailable zones
|
||||
- `ManagedTenantOnboardingWizard` verification step surface via the same shared verification-family core with onboarding-only assist, acknowledge, and technical-detail variations layered around it
|
||||
- `TenantVerificationReport` widget surface via the same completed-state verification-family core while preserving widget-owned no-run and in-progress framing
|
||||
|
||||
### Normalized Settings family
|
||||
|
||||
- `PolicyResource` normalized settings surface via `NormalizedSettingsSurface::build(...)`
|
||||
- `PolicyVersionResource` normalized settings surface via `NormalizedSettingsSurface::build(...)`
|
||||
- `policy-settings-standard.blade.php` retained only as an include-only compatibility shim into the normalized-settings family wrapper
|
||||
- `SettingsCatalogSettingsTable` retained as the settings-catalog subtype renderer inside the normalized-settings family
|
||||
|
||||
### Normalized Diff family
|
||||
|
||||
- `PolicyVersionResource` normalized diff surface via `NormalizedDiffSurface::build(...)` and the shared summary, grouped-rendering, and empty-state wrapper
|
||||
- `FindingResource` normalized diff surface via `NormalizedDiffSurface::build(...)` for available, unavailable, and zero-diff states
|
||||
|
||||
## Intentionally Allowed Remaining Variations
|
||||
|
||||
- Onboarding-specific assist, acknowledge, and technical-details actions remain host-scoped variations.
|
||||
- No-run and in-progress framing remain host-owned where the host genuinely owns that lifecycle state.
|
||||
- `SettingsCatalogSettingsTable` remains the settings-catalog subtype renderer inside the normalized-settings family.
|
||||
- Drift-specific surrounding context remains host-owned in `FindingResource` as long as the normalized diff family core stays shared.
|
||||
- The compatibility view `policy-settings-standard.blade.php` remains as a thin include-only shim so existing callers/tests do not become a new fork point.
|
||||
|
||||
## Manual Smoke Evidence
|
||||
|
||||
- Reviewer: GitHub Copilot via the local integrated browser using the local smoke-login flow
|
||||
- Review date: 2026-04-15
|
||||
- SC-197-003 result: Pass. Operation detail `Operation #6655`, the onboarding verify-step host, and the tenant verification widget all exposed the same verification-family core with recognizable summary, issues, passed, diagnostics, and read-only zones. Onboarding kept its bounded host-owned assist and technical-detail behavior, while the tenant widget kept its widget-specific framing on the tenant management detail page.
|
||||
- SC-197-004 result: Pass. Policy detail `Policy #233`, policy-version detail `Policy Version #270`, drift finding `#79`, and a temporary local-only unavailable drift finding clone all rendered family-consistent normalized settings or diff behavior. The available diff surface matched the policy-version grouped diff contract, and the unavailable diff surface rendered the same family-owned unavailable-state treatment instead of a host-local message.
|
||||
- Notes: Local browser smoke used `/admin/local/smoke-login` against Phoenicon in workspace `wp`. The tenant verification widget evidence came from the tenant management detail page rather than the tenant dashboard, which is the current widget host. The local dev dataset did not include a resumable verify-step onboarding draft or a natural missing-version drift finding for this tenant family, so temporary local-only fixture records were created, exercised, and deleted after verification. `./vendor/bin/sail bin pint --dirty --format agent` and the focused Sail regression pack had already passed in this feature session.
|
||||
|
||||
## Out-of-Scope Follow-Ups
|
||||
|
||||
- Local dev seed data still lacks a reusable resumable verify-step onboarding draft and a natural missing-version drift finding for this tenant family; future smoke work will need either richer seed data or the same kind of ephemeral local fixtures.
|
||||
- Shell-level refactors discovered during implementation remain intentionally out of scope.
|
||||
- Monitoring page-state topics discovered during implementation remain intentionally out of scope.
|
||||
- Any future shared-detail framework discussion beyond the two proven families remains intentionally out of scope.
|
||||
269
specs/197-shared-detail-contract/plan.md
Normal file
269
specs/197-shared-detail-contract/plan.md
Normal file
@ -0,0 +1,269 @@
|
||||
# Implementation Plan: Shared Detail Micro-UI Contract
|
||||
|
||||
**Branch**: `197-shared-detail-contract` | **Date**: 2026-04-15 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Standardize two already real shared detail families without introducing a generic UI framework. The first slice extends the existing verification support seam so `OperationRunResource`, `ManagedTenantOnboardingWizard`, and `TenantVerificationReport` render the same family-owned verification surface with one tab contract, one diagnostics contract, and explicit host-owned variation slots for assist, acknowledge, and host framing. The second slice standardizes normalized settings and normalized diff around family-owned wrappers that make subtype handling, unavailable states, section structure, and view behavior explicit across `PolicyResource`, `PolicyVersionResource`, and `FindingResource`, while preserving the existing Livewire settings table and rich diff rendering where the domain still needs it. The implementation stays derived-only, keeps Filament v5 + Livewire v4 semantics, adds no new assets or persistence, and protects the result with cross-host parity tests plus a small guard against reintroducing ad hoc host forks.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
|
||||
**Storage**: PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact
|
||||
**Testing**: Pest 4 unit and feature tests, including Livewire component coverage for widgets/pages and focused guard tests, run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment for staging and production
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Keep verification viewers DB-only at render time, avoid any new outbound HTTP or queued work, preserve current diff and settings render responsiveness, and prevent repeated host-local surface logic from re-normalizing or re-shaping the same data differently
|
||||
**Constraints**: No new persisted truth, no new Graph call path, no new panel/provider registration change, no hidden shell or monitoring-state refactor, no generic component framework, and no new destructive actions in the shared family core
|
||||
**Scale/Scope**: Two shared families across seven concrete host surfaces: verification in operation detail, onboarding wizard, and tenant widget; normalized settings or diff in policy detail, policy-version detail, and finding detail, plus the existing standard-settings sibling view that must be absorbed or demoted into an explicit subtype
|
||||
|
||||
## 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 | Both families remain pure renderers of already stored `OperationRun`, policy snapshot, and finding evidence truth. No new snapshot or inventory semantics are added. |
|
||||
| Read/write separation | PASS | PASS | The shared-family contracts are read-only. Existing host actions such as start verification, refresh, assist, or acknowledge remain host-owned and keep their current mutation semantics. |
|
||||
| Graph contract path | N/A | N/A | No Graph calls or `config/graph_contracts.php` changes are introduced. |
|
||||
| Deterministic capabilities | PASS | PASS | No new capability family is introduced. Existing host authorization and capability registries remain authoritative. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Every host continues to resolve data inside existing workspace and tenant scope. Tenantless operation detail remains subject to existing entitlement checks before rendering tenant-owned verification data. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | No 404/403 rules change. Non-members remain not found, in-scope capability denial stays forbidden where host actions already enforce it. |
|
||||
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type, lifecycle, or notification path is added. Existing operation links remain navigation-only. |
|
||||
| Data minimization | PASS | PASS | Contracts stay runtime-only and derive from existing stored payloads without new mirrors or caches. |
|
||||
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One narrow contract seam per family is justified because both families already exist across multiple concrete hosts. The design explicitly rejects a cross-domain shared-detail framework. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | No new tables, persisted UI artifacts, or new state families are introduced. |
|
||||
| UI semantics / few layers | PASS | PASS | The plan replaces duplicated host ownership with family-owned contracts. It does not add a new presenter stack or badge taxonomy. |
|
||||
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge domains remain authoritative. Shared-family work reuses them rather than introducing host-local mappings. |
|
||||
| Filament-native UI / Action Surface Contract | PASS | PASS | Family surfaces remain embedded detail/evidence surfaces inside existing Filament pages and widgets. No new row or bulk action hierarchy is introduced. |
|
||||
| Filament UX-001 | PASS | PASS | The plan changes detail-family ownership only. Existing view-style layouts remain in place and must stay operator-first. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The implementation remains within Filament v5 and Livewire v4 only. No legacy Filament or Livewire v3 APIs are introduced. |
|
||||
| Provider registration location | PASS | PASS | No panel or provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No global-search behavior changes are planned. The touched resources already render viewable detail pages, so the Filament global-search requirement remains satisfied. |
|
||||
| Destructive action safety | PASS | PASS | No new destructive actions are added inside either shared family. Any host mutation remains outside the family core and keeps existing confirmation and authorization rules. |
|
||||
| Asset strategy | PASS | PASS | No new JS or CSS assets are planned. No `filament:assets` deployment change is required. |
|
||||
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The test plan focuses on cross-host parity, DB-only rendering, unavailable-state consistency, and guard coverage against re-forking, instead of thin indirection-only tests. |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Standardize the Verification Report family by extending the existing `VerificationReportViewer` seam and one family-owned Blade root instead of creating a new Livewire component or a custom entry type that only solves one host class.
|
||||
- Treat onboarding-specific assist and acknowledge behavior as explicit host-owned variation slots of the verification family rather than a parallel report UI.
|
||||
- Converge `normalized-settings.blade.php` and `policy-settings-standard.blade.php` under one family-owned settings wrapper with explicit subtypes, instead of continuing host-level view switching.
|
||||
- Move normalized diff unavailable and partial-state behavior into the family contract so `FindingResource` and `PolicyVersionResource` stop expressing different availability semantics for the same concept.
|
||||
- Keep `SettingsCatalogSettingsTable` as the existing Livewire subtype used by the settings family, since it already provides search, sort, pagination, and context-safe query-string isolation.
|
||||
- Protect the family contracts with parity tests and a small fork guard, not with a generic UI meta-framework or a screenshot-heavy suite.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/`:
|
||||
|
||||
- `data-model.md`: runtime contracts, host variation model, and source-truth boundaries for both shared families
|
||||
- `contracts/verification-report-family.openapi.yaml`: internal logical contract for the verification family and its approved hosts
|
||||
- `contracts/normalized-detail-family.openapi.yaml`: internal logical contract for normalized settings and normalized diff families and their approved hosts
|
||||
- `quickstart.md`: focused implementation, verification, and smoke workflow
|
||||
|
||||
Release-acceptance artifact maintained during implementation:
|
||||
|
||||
- `migration-note.md`: closing migrated-host inventory, bounded allowed variations, manual smoke evidence, and out-of-scope follow-ups required for release acceptance
|
||||
|
||||
Design decisions:
|
||||
|
||||
- Extend the existing `VerificationReportViewer` support seam instead of replacing it.
|
||||
- Add narrow normalized-detail support builders under existing Filament support paths rather than creating a new base framework.
|
||||
- Keep subtype richness for settings catalog tables, standard key-value settings, script content rendering, and diff block rendering, but move wrapper ownership and unavailable-state ownership into one family contract.
|
||||
- Keep host no-run and in-progress framing local where the host genuinely owns that state, while making the completed or unavailable family core the same surface everywhere.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/197-shared-detail-contract/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── migration-note.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ ├── normalized-detail-family.openapi.yaml
|
||||
│ └── verification-report-family.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── Workspaces/
|
||||
│ │ │ └── ManagedTenantOnboardingWizard.php
|
||||
│ │ ├── Resources/
|
||||
│ │ │ ├── FindingResource.php
|
||||
│ │ │ ├── OperationRunResource.php
|
||||
│ │ │ ├── PolicyResource.php
|
||||
│ │ │ └── PolicyVersionResource.php
|
||||
│ │ ├── Support/
|
||||
│ │ │ ├── VerificationReportChangeIndicator.php
|
||||
│ │ │ ├── VerificationReportViewer.php
|
||||
│ │ │ ├── NormalizedSettingsSurface.php
|
||||
│ │ │ └── NormalizedDiffSurface.php
|
||||
│ │ └── Widgets/
|
||||
│ │ └── Tenant/
|
||||
│ │ └── TenantVerificationReport.php
|
||||
│ └── Livewire/
|
||||
│ └── SettingsCatalogSettingsTable.php
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── filament/
|
||||
│ ├── components/
|
||||
│ │ ├── verification-report-viewer.blade.php
|
||||
│ │ └── verification-report/
|
||||
│ ├── forms/
|
||||
│ │ └── components/
|
||||
│ │ └── managed-tenant-onboarding-verification-report.blade.php
|
||||
│ ├── infolists/
|
||||
│ │ └── entries/
|
||||
│ │ ├── normalized-diff.blade.php
|
||||
│ │ ├── normalized-settings.blade.php
|
||||
│ │ └── policy-settings-standard.blade.php
|
||||
│ └── widgets/
|
||||
│ └── tenant/
|
||||
│ └── tenant-verification-report.blade.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Drift/
|
||||
│ ├── Filament/
|
||||
│ ├── Onboarding/
|
||||
│ ├── Verification/
|
||||
│ └── Guards/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the existing Laravel monolith layout and current Filament resource/widget/view directories. Add only narrow support builders under `app/Filament/Support` and family-owned partials under existing `resources/views/filament/...` paths. Do not add a new shared UI framework directory or new base package.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Cut the Verification Report Family Contract
|
||||
|
||||
**Goal**: Make one family-owned verification surface the semantic owner of completed and unavailable report rendering across the covered hosts.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Filament/Support/VerificationReportViewer.php` | Extend the existing seam so it can build the shared verification surface contract, not only extract sanitized report payloads. |
|
||||
| A.2 | `apps/platform/resources/views/filament/components/verification-report-viewer.blade.php` and new family-owned partials under `apps/platform/resources/views/filament/components/verification-report/` | Move summary, issues, passed, diagnostics, and optional action-zone ownership into one shared Blade root with one tab or view contract. |
|
||||
| A.3 | `apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php` | Remove local verification-family tab ownership and local structural duplication; render the same family core with explicit onboarding-only variation slots for assist, acknowledge, and technical details. |
|
||||
| A.4 | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, and `apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php` | Pass only explicit host context and host-owned actions into the verification family contract while keeping no-run or in-progress framing local where appropriate. |
|
||||
|
||||
### Phase B — Standardize the Normalized Settings Family
|
||||
|
||||
**Goal**: Make one family-owned settings wrapper the contract owner for warnings, empty state, subtype selection, and section behavior.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Filament/Support/NormalizedSettingsSurface.php` | Add a narrow runtime builder that shapes normalized settings state into one explicit family contract with subtype markers and render expectations. |
|
||||
| B.2 | `apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php` and subtype partials under `apps/platform/resources/views/filament/infolists/entries/normalized-settings/` | Own warnings, empty-state behavior, wrapper headings, and subtype delegation inside one family surface instead of splitting ownership between sibling host-selected blades. |
|
||||
| B.3 | `apps/platform/resources/views/filament/infolists/entries/policy-settings-standard.blade.php` | Absorb this blade into the normalized-settings family as an internal subtype partial or retire it as a direct host-facing sibling view. |
|
||||
| B.4 | `apps/platform/app/Filament/Resources/PolicyResource.php` and `apps/platform/app/Filament/Resources/PolicyVersionResource.php` | Stop choosing between sibling settings views at the host level and always pass family contract state to the normalized settings surface. |
|
||||
| B.5 | `apps/platform/app/Livewire/SettingsCatalogSettingsTable.php` | Keep the existing table component as the settings-catalog subtype renderer while ensuring the shared family contract owns when and how it is shown. |
|
||||
|
||||
### Phase C — Standardize the Normalized Diff Family
|
||||
|
||||
**Goal**: Make one family-owned diff wrapper the contract owner for summary, unavailable state, zero-diff messaging, and grouped detail rendering.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Filament/Support/NormalizedDiffSurface.php` | Add a narrow runtime builder that shapes diff state, availability state, and zero-change semantics into one explicit family contract. |
|
||||
| C.2 | `apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php` and family-owned partials under `apps/platform/resources/views/filament/infolists/entries/normalized-diff/` | Move unavailable, partial, summary, and grouped-render ownership into the shared diff family. Preserve script highlighting and grouped row rendering as explicit subzones of that family. |
|
||||
| C.3 | `apps/platform/app/Filament/Resources/FindingResource.php` | Replace host-owned diff-unavailable `TextEntry` behavior with family-owned unavailable state input so drift detail uses the same contract as other normalized diff hosts. |
|
||||
| C.4 | `apps/platform/app/Filament/Resources/PolicyVersionResource.php` | Route normalized diff state through the shared diff builder so policy-version diff and finding diff expose the same family semantics for available and unavailable paths. |
|
||||
|
||||
### Phase D — Protect the Family Boundaries
|
||||
|
||||
**Goal**: Make future host changes extend the family contracts instead of silently re-forking them.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php` | Add a focused guard that blocks new ad hoc host forks, such as host-selected `policy-settings-standard` references or duplicated verification-family tab ownership outside the shared core. |
|
||||
| D.2 | `apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php` and `apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php` | Add cross-host parity tests that assert the same family zones, unavailable semantics, and allowed host-variation boundaries. |
|
||||
| D.3 | `apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php`, `apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php`, `apps/platform/tests/Feature/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php`, `apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php`, `apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php`, `apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php`, `apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php`, `apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php`, `apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php`, and `apps/platform/tests/Feature/Findings/FindingRbacTest.php` | Extend existing feature coverage so it asserts the new family contracts without losing current DB-only, RBAC-safe, deny-as-not-found, capability-safe, and domain-rich behavior. |
|
||||
|
||||
### Phase E — Close the Migration and Verify Operator Sameness
|
||||
|
||||
**Goal**: Finish the feature with an explicit migrated-host inventory and a manual smoke path that matches the spec’s acceptance model.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `specs/197-shared-detail-contract/tasks.md` | Break implementation into dependency-ordered tasks across verification family, normalized settings family, normalized diff family, tests, and migration notes. |
|
||||
| E.2 | `specs/197-shared-detail-contract/migration-note.md` | Record migrated hosts, consciously allowed remaining variations, smoke-review evidence, and out-of-scope follow-ups required by release acceptance. |
|
||||
| E.3 | `specs/197-shared-detail-contract/quickstart.md` | Use the verification order and smoke path below to prove operator sameness across hosts and feed the result into the migration note. |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — Verification Report remains one Blade-rooted family because its hosts are heterogeneous
|
||||
|
||||
The verification family already spans an infolist-style detail section, a form `ViewField`, and a widget include. A new custom infolist entry or a new Livewire component would only solve one host class and would risk over-abstracting the rest. The narrowest path is to extend the existing `VerificationReportViewer` seam and let one family-owned Blade root own structure.
|
||||
|
||||
### D-002 — Onboarding keeps host-owned actions but loses structural ownership
|
||||
|
||||
The onboarding wizard legitimately owns verify-step actions, assist routing, and acknowledge mutations. It does not need to own the report’s tab system or its core diagnostics structure. The plan therefore keeps these actions as explicit host slots, not as a second verification UI.
|
||||
|
||||
### D-003 — `policy-settings-standard` becomes a subtype, not a peer family
|
||||
|
||||
The repo already exposes two sibling settings surfaces for the same conceptual family. The plan explicitly keeps settings-catalog and standard-settings richness, but moves them under one family wrapper so hosts stop selecting unrelated siblings at the top level.
|
||||
|
||||
### D-004 — Unavailable-state ownership belongs inside normalized diff
|
||||
|
||||
`FindingResource` currently owns unavailable messaging outside the diff family, while other diff hosts do not. The family contract should own availability, partial-state, and zero-diff semantics so the same content concept cannot drift at the host boundary.
|
||||
|
||||
### D-005 — The test strategy protects family sameness, not only helper output
|
||||
|
||||
The plan prefers cross-host parity tests, DB-only safety tests, and one small fork guard. It explicitly avoids a new UI meta-test framework or broad screenshot infrastructure because the product need is bounded and already well represented by existing feature tests.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Verification onboarding loses assist or acknowledge power while removing its fork | High | Medium | Keep assist and acknowledge as host-owned variation slots and extend onboarding tests before removing duplicated structural markup. |
|
||||
| Standardizing settings removes useful subtype richness | High | Medium | Keep explicit subtypes for settings-catalog tables, standard blocks, and script-heavy content; standardize the wrapper, not the domain richness away. |
|
||||
| Diff unavailable semantics collapse distinct host cases | Medium | Medium | Make unavailable and partial states explicit contract inputs and cover missing-version and empty-side finding scenarios in feature tests. |
|
||||
| The feature grows into a generic shared-detail framework | High | Low | Limit support additions to one existing verification seam plus one settings builder and one diff builder under current Filament support paths. No cross-domain base class or registry is introduced. |
|
||||
| New hosts later reintroduce ad hoc forks | Medium | Medium | Add one focused fork guard and internal contract YAMLs that document approved consumers and forbidden host-level patterns. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend verification-family feature coverage so `OperationRunResource`, onboarding verification, and the tenant verification widget all assert the same family-owned summary, issues, passed, diagnostics, and unavailable semantics for equivalent report data.
|
||||
- Keep `VerificationReportViewerDbOnlyTest` and `TenantVerificationReportWidgetTest` as truth guards that the standardized family remains DB-only and does not introduce outbound work.
|
||||
- Extend onboarding verification tests to prove host-specific assist and acknowledge actions survive as bounded variations of the same family.
|
||||
- Extend normalized settings and diff coverage in `PolicyVersionSettingsTest`, `SettingsCatalogPolicyNormalizedDiffTest`, `GroupPolicyConfigurationNormalizedDiffTest`, and `DriftFindingDiffUnavailableTest` so they assert family-level structure and unavailable behavior instead of only content presence.
|
||||
- Add one new parity test per family and one focused guard test that blocks direct host fork patterns.
|
||||
- Use Livewire component tests for widgets and pages, and do not try to mount non-Livewire resource classes directly. This follows Filament v5 testing guidance.
|
||||
- Run the smallest Sail verification pack plus `pint --dirty --format agent` before implementation completion.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| One new normalized-settings builder and one new normalized-diff builder | The repo already has multiple concrete hosts for each family, but no existing support seam analogous to `VerificationReportViewer` for normalized detail families | Leaving host files to shape state ad hoc would preserve the exact drift this spec is meant to remove |
|
||||
| One focused fork guard | The feature’s long-term value depends on preventing new host forks after the first cleanup | Relying only on reviewer memory or prose documentation would not reliably stop drift from returning |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators encounter the same verification or normalized detail concept in multiple hosts, but the structure, next-step zone, diagnostics placement, and unavailable behavior vary enough to slow recognition and trust.
|
||||
- **Existing structure is insufficient because**: Shared Blade reuse exists, but shared contract ownership does not. Hosts still decide core structure and availability semantics themselves, which is why drift already exists.
|
||||
- **Narrowest correct implementation**: Extend the existing verification support seam, add two narrow normalized-detail support builders, and move family wrapper ownership into current Blade paths. Do not add persistence, a new framework, or a cross-domain taxonomy.
|
||||
- **Ownership cost created**: Three narrow support seams in current Filament support paths, a handful of family-owned partials, two internal contract YAMLs, and focused parity or guard tests.
|
||||
- **Alternative intentionally rejected**: Continue local host cleanups or invent a full shared-detail framework. Local cleanup would not stop drift, and a framework would import much more permanent complexity than this feature needs.
|
||||
- **Release truth**: Current-release truth. The affected families and host drift already exist in shipped operator surfaces.
|
||||
110
specs/197-shared-detail-contract/quickstart.md
Normal file
110
specs/197-shared-detail-contract/quickstart.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Quickstart: Shared Detail Micro-UI Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that the same verification and normalized detail concepts now render through family-owned contracts across their covered hosts, without adding persistence, Graph calls, or a generic UI framework.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail.
|
||||
2. Ensure you have a tenant with at least one completed verification run and one onboarding session that can resume on the verify step.
|
||||
3. Ensure you have policy and policy-version data that exercises both settings-catalog and standard-settings rendering.
|
||||
4. Ensure you have at least one drift finding with an available normalized diff and one drift finding with missing version references.
|
||||
5. Ensure the current user can open the covered hosts in tenant and workspace-safe contexts.
|
||||
|
||||
## Implementation Validation Order
|
||||
|
||||
### 1. Run verification-family regression coverage
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantVerificationReportWidgetTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/MonitoringOperationsTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAccessTest.php
|
||||
./vendor/bin/sail artisan test --compact --filter=SharedVerificationReportFamilyContract
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- Operation detail, onboarding, and tenant widget all expose the same verification-family core.
|
||||
- Verification rendering stays DB-only and does not dispatch outbound work.
|
||||
- Onboarding-only assist and acknowledge actions remain available as bounded host variations.
|
||||
- Workspace-context and tenant-context verification hosts keep deny-as-not-found and capability-safe behavior after the shared viewer refactor.
|
||||
|
||||
### 2. Run normalized detail-family regression coverage
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionSettingsTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Drift/DriftFindingDiffUnavailableTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRbacTest.php
|
||||
./vendor/bin/sail artisan test --compact --filter=NormalizedDetailFamilyContract
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- Policy and policy-version settings use the same family-owned wrapper with explicit subtype behavior.
|
||||
- Policy-version diff and finding diff use the same normalized diff family semantics for available, unavailable, and zero-diff cases.
|
||||
- No host still relies on a sibling top-level settings view or a host-only diff unavailable message for the same concept.
|
||||
- Tenant-scoped policy, policy-version, and finding detail hosts preserve existing deny-as-not-found and capability semantics.
|
||||
|
||||
### 3. Run the fork guard and targeted parity checks
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
|
||||
./vendor/bin/sail artisan test --compact tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php
|
||||
./vendor/bin/sail artisan test --compact --filter=FamilyContract
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- Guard coverage blocks reintroduction of direct host forks.
|
||||
- Parity tests prove the same family zones and the same unavailable-state semantics across hosts.
|
||||
|
||||
### 4. Format touched files
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
|
||||
./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- All touched PHP files conform to the repo’s Pint rules.
|
||||
|
||||
## Manual Smoke Check
|
||||
|
||||
1. Open the operation run detail that shows a verification report and confirm you can immediately identify summary, issues, passed, and technical details zones.
|
||||
2. Open the onboarding wizard verify step for the same or equivalent verification payload and confirm the same core verification surface appears, with onboarding-only assist or acknowledge behavior layered on top instead of a different tab model.
|
||||
3. Open the tenant verification widget and confirm the completed-state core matches the other verification hosts while no-run and in-progress framing stays widget-specific.
|
||||
4. Open a policy-version detail view and a policy detail view that both show normalized settings and confirm warnings, empty-state behavior, and subtype structure feel like one family.
|
||||
5. Open a policy-version detail view that shows normalized diff and confirm summary, grouped sections, and availability messaging match the same shared diff-family contract used elsewhere.
|
||||
6. Open a drift finding with missing referenced versions and confirm the diff area uses a family-consistent unavailable state.
|
||||
7. Open a drift finding with an available normalized diff and confirm the grouped diff surface feels like the same family as policy-version diff, not a new host-specific micro-app.
|
||||
|
||||
## Release Acceptance Recording
|
||||
|
||||
1. Update `specs/197-shared-detail-contract/migration-note.md` with the migrated hosts actually touched by the implementation.
|
||||
2. Record the consciously allowed remaining variations that still differ by host.
|
||||
3. Capture reviewer, review date, and pass or fail notes for SC-197-003 and SC-197-004 in the migration note.
|
||||
4. Record any shell, monitoring-state, or other intentionally out-of-scope follow-ups in the migration note instead of expanding this feature silently.
|
||||
|
||||
## Non-Goals For This Slice
|
||||
|
||||
- No database migration.
|
||||
- No new public API.
|
||||
- No new Graph call path or `OperationRun` lifecycle change.
|
||||
- No new Filament asset registration or `filament:assets` deployment change.
|
||||
- No shell, workspace context bar, or monitoring page-state refactor.
|
||||
74
specs/197-shared-detail-contract/research.md
Normal file
74
specs/197-shared-detail-contract/research.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Phase 0 Research: Shared Detail Micro-UI Contract
|
||||
|
||||
## Research Inputs
|
||||
|
||||
- Repository truth from current hosts and tests for `verification-report-viewer`, onboarding verification, tenant verification widget, `normalized-settings`, `policy-settings-standard`, and `normalized-diff`
|
||||
- Filament v5 notes in `docs/research/filament-v5-notes.md`
|
||||
- Version-specific Filament documentation for custom fields, custom infolist entries, render hooks, and testing
|
||||
|
||||
## Decision 1 — Verification Report should stay Blade-rooted and support-backed
|
||||
|
||||
**Decision**: Standardize the Verification Report family by extending the existing `VerificationReportViewer` support seam and one shared Blade root, instead of introducing a new Livewire component or a custom Filament entry class.
|
||||
|
||||
**Rationale**: The family already spans three host classes: an infolist-style run detail, a form `ViewField` inside onboarding, and a widget include. Filament custom entries are reusable, but they are still infolist-specific. Filament custom fields are form-specific. A new Livewire component would impose a heavier lifecycle and more new surface area than this feature needs. The existing support seam already owns report extraction, fingerprinting, and previous-run lookup, which is the right narrow base.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Create a custom infolist entry for verification: rejected because onboarding and widget hosts would still need separate structural ownership.
|
||||
- Create a dedicated Livewire verification viewer: rejected because it imports new lifecycle complexity for a mostly read-only, DB-only surface.
|
||||
- Keep the onboarding fork and only restyle it: rejected because the drift is structural, not cosmetic.
|
||||
|
||||
## Decision 2 — Onboarding-specific assist and acknowledge behavior must remain host-owned slots
|
||||
|
||||
**Decision**: Treat assist, acknowledge, refresh, and technical-details triggers in onboarding as explicit host-owned variations of the verification family, not as a second verification UI contract.
|
||||
|
||||
**Rationale**: The onboarding wizard legitimately owns interactive workflow actions and different no-run or in-progress framing. The shared family should own summary, tabs or view zones, issues, passed checks, diagnostics, and unavailable behavior. This respects the spec’s rule that hosts may extend actions and placement without redefining the family core.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Move assist and acknowledge logic into the shared core: rejected because those actions are wizard-specific and would leak workflow behavior into unrelated hosts.
|
||||
- Leave onboarding fully separate: rejected because it preserves the existing duplicate tab contract and duplicated grouping logic.
|
||||
|
||||
## Decision 3 — Normalized settings must converge under one family wrapper with explicit subtypes
|
||||
|
||||
**Decision**: Standardize normalized settings through one family-owned wrapper that explicitly supports at least two subtypes: settings-catalog table rendering and standard block or key-value rendering.
|
||||
|
||||
**Rationale**: `PolicyResource` and `PolicyVersionResource` currently choose between `normalized-settings` and `policy-settings-standard` at the host level. That means the host, not the family, owns warnings, wrapper titles, empty state, and subtype selection. The repo already proves the two subtypes are real. The narrowest fix is not to flatten them, but to put both behind one family wrapper.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Keep both views and only document when to use each: rejected because the host-level fork remains the same.
|
||||
- Force every settings subtype into one totally uniform table: rejected because the domain richness and script-oriented rendering would be weakened.
|
||||
|
||||
## Decision 4 — Normalized diff must own unavailable and partial-state semantics
|
||||
|
||||
**Decision**: Move unavailable, partial, and zero-diff behavior into the normalized diff family contract instead of leaving those semantics to individual hosts.
|
||||
|
||||
**Rationale**: `FindingResource` currently shows a host-owned unavailable text entry before the shared diff view, while `PolicyVersionResource` simply renders the diff view. This is the clearest current evidence that the same content concept does not yet own its own availability rules. The family should own these states so equivalent cases feel consistent across hosts.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Keep host-owned unavailable messages and only align wording: rejected because ownership would still be split.
|
||||
- Force all hosts to pre-normalize everything into “available only” and hide gaps: rejected because it would reduce diagnostic honesty.
|
||||
|
||||
## Decision 5 — The existing Livewire settings table remains the settings-catalog subtype renderer
|
||||
|
||||
**Decision**: Keep `SettingsCatalogSettingsTable` as the renderer for the settings-catalog subtype inside the normalized settings family.
|
||||
|
||||
**Rationale**: The table already provides search, sort, pagination, query-string isolation by context, and a details action. Replacing it would add risk and produce little user benefit. The problem is not that the table exists; the problem is that the host, rather than the family, currently decides when the table belongs to the surface.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Replace the table with static Blade markup: rejected because it would lose established usability and re-implement behavior already present.
|
||||
- Split settings-catalog into its own separate family: rejected because the operator still experiences it as a settings detail subtype, not a separate product surface.
|
||||
|
||||
## Decision 6 — Testing should prove cross-host sameness and DB-only behavior, then add a small fork guard
|
||||
|
||||
**Decision**: Reuse and extend the current feature tests for widgets, onboarding, verification DB-only behavior, policy-version settings, and finding diffs, then add one parity test per family and one focused guard against re-forking.
|
||||
|
||||
**Rationale**: The repo already has strong tests around verification and normalized detail content. The missing coverage is family-level sameness across hosts and explicit protection against new host forks. A small guard gives lasting value without forcing a generic UI compliance framework.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Rely only on manual smoke checks: rejected because the spec explicitly wants drift prevention.
|
||||
- Add a large screenshot or browser-regression suite: rejected because it would be expensive relative to the bounded scope and existing feature-level coverage.
|
||||
245
specs/197-shared-detail-contract/spec.md
Normal file
245
specs/197-shared-detail-contract/spec.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Feature Specification: Shared Detail Micro-UI Contract
|
||||
|
||||
**Feature Branch**: `197-shared-detail-contract`
|
||||
**Created**: 2026-04-15
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 197 — Shared Detail Micro-UI Contract"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Wiederverwendete Detail-Micro-UIs für dieselben fachlichen Objekte leben heute als leicht unterschiedliche Host-Varianten, sodass Operatoren dieselbe Surface je nach Host neu lesen und neu erlernen müssen.
|
||||
- **Today's failure**: Dieselbe fachliche Surface zeigt je nach Host andere Tabs, anders platzierte Next Steps, anders platzierte technische Details oder lokale Zusatzlogik. Dadurch entstehen inkonsistente Operator-Erwartungen, Drift bei Weiterentwicklungen und unnötiger Test- sowie Pflegeaufwand.
|
||||
- **User-visible improvement**: Operatoren erleben dieselbe Verification-Report- oder Diff-/Settings-Surface hostübergreifend als dieselbe fachliche Surface mit denselben Kernzonen, denselben erwartbaren Interaktionsmustern und klar begrenzten Host-Unterschieden.
|
||||
- **Smallest enterprise-capable version**: Nur zwei bereits real belegte Shared-Familien werden standardisiert: die Verification-Report-Familie sowie die Normalized Diff / Normalized Settings-Familie. Der Spec führt keinen generischen UI-Baukasten ein und modelliert keine weiteren Custom-Surfaces ohne nachgewiesene Familienwiederholung.
|
||||
- **Explicit non-goals**: Kein Shell- oder Monitoring-Page-State-Refactor, keine Vereinheitlichung aller Custom-Detailansichten, kein generisches internes Komponenten-Framework, keine Table-/CRUD-/Badge-Konsistenzkampagne außerhalb der belegten Shared-Familien.
|
||||
- **Permanent complexity imported**: Zwei explizite Shared-Family-Verträge, dokumentierte Pflicht-/Optional-/Host-Zonen, begrenzte Host-Variationsregeln, family-orientierte Regressionsabdeckung, eine kleine Abschlussdokumentation zu migrierten Hosts und erlaubten Restvarianten.
|
||||
- **Why now**: Beide Familien sind bereits in mehreren Hosts sichtbar und zeigen belegte Drift. Jeder weitere Host erhöht die Wahrscheinlichkeit, dass dieselbe Surface erneut als lokale Mini-App eingebaut wird.
|
||||
- **Why not local**: Lokale Bereinigungen pro Host beseitigen die Wiederholung nicht. Das Problem liegt in fehlendem gemeinsamen Vertrag für dieselbe Surface-Familie, nicht in einem einzelnen Host.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: Eine rote Flagge: Shared-Cross-Surface-Contract kann in ein zu breites UI-Framework kippen, wenn der Scope über die zwei belegten Familien hinaus wächst.
|
||||
- **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**:
|
||||
- Monitoring- beziehungsweise Operations-Detailflächen, die Verification Reports als eingebettete Detail-Surface zeigen
|
||||
- Tenant-onboarding- und tenantbezogene Verifikationsflächen, die denselben Verification Report in Wizard-, Widget- oder Inline-Kontext rendern
|
||||
- Tenantbezogene Policy-, Policy-Version- und Finding-Detailflächen, die Normalized Settings oder Normalized Diff als Detail-Surface zeigen
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned: verifikationsbezogene Detaildaten, Policy-, Policy-Version- und Finding-Daten, die innerhalb der Shared-Familien dargestellt werden
|
||||
- Workspace-context / canonical-view owned: die hostseitige Einbettung in kanonische Monitoring- oder Operations-Detailflächen
|
||||
- Dieser Spec führt keine neue persistierte UI- oder Vertrags-Entität ein; die Shared-Family-Verträge bleiben rein darstellungs- und verhaltensbezogen
|
||||
- **RBAC**:
|
||||
- Bestehende Workspace-Mitgliedschaft und Tenant-Entitlement bleiben die Zugangsvoraussetzung für alle Hosts, die diese Shared-Familien rendern
|
||||
- Bestehende View-/Inspect-Berechtigungen der Hosts bleiben maßgeblich; der Spec führt keine neue Capability und keinen neuen Autorisierungsweg ein
|
||||
- Host-spezifische Aktionen innerhalb oder neben der Shared-Family bleiben weiter an die bereits vorhandenen Capability-Regeln des Hosts gebunden
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Wenn Tenant-Kontext aktiv ist, rendern Shared-Familien ausschließlich Daten aus dem aktuell aktiven Tenant-Kontext des Hosts. Ein Host darf keine Shared-Family tenantübergreifend oder tenantlos auf tenant-owned Daten erweitern.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Jeder Host bleibt dafür verantwortlich, nur bereits autorisierte Records oder Reports an die Shared-Family zu übergeben. Tenantlose kanonische Hosts dürfen tenant-owned Inhalte nur nach bestehender Entitlement-Prüfung sichtbar machen; Nicht-Mitglieder bleiben deny-as-not-found.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Verification Report family | Secondary Context Surface | Operator prüft, warum ein Verify- oder Onboarding-Schritt bereit, blockiert oder nacharbeitspflichtig ist | Ergebnisstatus, Blocker oder Warnungen, zentrale Next Steps, klarer Host-Kontext | Technische Details, Run-Identität, tiefere Diagnose, Host-spezifische Assist-Einstiege | Nicht primär, weil die eigentliche Entscheidung im Host-Workflow liegt; diese Surface liefert die entscheidungsrelevante Begründung | Unterstützt Verify-, Onboarding- und Tenant-Follow-up-Workflows, ohne selbst eine separate Prozessfläche zu werden | Gleiche Grundstruktur in mehreren Hosts reduziert erneutes Uminterpretieren derselben Report-Aussage |
|
||||
| Normalized Diff / Settings family | Tertiary Evidence / Diagnostics Surface | Operator prüft, was sich fachlich geändert hat oder welche Settings inhaltlich gelten | Klar gegliederte Abschnitte, aktiver View-Modus, wichtigste Diff- oder Settings-Inhalte | Zusätzliche Abschnitte, tiefere technische Darstellung, volle Detailtiefe, optionale Expand-/Fullscreen-Zonen | Nicht primär, weil die Entscheidung meist von einer übergeordneten Policy-, Version- oder Finding-Detailfläche ausgelöst wird | Unterstützt Review-, Diagnose- und Vergleichsarbeit innerhalb bestehender Detail-Workflows | Konsistente View-Zonen und Zustände reduzieren Vergleichsaufwand zwischen Policy-, Version- und Finding-Hosts |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Verification Report family | Detail / Embedded Review | Shared detail micro-UI | Read blockers, follow next step, open related operation or assist path | Host-owned embedded detail surface | forbidden | Innerhalb der gemeinsamen Action-/Assist-Zone oder hostseitig unmittelbar angrenzend, klar getrennt von Kernstatus und Diagnose | Keine destruktiven Aktionen im Shared Core; hostseitige Mutationen bleiben außerhalb des Core-Vertrags und confirmation-gated | Host-owned verification entry surfaces | Host-owned verification detail contexts in operations, onboarding, or tenant review flows | Aktiver Tenant- oder Workspace-Kontext des Hosts, Report-Status, Ergebnis-/Readiness-Signale | Verification report | Readiness, blockers or warnings, and next action intent | Embedded shared family; kein eigenständiges list-first oder standalone resource detail |
|
||||
| Normalized Diff / Settings family | Detail / Evidence | Shared detail micro-UI | Inspect sections, switch view mode, expand detail, compare meaningfully | Host-owned embedded detail surface | forbidden | Innerhalb der gemeinsamen View-/Action-Zone der Surface; hostseitige Navigation bleibt außerhalb der Kern-Interaktion | Keine destruktiven Aktionen innerhalb der Shared-Family | Host-owned policy, policy version, or finding detail entries | Host-owned policy, version, and finding detail contexts | Aktiver Tenant-Kontext des Hosts, Inhaltstyp, verfügbarer Detailmodus, Empty/Unavailable-Signale | Normalized settings / Normalized diff | Die aktuell relevante fachliche Detailaussage des Settings- oder Diff-Inhalts | Embedded evidence family; kein eigenständiges CRUD- oder queue-first surface |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Verification Report family | Tenant operator, workspace operator, onboarding operator | Verstehen, ob ein Verify- oder Onboarding-Schritt bereit ist, was blockiert, und was als Nächstes zu tun ist | Embedded report detail | Why is this verification ready, blocked, or cautionary, and what should I do next? | Summary oder header zone, outcome or readiness status, issues or passes overview, next-step or assist entry points | Technical details, operation identity, deeper diagnostics, host-specific helper content | readiness, outcome, severity of issues, data completeness | Shared core is read-only; host-owned acknowledge or assist mutations remain explicitly bounded | Review issues, switch view zone, follow next step, open technical detail or related run | None in shared core |
|
||||
| Normalized Diff / Settings family | Tenant operator, reviewer, diagnostician | Verstehen, welche Inhalte gelten oder was sich fachlich geändert hat | Embedded evidence detail | What changed or what settings are in force, and where should I inspect next? | Shared section structure, active view or tab, visible content blocks, empty or unavailable explanation | Secondary sections, deeper technical content, larger expansions, raw or diagnostic follow-up hosted elsewhere | content availability, comparison completeness, section context | Read-only | Switch view or tab, expand relevant detail, inspect targeted sections | None |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: No
|
||||
- **New persisted entity/table/artifact?**: No
|
||||
- **New abstraction?**: Yes
|
||||
- **New enum/state/reason family?**: No
|
||||
- **New cross-domain UI framework/taxonomy?**: No
|
||||
- **Current operator problem**: Dieselbe fachliche Detail-Surface wirkt in verschiedenen Hosts wie verschiedene kleine Anwendungen. Das erschwert sichere Wiedererkennung, verlangsamt Folgeschritte und erhöht die Wahrscheinlichkeit, dass Weiterentwicklungen inkonsistent erfolgen.
|
||||
- **Existing structure is insufficient because**: Reines Fragment-Sharing auf View-Ebene verhindert keine Drift bei Tabs, Assist-Zonen, Diagnostics-Zonen, lokalen Zuständen oder Action-Platzierung. Ohne expliziten Familienvertrag bleibt jeder Host faktisch Mitbesitzer der Surface-Logik.
|
||||
- **Narrowest correct implementation**: Definiere nur für die zwei bereits belegten Shared-Familien einen gemeinsamen Detailvertrag mit expliziten Pflicht-, Optional- und Host-Variationszonen; keine generische Plattform, kein globales UI-System, keine neue Persistenz.
|
||||
- **Ownership cost**: Dauerhaft entstehen begrenzte zusätzliche Vertragsdokumentation, cross-host Regressionstests und ein klarer Review-Maßstab für künftige Host-Einbindungen. Im Gegenzug sinken parallele View-Logik, Drift-Risiko und Teststreuung.
|
||||
- **Alternative intentionally rejected**: Einzelne Hosts lokal aufzuräumen oder weitere Blade-Sharing-Fixes einzubauen wurde verworfen, weil das die gemeinsame Kernlogik nicht schützt. Ein generisches internes UI-Framework wurde ebenfalls verworfen, weil nur zwei reale Familien standardisiert werden müssen.
|
||||
- **Release truth**: Current-release truth
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Recognize the same verification surface everywhere (Priority: P1)
|
||||
|
||||
As an operator, I want verification results to use the same recognizable structure in operation detail, onboarding, and tenant verification hosts, so that I can immediately see the same core meaning, next steps, and diagnostics without relearning the surface.
|
||||
|
||||
**Why this priority**: The most visible current drift is inside the Verification Report family, where the same report meaning is rendered with host-specific structure and local state.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering each covered verification host with equivalent verification data and confirming that the same core zones, same status meaning, and same next-step intent are visible in each host while allowed host variations remain bounded.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the same verification outcome is rendered in an operations detail host and an onboarding host, **When** the operator opens each host, **Then** the same core summary, issue/pass structure, and diagnostics contract are recognizable.
|
||||
2. **Given** a verification host offers host-specific assist or acknowledge behavior, **When** the operator uses that host, **Then** the host-specific behavior appears as an explicit variation of the same shared verification contract rather than as a different report UI.
|
||||
3. **Given** a verification report has no technical detail payload available, **When** it is rendered in any covered host, **Then** the absence is communicated through the same contractually defined unavailable or optional zone behavior rather than host-specific omission rules.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Inspect normalized settings and diffs consistently (Priority: P1)
|
||||
|
||||
As an operator reviewing policy, version, or finding detail, I want normalized settings and normalized diff surfaces to behave like one family, so that tabs, view modes, sections, and unavailable states do not feel reinvented per host.
|
||||
|
||||
**Why this priority**: The diff/settings family already appears across multiple detail hosts and contains subtle host-specific drift around visibility, context handling, and section behavior.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering representative policy, policy-version, and finding hosts and verifying that the same family-level structure, same core view behavior, and same empty or unavailable semantics apply wherever the same content concept is shown.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a normalized diff is available in more than one covered host, **When** the operator opens each host, **Then** the same family-level view structure and section logic are recognizable.
|
||||
2. **Given** a normalized settings surface renders different content subtypes, **When** those subtypes are shown in covered hosts, **Then** subtype differences remain explicit and do not appear as ad hoc host forks.
|
||||
3. **Given** a host cannot show a diff or settings payload, **When** the operator reaches that area, **Then** the surface shows a family-consistent unavailable or partial state rather than a host-specific gap.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Add or update a host without re-forking the family (Priority: P2)
|
||||
|
||||
As a developer or reviewer, I want a clear shared-family contract for repeated detail surfaces, so that a new host or host change can extend the family through known variation points instead of quietly re-forking the whole micro-UI.
|
||||
|
||||
**Why this priority**: The product gain only lasts if later host work cannot easily recreate drift.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing a changed or newly added host against the family contract and verifying that only allowed host-driven inputs, zones, and actions differ.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a developer introduces a new host for an existing shared family, **When** the host is reviewed and tested, **Then** the host declares or uses only the family's allowed variation points.
|
||||
2. **Given** an existing host needs a unique action or assist entry, **When** that difference is added, **Then** the difference remains visibly host-scoped and does not redefine the family core structure.
|
||||
3. **Given** a future host tries to introduce a different tab or view contract for the same family without domain justification, **When** the change is reviewed, **Then** it is rejected as out of contract.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A covered host lacks one optional zone, such as technical details, but still must preserve the same family-level structure and optional-state behavior.
|
||||
- A host needs an assist or acknowledge action that no other host needs; the action must remain an explicit host variation and not become a hidden structural fork.
|
||||
- The same family appears in both tenant-context and workspace-context hosts; tenant scope must stay explicit and inaccessible data must remain not found.
|
||||
- A diff or settings payload is partially available, unavailable, or subtype-specific; the surface must communicate this consistently without silently dropping the zone.
|
||||
- A family contains local state, such as view selection or expansion state; that state must be owned once at the family level rather than recreated differently in each host.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new long-running or queued work, and no new write workflow. It standardizes repeated operator-facing detail surfaces that already exist and must remain derived from the host's current domain truth.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one narrow kind of abstraction: an explicit shared detail-family contract for two already proven families. It does not introduce new persistence, new state families, or a cross-domain UI framework. The purpose is to replace duplicate cross-host ownership, not to layer a new semantic system on top of existing truth.
|
||||
|
||||
**Constitution alignment (OPS-UX):** No new `OperationRun` type or execution path is introduced. Existing links to operations remain navigation only and continue to rely on the current operations contract.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature does not change authorization behavior. Existing workspace and tenant entitlement rules continue to govern every host. Non-members remain deny-as-not-found, members without capability remain forbidden where host-owned actions already enforce capability, and no shared family may bypass host authorization.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Existing centralized status, severity, readiness, and availability semantics remain the source of truth. This feature must not create host-local badge or tone vocabularies for the same shared family.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** Existing native detail containers, shared status primitives, and current custom viewer bodies remain the basis. The work must consolidate contract ownership and avoid new host-local replacement markup for status, assist, or action zones. Exception: the core bodies of the two shared families remain domain-specific rich viewers rather than being flattened into generic primitives.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Shared family vocabulary must stay stable across hosts. Operators should continue to see the same canonical nouns, such as verification report, technical details, next steps, normalized settings, and diff, instead of host-specific synonyms for the same concept.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** Verification Report surfaces remain secondary context surfaces, and Normalized Diff / Settings surfaces remain tertiary evidence surfaces. Each host must still make the first decision possible without forcing the operator to decode different family structures for the same concept.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature standardizes embedded detail and evidence surfaces. Each family keeps exactly one primary inspect model per host: the host opens the record, and the family provides the repeated inner surface. Pure navigation remains host-owned or within the family's secondary action zone. Destructive behavior is outside the shared core and remains governed by host rules.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Shared-family actions are limited to navigation, inspection, view switching, assist entry, and other low-risk context actions. Any mutating or destructive host action remains clearly separated from the shared-family core and may not compete with the family's primary evidence or review function.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first. Shared-family surfaces must show the core meaning, next step, or relevant content first, while deeper diagnostics remain secondary and intentionally revealed. Host context remains visible so operators understand scope.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct host-by-host mapping is currently insufficient because it has already produced duplicate ownership and drift. This feature must replace that duplicate ownership with one shared contract per family rather than adding extra presenter layers. Regression tests must assert the business-visible consequences of sameness across hosts.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. These are embedded read-heavy detail families, not new standalone CRUD resources. Each host continues to own its one primary inspect/open model, no redundant View action is introduced by the family work, and destructive actions stay outside the shared core. UI-FIL-001 is satisfied with the documented exception that family-specific detail viewers remain custom where the domain requires it.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature does not add create or edit screens. Existing detail hosts must continue to use appropriate detail layouts, clear sectioning, explicit empty or unavailable states, and operator-first information ordering. Where a host already uses a view-style detail surface, the shared family must strengthen rather than weaken that clarity.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-197-001**: The system MUST define a shared detail contract for the Verification Report family that covers every currently in-scope host where the same verification surface appears.
|
||||
- **FR-197-002**: The Verification Report contract MUST identify which zones are mandatory, optional, host-extendable, and host-governed, including summary or header, outcome or readiness, view or tab zone, next-step or assist zone, technical-details or diagnostics zone, and empty or unavailable state handling.
|
||||
- **FR-197-003**: Covered Verification Report hosts MUST use the same family-level core structure and MUST NOT redefine the family through incompatible local tab, view, or zone contracts.
|
||||
- **FR-197-004**: Host-specific Verification Report differences MAY vary action availability, host placement, or assist entry behavior, but MUST NOT change the family's core structure, diagnostics contract, or next-step contract without explicit domain justification.
|
||||
- **FR-197-005**: The Verification Report family MUST remain at least functionally equivalent to the pre-standardized state, preserving current information value, current operator usefulness, and current diagnosis depth.
|
||||
- **FR-197-006**: The system MUST define a shared detail contract for the Normalized Diff / Normalized Settings family across every currently in-scope host that presents the same content concept.
|
||||
- **FR-197-007**: The Normalized Diff / Settings contract MUST standardize family-level view zones, section behavior, empty or unavailable states, partial states, and any supported expand or fullscreen semantics where those behaviors are part of the family.
|
||||
- **FR-197-008**: If the Normalized Diff / Settings family contains meaningful subtypes, those subtypes MUST be explicit variations of the same family contract and MUST NOT exist only as accidental host forks.
|
||||
- **FR-197-009**: Covered hosts for the Normalized Diff / Settings family MAY vary host framing or surrounding context, but MUST preserve recognizable family-level interaction patterns for view switching, section reading, and detail inspection.
|
||||
- **FR-197-010**: For each in-scope shared family, the required inputs, supported states, and render expectations MUST be explicit enough that a host does not need hidden local assumptions to render the family correctly.
|
||||
- **FR-197-011**: If a shared family requires local UI state, that state MUST be owned once at the family level and MUST NOT be recreated differently in each host.
|
||||
- **FR-197-012**: New or updated hosts using an in-scope shared family MUST extend the family only through the contract's documented variation points and MUST NOT introduce new ad hoc Blade-level main variants for the same surface.
|
||||
- **FR-197-013**: Domain-specific viewers that are similar in shape but intentionally separate from the two in-scope families MAY remain outside this spec, but they MUST stay explicitly out of scope rather than becoming accidental exceptions inside the standardized families.
|
||||
- **FR-197-014**: Regression coverage MUST prove that multiple hosts for each in-scope shared family use the same family-level contract and that any remaining differences are explicit, bounded host variations.
|
||||
- **FR-197-015**: Release acceptance for this spec MUST include a closing inventory of migrated hosts, intentionally allowed remaining variations, and follow-up topics that were found to be shell, monitoring-state, or other out-of-scope concerns.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Verification Report family | Embedded inside operations detail, onboarding verification, and tenant verification hosts | Host-owned header actions; shared family may expose low-risk report actions such as open related operation, open technical details, or follow next step | Host-owned detail inspection; not a list/table surface | None at family level; any host-specific per-item action stays explicitly host-scoped | None | Family-consistent empty, unavailable, or assist CTA where relevant | N/A | N/A | No new audit path introduced | Action Surface Contract satisfied. Shared core is read-heavy; host-owned acknowledge or assist mutations remain outside the core contract and retain existing authorization and confirmation rules. |
|
||||
| Normalized Diff / Settings family | Embedded inside policy, policy version, and finding detail hosts | Host-owned detail header actions only | Host-owned detail inspection; not a list/table surface | None | None | Family-consistent unavailable or partial-state explanation; CTA only if the host already owns a follow-up path | N/A | N/A | No | Action Surface Contract satisfied. Read-only evidence family; no new mutation or destructive affordance is introduced. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Shared detail family**: A repeated domain detail surface that appears in more than one host and should read as the same surface wherever it appears.
|
||||
- **Family contract**: The explicit definition of a shared family's inputs, supported states, mandatory zones, optional zones, and allowed host variations.
|
||||
- **Host variation**: A deliberately bounded host-specific difference, such as action availability, placement, or assist entry, that does not redefine the family core.
|
||||
- **Verification report detail**: The domain report surface that communicates verification readiness, blockers, passes, next steps, and deeper diagnostics.
|
||||
- **Normalized detail surface**: The domain evidence surface that communicates structured settings content or structured diff content through one recognizable family.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- **D-197-001**: A standardized Verification Report shared-family contract covering the currently in-scope hosts.
|
||||
- **D-197-002**: A standardized Normalized Diff / Normalized Settings shared-family contract covering the currently in-scope hosts.
|
||||
- **D-197-003**: Documented variation rules per family that state what is shared core, what hosts may extend, and what hosts must not re-invent.
|
||||
- **D-197-004**: A closing migration note listing migrated hosts, consciously allowed remaining differences, and related topics that were intentionally left out of scope.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-197-001**: In regression coverage, every currently in-scope host for the Verification Report family passes the shared-family contract assertions with zero unexplained core-structure differences.
|
||||
- **SC-197-002**: In regression coverage, every currently in-scope host for the Normalized Diff / Settings family passes the shared-family contract assertions with zero unexplained view-structure differences.
|
||||
- **SC-197-003**: In operator smoke review, a reviewer can identify summary, next-step or assist placement, and diagnostics placement within 10 seconds on each covered Verification Report host.
|
||||
- **SC-197-004**: In operator smoke review, covered diff/settings hosts are recognized as the same family for view modes, section behavior, and unavailable-state behavior in 100% of reviewed scenarios.
|
||||
- **SC-197-005**: The release contains no remaining unbounded host-only main variant for either in-scope shared family.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The current repository already contains the two in-scope shared families in enough hosts to justify a family-level contract now.
|
||||
- Existing host authorization, route structure, and domain truth remain correct; this spec changes shared UI contract ownership, not host entitlement logic.
|
||||
- Rich domain-specific detail rendering remains necessary; only the family contract is being standardized, not the domain detail richness itself.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Standardizing every custom detail surface in the repository
|
||||
- Refactoring the global shell, workspace context bar, or monitoring page-state behavior
|
||||
- Reworking Evidence Overview, baseline compare matrix, or generic table surfaces
|
||||
- Eliminating every custom view body in favor of a generic component system
|
||||
- Pulling unrelated domain-specific diff viewers into scope without proving that they are the same shared family
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing operations detail hosts, tenant verification hosts, and onboarding verification hosts that already render Verification Report content
|
||||
- Existing policy, policy version, and finding detail hosts that already render Normalized Settings or Normalized Diff content
|
||||
- Existing host-level authorization, detail routing, and status semantics that the shared-family contracts must preserve rather than replace
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Spec 197 is complete when:
|
||||
|
||||
- both confirmed in-scope shared families use explicit family contracts,
|
||||
- the main hosts for each family no longer behave like loosely drifted mini-app variants of the same surface,
|
||||
- remaining host differences are explicitly documented as bounded variations,
|
||||
- regression and smoke verification prove operator sameness and preserved usefulness,
|
||||
- and no shell-, monitoring-state-, or other out-of-scope topic has been silently absorbed into this work.
|
||||
229
specs/197-shared-detail-contract/tasks.md
Normal file
229
specs/197-shared-detail-contract/tasks.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Tasks: Shared Detail Micro-UI Contract
|
||||
|
||||
**Input**: Design documents from `/specs/197-shared-detail-contract/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature. Use Pest feature coverage and Livewire-safe Filament tests via Laravel Sail.
|
||||
**Operations**: No new `OperationRun`, queue, scheduler, or notification lifecycle is introduced by this feature.
|
||||
**RBAC**: No new capability or authorization plane is introduced; all hosts must preserve existing deny-as-not-found and capability enforcement behavior.
|
||||
**Release Artifact**: `specs/197-shared-detail-contract/migration-note.md` records migrated hosts, bounded variations, manual smoke evidence, and out-of-scope follow-ups.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each family can be implemented and verified independently.
|
||||
|
||||
## Phase 1: Setup (Shared Scaffolding)
|
||||
|
||||
**Purpose**: Create the new support, view, and test entry points that later phases will fill.
|
||||
|
||||
- [X] T001 [P] Create shared-detail support class skeletons in apps/platform/app/Filament/Support/NormalizedSettingsSurface.php and apps/platform/app/Filament/Support/NormalizedDiffSurface.php
|
||||
- [X] T002 [P] Create family partial entry points in apps/platform/resources/views/filament/components/verification-report/summary.blade.php, apps/platform/resources/views/filament/components/verification-report/issues.blade.php, apps/platform/resources/views/filament/infolists/entries/normalized-settings/wrapper.blade.php, and apps/platform/resources/views/filament/infolists/entries/normalized-diff/wrapper.blade.php
|
||||
- [X] T003 [P] Create focused contract-test shells in apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php, apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php, and apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php
|
||||
|
||||
**Checkpoint**: The new files and entry points exist so the implementation can proceed without inventing paths mid-stream.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Contract Seams)
|
||||
|
||||
**Purpose**: Establish the shared support builders that every story relies on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start before these support seams exist.
|
||||
|
||||
- [X] T004 [P] Extend apps/platform/app/Filament/Support/VerificationReportViewer.php to build an explicit verification surface contract with shared zones and host-variation metadata
|
||||
- [X] T005 [P] Implement normalized settings contract shaping in apps/platform/app/Filament/Support/NormalizedSettingsSurface.php for subtype, warning, and empty-state ownership
|
||||
- [X] T006 [P] Implement normalized diff contract shaping in apps/platform/app/Filament/Support/NormalizedDiffSurface.php for availability, zero-diff, partial-state, and grouped-render ownership
|
||||
|
||||
**Checkpoint**: Verification, normalized settings, and normalized diff each have a single contract seam that hosts can consume.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Recognize The Same Verification Surface Everywhere (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make operation detail, onboarding, and tenant verification render the same verification-family core while keeping only bounded host-specific actions and framing.
|
||||
|
||||
**Independent Test**: Render equivalent verification data through the operation detail, onboarding, and tenant widget hosts and confirm the same summary, issue/pass grouping, diagnostics contract, unavailable semantics, and authorization boundaries are recognizable everywhere.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Add cross-host parity assertions in apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php for operation detail, onboarding, and tenant widget verification hosts
|
||||
- [X] T008 [P] [US1] Extend DB-only and widget regressions in apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php and apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php
|
||||
- [X] T009 [P] [US1] Extend onboarding verification regressions in apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php and apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
|
||||
- [X] T010 [P] [US1] Extend workspace-context and onboarding entitlement regressions in apps/platform/tests/Feature/MonitoringOperationsTest.php, apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php, and apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php so the verification-family refactor preserves 404-for-non-members and 403-for-in-scope-capability-denial behavior
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Refactor apps/platform/resources/views/filament/components/verification-report-viewer.blade.php and the partials under apps/platform/resources/views/filament/components/verification-report/ to own the shared summary, issues, passed, diagnostics, next-step, and unavailable zones
|
||||
- [X] T012 [US1] Rebuild apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php around the shared verification-family core while keeping assist, acknowledge, and technical-details controls host-scoped
|
||||
- [X] T013 [US1] Route operation-detail and onboarding host context through the verification contract in apps/platform/app/Filament/Resources/OperationRunResource.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [X] T014 [US1] Route tenant-widget host context through the verification contract in apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php and apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php
|
||||
|
||||
**Checkpoint**: Verification-family hosts share one recognizable micro-UI contract and remain DB-only, host-authorized, and operator-first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Inspect Normalized Settings And Diffs Consistently (Priority: P1)
|
||||
|
||||
**Goal**: Make policy, policy-version, and finding detail surfaces use one normalized settings family and one normalized diff family with explicit subtype and availability semantics.
|
||||
|
||||
**Independent Test**: Render representative policy, policy-version, and finding hosts and confirm that normalized settings and normalized diff use consistent family-owned wrappers, view behavior, subtype handling, unavailable-state semantics, and existing tenant-scope authorization behavior.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T015 [P] [US2] Add family parity assertions in apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php for policy settings, policy-version settings, policy-version diff, and finding diff hosts
|
||||
- [X] T016 [P] [US2] Extend normalized settings regressions in apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php and apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php
|
||||
- [X] T017 [P] [US2] Extend normalized diff availability regressions in apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php and apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php
|
||||
- [X] T018 [P] [US2] Extend tenant-scope authorization regressions in apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php, apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, and apps/platform/tests/Feature/Findings/FindingRbacTest.php so normalized-detail hosts preserve deny-as-not-found and capability-safe behavior after family wiring
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T019 [US2] Refactor apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php and subtype partials under apps/platform/resources/views/filament/infolists/entries/normalized-settings/ to own warnings, wrapper structure, subtype selection, and empty-state behavior
|
||||
- [X] T020 [US2] Absorb or retire apps/platform/resources/views/filament/infolists/entries/policy-settings-standard.blade.php as a direct host-facing sibling so standard settings render only as a normalized-settings subtype
|
||||
- [X] T021 [US2] Route policy and policy-version settings hosts through NormalizedSettingsSurface in apps/platform/app/Filament/Resources/PolicyResource.php and apps/platform/app/Filament/Resources/PolicyVersionResource.php
|
||||
- [X] T022 [P] [US2] Adapt apps/platform/app/Livewire/SettingsCatalogSettingsTable.php so the settings-catalog table stays a subtype renderer under the normalized-settings family wrapper
|
||||
- [X] T023 [US2] Refactor apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php and the partials under apps/platform/resources/views/filament/infolists/entries/normalized-diff/ to own summary, grouped rendering, and unavailable or zero-diff states
|
||||
- [X] T024 [US2] Route policy-version and finding diff hosts through NormalizedDiffSurface in apps/platform/app/Filament/Resources/PolicyVersionResource.php and apps/platform/app/Filament/Resources/FindingResource.php
|
||||
|
||||
**Checkpoint**: Normalized settings and diff surfaces read as one family across policy, policy-version, and finding detail hosts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Add Or Update A Host Without Re-Forking The Family (Priority: P2)
|
||||
|
||||
**Goal**: Make future host work extend the documented family variation points instead of silently reintroducing host-local forks.
|
||||
|
||||
**Independent Test**: Run the guard suite and verify it fails on forbidden verification tab ownership or direct top-level `policy-settings-standard` host usage while the contract docs enumerate the approved consumers and allowed variations.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Implement fork-guard coverage in apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php for forbidden verification tab ownership and direct host-level policy-settings-standard usage
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Sync approved consumers, required markers, and forbidden host patterns in specs/197-shared-detail-contract/contracts/verification-report-family.openapi.yaml and specs/197-shared-detail-contract/contracts/normalized-detail-family.openapi.yaml
|
||||
- [X] T027 [US3] Record the migrated host inventory, bounded variations, smoke-review evidence, and out-of-scope follow-ups in specs/197-shared-detail-contract/migration-note.md
|
||||
|
||||
**Checkpoint**: Reviewers and future implementers have an executable guard and a written inventory that block ad hoc family forks.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Validate the full feature slice and record release acceptance evidence.
|
||||
|
||||
- [X] T028 [P] Run formatting for touched PHP files including apps/platform/app/Filament/Support/VerificationReportViewer.php, apps/platform/app/Filament/Support/NormalizedSettingsSurface.php, and apps/platform/app/Filament/Support/NormalizedDiffSurface.php with `./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T029 Run the focused Sail validation pack from specs/197-shared-detail-contract/quickstart.md against apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php, apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php, apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php, apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php, apps/platform/tests/Feature/MonitoringOperationsTest.php, apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php, apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php, apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php, apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php, apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php, apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php, apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, apps/platform/tests/Feature/Findings/FindingRbacTest.php, apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php, apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php, and apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php
|
||||
- [X] T030 Execute the Manual Smoke Check in specs/197-shared-detail-contract/quickstart.md and capture SC-197-003 and SC-197-004 evidence in specs/197-shared-detail-contract/migration-note.md
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story implementation.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 completion because the guard and inventory must reflect the final family boundaries.
|
||||
- **Polish (Phase 6)**: Depends on all targeted user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Independent after Phase 2; no dependency on US2.
|
||||
- **US2**: Independent after Phase 2; no dependency on US1.
|
||||
- **US3**: Depends on US1 and US2 because it locks the final approved host patterns for both families.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or extend the listed tests before finishing implementation.
|
||||
- Complete support or wrapper ownership before wiring host files to the new family contracts.
|
||||
- Keep host framing and host-owned actions bounded to the variation points defined in the contracts.
|
||||
- Validate each story independently before moving to the next story or polish phase.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001, T002, and T003 can run in parallel.
|
||||
- T004, T005, and T006 can run in parallel.
|
||||
- After Phase 2, US1 and US2 can proceed in parallel if separate implementers avoid the shared `PolicyVersionResource.php` touchpoint.
|
||||
- Within US1, T007, T008, T009, and T010 can run in parallel.
|
||||
- Within US2, T015, T016, T017, and T018 can run in parallel.
|
||||
- Within US3, T026 and T027 can run in parallel after T025 is in place.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Start the verification-family test extensions together:
|
||||
Task: "T007 Add cross-host parity assertions in apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php"
|
||||
Task: "T008 Extend DB-only and widget regressions in apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php and apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php"
|
||||
Task: "T009 Extend onboarding verification regressions in apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php and apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php"
|
||||
Task: "T010 Extend workspace-context and onboarding entitlement regressions in apps/platform/tests/Feature/MonitoringOperationsTest.php, apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php, and apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php"
|
||||
|
||||
# After the shared verification wrapper is in place, these can proceed side by side:
|
||||
Task: "T012 Rebuild apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php"
|
||||
Task: "T014 Route tenant-widget host context through the verification contract in apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php and apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Start the normalized-detail test extensions together:
|
||||
Task: "T015 Add family parity assertions in apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php"
|
||||
Task: "T016 Extend normalized settings regressions in apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php and apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php"
|
||||
Task: "T017 Extend normalized diff availability regressions in apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php and apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php"
|
||||
Task: "T018 Extend tenant-scope authorization regressions in apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php, apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, and apps/platform/tests/Feature/Findings/FindingRbacTest.php"
|
||||
|
||||
# Once the family builders exist, these can proceed in parallel without touching the same files:
|
||||
Task: "T022 Adapt apps/platform/app/Livewire/SettingsCatalogSettingsTable.php"
|
||||
Task: "T023 Refactor apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php and partials under apps/platform/resources/views/filament/infolists/entries/normalized-diff/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# After the guard exists, finish the enforcement artifacts together:
|
||||
Task: "T026 Sync approved consumers and forbidden host patterns in specs/197-shared-detail-contract/contracts/verification-report-family.openapi.yaml and specs/197-shared-detail-contract/contracts/normalized-detail-family.openapi.yaml"
|
||||
Task: "T027 Record the migrated host inventory and bounded variations in specs/197-shared-detail-contract/migration-note.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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. Validate the verification family independently across operation detail, onboarding, and tenant widget hosts.
|
||||
5. Demo or merge the MVP slice if the repo strategy allows partial delivery.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup + Foundational once.
|
||||
2. Deliver US1 and validate verification-family sameness.
|
||||
3. Deliver US2 and validate normalized-detail sameness.
|
||||
4. Deliver US3 to lock the boundaries with a guard and final inventory.
|
||||
5. Finish with polish, the focused Sail pack from quickstart.md, and the manual smoke evidence recorded in migration-note.md.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One implementer completes Phase 1 and Phase 2.
|
||||
2. After that, one implementer can take US1 while another takes US2.
|
||||
3. US3 starts once both family implementations settle.
|
||||
4. Finish with one shared cleanup and validation pass.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` means the task can run in parallel because it touches different files and has no dependency on incomplete work.
|
||||
- `[US1]`, `[US2]`, and `[US3]` map each task back to the spec’s user stories.
|
||||
- This feature must stay inside Filament v5 + Livewire v4; no provider registration change is required because `bootstrap/providers.php` remains unchanged.
|
||||
- No globally searchable resource is added or removed by this feature; existing host resources continue to own their Edit or View surfaces.
|
||||
- No destructive action is introduced inside either shared family; any existing host mutation remains host-owned and must keep existing confirmation and authorization behavior.
|
||||
- No new assets are introduced, so no `filament:assets` deployment step changes are needed.
|
||||
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