Compare commits

...

4 Commits

Author SHA1 Message Date
e02799b383 feat: implement spec 198 monitoring page state contract (#238)
## Summary
- implement Spec 198 monitoring page-state contracts across Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix
- align selected-record and draft/apply behavior with query/session restoration semantics, including canonical navigation and tenant-filter normalization helpers
- add Spec 198 feature and browser coverage, update closure/spec artifacts, and refresh affected regression tests that asserted pre-contract behavior

## Verification
- focused Spec 198 feature pack passed through Sail
- Spec 198 browser smoke passed through Sail
- existing Spec 190 and Spec 194 browser smokes passed through Sail
- targeted fallout tests were updated and rerun during full-suite triage

## Notes
- Livewire v4 / Filament v5 compliant only; no legacy API reintroduction
- no provider registration changes; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no global-search behavior changed for any resource
- destructive queue decision actions remain confirmation-gated and authorization-backed
- no new Filament assets were added; existing deploy step for `php artisan filament:assets` remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #238
2026-04-15 21:59:42 +00:00
c0f4587d90 Spec 197: standardize shared detail family contracts (#237)
## Summary
- standardize the shared verification report family across operation detail, onboarding, and tenant verification widget hosts
- standardize normalized settings and normalized diff family wrappers across policy, policy version, and finding detail hosts
- add parity and guard coverage plus the full Spec 197 artifacts, including recorded manual smoke evidence

## Testing
- focused Sail regression pack from `specs/197-shared-detail-contract/quickstart.md`
- local integrated-browser manual smoke for SC-197-003 and SC-197-004

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #237
2026-04-15 09:51:42 +00:00
4699f13a72 Spec 196: restore native Filament table contracts (#236)
## Summary
- replace the inventory dependency GET/apply flow with an embedded native Filament `TableComponent`
- convert tenant required permissions and evidence overview to native page-owned Filament tables with mount-only query seeding and preserved scope authority
- extend focused Pest, Livewire, RBAC, and guard coverage, and update the Spec 196 artifacts and release close-out notes

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php tests/Feature/Filament/TenantRequiredPermissionsPageTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Unit/TenantRequiredPermissionsFilteringTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` (`45` tests, `177` assertions)
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated-browser smoke on localhost for inventory detail dependencies, tenant required permissions, and evidence overview

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #236
2026-04-14 23:30:53 +00:00
bb72a54e84 Refactor: remove compare job legacy drift path (#235)
## Summary
- remove the dead legacy drift-computation path from `CompareBaselineToTenantJob` so the strategy-driven compare engine is the only execution path left in the orchestration file
- tighten compare guard and regression coverage around strategy selection, strategy execution context, findings, gaps, and no-drift outcomes
- fix the repo-wide suite blockers uncovered during validation by making the governance taxonomy registry test-double compatible and aligning the capture capability guard test with current unsupported-scope behavior
- add the Spec 205 planning artifacts and mark the implementation tasks complete

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests --stop-on-failure`
  - result: `3659 passed, 8 skipped (21016 assertions)`
- browser smoke test passed on the Baseline Compare landing surface via the local smoke-login flow

## Notes
- no Filament resource, panel, global search, destructive action, or asset registration behavior was changed
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- the compare path remains strategy-driven and Livewire v4 / Filament v5 assumptions are unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #235
2026-04-14 21:54:37 +00:00
135 changed files with 11512 additions and 4159 deletions

View File

@ -184,6 +184,12 @@ ## Active Technologies
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
- 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 (204-platform-core-vocabulary-hardening)
- 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)
@ -218,8 +224,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 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
- 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
- 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
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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>
*/

View File

@ -20,14 +20,89 @@
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Filament\TablePaginationProfiles;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum;
class EvidenceOverview extends Page
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;
@ -45,7 +120,12 @@ class EvidenceOverview extends Page
*/
public array $rows = [];
public ?int $tenantFilter = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $accessibleTenants = null;
private ?Collection $cachedSnapshots = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
@ -57,87 +137,102 @@ 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
{
$user = auth()->user();
$this->authorizeWorkspaceAccess();
$this->seedTableStateFromQuery();
$this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all();
if (! $user instanceof User) {
throw new AuthenticationException;
}
$this->mountInteractsWithTable();
}
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
$workspaceId = (int) $workspace->getKey();
public function table(Table $table): Table
{
return $table
->defaultSort('tenant_name')
->defaultPaginationPageOption(25)
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable()
->searchPlaceholder('Search tenant or next step')
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->rowsForState($filters, $search);
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
$accessibleTenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
->values();
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
$query = EvidenceSnapshot::query()
->with('tenant')
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $tenantIds)
->where('status', 'active')
->latest('generated_at');
if ($this->tenantFilter !== null) {
$query->where('tenant_id', $this->tenantFilter);
}
$snapshots = $query->get()->unique('tenant_id')->values();
$currentReviewTenantIds = TenantReview::query()
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [
TenantReviewStatus::Draft->value,
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
return $this->paginateRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
])
->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
$truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
? 'Create a current review from this evidence snapshot'
: $truth->nextStepText();
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(),
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
})->all();
->columns([
TextColumn::make('tenant_name')
->label('Tenant')
->sortable(),
TextColumn::make('artifact_truth_label')
->label('Artifact truth')
->badge()
->color(fn (array $record): string => (string) ($record['artifact_truth_color'] ?? 'gray'))
->icon(fn (array $record): ?string => is_string($record['artifact_truth_icon'] ?? null) ? $record['artifact_truth_icon'] : null)
->description(fn (array $record): ?string => is_string($record['artifact_truth_explanation'] ?? null) ? $record['artifact_truth_explanation'] : null)
->sortable()
->wrap(),
TextColumn::make('freshness_label')
->label('Freshness')
->badge()
->color(fn (array $record): string => (string) ($record['freshness_color'] ?? 'gray'))
->icon(fn (array $record): ?string => is_string($record['freshness_icon'] ?? null) ? $record['freshness_icon'] : null)
->sortable(),
TextColumn::make('generated_at')
->label('Generated')
->placeholder('—')
->sortable(),
TextColumn::make('missing_dimensions')
->label('Not collected yet')
->numeric()
->sortable(),
TextColumn::make('stale_dimensions')
->label('Refresh recommended')
->numeric()
->sortable(),
TextColumn::make('next_step')
->label('Next step')
->wrap(),
])
->recordUrl(fn ($record): ?string => is_array($record) ? (is_string($record['view_url'] ?? null) ? $record['view_url'] : null) : null)
->actions([])
->bulkActions([])
->emptyStateHeading('No evidence snapshots in this scope')
->emptyStateDescription(fn (): string => $this->hasActiveOverviewFilters()
? 'Clear the current filters to return to the full workspace evidence overview.'
: 'Adjust filters or create a tenant snapshot to populate the workspace overview.')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveOverviewFilters())
->action(fn (): mixed => $this->clearOverviewFilters()),
]);
}
/**
@ -149,11 +244,26 @@ protected function getHeaderActions(): array
Action::make('clear_filters')
->label('Clear filters')
->color('gray')
->visible(fn (): bool => $this->tenantFilter !== null)
->url(route('admin.evidence.overview')),
->visible(fn (): bool => $this->hasActiveOverviewFilters())
->action(fn (): mixed => $this->clearOverviewFilters()),
];
}
public function clearOverviewFilters(): void
{
$this->tableFilters = [
'tenant_id' => ['value' => null],
];
$this->tableDeferredFilters = $this->tableFilters;
$this->tableSearch = '';
$this->rows = $this->rowsForState($this->tableFilters, $this->tableSearch)->values()->all();
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
$this->redirect($this->overviewUrl(), navigate: true);
}
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
@ -162,4 +272,298 @@ private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false):
? $presenter->forEvidenceSnapshotFresh($snapshot)
: $presenter->forEvidenceSnapshot($snapshot);
}
private function authorizeWorkspaceAccess(): void
{
$user = auth()->user();
if (! $user instanceof User) {
throw new AuthenticationException;
}
app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
}
/**
* @return array<int, Tenant>
*/
private function accessibleTenants(): array
{
if (is_array($this->accessibleTenants)) {
return $this->accessibleTenants;
}
$user = auth()->user();
if (! $user instanceof User) {
return $this->accessibleTenants = [];
}
$workspaceId = $this->workspaceId();
return $this->accessibleTenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
->values()
->all();
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->accessibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
/**
* @param array<string, mixed> $filters
* @return Collection<string, array<string, mixed>>
*/
private function rowsForState(array $filters = [], ?string $search = null): Collection
{
$rows = $this->baseRows();
$tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value'));
$normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch)));
if ($tenantFilter !== null) {
$rows = $rows->where('tenant_id', $tenantFilter);
}
if ($normalizedSearch === '') {
return $rows;
}
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
$haystack = implode(' ', [
(string) ($row['tenant_name'] ?? ''),
(string) ($row['artifact_truth_label'] ?? ''),
(string) ($row['artifact_truth_explanation'] ?? ''),
(string) ($row['freshness_label'] ?? ''),
(string) ($row['next_step'] ?? ''),
]);
return str_contains(Str::lower($haystack), $normalizedSearch);
});
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function baseRows(): Collection
{
$snapshots = $this->latestAccessibleSnapshots();
$currentReviewTenantIds = $this->currentReviewTenantIds($snapshots);
return $snapshots->mapWithKeys(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
return [(string) $snapshot->getKey() => $this->rowForSnapshot($snapshot, $currentReviewTenantIds)];
});
}
/**
* @return Collection<int, EvidenceSnapshot>
*/
private function latestAccessibleSnapshots(): Collection
{
if ($this->cachedSnapshots instanceof Collection) {
return $this->cachedSnapshots;
}
$tenantIds = collect($this->accessibleTenants())
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
->all();
$query = EvidenceSnapshot::query()
->with('tenant')
->where('workspace_id', $this->workspaceId())
->where('status', 'active')
->latest('generated_at');
if ($tenantIds === []) {
$query->whereRaw('1 = 0');
} else {
$query->whereIn('tenant_id', $tenantIds);
}
return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
}
/**
* @param Collection<int, EvidenceSnapshot> $snapshots
* @return array<int, bool>
*/
private function currentReviewTenantIds(Collection $snapshots): array
{
return TenantReview::query()
->where('workspace_id', $this->workspaceId())
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [
TenantReviewStatus::Draft->value,
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
])
->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all();
}
/**
* @param array<int, bool> $currentReviewTenantIds
* @return array<string, mixed>
*/
private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array
{
$truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
? 'Create a current review from this evidence snapshot'
: $truth->nextStepText();
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(),
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0),
'stale_dimensions' => (int) ($snapshot->summary['stale_dimensions'] ?? 0),
'artifact_truth_label' => $truth->primaryLabel,
'artifact_truth_color' => $truth->primaryBadgeSpec()->color,
'artifact_truth_icon' => $truth->primaryBadgeSpec()->icon,
'artifact_truth_explanation' => $truth->primaryExplanation,
'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness_label' => $freshnessSpec->label,
'freshness_color' => $freshnessSpec->color,
'freshness_icon' => $freshnessSpec->icon,
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true)
? $sortColumn
: 'tenant_name';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = in_array($sortColumn, ['missing_dimensions', 'stale_dimensions'], true)
? ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0))
: strnatcasecmp((string) ($left[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? ''));
if ($comparison === 0) {
$comparison = strnatcasecmp((string) ($left['tenant_name'] ?? ''), (string) ($right['tenant_name'] ?? ''));
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
private function seedTableStateFromQuery(): void
{
$query = request()->query();
if (array_key_exists('search', $query)) {
$this->tableSearch = trim((string) request()->query('search', ''));
}
if (! array_key_exists('tenant_id', $query)) {
return;
}
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
if ($tenantFilter === null) {
return;
}
$this->tableFilters = [
'tenant_id' => ['value' => (string) $tenantFilter],
];
$this->tableDeferredFilters = $this->tableFilters;
}
private function normalizeTenantFilter(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$requestedTenantId = (int) $value;
$allowedTenantIds = collect($this->accessibleTenants())
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
->all();
return in_array($requestedTenantId, $allowedTenantIds, true)
? $requestedTenantId
: null;
}
private function hasActiveOverviewFilters(): bool
{
return filled(data_get($this->tableFilters, 'tenant_id.value'))
|| 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();
if (! $user instanceof User) {
throw new AuthenticationException;
}
return (int) app(WorkspaceContext::class)
->currentWorkspaceForMemberOrFail($user, request())
->getKey();
}
}

View File

@ -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

View File

@ -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,
];
}
}

View File

@ -10,16 +10,30 @@
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantRequiredPermissions extends Page
class TenantRequiredPermissions extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
@ -40,25 +54,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
}
public string $status = 'missing';
public string $type = 'all';
/**
* @var array<int, string>
*/
public array $features = [];
public string $search = '';
/**
* @var array<string, mixed>
*/
public array $viewModel = [];
#[Locked]
public ?int $scopedTenantId = null;
/**
* @var array<string, mixed>|null
*/
private ?array $cachedViewModel = null;
private ?string $cachedViewModelStateKey = null;
public static function canAccess(): bool
{
return static::hasScopedTenantAccess(static::resolveScopedTenant());
@ -69,9 +74,9 @@ public function currentTenant(): ?Tenant
return $this->trustedScopedTenant();
}
public function mount(): void
public function mount(Tenant|string|null $tenant = null): void
{
$tenant = static::resolveScopedTenant();
$tenant = static::resolveScopedTenant($tenant);
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404);
@ -81,109 +86,120 @@ public function mount(): void
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
$queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => request()->query('status', $this->status),
'type' => request()->query('type', $this->type),
'features' => is_array($queryFeatures) ? $queryFeatures : [],
'search' => request()->query('search', $this->search),
]);
$this->status = $state['status'];
$this->type = $state['type'];
$this->features = $state['features'];
$this->search = $state['search'];
$this->refreshViewModel();
$this->seedTableStateFromQuery();
$this->mountInteractsWithTable();
}
public function updatedStatus(): void
public function table(Table $table): Table
{
$this->refreshViewModel();
return $table
->defaultSort('sort_priority')
->defaultPaginationPageOption(25)
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable()
->searchPlaceholder('Search permission key or description…')
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$state = $this->filterState(filters: $filters, search: $search);
$rows = $this->permissionRowsForState($state);
$rows = $this->sortPermissionRows($rows, $sortColumn, $sortDirection);
return $this->paginatePermissionRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('status')
->label('Status')
->default('missing')
->options([
'missing' => 'Missing',
'present' => 'Present',
'all' => 'All',
]),
SelectFilter::make('type')
->label('Type')
->default('all')
->options([
'all' => 'All',
'application' => 'Application',
'delegated' => 'Delegated',
]),
SelectFilter::make('features')
->label('Features')
->multiple()
->options(fn (): array => $this->featureFilterOptions())
->searchable(),
])
->columns([
TextColumn::make('key')
->label('Permission')
->description(fn (array $record): ?string => is_string($record['description'] ?? null) ? $record['description'] : null)
->wrap()
->sortable(),
TextColumn::make('type_label')
->label('Type')
->badge()
->color('gray')
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus))
->sortable(),
TextColumn::make('features_label')
->label('Features')
->wrap()
->toggleable(),
])
->actions([])
->bulkActions([])
->emptyStateHeading(fn (): string => $this->permissionsEmptyStateHeading())
->emptyStateDescription(fn (): string => $this->permissionsEmptyStateDescription())
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActivePermissionFilters())
->action(fn (): mixed => $this->clearPermissionFilters()),
]);
}
public function updatedType(): void
/**
* @return array<string, mixed>
*/
public function viewModel(): array
{
$this->refreshViewModel();
return $this->viewModelForState($this->filterState());
}
public function updatedFeatures(): void
public function clearPermissionFilters(): void
{
$this->refreshViewModel();
}
$this->tableFilters = [
'status' => ['value' => 'missing'],
'type' => ['value' => 'all'],
'features' => ['values' => []],
];
$this->tableDeferredFilters = $this->tableFilters;
$this->tableSearch = '';
$this->cachedViewModel = null;
$this->cachedViewModelStateKey = null;
public function updatedSearch(): void
{
$this->refreshViewModel();
}
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
public function applyFeatureFilter(string $feature): void
{
$feature = trim($feature);
if ($feature === '') {
return;
}
if (in_array($feature, $this->features, true)) {
$this->features = array_values(array_filter(
$this->features,
static fn (string $value): bool => $value !== $feature,
));
} else {
$this->features[] = $feature;
}
$this->features = array_values(array_unique($this->features));
$this->refreshViewModel();
}
public function clearFeatureFilter(): void
{
$this->features = [];
$this->refreshViewModel();
}
public function resetFilters(): void
{
$this->status = 'missing';
$this->type = 'all';
$this->features = [];
$this->search = '';
$this->refreshViewModel();
}
private function refreshViewModel(): void
{
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
return;
}
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
$this->viewModel = $builder->build($tenant, [
'status' => $this->status,
'type' => $this->type,
'features' => $this->features,
'search' => $this->search,
]);
$filters = $this->viewModel['filters'] ?? null;
if (is_array($filters)) {
$this->status = (string) ($filters['status'] ?? $this->status);
$this->type = (string) ($filters['type'] ?? $this->type);
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
$this->search = (string) ($filters['search'] ?? $this->search);
}
$this->resetPage();
}
public function reRunVerificationUrl(): string
@ -208,8 +224,18 @@ public function manageProviderConnectionUrl(): ?string
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant
protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant
{
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
$routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) {
@ -222,6 +248,14 @@ protected static function resolveScopedTenant(): ?Tenant
->first();
}
$queryTenant = request()->query('tenant');
if (is_string($queryTenant) && $queryTenant !== '') {
return Tenant::query()
->where('external_id', $queryTenant)
->first();
}
return null;
}
@ -293,4 +327,216 @@ private function trustedScopedTenant(): ?Tenant
return null;
}
}
/**
* @param array<string, mixed> $filters
* @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string}
*/
private function filterState(array $filters = [], ?string $search = null): array
{
return TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'),
'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'),
'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []),
'search' => $search ?? $this->tableSearch,
]);
}
/**
* @param array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string} $state
* @return array<string, mixed>
*/
private function viewModelForState(array $state): array
{
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
return [];
}
$stateKey = json_encode([$tenant->getKey(), $state]);
if ($this->cachedViewModelStateKey === $stateKey && is_array($this->cachedViewModel)) {
return $this->cachedViewModel;
}
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
$this->cachedViewModelStateKey = $stateKey ?: null;
$this->cachedViewModel = $builder->build($tenant, $state);
return $this->cachedViewModel;
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function permissionRowsForState(array $state): Collection
{
return collect($this->viewModelForState($state)['permissions'] ?? [])
->filter(fn (mixed $row): bool => is_array($row) && is_string($row['key'] ?? null))
->mapWithKeys(function (array $row): array {
$key = (string) $row['key'];
return [
$key => [
'key' => $key,
'description' => is_string($row['description'] ?? null) ? $row['description'] : null,
'type' => (string) ($row['type'] ?? 'application'),
'type_label' => ($row['type'] ?? 'application') === 'delegated' ? 'Delegated' : 'Application',
'status' => (string) ($row['status'] ?? 'missing'),
'features_label' => implode(', ', array_filter((array) ($row['features'] ?? []), 'is_string')),
'sort_priority' => $this->statusSortWeight((string) ($row['status'] ?? 'missing')),
],
];
});
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortPermissionRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['sort_priority', 'key', 'type_label', 'status', 'features_label'], true)
? $sortColumn
: 'sort_priority';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = match ($sortColumn) {
'sort_priority' => ((int) ($left['sort_priority'] ?? 0)) <=> ((int) ($right['sort_priority'] ?? 0)),
default => strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
),
};
if ($comparison === 0) {
$comparison = strnatcasecmp(
(string) ($left['key'] ?? ''),
(string) ($right['key'] ?? ''),
);
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginatePermissionRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
/**
* @return array<string, string>
*/
private function featureFilterOptions(): array
{
return collect(data_get($this->viewModelForState([
'status' => 'all',
'type' => 'all',
'features' => [],
'search' => '',
]), 'overview.feature_impacts', []))
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
->mapWithKeys(fn (array $impact): array => [
(string) $impact['feature'] => (string) $impact['feature'],
])
->all();
}
private function permissionsEmptyStateHeading(): string
{
$viewModel = $this->viewModel();
$counts = is_array(data_get($viewModel, 'overview.counts')) ? data_get($viewModel, 'overview.counts') : [];
$state = $this->filterState();
$allPermissions = data_get($this->viewModelForState([
'status' => 'all',
'type' => 'all',
'features' => [],
'search' => '',
]), 'permissions', []);
$missingTotal = (int) ($counts['missing_application'] ?? 0)
+ (int) ($counts['missing_delegated'] ?? 0)
+ (int) ($counts['error'] ?? 0);
$requiredTotal = $missingTotal + (int) ($counts['present'] ?? 0);
if (! is_array($allPermissions) || $allPermissions === []) {
return 'No permissions configured';
}
if ($state['status'] === 'missing' && $missingTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') {
return 'All required permissions are present';
}
return 'No matches';
}
private function permissionsEmptyStateDescription(): string
{
return match ($this->permissionsEmptyStateHeading()) {
'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.',
'All required permissions are present' => 'Switch Status to All if you want to review the full matrix.',
default => 'No permissions match the current filters.',
};
}
private function hasActivePermissionFilters(): bool
{
$state = $this->filterState();
return $state['status'] !== 'missing'
|| $state['type'] !== 'all'
|| $state['features'] !== []
|| trim($state['search']) !== '';
}
private function seedTableStateFromQuery(): void
{
$query = request()->query();
if (! array_key_exists('status', $query) && ! array_key_exists('type', $query) && ! array_key_exists('features', $query) && ! array_key_exists('search', $query)) {
return;
}
$queryFeatures = request()->query('features', []);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => request()->query('status', 'missing'),
'type' => request()->query('type', 'all'),
'features' => is_array($queryFeatures) ? $queryFeatures : [],
'search' => request()->query('search', ''),
]);
$this->tableFilters = [
'status' => ['value' => $state['status']],
'type' => ['value' => $state['type']],
'features' => ['values' => $state['features']],
];
$this->tableDeferredFilters = $this->tableFilters;
$this->tableSearch = $state['search'];
}
private function statusSortWeight(string $status): int
{
return match ($status) {
'missing' => 0,
'error' => 1,
default => 2,
};
}
}

View File

@ -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',

View File

@ -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')

View File

@ -10,14 +10,11 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -179,29 +176,6 @@ public static function infolist(Schema $schema): Schema
ViewEntry::make('dependencies')
->label('')
->view('filament.components.dependency-edges')
->state(function (InventoryItem $record) {
$direction = request()->query('direction', 'all');
$relationshipType = request()->query('relationship_type', 'all');
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
$relationshipType = $relationshipType === 'all'
? null
: RelationshipType::tryFrom($relationshipType)?->value;
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$tenant = static::resolveTenantContextForCurrentPanel();
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
}
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
})
->columnSpanFull(),
])
->columnSpanFull(),

View File

@ -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),
];
}

View File

@ -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()

View File

@ -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)')

View 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;
}
}

View File

@ -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,
];
}
}

View File

@ -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 doesnt 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>

View File

@ -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,

View File

@ -12,7 +12,6 @@
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
@ -21,19 +20,13 @@
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\Findings\FindingWorkflowService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineCaptureMode;
@ -76,11 +69,6 @@ class CompareBaselineToTenantJob implements ShouldQueue
public bool $failOnTimeout = true;
/**
* @var array<int, string>
*/
private array $baselineContentHashCache = [];
public ?OperationRun $operationRun = null;
public function __construct(
@ -825,7 +813,7 @@ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $r
* captured_versions?: array<string, array{
* policy_type: string,
* subject_external_id: string,
* version: PolicyVersion,
* version: \App\Models\PolicyVersion,
* observed_at: string,
* observed_operation_run_id: ?int
* }>
@ -855,7 +843,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
if (! $version instanceof \App\Models\PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
continue;
}
@ -870,6 +858,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
return $resolved;
}
private function completeWithCoverageWarning(
OperationRunService $operationRunService,
AuditLogger $auditLogger,
@ -1423,750 +1412,6 @@ private function truthfulTypesFromContext(array $context, BaselineScope $effecti
return $effectiveScope->allTypes();
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
* @param array<string, string> $severityMapping
* @return array{
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
* evidence_gaps: array<string, int>,
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
* }
*/
private function computeDrift(
Tenant $tenant,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
DriftHasher $hasher,
SettingsNormalizer $settingsNormalizer,
AssignmentsNormalizer $assignmentsNormalizer,
ScopeTagsNormalizer $scopeTagsNormalizer,
ContentEvidenceProvider $contentEvidenceProvider,
): array {
$drift = [];
$evidenceGaps = [];
$evidenceGapSubjects = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
$baselinePlaceholderProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: null,
);
$currentMissingProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: $inventorySyncRunId,
);
foreach ($baselineItems as $key => $baselineItem) {
$currentItem = $currentItems[$key] ?? null;
$policyType = (string) ($baselineItem['policy_type'] ?? '');
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
tenant: $tenant,
baselineItem: $baselineItem,
baselineProvenance: $baselineProvenance,
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
);
$baselineComparableHash = $this->effectiveBaselineHash(
tenant: $tenant,
baselineItem: $baselineItem,
baselinePolicyVersionId: $baselinePolicyVersionId,
contentEvidenceProvider: $contentEvidenceProvider,
);
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
$displayName = is_string($displayName) ? (string) $displayName : null;
$evidence = $this->buildDriftEvidenceContract(
changeType: 'missing_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: $baselineComparableHash,
currentHash: null,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentMissingProvenance,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
summaryKind: 'policy_snapshot',
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: [],
diffKind: 'missing',
);
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['missing']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'missing_policy',
'severity' => $isRbacRoleDefinition
? Finding::SEVERITY_HIGH
: $this->severityForChangeType($severityMapping, 'missing_policy'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => $baselineComparableHash,
'current_hash' => '',
'evidence' => $evidence,
];
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($baselineComparableHash !== $currentEvidence->hash) {
$displayName = $currentItem['meta_jsonb']['display_name']
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
$displayName = is_string($displayName) ? (string) $displayName : null;
$roleDefinitionDiff = null;
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
if ($currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
normalizer: $roleDefinitionNormalizer,
);
if ($roleDefinitionDiff === null) {
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
continue;
}
}
$summaryKind = $isRbacRoleDefinition
? 'rbac_role_definition'
: $this->selectSummaryKind(
tenant: $tenant,
policyType: $policyType,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
hasher: $hasher,
settingsNormalizer: $settingsNormalizer,
assignmentsNormalizer: $assignmentsNormalizer,
scopeTagsNormalizer: $scopeTagsNormalizer,
);
$evidence = $this->buildDriftEvidenceContract(
changeType: 'different_version',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: $baselineComparableHash,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: $summaryKind,
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: (string) $roleDefinitionDiff['diff_kind'],
roleDefinitionDiff: $roleDefinitionDiff,
);
$rbacRoleDefinitionSummary['modified']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'different_version',
'severity' => $isRbacRoleDefinition
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
: $this->severityForChangeType($severityMapping, 'different_version'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $currentItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => $baselineComparableHash,
'current_hash' => $currentEvidence->hash,
'evidence' => $evidence,
];
continue;
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unchanged']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
}
foreach ($currentItems as $key => $currentItem) {
if (! array_key_exists($key, $baselineItems)) {
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
$policyType = (string) ($currentItem['policy_type'] ?? '');
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
$displayName = is_string($displayName) ? (string) $displayName : null;
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
$evidence = $this->buildDriftEvidenceContract(
changeType: 'unexpected_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: null,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselinePlaceholderProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: 'policy_snapshot',
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: 'unexpected',
);
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unexpected']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'unexpected_policy',
'severity' => $isRbacRoleDefinition
? Finding::SEVERITY_MEDIUM
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
'subject_type' => 'policy',
'subject_external_id' => $currentItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => '',
'current_hash' => $currentEvidence->hash,
'evidence' => $evidence,
];
}
}
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'evidence_gap_subjects' => $evidenceGapSubjects,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
/**
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
*/
private function effectiveBaselineHash(
Tenant $tenant,
array $baselineItem,
?int $baselinePolicyVersionId,
ContentEvidenceProvider $contentEvidenceProvider,
): string {
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
if ($baselinePolicyVersionId === null) {
return $storedHash;
}
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
return $this->baselineContentHashCache[$baselinePolicyVersionId];
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion) {
return $storedHash;
}
$hash = $contentEvidenceProvider->fromPolicyVersion(
version: $baselineVersion,
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
)->hash;
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
return $hash;
}
private function resolveBaselinePolicyVersionId(
Tenant $tenant,
array $baselineItem,
array $baselineProvenance,
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
): ?int {
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
if (is_numeric($versionReferenceId)) {
return (int) $versionReferenceId;
}
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
return null;
}
$observedAt = $baselineProvenance['observed_at'] ?? null;
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
if (! is_string($observedAt) || $observedAt === '') {
return null;
}
return $baselinePolicyVersionResolver->resolve(
tenant: $tenant,
policyType: (string) ($baselineItem['policy_type'] ?? ''),
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
observedAt: $observedAt,
);
}
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
{
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
}
private function selectSummaryKind(
Tenant $tenant,
string $policyType,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
DriftHasher $hasher,
SettingsNormalizer $settingsNormalizer,
AssignmentsNormalizer $assignmentsNormalizer,
ScopeTagsNormalizer $scopeTagsNormalizer,
): string {
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
return 'policy_snapshot';
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
$currentVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return 'policy_snapshot';
}
$platform = is_string($baselineVersion->platform ?? null)
? (string) $baselineVersion->platform
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
snapshot: $baselineSnapshot,
policyType: $policyType,
platform: $platform,
);
$currentNormalized = $settingsNormalizer->normalizeForDiff(
snapshot: $currentSnapshot,
policyType: $policyType,
platform: $platform,
);
$baselineSnapshotHash = $hasher->hashNormalized([
'settings' => $baselineNormalized,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
]);
$currentSnapshotHash = $hasher->hashNormalized([
'settings' => $currentNormalized,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
]);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
return 'policy_snapshot';
}
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
$baselineAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
]);
$currentAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
]);
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
return 'policy_assignments';
}
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
return 'policy_snapshot';
}
$baselineScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $baselineScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
]);
$currentScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $currentScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
]);
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
return 'policy_scope_tags';
}
return 'policy_snapshot';
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
/**
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
* @param array<string, mixed> $currentProvenance
* @return array<string, mixed>
*/
private function buildDriftEvidenceContract(
string $changeType,
string $policyType,
string $subjectKey,
?string $displayName,
?string $baselineHash,
?string $currentHash,
array $baselineProvenance,
array $currentProvenance,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
string $summaryKind,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
): array {
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
return [
'change_type' => $changeType,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'display_name' => $displayName,
'summary' => [
'kind' => $summaryKind,
],
'baseline' => [
'policy_version_id' => $baselinePolicyVersionId,
'hash' => $baselineHash,
'provenance' => $baselineProvenance,
],
'current' => [
'policy_version_id' => $currentPolicyVersionId,
'hash' => $currentHash,
'provenance' => $currentProvenance,
],
'fidelity' => $fidelity,
'provenance' => [
'baseline_profile_id' => $baselineProfileId,
'baseline_snapshot_id' => $baselineSnapshotId,
'compare_operation_run_id' => $compareOperationRunId,
'inventory_sync_run_id' => $inventorySyncRunId,
],
];
}
/**
* @param array<string, mixed> $baselineMeta
* @param array<string, mixed> $currentMeta
* @param array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }|null $roleDefinitionDiff
* @return array{
* diff_kind: string,
* diff_fingerprint: string,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
* }
*/
private function buildRoleDefinitionEvidencePayload(
Tenant $tenant,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
array $baselineMeta,
array $currentMeta,
string $diffKind,
?array $roleDefinitionDiff = null,
): array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
? $roleDefinitionDiff['baseline']
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
? $roleDefinitionDiff['current']
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
: $this->roleDefinitionPermissionKeys($changedKeys);
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
? (string) $roleDefinitionDiff['diff_kind']
: $diffKind;
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
? (string) $roleDefinitionDiff['diff_fingerprint']
: hash(
'sha256',
json_encode([
'diff_kind' => $resolvedDiffKind,
'changed_keys' => $changedKeys,
'baseline' => $baselineNormalized,
'current' => $currentNormalized,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
return [
'diff_kind' => $resolvedDiffKind,
'diff_fingerprint' => $diffFingerprint,
'changed_keys' => $changedKeys,
'metadata_keys' => $metadataKeys,
'permission_keys' => $permissionKeys,
'baseline' => [
'normalized' => $baselineNormalized,
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
],
'current' => [
'normalized' => $currentNormalized,
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
],
];
}
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
{
if ($policyVersionId === null) {
return null;
}
return PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($policyVersionId);
}
/**
* @param array<string, mixed> $meta
* @return array<string, mixed>
*/
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
{
if ($version instanceof PolicyVersion) {
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
is_array($version->snapshot) ? $version->snapshot : [],
is_string($version->platform ?? null) ? (string) $version->platform : null,
);
}
$normalized = [];
$displayName = $meta['display_name'] ?? null;
if (is_string($displayName) && trim($displayName) !== '') {
$normalized['Role definition > Display name'] = trim($displayName);
}
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
if (is_bool($isBuiltIn)) {
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
}
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
if (is_numeric($rolePermissionCount)) {
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
}
return $normalized;
}
/**
* @param array<string, mixed> $baselineNormalized
* @param array<string, mixed> $currentNormalized
* @return list<string>
*/
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
{
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
sort($keys, SORT_STRING);
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
}
/**
* @param list<string> $keys
* @return list<string>
*/
private function roleDefinitionPermissionKeys(array $keys): array
{
return array_values(array_filter(
$keys,
fn (string $key): bool => str_starts_with($key, 'Permission block ')
));
}
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
{
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
return 'content';
}
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
return 'mixed';
}
return 'meta';
}
private function normalizeSubjectKey(
string $policyType,
?string $storedSubjectKey = null,
@ -2182,50 +1427,6 @@ private function normalizeSubjectKey(
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
}
/**
* @return array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }|null
*/
private function resolveRoleDefinitionDiff(
Tenant $tenant,
int $baselinePolicyVersionId,
int $currentPolicyVersionId,
IntuneRoleDefinitionNormalizer $normalizer,
): ?array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return null;
}
return $normalizer->classifyDiff(
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
platform: is_string($currentVersion->platform ?? null)
? (string) $currentVersion->platform
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
);
}
/**
* @param array{diff_kind?: string}|null $roleDefinitionDiff
*/
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
{
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
'metadata_only' => Finding::SEVERITY_LOW,
default => Finding::SEVERITY_HIGH,
};
}
/**
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
*/

View File

@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class InventoryItemDependencyEdgesTable extends TableComponent
{
public int $inventoryItemId;
private ?InventoryItem $cachedInventoryItem = null;
public function mount(int $inventoryItemId): void
{
$this->inventoryItemId = $inventoryItemId;
$this->resolveInventoryItem();
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId))
->defaultSort('relationship_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->striped()
->deferLoading(! app()->runningUnitTests())
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->dependencyRows(
direction: (string) ($filters['direction']['value'] ?? 'all'),
relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null),
);
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
return $this->paginateRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('direction')
->label('Direction')
->default('all')
->options([
'all' => 'All',
'inbound' => 'Inbound',
'outbound' => 'Outbound',
]),
SelectFilter::make('relationship_type')
->label('Relationship')
->options([
'all' => 'All',
...RelationshipType::options(),
])
->default('all')
->searchable(),
])
->columns([
TextColumn::make('relationship_label')
->label('Relationship')
->badge()
->sortable(),
TextColumn::make('target_label')
->label('Target')
->badge()
->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null)
->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null)
->wrap(),
TextColumn::make('missing_state')
->label('Status')
->badge()
->placeholder('—')
->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray')
->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null)
->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null)
->wrap(),
])
->actions([])
->bulkActions([])
->emptyStateHeading('No dependencies found')
->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.');
}
public function render(): View
{
return view('livewire.inventory-item-dependency-edges-table');
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function dependencyRows(string $direction, ?string $relationshipType): Collection
{
$inventoryItem = $this->resolveInventoryItem();
$tenant = $this->resolveCurrentTenant();
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType));
}
return $resolver->attachRenderedTargets($edges->take(100), $tenant)
->map(function (array $edge): array {
$targetId = $edge['target_id'] ?? null;
$renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : [];
$badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null;
$linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null;
$lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null;
$isMissing = ($edge['target_type'] ?? null) === 'missing';
$missingHint = null;
if ($isMissing) {
$missingHint = 'Missing target';
if (filled($lastKnownName)) {
$missingHint .= ". Last known: {$lastKnownName}";
}
$rawRef = data_get($edge, 'metadata.raw_ref');
$encodedRef = $rawRef !== null ? json_encode($rawRef) : null;
if (is_string($encodedRef) && $encodedRef !== '') {
$missingHint .= '. Ref: '.Str::limit($encodedRef, 200);
}
}
$fallbackLabel = null;
if (filled($lastKnownName)) {
$fallbackLabel = $lastKnownName;
} elseif (is_string($targetId) && $targetId !== '') {
$fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…';
} else {
$fallbackLabel = 'External reference';
}
$relationshipType = (string) ($edge['relationship_type'] ?? 'unknown');
return [
'id' => (string) ($edge['id'] ?? Str::uuid()->toString()),
'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType),
'target_label' => $badgeText ?? $fallbackLabel,
'target_url' => $linkUrl,
'target_tooltip' => is_string($targetId) ? $targetId : null,
'missing_state' => $isMissing ? 'Missing' : null,
'missing_hint' => $missingHint,
];
})
->mapWithKeys(fn (array $row): array => [$row['id'] => $row]);
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true)
? $sortColumn
: 'relationship_label';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
if ($comparison === 0) {
$comparison = strnatcasecmp(
(string) ($left['target_label'] ?? ''),
(string) ($right['target_label'] ?? ''),
);
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
private function resolveInventoryItem(): InventoryItem
{
if ($this->cachedInventoryItem instanceof InventoryItem) {
return $this->cachedInventoryItem;
}
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
$tenant = $this->resolveCurrentTenant();
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
throw new NotFoundHttpException;
}
return $this->cachedInventoryItem = $inventoryItem;
}
private function resolveCurrentTenant(): Tenant
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
return $tenant;
}
private function normalizeRelationshipType(mixed $value): ?string
{
if (! is_string($value) || $value === '' || $value === 'all') {
return null;
}
return RelationshipType::tryFrom($value)?->value;
}
}

View File

@ -53,6 +53,8 @@ public function build(Tenant $tenant, array $filters = []): array
$filteredPermissions = self::applyFilterState($allPermissions, $state);
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
$summaryPermissions = $filteredPermissions;
return [
'tenant' => [
'id' => (int) $tenant->getKey(),
@ -60,9 +62,9 @@ public function build(Tenant $tenant, array $filters = []): array
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($allPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($summaryPermissions),
'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions),
'freshness' => $freshness,
],
'permissions' => $filteredPermissions,

View File

@ -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
*/

View File

@ -6,7 +6,7 @@
use App\Support\Inventory\InventoryPolicyTypeMeta;
final class GovernanceSubjectTaxonomyRegistry
class GovernanceSubjectTaxonomyRegistry
{
/**
* @var array<string, list<string>>

View File

@ -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.',
),

View File

@ -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 !== '');
}
}

View File

@ -1,85 +1,6 @@
@php /** @var callable $getState */ @endphp
<div class="space-y-4">
<form method="GET" class="flex items-center gap-2">
<label for="direction" class="text-sm text-gray-600">Direction</label>
<select id="direction" name="direction" class="fi-input fi-select">
<option value="all" {{ request('direction', 'all') === 'all' ? 'selected' : '' }}>All</option>
<option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option>
<option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option>
</select>
<label for="relationship_type" class="text-sm text-gray-600">Relationship</label>
<select id="relationship_type" name="relationship_type" class="fi-input fi-select">
<option value="all" {{ request('relationship_type', 'all') === 'all' ? 'selected' : '' }}>All</option>
@foreach (\App\Support\Enums\RelationshipType::options() as $value => $label)
<option value="{{ $value }}" {{ request('relationship_type') === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="fi-btn">Apply</button>
</form>
@php
$raw = $getState();
$edges = $raw instanceof \Illuminate\Support\Collection ? $raw : collect($raw);
@endphp
@if ($edges->isEmpty())
<div class="text-sm text-gray-500">No dependencies found</div>
@else
<div class="divide-y">
@foreach ($edges->groupBy('relationship_type') as $type => $group)
<div class="py-2">
<div class="text-xs uppercase tracking-wide text-gray-600 mb-2">{{ str_replace('_', ' ', $type) }}</div>
<ul class="space-y-1">
@foreach ($group as $edge)
@php
$isMissing = ($edge['target_type'] ?? null) === 'missing';
$targetId = $edge['target_id'] ?? null;
$rendered = $edge['rendered_target'] ?? [];
$badgeText = is_array($rendered) ? ($rendered['badge_text'] ?? null) : null;
$linkUrl = is_array($rendered) ? ($rendered['link_url'] ?? null) : null;
$missingTitle = 'Missing target';
$lastKnownName = $edge['metadata']['last_known_name'] ?? null;
if (is_string($lastKnownName) && $lastKnownName !== '') {
$missingTitle .= ". Last known: {$lastKnownName}";
}
$rawRef = $edge['metadata']['raw_ref'] ?? null;
if ($rawRef !== null) {
$encodedRef = json_encode($rawRef);
if (is_string($encodedRef) && $encodedRef !== '') {
$missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200);
}
}
$fallbackDisplay = null;
if (is_string($lastKnownName) && $lastKnownName !== '') {
$fallbackDisplay = $lastKnownName;
} elseif (is_string($targetId) && $targetId !== '') {
$fallbackDisplay = 'ID: '.substr($targetId, 0, 6).'…';
} else {
$fallbackDisplay = 'External reference';
}
@endphp
<li class="flex items-center gap-2 text-sm">
@if (is_string($badgeText) && $badgeText !== '')
@if (is_string($linkUrl) && $linkUrl !== '')
<a class="fi-badge" href="{{ $linkUrl }}" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</a>
@else
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</span>
@endif
@else
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $fallbackDisplay }}</span>
@endif
@if ($isMissing)
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
@endif
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
@endif
<livewire:inventory-item-dependency-edges-table
:inventory-item-id="(int) $getRecord()->getKey()"
:key="'inventory-item-dependency-edges-'.$getRecord()->getKey()"
/>
</div>

View File

@ -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 doesnt have a report yet. If its 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 doesnt 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>

View File

@ -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() ?? []])

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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() ?? []])

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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() ?? []])

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -1,59 +1,12 @@
<x-filament-panels::page>
<div class="space-y-6">
@if ($rows === [])
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
<h2 class="text-lg font-semibold text-gray-950">No evidence snapshots in this scope</h2>
<p class="mt-2 text-sm text-gray-600">Adjust filters or create a tenant snapshot to populate the workspace overview.</p>
<div class="mt-4">
<a href="{{ route('admin.evidence.overview') }}" class="inline-flex items-center rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white">
Clear filters
</a>
</div>
<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>
@else
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-gray-600">
<tr>
<th class="px-4 py-3 font-medium">Tenant</th>
<th class="px-4 py-3 font-medium">Artifact truth</th>
<th class="px-4 py-3 font-medium">Freshness</th>
<th class="px-4 py-3 font-medium">Generated</th>
<th class="px-4 py-3 font-medium">Not collected yet</th>
<th class="px-4 py-3 font-medium">Refresh recommended</th>
<th class="px-4 py-3 font-medium">Next step</th>
<th class="px-4 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
@foreach ($rows as $row)
<tr>
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
<td class="px-4 py-3">
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
</x-filament::badge>
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
@endif
</td>
<td class="px-4 py-3">
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
{{ data_get($row, 'freshness.label', 'Unknown') }}
</x-filament::badge>
</td>
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
<td class="px-4 py-3">{{ $row['next_step'] ?? 'No action needed' }}</td>
<td class="px-4 py-3">
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</x-filament::section>
{{ $this->table }}
</div>
</x-filament-panels::page>

View File

@ -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.

View File

@ -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.

View File

@ -6,7 +6,7 @@
$tenant = $this->currentTenant();
$vm = is_array($viewModel ?? null) ? $viewModel : [];
$vm = $this->viewModel();
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
@ -14,20 +14,6 @@
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
$selectedStatus = (string) ($filters['status'] ?? 'missing');
$selectedType = (string) ($filters['type'] ?? 'all');
$searchTerm = (string) ($filters['search'] ?? '');
$featureOptions = collect($featureImpacts)
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
->map(fn (array $impact): string => (string) $impact['feature'])
->filter()
->unique()
->sort()
->values()
->all();
$permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : [];
$overall = $overview['overall'] ?? null;
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
@ -226,10 +212,8 @@ class="text-primary-600 hover:underline dark:text-primary-400"
$selected = in_array($featureKey, $selectedFeatures, true);
@endphp
<button
type="button"
wire:click="applyFeatureFilter(@js($featureKey))"
class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/40 {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
<div
class="rounded-xl border p-4 text-left {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
@ -245,17 +229,9 @@ class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
</x-filament::badge>
</div>
</button>
</div>
@endforeach
</div>
@if ($selectedFeatures !== [])
<div>
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
Clear feature filter
</x-filament::button>
</div>
@endif
@endif
<div
@ -475,182 +451,14 @@ class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800
</div>
@else
<div class="space-y-6">
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Search doesnt affect copy actions. Feature filters do.
</div>
</div>
<x-filament::button color="gray" size="sm" wire:click="resetFilters">
Reset
</x-filament::button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-4">
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</label>
<select wire:model.live="status" class="fi-input fi-select w-full">
<option value="missing">Missing</option>
<option value="present">Present</option>
<option value="all">All</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Type</label>
<select wire:model.live="type" class="fi-input fi-select w-full">
<option value="all">All</option>
<option value="application">Application</option>
<option value="delegated">Delegated</option>
</select>
</div>
<div class="space-y-1 sm:col-span-2">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Search</label>
<input
type="search"
wire:model.live.debounce.500ms="search"
class="fi-input w-full"
placeholder="Search permission key or description…"
/>
</div>
@if ($featureOptions !== [])
<div class="space-y-1 sm:col-span-4">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Features</label>
<select wire:model.live="features" class="fi-input fi-select w-full" multiple>
@foreach ($featureOptions as $feature)
<option value="{{ $feature }}">{{ $feature }}</option>
@endforeach
</select>
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">Native permission matrix</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Search doesnt affect copy actions. Feature filters do.
</div>
</div>
@if ($requiredTotal === 0)
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">No permissions configured</div>
<div class="mt-1">
No required permissions are currently configured in <code class="font-mono text-xs">config/intune_permissions.php</code>.
</div>
</div>
@elseif ($permissions === [])
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
@if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '')
<div class="font-semibold text-gray-950 dark:text-white">All required permissions are present</div>
<div class="mt-1">
Switch Status to “All” if you want to review the full matrix.
</div>
@else
<div class="font-semibold text-gray-950 dark:text-white">No matches</div>
<div class="mt-1">
No permissions match the current filters.
</div>
@endif
</div>
@else
@php
$featuresToRender = $featureImpacts;
if ($selectedFeatures !== []) {
$featuresToRender = collect($featureImpacts)
->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true))
->values()
->all();
}
@endphp
@foreach ($featuresToRender as $impact)
@php
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
$featureKey = is_string($featureKey) ? $featureKey : null;
if ($featureKey === null) {
continue;
}
$rows = collect($permissions)
->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true))
->values()
->all();
if ($rows === []) {
continue;
}
@endphp
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $featureKey }}
</div>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Permission
</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Type
</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-950">
@foreach ($rows as $row)
@php
$key = is_array($row) ? (string) ($row['key'] ?? '') : '';
$type = is_array($row) ? (string) ($row['type'] ?? '') : '';
$status = is_array($row) ? (string) ($row['status'] ?? '') : '';
$description = is_array($row) ? ($row['description'] ?? null) : null;
$description = is_string($description) ? $description : null;
$statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status);
@endphp
<tr
class="align-top"
data-permission-key="{{ $key }}"
data-permission-type="{{ $type }}"
data-permission-status="{{ $status }}"
>
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $key }}
</div>
@if ($description)
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
{{ $description }}
</div>
@endif
</td>
<td class="px-4 py-3">
<x-filament::badge color="gray" size="sm">
{{ $type === 'delegated' ? 'Delegated' : 'Application' }}
</x-filament::badge>
</td>
<td class="px-4 py-3">
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
@endif
{{ $this->table }}
</div>
@endif
</div>

View File

@ -94,8 +94,7 @@
@endif
@else
@include('filament.components.verification-report-viewer', [
'run' => $runData,
'report' => $report,
'surface' => $surface ?? [],
'redactionNotes' => $redactionNotes ?? [],
])

View File

@ -0,0 +1,3 @@
<div>
{{ $this->table }}
</div>

View File

@ -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.')

View File

@ -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');

View File

@ -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();
});

View File

@ -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]);
});

View File

@ -138,6 +138,13 @@
$opService,
);
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($compareRun->context, 'baseline_compare.strategy.matched_scope_entries.0.domain_key'))->toBe('intune')
->and(data_get($compareRun->context, 'baseline_compare.strategy.execution_diagnostics.rbac_role_definitions.total_compared'))->toBe(0);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('subject_external_id', (string) $policy->external_id)

View File

@ -130,6 +130,9 @@
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
expect(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($run->context, 'baseline_compare.strategy.state_counts.drift'))->toBe(3);
$context = is_array($run->context) ? $run->context : [];
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;

View File

@ -123,6 +123,8 @@
$run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);

View File

@ -85,7 +85,9 @@
);
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0)
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported');
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
});
@ -200,7 +202,10 @@
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1)
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($compareRun->context, 'baseline_compare.strategy.state_counts.no_drift'))->toBe(1);
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
});

View File

@ -10,6 +10,8 @@
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
@ -85,7 +87,7 @@ function appendBrokenFoundationSupportConfig(): void
Bus::assertDispatched(CompareBaselineToTenantJob::class);
});
it('persists the same truthful scope capability decisions before dispatching capture work', function (): void {
it('blocks capture work when the scope still contains unsupported types, while preserving truthful capability context', function (): void {
Bus::fake();
appendBrokenFoundationSupportConfig();
@ -102,10 +104,13 @@ function appendBrokenFoundationSupportConfig(): void
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
$scope = $profile->normalizedScope()->toEffectiveScopeContext(
app(BaselineSupportCapabilityGuard::class),
'capture',
);
$run = $result['run'];
$scope = data_get($run->context, 'effective_scope');
expect($result['ok'])->toBeFalse()
->and($result['reason_code'] ?? null)->toBe(BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE);
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
@ -117,5 +122,5 @@ function appendBrokenFoundationSupportConfig(): void
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CaptureBaselineSnapshotJob::class);
Bus::assertNotDispatched(CaptureBaselineSnapshotJob::class);
});

View File

@ -362,18 +362,11 @@ public function compare(
}
}
final class FakeGovernanceSubjectTaxonomyRegistry
final class FakeGovernanceSubjectTaxonomyRegistry extends GovernanceSubjectTaxonomyRegistry
{
private readonly GovernanceSubjectTaxonomyRegistry $inner;
public function __construct()
{
$this->inner = new GovernanceSubjectTaxonomyRegistry;
}
public function all(): array
{
return array_values(array_merge($this->inner->all(), [
return array_values(array_merge(parent::all(), [
new GovernanceSubjectType(
domainKey: GovernanceDomainKey::Entra,
subjectClass: GovernanceSubjectClass::Control,
@ -389,66 +382,4 @@ public function all(): array
),
]));
}
public function active(): array
{
return array_values(array_filter(
$this->all(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
));
}
public function activeLegacyBucketKeys(string $legacyBucket): array
{
$subjectTypes = array_filter(
$this->active(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
);
$keys = array_map(
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
$subjectTypes,
);
sort($keys, SORT_STRING);
return array_values(array_unique($keys));
}
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
{
foreach ($this->all() as $subjectType) {
if ($subjectType->domainKey->value !== trim($domainKey)) {
continue;
}
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
continue;
}
return $subjectType;
}
return null;
}
public function isKnownDomain(string $domainKey): bool
{
return $this->inner->isKnownDomain($domainKey);
}
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
{
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
}
public function supportsFilters(string $domainKey, string $subjectClass): bool
{
return $this->inner->supportsFilters($domainKey, $subjectClass);
}
public function groupLabel(string $domainKey, string $subjectClass): string
{
return $this->inner->groupLabel($domainKey, $subjectClass);
}
}

View File

@ -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"');
});

View File

@ -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"');
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Pages\Monitoring\EvidenceOverview;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
@ -10,6 +11,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
@ -122,3 +124,56 @@
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), false);
});
it('seeds the native entitled-tenant prefilter once and clears it through the page action', 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');
$snapshotA = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
$snapshotB = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Partial->value,
'summary' => ['missing_dimensions' => 1, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
$this->actingAs($user);
setAdminPanelContext();
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
$component = Livewire::withQueryParams([
'tenant_id' => (string) $tenantB->getKey(),
'search' => $tenantB->name,
])->test(EvidenceOverview::class);
$component
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertSet('tableSearch', $tenantB->name)
->assertCanSeeTableRecords([(string) $snapshotB->getKey()])
->assertCanNotSeeTableRecords([(string) $snapshotA->getKey()]);
$component
->callAction('clear_filters')
->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);
});

View File

@ -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]);
});

View File

@ -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')

View File

@ -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);

View File

@ -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');
});

View File

@ -40,6 +40,8 @@
Livewire::actingAs($user)
->test(EvidenceOverview::class)
->assertCountTableRecords(1)
->assertCanSeeTableRecords([(string) $snapshot->getKey()])
->assertSee($tenant->name)
->assertSee('Artifact truth');

View File

@ -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"');
});

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Livewire\InventoryItemDependencyEdgesTable;
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function dependencyEdgesTableComponent(User $user, Tenant $tenant, InventoryItem $item)
{
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
test()->actingAs($user);
return Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [
'inventoryItemId' => (int) $item->getKey(),
]);
}
it('renders dependency rows through native table filters and preserves missing-target hints', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$item = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
$assigned = InventoryLink::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => 'Assigned Target',
'raw_ref' => ['example' => 'assigned'],
],
]);
$scoped = InventoryLink::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'scoped_by',
'metadata' => [
'last_known_name' => 'Scoped Target',
'raw_ref' => ['example' => 'scoped'],
],
]);
$inbound = InventoryLink::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
]);
$component = dependencyEdgesTableComponent($user, $tenant, $item)
->assertTableFilterExists('direction')
->assertTableFilterExists('relationship_type')
->assertCanSeeTableRecords([
(string) $assigned->getKey(),
(string) $scoped->getKey(),
(string) $inbound->getKey(),
])
->assertSee('Assigned Target')
->assertSee('Scoped Target')
->assertSee('Missing');
$component
->filterTable('direction', 'outbound')
->assertCanSeeTableRecords([
(string) $assigned->getKey(),
(string) $scoped->getKey(),
])
->assertCanNotSeeTableRecords([(string) $inbound->getKey()])
->removeTableFilters()
->filterTable('direction', 'inbound')
->assertCanSeeTableRecords([(string) $inbound->getKey()])
->assertCanNotSeeTableRecords([
(string) $assigned->getKey(),
(string) $scoped->getKey(),
])
->removeTableFilters()
->filterTable('relationship_type', 'scoped_by')
->assertCanSeeTableRecords([(string) $scoped->getKey()])
->assertCanNotSeeTableRecords([
(string) $assigned->getKey(),
(string) $inbound->getKey(),
])
->removeTableFilters()
->filterTable('direction', 'outbound')
->filterTable('relationship_type', 'depends_on')
->assertCountTableRecords(0)
->assertSee('No dependencies found');
});
it('returns deny-as-not-found when mounted for an item outside the current tenant scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$foreignItem = InventoryItem::factory()->create([
'tenant_id' => (int) Tenant::factory()->create()->getKey(),
'external_id' => (string) Str::uuid(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$this->actingAs($user);
$component = Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [
'inventoryItemId' => (int) $foreignItem->getKey(),
]);
$component->assertSee('Not Found');
expect($component->instance())->toBeNull();
});

View File

@ -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"');
});

View File

@ -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();
});

View File

@ -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"');
});

View File

@ -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"');
});

View File

@ -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"');
});

View File

@ -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',

View File

@ -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"');
});

View File

@ -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');

View File

@ -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();
});

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantRequiredPermissions;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function seedTenantRequiredPermissionsFixture(Tenant $tenant): void
{
config()->set('intune_permissions.permissions', [
[
'key' => 'DeviceManagementApps.Read.All',
'type' => 'application',
'description' => 'Backup application permission',
'features' => ['backup'],
],
[
'key' => 'Group.Read.All',
'type' => 'delegated',
'description' => 'Backup delegated permission',
'features' => ['backup'],
],
[
'key' => 'Reports.Read.All',
'type' => 'application',
'description' => 'Reporting permission',
'features' => ['reporting'],
],
]);
config()->set('entra_permissions.permissions', []);
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'Group.Read.All',
'status' => 'missing',
'details' => ['source' => 'fixture'],
'last_checked_at' => now(),
]);
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'Reports.Read.All',
'status' => 'granted',
'details' => ['source' => 'fixture'],
'last_checked_at' => now(),
]);
}
function tenantRequiredPermissionsComponent(User $user, Tenant $tenant, array $query = [])
{
test()->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$query = array_merge([
'tenant' => (string) $tenant->external_id,
], $query);
return Livewire::withQueryParams($query)->test(TenantRequiredPermissions::class);
}
it('uses native table filters and search while keeping summary state aligned with visible rows', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
seedTenantRequiredPermissionsFixture($tenant);
$component = tenantRequiredPermissionsComponent($user, $tenant)
->assertTableFilterExists('status')
->assertTableFilterExists('type')
->assertTableFilterExists('features')
->assertCanSeeTableRecords([
'DeviceManagementApps.Read.All',
'Group.Read.All',
])
->assertCanNotSeeTableRecords(['Reports.Read.All'])
->assertSee('Missing application permissions')
->assertSee('Guidance');
$component
->filterTable('status', 'present')
->filterTable('type', 'application')
->searchTable('Reports')
->assertCountTableRecords(1)
->assertCanSeeTableRecords(['Reports.Read.All'])
->assertCanNotSeeTableRecords([
'DeviceManagementApps.Read.All',
'Group.Read.All',
]);
$viewModel = $component->instance()->viewModel();
expect($viewModel['overview']['counts'])->toBe([
'missing_application' => 0,
'missing_delegated' => 0,
'present' => 1,
'error' => 0,
])
->and(array_column($viewModel['permissions'], 'key'))->toBe(['Reports.Read.All'])
->and($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All');
});
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
seedTenantRequiredPermissionsFixture($tenant);
$component = tenantRequiredPermissionsComponent($user, $tenant)
->set('tableFilters.features.values', ['backup'])
->assertSet('tableFilters.features.values', ['backup']);
$viewModel = $component->instance()->viewModel();
expect($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All')
->and($viewModel['copy']['delegated'])->toBe('Group.Read.All');
$component
->searchTable('no-such-permission')
->assertCountTableRecords(0)
->assertSee('No matches')
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
});

View File

@ -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"');
});
});

View File

@ -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"');
});

View File

@ -29,7 +29,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php',
@ -39,6 +41,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/InventoryItemDependencyEdgesTable.php',
'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php',
@ -81,7 +84,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Pages/TenantRequiredPermissions.php' => ['->emptyStateHeading('],
'app/Filament/Pages/InventoryCoverage.php' => ['->emptyStateHeading('],
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
@ -91,6 +96,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
'app/Livewire/InventoryItemDependencyEdgesTable.php' => ['->emptyStateHeading('],
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
@ -134,6 +140,8 @@
'app/Filament/Resources/EntraGroupResource.php',
'app/Filament/Resources/OperationRunResource.php',
'app/Filament/Resources/BaselineSnapshotResource.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
];
@ -310,7 +318,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php',
@ -320,6 +330,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/InventoryItemDependencyEdgesTable.php',
'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php',
@ -337,6 +348,85 @@
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
});
it('keeps spec 196 surfaces on native table contracts without faux controls or hand-built primary tables', function (): void {
$requiredPatterns = [
'app/Filament/Pages/TenantRequiredPermissions.php' => [
'implements HasTable',
'InteractsWithTable',
],
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => [
'implements HasTable',
'InteractsWithTable',
],
'app/Livewire/InventoryItemDependencyEdgesTable.php' => [
'extends TableComponent',
],
'resources/views/filament/components/dependency-edges.blade.php' => [
'inventory-item-dependency-edges-table',
],
'resources/views/filament/pages/tenant-required-permissions.blade.php' => [
'$this->table',
'data-testid="technical-details"',
],
'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [
'$this->table',
],
];
$forbiddenPatterns = [
'resources/views/filament/components/dependency-edges.blade.php' => [
'<form method="GET"',
'request(',
],
'resources/views/filament/pages/tenant-required-permissions.blade.php' => [
'wire:model.live="status"',
'wire:model.live="type"',
'wire:model.live="features"',
'wire:model.live.debounce.500ms="search"',
'<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">',
],
'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [
'<table class="min-w-full divide-y divide-gray-200 text-sm">',
],
];
$missing = [];
$unexpected = [];
foreach ($requiredPatterns as $relativePath => $patterns) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
$missing[] = $relativePath;
continue;
}
foreach ($patterns as $pattern) {
if (! str_contains($contents, $pattern)) {
$missing[] = "{$relativePath} ({$pattern})";
}
}
}
foreach ($forbiddenPatterns as $relativePath => $patterns) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
continue;
}
foreach ($patterns as $pattern) {
if (str_contains($contents, $pattern)) {
$unexpected[] = "{$relativePath} ({$pattern})";
}
}
}
expect($missing)->toBeEmpty('Missing native table contract patterns: '.implode(', ', $missing))
->and($unexpected)->toBeEmpty('Unexpected faux-control or hand-built table patterns remain: '.implode(', ', $unexpected));
});
it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
$patternByPath = [
'app/Filament/Resources/TenantResource.php' => [

View File

@ -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([]);
});

View File

@ -6,6 +6,10 @@
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver');
expect($compareJob)->toContain('compareStrategyRegistry->select(');
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
expect($compareJob)->toContain('$strategy->compare(');
expect($compareJob)->not->toContain('computeDrift(');
expect($compareJob)->not->toContain('->fingerprint(');
expect($compareJob)->not->toContain('::fingerprint(');

View File

@ -7,6 +7,24 @@
'PolicyNormalizer',
'VersionDiff',
'flattenForDiff',
'computeDrift(',
'effectiveBaselineHash(',
'resolveBaselinePolicyVersionId(',
'selectSummaryKind(',
'buildDriftEvidenceContract(',
'buildRoleDefinitionEvidencePayload(',
'resolveRoleDefinitionVersion(',
'fallbackRoleDefinitionNormalized(',
'roleDefinitionChangedKeys(',
'roleDefinitionPermissionKeys(',
'resolveRoleDefinitionDiff(',
'severityForRoleDefinitionDiff(',
'BaselinePolicyVersionResolver',
'DriftHasher',
'SettingsNormalizer',
'AssignmentsNormalizer',
'ScopeTagsNormalizer',
'IntuneRoleDefinitionNormalizer',
];
$captureForbiddenTokens = [
@ -20,6 +38,9 @@
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver');
expect($compareJob)->toContain('compareStrategyRegistry->select(');
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
expect($compareJob)->toContain('$strategy->compare(');
foreach ($compareForbiddenTokens as $token) {
expect($compareJob)->not->toContain($token);

View File

@ -41,7 +41,7 @@
->assertSee('Last known: Ghost Target');
});
it('direction filter limits to outbound or inbound', function () {
it('renders native dependency controls in place instead of a GET apply workflow', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
@ -51,34 +51,48 @@
'external_id' => (string) Str::uuid(),
]);
$inboundSource = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
'display_name' => 'Inbound Source',
]);
// Outbound only
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => 'Assigned Target',
],
]);
// Inbound only
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'source_id' => $inboundSource->external_id,
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
]);
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound';
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
$this->get($url)
->assertOk()
->assertSee('Direction')
->assertSee('Inbound')
->assertSee('Outbound')
->assertSee('Relationship')
->assertSee('Assigned Target')
->assertDontSee('No dependencies found');
});
it('relationship filter limits edges by type', function () {
it('ignores legacy relationship query state while preserving visible target safety', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
@ -115,7 +129,7 @@
$this->get($url)
->assertOk()
->assertSee('Scoped Target')
->assertDontSee('Assigned Target');
->assertSee('Assigned Target');
});
it('does not show edges from other tenants (tenant isolation)', function () {

View File

@ -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 {

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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());
});

View File

@ -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"');
});

View File

@ -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([

View File

@ -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 {

View File

@ -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"');
});

View File

@ -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"');
});

View File

@ -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');
});

View File

@ -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());
});

Some files were not shown because too many files have changed in this diff Show More