Compare commits
4 Commits
205-compar
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e02799b383 | |||
| c0f4587d90 | |||
| 4699f13a72 | |||
| bb72a54e84 |
12
.github/agents/copilot-instructions.md
vendored
12
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -36,6 +38,60 @@ class BaselineCompareLanding extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'baseline_compare_landing',
|
||||
'surfaceType' => 'launch_context_support',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'baseline_profile_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'scoped_deeplink',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'subject_key',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'scoped_deeplink',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'nav',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['baseline_profile_id', 'subject_key', 'nav'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'openedBy' => ['launch_context'],
|
||||
'presentation' => 'none',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['baseline_profile_id', 'subject_key', 'nav'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
@ -137,6 +193,14 @@ public static function canAccess(): bool
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
@ -266,6 +330,7 @@ protected function getViewData(): array
|
||||
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
||||
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
||||
'matrixSubjectKey' => $this->matrixSubjectKey,
|
||||
'openCompareMatrixUrl' => $this->openCompareMatrixUrl(),
|
||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
@ -465,6 +530,26 @@ public function getRunUrl(): ?string
|
||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||
}
|
||||
|
||||
public function openCompareMatrixUrl(): ?string
|
||||
{
|
||||
$profile = $this->resolveCompareMatrixProfile();
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = BaselineProfileResource::compareMatrixUrl($profile);
|
||||
$query = array_filter([
|
||||
'subject_key' => $this->matrixSubjectKey,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
@ -482,8 +567,33 @@ private function navigationContext(): ?CanonicalNavigationContext
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||
}
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
private function resolveCompareMatrixProfile(): ?BaselineProfile
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidateIds = array_values(array_filter([
|
||||
$this->matrixBaselineProfileId,
|
||||
$this->profileId,
|
||||
], static fn (mixed $value): bool => is_int($value) && $value > 0));
|
||||
|
||||
foreach ($candidateIds as $profileId) {
|
||||
$profile = BaselineProfile::query()
|
||||
->whereKey($profileId)
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->first();
|
||||
|
||||
if ($profile instanceof BaselineProfile) {
|
||||
return $profile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,106 @@ class BaselineCompareMatrix extends Page implements HasForms
|
||||
use InteractsWithForms;
|
||||
use InteractsWithRecord;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'baseline_compare_matrix',
|
||||
'surfaceType' => 'draft_apply_analysis',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'mode',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'policy_type',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'state',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'severity',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_sort',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'subject_sort',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'subject_key',
|
||||
'stateClass' => 'inspect',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['mode', 'policy_type', 'state', 'severity', 'tenant_sort', 'subject_sort', 'subject_key'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'baseline_subject',
|
||||
'selectedStateKey' => 'focusedSubjectKey',
|
||||
'openedBy' => ['query_param', 'focus_link'],
|
||||
'presentation' => 'focused_matrix',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['mode', 'policy_type', 'state', 'severity', 'tenant_sort', 'subject_sort', 'subject_key'],
|
||||
'localOnlyStateKeys' => [
|
||||
'draftSelectedPolicyTypes',
|
||||
'draftSelectedStates',
|
||||
'draftSelectedSeverities',
|
||||
'draftTenantSort',
|
||||
'draftSubjectSort',
|
||||
],
|
||||
];
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -107,6 +207,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
$this->record = $this->resolveRecord($record);
|
||||
|
||||
@ -36,8 +36,8 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -45,6 +45,60 @@ class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'audit_log',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'event',
|
||||
'stateClass' => 'inspect',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableSearch',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id', 'action', 'actor_label', 'resource_type'],
|
||||
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => AuditLogModel::class,
|
||||
'selectedStateKey' => 'selectedAuditLogId',
|
||||
'openedBy' => ['query_param', 'inspect_action'],
|
||||
'presentation' => 'inline_detail',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['event'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
@ -82,6 +136,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
@ -92,8 +154,7 @@ public function mount(): void
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->selectedAuditLogId = $requestedEventId;
|
||||
$this->selectedAuditLogId = $this->resolveSelectedAuditLogId($requestedEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,9 +180,41 @@ protected function getHeaderActions(): array
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedAudit = $this->selectedAuditRecord();
|
||||
$selectedAuditLink = $selectedAudit instanceof AuditLogModel
|
||||
? $this->auditTargetLink($selectedAudit)
|
||||
: null;
|
||||
|
||||
if ($selectedAudit instanceof AuditLogModel) {
|
||||
array_splice($actions, 1, 0, array_values(array_filter([
|
||||
Action::make('close_selected_audit_event')
|
||||
->label('Close details')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->url($this->auditLogUrl(['event' => null])),
|
||||
$selectedAuditLink !== null
|
||||
? Action::make('open_selected_audit_target')
|
||||
->label($selectedAuditLink['label'])
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url($selectedAuditLink['url'])
|
||||
: null,
|
||||
])));
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function updatedTableFilters(): void
|
||||
{
|
||||
$this->normalizeSelectedAuditLogId();
|
||||
}
|
||||
|
||||
public function updatedTableSearch(): void
|
||||
{
|
||||
$this->normalizeSelectedAuditLogId();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -192,19 +285,7 @@ public function table(Table $table): Table
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->before(function (AuditLogModel $record): void {
|
||||
$this->selectedAuditLogId = (int) $record->getKey();
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
|
||||
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
|
||||
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||
'selectedAudit' => $record,
|
||||
'selectedAuditLink' => $this->auditTargetLink($record),
|
||||
])),
|
||||
->url(fn (AuditLogModel $record): string => $this->auditLogUrl(['event' => (int) $record->getKey()])),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
@ -216,6 +297,7 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
@ -312,6 +394,12 @@ public function selectedAuditRecord(): ?AuditLogModel
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->normalizeSelectedAuditLogId();
|
||||
|
||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||
} catch (NotFoundHttpException) {
|
||||
@ -341,6 +429,137 @@ private function auditTargetLink(AuditLogModel $record): ?array
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
private function auditLogUrl(array $overrides = []): string
|
||||
{
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
['event' => $this->selectedAuditLogId],
|
||||
$overrides,
|
||||
);
|
||||
|
||||
return route(
|
||||
'admin.monitoring.audit-log',
|
||||
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function normalizeSelectedAuditLogId(): void
|
||||
{
|
||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||
$this->selectedAuditLogId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedAuditLogId = $this->resolveSelectedAuditLogId($this->selectedAuditLogId);
|
||||
}
|
||||
|
||||
private function resolveSelectedAuditLogId(int $auditLogId): ?int
|
||||
{
|
||||
try {
|
||||
$record = $this->resolveAuditLog($auditLogId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->selectedAuditVisible((int) $record->getKey())
|
||||
? (int) $record->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function selectedAuditVisible(int $auditLogId): bool
|
||||
{
|
||||
$record = $this->resolveAuditLog($auditLogId);
|
||||
|
||||
return $this->matchesSelectedAuditFilters($record)
|
||||
&& $this->matchesSelectedAuditSearch($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentTableFiltersState(): array
|
||||
{
|
||||
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
||||
|
||||
return array_replace_recursive(
|
||||
is_array($persisted) ? $persisted : [],
|
||||
$this->tableFilters ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private function currentTableSearchState(): string
|
||||
{
|
||||
$search = trim((string) ($this->tableSearch ?? ''));
|
||||
|
||||
if ($search !== '') {
|
||||
return $search;
|
||||
}
|
||||
|
||||
$persisted = session()->get($this->getTableSearchSessionKey(), '');
|
||||
|
||||
return trim(is_string($persisted) ? $persisted : '');
|
||||
}
|
||||
|
||||
private function matchesSelectedAuditFilters(AuditLogModel $record): bool
|
||||
{
|
||||
$filters = $this->currentTableFiltersState();
|
||||
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
|
||||
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actionFilter = data_get($filters, 'action.value');
|
||||
|
||||
if (is_string($actionFilter) && $actionFilter !== '' && (string) $record->action !== $actionFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$outcomeFilter = data_get($filters, 'outcome.value');
|
||||
|
||||
if (is_string($outcomeFilter) && $outcomeFilter !== '' && $record->normalizedOutcome()->value !== $outcomeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actorFilter = data_get($filters, 'actor_label.value');
|
||||
|
||||
if (is_string($actorFilter) && $actorFilter !== '' && (string) $record->actor_label !== $actorFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resourceTypeFilter = data_get($filters, 'resource_type.value');
|
||||
|
||||
if (is_string($resourceTypeFilter) && $resourceTypeFilter !== '' && (string) $record->resource_type !== $resourceTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function matchesSelectedAuditSearch(AuditLogModel $record): bool
|
||||
{
|
||||
$search = Str::lower($this->currentTableSearchState());
|
||||
|
||||
if ($search === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = Str::lower(implode(' ', [
|
||||
$record->summaryText(),
|
||||
$record->actorDisplayLabel(),
|
||||
$record->targetDisplayLabel() ?? '',
|
||||
]));
|
||||
|
||||
return str_contains($haystack, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,8 +16,10 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
@ -40,9 +42,9 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -50,9 +52,71 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'exception',
|
||||
'stateClass' => 'inspect',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableFilters',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableSearch',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant', 'tenant_id', 'status', 'current_validity_state'],
|
||||
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => FindingException::class,
|
||||
'selectedStateKey' => 'selectedFindingExceptionId',
|
||||
'openedBy' => ['query_param', 'inspect_action'],
|
||||
'presentation' => 'summary_plus_related_actions',
|
||||
'shareable' => true,
|
||||
'invalidSelectionFallback' => 'clear_selection_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant', 'exception'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public bool $showSelectedExceptionSummary = false;
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
@ -87,6 +151,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
@ -120,13 +192,12 @@ public static function canAccess(): bool
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
|
||||
$this->mountInteractsWithTable();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$requestedExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
|
||||
if ($this->selectedFindingExceptionId !== null) {
|
||||
$this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
if ($requestedExceptionId !== null) {
|
||||
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($requestedExceptionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +218,6 @@ protected function getHeaderActions(): array
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
@ -170,23 +240,21 @@ protected function getHeaderActions(): array
|
||||
Action::make('clear_selected_exception')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->clearSelectedException();
|
||||
}),
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): string => $this->queueUrl(['exception' => null])),
|
||||
|
||||
Action::make('open_selected_exception')
|
||||
->label('Open tenant detail')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): ?string => $this->selectedExceptionUrl()),
|
||||
|
||||
Action::make('open_selected_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): ?string => $this->selectedFindingUrl()),
|
||||
];
|
||||
|
||||
@ -270,7 +338,7 @@ protected function getHeaderActions(): array
|
||||
->label('Selected context')
|
||||
->icon('heroicon-o-rectangle-stack')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null);
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException);
|
||||
|
||||
$actions[] = ActionGroup::make($selectedDecisionActions)
|
||||
->label('Review selected')
|
||||
@ -353,32 +421,7 @@ public function table(Table $table): Table
|
||||
->label('Inspect exception')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->before(function (FindingException $record): void {
|
||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||
->modalHeading(function (): string {
|
||||
$record = $this->inspectedFindingException();
|
||||
|
||||
return $record instanceof FindingException
|
||||
? 'Finding exception #'.$record->getKey()
|
||||
: 'Finding exception';
|
||||
})
|
||||
->modalDescription(fn (): ?string => $this->inspectedFindingException()?->requested_at?->toDayDateTimeString())
|
||||
->modalContent(function (): View {
|
||||
$record = $this->inspectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
return view('filament.pages.monitoring.partials.finding-exception-queue-unavailable');
|
||||
}
|
||||
|
||||
return view('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
||||
'selectedException' => $record,
|
||||
]);
|
||||
}),
|
||||
->url(fn (FindingException $record): string => $this->queueUrl(['exception' => (int) $record->getKey()])),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No exceptions match this queue')
|
||||
@ -394,19 +437,38 @@ public function table(Table $table): Table
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatedTableFilters(): void
|
||||
{
|
||||
$this->normalizeSelectedFindingExceptionId();
|
||||
}
|
||||
|
||||
public function updatedTableSearch(): void
|
||||
{
|
||||
$this->normalizeSelectedFindingExceptionId();
|
||||
}
|
||||
|
||||
public function selectedFindingException(): ?FindingException
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
$this->normalizeSelectedFindingExceptionId();
|
||||
|
||||
if (! is_int($this->selectedFindingExceptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function selectedExceptionUrl(): ?string
|
||||
@ -434,7 +496,6 @@ public function selectedFindingUrl(): ?string
|
||||
public function clearSelectedException(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -542,11 +603,11 @@ private function filteredTenant(): ?Tenant
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
$tenantFilter = app(CanonicalAdminTenantFilterState::class)->currentFilterValue(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
$this->tableFilters ?? [],
|
||||
request(),
|
||||
);
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
}
|
||||
@ -571,15 +632,126 @@ private function resolveSelectedFindingException(int $findingExceptionId): Findi
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function inspectedFindingException(): ?FindingException
|
||||
private function queueUrl(array $overrides = []): string
|
||||
{
|
||||
$mountedRecord = $this->getMountedTableActionRecord();
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
[
|
||||
'tenant' => $this->filteredTenant()?->getKey(),
|
||||
'exception' => $this->selectedFindingExceptionId,
|
||||
],
|
||||
$overrides,
|
||||
);
|
||||
|
||||
if ($mountedRecord instanceof FindingException) {
|
||||
return $mountedRecord;
|
||||
return static::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function normalizeSelectedFindingExceptionId(): void
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return $this->selectedFindingException();
|
||||
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($this->selectedFindingExceptionId);
|
||||
}
|
||||
|
||||
private function resolveSelectedFindingExceptionId(int $findingExceptionId): ?int
|
||||
{
|
||||
try {
|
||||
$record = $this->resolveSelectedFindingException($findingExceptionId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->selectedFindingExceptionVisible((int) $record->getKey())
|
||||
? (int) $record->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function selectedFindingExceptionVisible(int $findingExceptionId): bool
|
||||
{
|
||||
$record = $this->resolveSelectedFindingException($findingExceptionId);
|
||||
|
||||
return $this->matchesSelectedFindingExceptionFilters($record)
|
||||
&& $this->matchesSelectedFindingExceptionSearch($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentQueueFiltersState(): array
|
||||
{
|
||||
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
||||
|
||||
return array_replace_recursive(
|
||||
is_array($persisted) ? $persisted : [],
|
||||
$this->tableFilters ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private function currentQueueSearchState(): string
|
||||
{
|
||||
$search = trim((string) ($this->tableSearch ?? ''));
|
||||
|
||||
if ($search !== '') {
|
||||
return $search;
|
||||
}
|
||||
|
||||
$persisted = session()->get($this->getTableSearchSessionKey(), '');
|
||||
|
||||
return trim(is_string($persisted) ? $persisted : '');
|
||||
}
|
||||
|
||||
private function matchesSelectedFindingExceptionFilters(FindingException $record): bool
|
||||
{
|
||||
$filters = $this->currentQueueFiltersState();
|
||||
|
||||
$tenantFilter = data_get($filters, 'tenant_id.value');
|
||||
|
||||
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusFilter = data_get($filters, 'status.value');
|
||||
|
||||
if (is_string($statusFilter) && $statusFilter !== '' && (string) $record->status !== $statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$validityFilter = data_get($filters, 'current_validity_state.value');
|
||||
|
||||
if (is_string($validityFilter) && $validityFilter !== '' && (string) $record->current_validity_state !== $validityFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function matchesSelectedFindingExceptionSearch(FindingException $record): bool
|
||||
{
|
||||
$search = Str::lower($this->currentQueueSearchState());
|
||||
|
||||
if ($search === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = Str::lower(implode(' ', [
|
||||
$record->tenant?->name ?? '',
|
||||
$record->finding?->resolvedSubjectDisplayName() ?? 'Finding #'.$record->finding_id,
|
||||
$record->request_reason ?? '',
|
||||
]));
|
||||
|
||||
return str_contains($haystack, $search);
|
||||
}
|
||||
|
||||
private function governanceWarning(FindingException $record): ?string
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -38,6 +39,80 @@ class Operations extends Page implements HasForms, HasTable
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected const MONITORING_PAGE_STATE_CONTRACT = [
|
||||
'surfaceKey' => 'operations',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'stateFields' => [
|
||||
[
|
||||
'stateKey' => 'tenant_id',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tenant_scope',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'reset_to_default_scope',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'problemClass',
|
||||
'stateClass' => 'contextual_prefilter',
|
||||
'carrier' => 'query_param',
|
||||
'queryRole' => 'scoped_deeplink',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'activeTab',
|
||||
'stateClass' => 'active',
|
||||
'carrier' => 'livewire_property',
|
||||
'queryRole' => 'durable_restorable',
|
||||
'shareable' => true,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => false,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
[
|
||||
'stateKey' => 'tableFilters',
|
||||
'stateClass' => 'shareable_restorable',
|
||||
'carrier' => 'session',
|
||||
'queryRole' => 'unsupported',
|
||||
'shareable' => false,
|
||||
'restorableOnRefresh' => true,
|
||||
'tenantSensitive' => true,
|
||||
'invalidFallback' => 'discard_and_continue',
|
||||
],
|
||||
],
|
||||
'hydrationRule' => [
|
||||
'precedenceOrder' => ['query', 'session', 'default'],
|
||||
'appliesOnInitialMountOnly' => true,
|
||||
'activeStateBecomesAuthoritativeAfterMount' => true,
|
||||
'clearsOnTenantSwitch' => ['tenant_id', 'type', 'initiator_name'],
|
||||
'invalidRequestedStateFallback' => 'discard_and_continue',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'openedBy' => [],
|
||||
'presentation' => 'none',
|
||||
'shareable' => false,
|
||||
'invalidSelectionFallback' => 'discard_and_continue',
|
||||
],
|
||||
'shareableStateKeys' => ['tenant_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||
'localOnlyStateKeys' => [],
|
||||
];
|
||||
|
||||
public string $activeTab = 'all';
|
||||
|
||||
/**
|
||||
@ -70,6 +145,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function monitoringPageStateContract(): array
|
||||
{
|
||||
return self::MONITORING_PAGE_STATE_CONTRACT;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
@ -185,15 +268,23 @@ public function landingHierarchySummary(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function tabUrl(string $tab): string
|
||||
{
|
||||
$normalizedTab = in_array($tab, self::supportedTabs(), true) ? $tab : 'all';
|
||||
|
||||
return $this->operationsUrl([
|
||||
'activeTab' => $normalizedTab !== 'all' ? $normalizedTab : null,
|
||||
'problemClass' => in_array($normalizedTab, self::problemClassTabs(), true) ? $normalizedTab : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
@ -206,11 +297,7 @@ public function table(Table $table): Table
|
||||
return OperationRunResource::table($table)
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
$tenantFilter = $this->currentTenantFilterId();
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
@ -224,8 +311,8 @@ public function table(Table $table): Table
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
$tenantFilter !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
@ -300,26 +387,22 @@ private function scopedSummaryQuery(): ?Builder
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
$tenantFilter = $this->currentTenantFilterId();
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
$tenantFilter !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
||||
);
|
||||
}
|
||||
|
||||
private function applyRequestedDashboardPrefilter(): void
|
||||
{
|
||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||
$requestedTenantId = request()->query('tenant_id');
|
||||
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('tenant_id'));
|
||||
|
||||
if (is_numeric($requestedTenantId)) {
|
||||
if ($requestedTenantId !== null) {
|
||||
$tenantId = (string) $requestedTenantId;
|
||||
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
||||
@ -328,10 +411,7 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
|
||||
$requestedProblemClass = request()->query('problemClass');
|
||||
|
||||
if (in_array($requestedProblemClass, [
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
if (in_array($requestedProblemClass, self::problemClassTabs(), true)) {
|
||||
$this->activeTab = (string) $requestedProblemClass;
|
||||
|
||||
return;
|
||||
@ -339,16 +419,7 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
|
||||
$requestedTab = request()->query('activeTab');
|
||||
|
||||
if (in_array($requestedTab, [
|
||||
'all',
|
||||
'active',
|
||||
'blocked',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
if (in_array($requestedTab, self::supportedTabs(), true)) {
|
||||
$this->activeTab = (string) $requestedTab;
|
||||
}
|
||||
}
|
||||
@ -357,4 +428,94 @@ private function shouldForceWorkspaceWideTenantScope(): bool
|
||||
{
|
||||
return request()->query('tenant_scope') === 'all';
|
||||
}
|
||||
|
||||
private function operationsUrl(array $overrides = []): string
|
||||
{
|
||||
$parameters = array_merge(
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
[
|
||||
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,
|
||||
'tenant_id' => $this->shouldForceWorkspaceWideTenantScope() ? null : $this->currentTenantFilterId(),
|
||||
'activeTab' => $this->activeTab !== 'all' ? $this->activeTab : null,
|
||||
'problemClass' => in_array($this->activeTab, self::problemClassTabs(), true) ? $this->activeTab : null,
|
||||
],
|
||||
$overrides,
|
||||
);
|
||||
|
||||
return route(
|
||||
'admin.operations.index',
|
||||
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||
);
|
||||
}
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = app(CanonicalAdminTenantFilterState::class)->currentFilterValue(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
$this->tableFilters ?? [],
|
||||
request(),
|
||||
);
|
||||
|
||||
return $this->normalizeEntitledTenantFilter($tenantFilter);
|
||||
}
|
||||
|
||||
private function normalizeEntitledTenantFilter(mixed $value): ?int
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = (int) $value;
|
||||
|
||||
return in_array($tenantId, $this->authorizedTenantIds(), true)
|
||||
? $tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function authorizedTenantIds(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $user instanceof User || ! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function supportedTabs(): array
|
||||
{
|
||||
return [
|
||||
'all',
|
||||
'active',
|
||||
'blocked',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
...self::problemClassTabs(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function problemClassTabs(): array
|
||||
{
|
||||
return [
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1760,6 +1760,8 @@ private function verificationReportViewData(): array
|
||||
'previousRunUrl' => null,
|
||||
'canAcknowledge' => false,
|
||||
'acknowledgements' => [],
|
||||
'surface' => [],
|
||||
'redactionNotes' => [],
|
||||
'assistVisibility' => $assistVisibility,
|
||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||
@ -1809,7 +1811,28 @@ private function verificationReportViewData(): array
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||
$verificationReport = VerificationReportViewer::report($run);
|
||||
$surface = VerificationReportViewer::surface($run, $acknowledgements, [
|
||||
'hostKind' => 'onboarding_wizard',
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'nextStepPlacement' => ($assistVisibility['is_visible'] ?? false) ? 'host_action_zone' : 'shared_zone',
|
||||
'hostActions' => array_values(array_filter([
|
||||
($assistVisibility['is_visible'] ?? false)
|
||||
? ['kind' => 'assist', 'label' => 'View required permissions', 'ownedByHost' => true]
|
||||
: null,
|
||||
['kind' => 'technical_details', 'label' => 'Technical details', 'ownedByHost' => true],
|
||||
$canAcknowledge
|
||||
? ['kind' => 'acknowledge', 'label' => 'Acknowledge', 'ownedByHost' => true]
|
||||
: null,
|
||||
])),
|
||||
'hostVariation' => [
|
||||
'ownsNoRunState' => true,
|
||||
'ownsActiveState' => true,
|
||||
'supportsAssist' => (bool) ($assistVisibility['is_visible'] ?? false),
|
||||
'supportsAcknowledge' => $canAcknowledge,
|
||||
'supportsTechnicalDetailsTrigger' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
@ -1832,6 +1855,8 @@ private function verificationReportViewData(): array
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
'surface' => $surface,
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
'assistVisibility' => $assistVisibility,
|
||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Filament\Support\NormalizedDiffSurface;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
@ -412,11 +413,6 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Diff')
|
||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||
->schema([
|
||||
TextEntry::make('diff_unavailable')
|
||||
->label('')
|
||||
->state(fn (Finding $record): string => static::driftDiffUnavailableMessage($record))
|
||||
->visible(fn (Finding $record): bool => ! static::canRenderDriftDiff($record))
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('rbac_role_definition_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.rbac-role-definition-diff')
|
||||
@ -429,13 +425,13 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
return NormalizedDiffSurface::build(static::unavailableDiffState('No tenant context'), 'finding');
|
||||
}
|
||||
|
||||
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
||||
|
||||
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
||||
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
|
||||
return NormalizedDiffSurface::build(static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'), 'finding');
|
||||
}
|
||||
|
||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||
@ -452,9 +448,9 @@ public static function infolist(Schema $schema): Schema
|
||||
);
|
||||
}
|
||||
|
||||
return $diff;
|
||||
return NormalizedDiffSurface::build($diff, 'finding');
|
||||
})
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('scope_tags_diff')
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -1178,6 +1178,18 @@ private static function verificationReportViewData(OperationRun $record): array
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
'surface' => VerificationReportViewer::surface($record, $acknowledgements, [
|
||||
'hostKind' => 'operation_run_detail',
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'hostVariation' => [
|
||||
'ownsNoRunState' => false,
|
||||
'ownsActiveState' => false,
|
||||
'supportsAssist' => false,
|
||||
'supportsAcknowledge' => false,
|
||||
'supportsTechnicalDetailsTrigger' => false,
|
||||
],
|
||||
]),
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
];
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\PolicyResource\Pages;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Filament\Support\NormalizedSettingsSurface;
|
||||
use App\Jobs\BulkPolicyDeleteJob;
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Jobs\BulkPolicyUnignoreJob;
|
||||
@ -238,25 +239,13 @@ public static function infolist(Schema $schema): Schema
|
||||
Tab::make('Settings')
|
||||
->id('settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings_catalog')
|
||||
ViewEntry::make('settings')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (Policy $record) {
|
||||
return static::settingsTabState($record);
|
||||
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
|
||||
})
|
||||
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
|
||||
$record->versions()->exists()
|
||||
),
|
||||
|
||||
ViewEntry::make('settings_standard')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (Policy $record) {
|
||||
return static::settingsTabState($record);
|
||||
})
|
||||
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
|
||||
$record->versions()->exists()
|
||||
),
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
|
||||
TextEntry::make('no_settings_available')
|
||||
->label('Settings')
|
||||
@ -301,16 +290,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (Policy $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
static::latestSnapshot($record),
|
||||
$record->policy_type ?? '',
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'policy';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
|
||||
}),
|
||||
])
|
||||
->columnSpanFull()
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
use App\Filament\Support\NormalizedDiffSurface;
|
||||
use App\Filament\Support\NormalizedSettingsSurface;
|
||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||
@ -180,7 +182,7 @@ public static function infolist(Schema $schema): Schema
|
||||
Tab::make('Normalized settings')
|
||||
->id('normalized-settings')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_catalog')
|
||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
@ -189,29 +191,12 @@ public static function infolist(Schema $schema): Schema
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'version';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_standard')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
is_array($record->snapshot) ? $record->snapshot : [],
|
||||
$record->policy_type ?? '',
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'version';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
$normalized['policy_type'] = $record->policy_type;
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
||||
}),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
->id('raw-json')
|
||||
@ -238,7 +223,7 @@ public static function infolist(Schema $schema): Schema
|
||||
$result = $diff->compare($from, $to);
|
||||
$result['policy_type'] = $record->policy_type;
|
||||
|
||||
return $result;
|
||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
||||
}),
|
||||
Infolists\Components\ViewEntry::make('diff_json')
|
||||
->label('Raw diff (advanced)')
|
||||
|
||||
155
apps/platform/app/Filament/Support/NormalizedDiffSurface.php
Normal file
155
apps/platform/app/Filament/Support/NormalizedDiffSurface.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
final class NormalizedDiffSurface
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $diff
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(array $diff, string $hostKind): array
|
||||
{
|
||||
$summary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
|
||||
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||
$message = is_string($summary['message'] ?? null) && trim((string) $summary['message']) !== ''
|
||||
? trim((string) $summary['message'])
|
||||
: null;
|
||||
|
||||
$addedCount = is_numeric($summary['added'] ?? null) ? (int) $summary['added'] : count($added);
|
||||
$removedCount = is_numeric($summary['removed'] ?? null) ? (int) $summary['removed'] : count($removed);
|
||||
$changedCount = is_numeric($summary['changed'] ?? null) ? (int) $summary['changed'] : count($changed);
|
||||
$availabilityState = self::availabilityState($message, $addedCount, $removedCount, $changedCount);
|
||||
|
||||
return [
|
||||
'hostKind' => $hostKind,
|
||||
'availabilityState' => $availabilityState,
|
||||
'summary' => [
|
||||
'added' => $addedCount,
|
||||
'removed' => $removedCount,
|
||||
'changed' => $changedCount,
|
||||
'message' => $message,
|
||||
],
|
||||
'viewModes' => [
|
||||
['key' => 'grouped', 'label' => 'Grouped diff', 'default' => true],
|
||||
],
|
||||
'sectionBehavior' => [
|
||||
'preservesGroupOrder' => true,
|
||||
'supportsExpansion' => true,
|
||||
'supportsFullscreen' => true,
|
||||
],
|
||||
'renderExpectations' => [
|
||||
'ownsAvailabilityState' => true,
|
||||
'ownsZeroDiffMessaging' => true,
|
||||
'keepsHostFramingOutsideCore' => true,
|
||||
],
|
||||
'groups' => [
|
||||
self::group('changed', 'Changed', $changed, false),
|
||||
self::group('added', 'Added', $added, true),
|
||||
self::group('removed', 'Removed', $removed, true),
|
||||
],
|
||||
'scriptRendering' => [
|
||||
'policyType' => $diff['policy_type'] ?? null,
|
||||
'showScriptContent' => (bool) config('tenantpilot.display.show_script_content', false),
|
||||
],
|
||||
'emptyState' => self::emptyState($availabilityState, $message, $addedCount, $removedCount, $changedCount),
|
||||
'raw' => [
|
||||
'added' => $added,
|
||||
'removed' => $removed,
|
||||
'changed' => $changed,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function group(string $key, string $label, array $items, bool $collapsed): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'collapsed' => $collapsed,
|
||||
'count' => count($items),
|
||||
'items' => self::groupByBlock($items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function groupByBlock(array $items): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
$group = count($parts) === 2 ? $parts[0] : 'Other';
|
||||
$label = count($parts) === 2 ? $parts[1] : $path;
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
private static function availabilityState(?string $message, int $addedCount, int $removedCount, int $changedCount): string
|
||||
{
|
||||
if ($message !== null && str_contains(strtolower($message), 'unavailable')) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
if ($message !== null && str_contains(strtolower($message), 'partial')) {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'available';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, message: string}|null
|
||||
*/
|
||||
private static function emptyState(
|
||||
string $availabilityState,
|
||||
?string $message,
|
||||
int $addedCount,
|
||||
int $removedCount,
|
||||
int $changedCount,
|
||||
): ?array
|
||||
{
|
||||
if ($availabilityState === 'unavailable' && $message !== null) {
|
||||
return [
|
||||
'title' => 'Diff unavailable',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
if ($availabilityState === 'partial' && $message !== null) {
|
||||
return [
|
||||
'title' => 'Diff partially available',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
if ($availabilityState === 'available' && ($addedCount + $removedCount + $changedCount) === 0) {
|
||||
return [
|
||||
'title' => 'No normalized changes',
|
||||
'message' => $message ?? 'No normalized changes were found.',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
final class NormalizedSettingsSurface
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $normalized
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(array $normalized, string $hostKind): array
|
||||
{
|
||||
$warnings = collect($normalized['warnings'] ?? [])
|
||||
->filter(static fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
|
||||
->map(static fn (string $warning): string => trim($warning))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$settingsTable = is_array($normalized['settings_table'] ?? null) ? $normalized['settings_table'] : null;
|
||||
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
|
||||
$blocks = collect($normalized['settings'] ?? [])
|
||||
->filter(static fn (mixed $block): bool => is_array($block))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$context = is_string($normalized['context'] ?? null) && $normalized['context'] !== ''
|
||||
? (string) $normalized['context']
|
||||
: 'policy';
|
||||
|
||||
$variant = $settingsTableRows !== [] ? 'settings_catalog_table' : 'standard_blocks';
|
||||
|
||||
return [
|
||||
'hostKind' => $hostKind,
|
||||
'context' => $context,
|
||||
'variant' => $variant,
|
||||
'warnings' => $warnings,
|
||||
'settingsTable' => $settingsTableRows !== [] ? $settingsTable : null,
|
||||
'blocks' => $blocks,
|
||||
'sectionBehavior' => [
|
||||
'preservesSectionOrder' => true,
|
||||
'supportsExpansion' => true,
|
||||
'ownsEmptyState' => true,
|
||||
],
|
||||
'renderExpectations' => [
|
||||
'ownsWarningsInWrapper' => true,
|
||||
'ownsSubtypeDelegation' => true,
|
||||
'keepsHostFramingOutsideCore' => true,
|
||||
],
|
||||
'emptyState' => $settingsTableRows === [] && $blocks === []
|
||||
? [
|
||||
'title' => 'No settings available.',
|
||||
'message' => 'No normalized settings payload is available for this host.',
|
||||
]
|
||||
: null,
|
||||
'titlePolicy' => [
|
||||
'showWrapperTitle' => false,
|
||||
],
|
||||
'recordId' => $normalized['record_id'] ?? null,
|
||||
'policyType' => $normalized['policy_type'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
@ -91,6 +93,276 @@ public static function shouldRenderForRun(OperationRun $run): bool
|
||||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $acknowledgements
|
||||
* @param array{
|
||||
* hostKind?: string,
|
||||
* changeIndicator?: array{state: string, previous_report_id: int}|null,
|
||||
* previousRunUrl?: string|null,
|
||||
* nextStepPlacement?: 'shared_zone'|'host_action_zone',
|
||||
* hostActions?: array<int, array{kind: string, label: string, ownedByHost: bool}>,
|
||||
* hostVariation?: array{
|
||||
* ownsNoRunState?: bool,
|
||||
* ownsActiveState?: bool,
|
||||
* supportsAssist?: bool,
|
||||
* supportsAcknowledge?: bool,
|
||||
* supportsTechnicalDetailsTrigger?: bool
|
||||
* },
|
||||
* optionalZones?: array<int, string>
|
||||
* } $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function surface(OperationRun $run, array $acknowledgements = [], array $options = []): array
|
||||
{
|
||||
$report = self::report($run);
|
||||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
$groupedChecks = self::groupedChecks($report, $acknowledgements);
|
||||
$changeIndicator = $options['changeIndicator'] ?? null;
|
||||
$hostKind = is_string($options['hostKind'] ?? null) && $options['hostKind'] !== ''
|
||||
? (string) $options['hostKind']
|
||||
: 'operation_run_detail';
|
||||
$nextStepPlacement = ($options['nextStepPlacement'] ?? 'shared_zone') === 'host_action_zone'
|
||||
? 'host_action_zone'
|
||||
: 'shared_zone';
|
||||
|
||||
$hostActions = collect($options['hostActions'] ?? [])
|
||||
->filter(static fn (mixed $action): bool => is_array($action))
|
||||
->map(static function (array $action): array {
|
||||
$kind = is_string($action['kind'] ?? null) ? trim((string) $action['kind']) : 'navigation';
|
||||
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : 'Action';
|
||||
|
||||
return [
|
||||
'kind' => $kind !== '' ? $kind : 'navigation',
|
||||
'label' => $label !== '' ? $label : 'Action',
|
||||
'ownedByHost' => (bool) ($action['ownedByHost'] ?? true),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$hostVariation = [
|
||||
'ownsNoRunState' => (bool) (($options['hostVariation']['ownsNoRunState'] ?? false)),
|
||||
'ownsActiveState' => (bool) (($options['hostVariation']['ownsActiveState'] ?? false)),
|
||||
'supportsAssist' => (bool) (($options['hostVariation']['supportsAssist'] ?? false)),
|
||||
'supportsAcknowledge' => (bool) (($options['hostVariation']['supportsAcknowledge'] ?? false)),
|
||||
'supportsTechnicalDetailsTrigger' => (bool) (($options['hostVariation']['supportsTechnicalDetailsTrigger'] ?? false)),
|
||||
];
|
||||
|
||||
$optionalZones = collect($options['optionalZones'] ?? ['technical_details', 'change_indicator', 'previous_run_context'])
|
||||
->filter(static fn (mixed $zone): bool => is_string($zone) && trim($zone) !== '')
|
||||
->map(static fn (string $zone): string => trim($zone))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$overall = $summary['overall'] ?? null;
|
||||
$overallSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall);
|
||||
|
||||
return [
|
||||
'hostKind' => $hostKind,
|
||||
'coreState' => $report === null ? 'unavailable' : 'completed',
|
||||
'summary' => [
|
||||
'overall' => $overall,
|
||||
'overallLabel' => $overallSpec->label,
|
||||
'counts' => [
|
||||
'total' => (int) ($counts['total'] ?? 0),
|
||||
'pass' => (int) ($counts['pass'] ?? 0),
|
||||
'fail' => (int) ($counts['fail'] ?? 0),
|
||||
'warn' => (int) ($counts['warn'] ?? 0),
|
||||
'skip' => (int) ($counts['skip'] ?? 0),
|
||||
'running' => (int) ($counts['running'] ?? 0),
|
||||
],
|
||||
'changeIndicator' => is_array($changeIndicator) ? $changeIndicator : null,
|
||||
],
|
||||
'issueGroups' => $groupedChecks['issueGroups'],
|
||||
'passedChecks' => $groupedChecks['passedChecks'],
|
||||
'diagnostics' => [
|
||||
'hasTechnicalZone' => true,
|
||||
'fingerprint' => is_array($report) ? self::fingerprint($report) : null,
|
||||
'previousRunUrl' => is_string($options['previousRunUrl'] ?? null) && $options['previousRunUrl'] !== ''
|
||||
? (string) $options['previousRunUrl']
|
||||
: null,
|
||||
'operationRunId' => (int) $run->getKey(),
|
||||
'flow' => (string) $run->type,
|
||||
'completedAt' => $run->completed_at?->toJSON(),
|
||||
],
|
||||
'viewZones' => [
|
||||
['key' => 'issues', 'label' => 'Issues', 'defaultVisible' => true],
|
||||
['key' => 'passed', 'label' => 'Passed', 'defaultVisible' => false],
|
||||
],
|
||||
'nextSteps' => self::nextSteps($groupedChecks['issueGroups'], $nextStepPlacement),
|
||||
'hostActions' => $hostActions,
|
||||
'hostVariation' => $hostVariation,
|
||||
'optionalZones' => $optionalZones,
|
||||
'emptyState' => $report === null
|
||||
? [
|
||||
'title' => 'Verification report unavailable',
|
||||
'message' => 'This operation doesn’t have a report yet. If it is still running, refresh in a moment. If it already completed, start verification again.',
|
||||
]
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $report
|
||||
* @param array<string, array<string, mixed>> $acknowledgements
|
||||
* @return array{issueGroups: array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}>, passedChecks: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private static function groupedChecks(?array $report, array $acknowledgements): array
|
||||
{
|
||||
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $acknowledgement) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($acknowledgement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $acknowledgement;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = is_string($check['status'] ?? null) ? strtolower(trim((string) $check['status'])) : '';
|
||||
$blocking = (bool) ($check['blocking'] ?? false);
|
||||
$normalizedCheck = self::normalizeCheck($check, $ackByKey[$key] ?? null);
|
||||
|
||||
if ($normalizedCheck['acknowledgement'] !== null) {
|
||||
$acknowledgedIssues[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'pass') {
|
||||
$passed[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'fail' && $blocking) {
|
||||
$blockers[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'fail') {
|
||||
$failures[] = $normalizedCheck;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'warn') {
|
||||
$warnings[] = $normalizedCheck;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static fn (array $left, array $right): int => strcmp((string) ($left['key'] ?? ''), (string) ($right['key'] ?? ''));
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
|
||||
return [
|
||||
'issueGroups' => array_values(array_filter([
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
['label' => 'Acknowledged issues', 'checks' => $acknowledgedIssues, 'acknowledged' => true],
|
||||
], static fn (array $group): bool => ($group['checks'] ?? []) !== [])),
|
||||
'passedChecks' => $passed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
* @param array<string, mixed>|null $acknowledgement
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeCheck(array $check, ?array $acknowledgement): array
|
||||
{
|
||||
$nextSteps = collect($check['next_steps'] ?? [])
|
||||
->filter(static fn (mixed $step): bool => is_array($step))
|
||||
->map(static function (array $step): array {
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
})
|
||||
->filter(static fn (array $step): bool => $step['label'] !== '' && $step['url'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'key' => is_string($check['key'] ?? null) ? trim((string) $check['key']) : '',
|
||||
'title' => is_string($check['title'] ?? null) && trim((string) $check['title']) !== ''
|
||||
? trim((string) $check['title'])
|
||||
: 'Check',
|
||||
'message' => is_string($check['message'] ?? null) && trim((string) $check['message']) !== ''
|
||||
? trim((string) $check['message'])
|
||||
: null,
|
||||
'status' => is_string($check['status'] ?? null) ? trim((string) $check['status']) : null,
|
||||
'severity' => is_string($check['severity'] ?? null) ? trim((string) $check['severity']) : null,
|
||||
'reason_code' => is_string($check['reason_code'] ?? null) ? trim((string) $check['reason_code']) : null,
|
||||
'blocking' => (bool) ($check['blocking'] ?? false),
|
||||
'next_steps' => $nextSteps,
|
||||
'acknowledgement' => is_array($acknowledgement) ? $acknowledgement : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}> $issueGroups
|
||||
* @return array<int, array{label: string, placement: string, ownedByHost: bool, actionKind: string|null}>
|
||||
*/
|
||||
private static function nextSteps(array $issueGroups, string $placement): array
|
||||
{
|
||||
$steps = [];
|
||||
|
||||
foreach ($issueGroups as $group) {
|
||||
foreach ($group['checks'] as $check) {
|
||||
foreach ($check['next_steps'] ?? [] as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
|
||||
if ($label === '' || array_key_exists($label, $steps)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$steps[$label] = [
|
||||
'label' => $label,
|
||||
'placement' => $placement,
|
||||
'ownedByHost' => $placement === 'host_action_zone',
|
||||
'actionKind' => $placement === 'host_action_zone' ? 'assist' : 'navigation',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $report
|
||||
* @return array<int, string>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -189,6 +190,12 @@ protected function getViewData(): array
|
||||
$report = $run instanceof OperationRun
|
||||
? VerificationReportViewer::report($run)
|
||||
: null;
|
||||
$changeIndicator = $run instanceof OperationRun
|
||||
? VerificationReportChangeIndicator::forRun($run)
|
||||
: null;
|
||||
$previousRunUrl = is_array($changeIndicator) && is_numeric($changeIndicator['previous_report_id'] ?? null)
|
||||
? OperationRunLinks::tenantlessView((int) $changeIndicator['previous_report_id'])
|
||||
: null;
|
||||
|
||||
$isInProgress = $run instanceof OperationRun
|
||||
&& (string) $run->status !== OperationRunStatus::Completed->value;
|
||||
@ -230,6 +237,20 @@ protected function getViewData(): array
|
||||
'runData' => $runData,
|
||||
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
|
||||
'report' => $report,
|
||||
'surface' => $run instanceof OperationRun
|
||||
? VerificationReportViewer::surface($run, [], [
|
||||
'hostKind' => 'tenant_widget',
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'hostVariation' => [
|
||||
'ownsNoRunState' => true,
|
||||
'ownsActiveState' => true,
|
||||
'supportsAssist' => false,
|
||||
'supportsAcknowledge' => false,
|
||||
'supportsTechnicalDetailsTrigger' => false,
|
||||
],
|
||||
])
|
||||
: [],
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
'isInProgress' => $isInProgress,
|
||||
'showStartAction' => ! ($run instanceof OperationRun) && $isTenantMember && $canOperate,
|
||||
|
||||
@ -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}
|
||||
*/
|
||||
|
||||
264
apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
Normal file
264
apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
final class GovernanceSubjectTaxonomyRegistry
|
||||
class GovernanceSubjectTaxonomyRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
|
||||
@ -260,7 +260,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
"public string \$status = 'missing';",
|
||||
"SelectFilter::make('status')",
|
||||
"'status' => ['value' => 'missing'],",
|
||||
"'status' => \$filters['status']['value'] ?? data_get(\$this->tableFilters, 'status.value'),",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
@ -272,7 +274,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
"public string \$type = 'all';",
|
||||
"SelectFilter::make('type')",
|
||||
"'type' => ['value' => 'all'],",
|
||||
"'type' => \$filters['type']['value'] ?? data_get(\$this->tableFilters, 'type.value'),",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
@ -284,7 +288,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
'public array $features = [];',
|
||||
"SelectFilter::make('features')",
|
||||
"'features' => ['values' => []],",
|
||||
"'features' => \$filters['features']['values'] ?? data_get(\$this->tableFilters, 'features.values', []),",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
@ -296,7 +302,9 @@ public function firstSlice(): array
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
"public string \$search = '';",
|
||||
'->searchable()',
|
||||
"'search' => \$search ?? \$this->tableSearch,",
|
||||
"\$this->tableSearch = '';",
|
||||
],
|
||||
notes: 'Filter-only state for the permissions view model.',
|
||||
),
|
||||
|
||||
@ -23,6 +23,18 @@ public function __construct(
|
||||
public array $filterPayload = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $payload
|
||||
*/
|
||||
public static function fromPayload(?array $payload): ?self
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromRequest(new Request(query: ['nav' => $payload]));
|
||||
}
|
||||
|
||||
public static function fromRequest(Request $request): ?self
|
||||
{
|
||||
$payload = $request->query('nav');
|
||||
@ -56,17 +68,19 @@ public static function fromRequest(Request $request): ?self
|
||||
public function toQuery(): array
|
||||
{
|
||||
$query = $this->filterPayload;
|
||||
$query['nav'] = array_filter([
|
||||
'source_surface' => $this->sourceSurface,
|
||||
'canonical_route_name' => $this->canonicalRouteName,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'back_label' => $this->backLinkLabel,
|
||||
'back_url' => $this->backLinkUrl,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
$query['nav'] = $this->navPayload();
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{nav: array<string, mixed>}
|
||||
*/
|
||||
public function navQuery(): array
|
||||
{
|
||||
return ['nav' => $this->navPayload()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
@ -93,4 +107,18 @@ public static function forBaselineCompareMatrix(
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function navPayload(): array
|
||||
{
|
||||
return array_filter([
|
||||
'source_surface' => $this->sourceSurface,
|
||||
'canonical_route_name' => $this->canonicalRouteName,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'back_label' => $this->backLinkLabel,
|
||||
'back_url' => $this->backLinkUrl,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,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>
|
||||
|
||||
@ -1,108 +1,38 @@
|
||||
@php
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
$redactionNotes = $redactionNotes ?? [];
|
||||
$redactionNotes = is_array($redactionNotes) ? array_values(array_filter($redactionNotes, 'is_string')) : [];
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
$surface = is_array($surface ?? null) ? $surface : [];
|
||||
$coreState = is_string($surface['coreState'] ?? null) ? (string) $surface['coreState'] : 'unavailable';
|
||||
$hostVariation = is_array($surface['hostVariation'] ?? null) ? $surface['hostVariation'] : [];
|
||||
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
|
||||
$showDiagnosticsZone = (bool) ($diagnostics['hasTechnicalZone'] ?? true)
|
||||
&& ! (bool) ($hostVariation['supportsTechnicalDetailsTrigger'] ?? false);
|
||||
$redactionNotes = is_array($redactionNotes ?? null)
|
||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||
: [];
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
$ackAction = $ackAction ?? null;
|
||||
$showAssist = (bool) ($showAssist ?? false);
|
||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||
? trim((string) $assistActionName)
|
||||
: 'wizardVerificationRequiredPermissionsAssist';
|
||||
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
|
||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div
|
||||
data-shared-detail-family="verification-report"
|
||||
data-host-kind="{{ (string) ($surface['hostKind'] ?? 'operation_run_detail') }}"
|
||||
class="space-y-4"
|
||||
>
|
||||
@if ($coreState === 'unavailable')
|
||||
<div
|
||||
data-shared-zone="unavailable"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
{{ $emptyState['title'] ?? 'Verification report unavailable' }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This operation doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||
{{ $emptyState['message'] ?? 'This operation does not have a report yet.' }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
@ -117,68 +47,10 @@
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
|
||||
@if ($redactionNotes !== [])
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
@foreach ($redactionNotes as $note)
|
||||
<div>{{ $note }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@include('filament.components.verification-report.summary', [
|
||||
'surface' => $surface,
|
||||
'redactionNotes' => $redactionNotes,
|
||||
])
|
||||
|
||||
<div x-data="{ tab: 'issues' }" class="space-y-4">
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
@ -196,313 +68,30 @@
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'technical'"
|
||||
x-on:click="tab = 'technical'"
|
||||
>
|
||||
Technical details
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@include('filament.components.verification-report.issues', [
|
||||
'surface' => $surface,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'ackAction' => $ackAction,
|
||||
'showAssist' => $showAssist,
|
||||
'assistActionName' => $assistActionName,
|
||||
'linkBehavior' => $linkBehavior,
|
||||
])
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'technical'" style="display: none;">
|
||||
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Identifiers
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@if ($run !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Operation ID:</span>
|
||||
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($fingerprint)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($previousRunUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $previousRunUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous operation
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@include('filament.components.verification-report.passed', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showDiagnosticsZone)
|
||||
@include('filament.components.verification-report.diagnostics', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
@php
|
||||
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-shared-zone="diagnostics"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Diagnostics
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Operation ID:</span>
|
||||
<span class="font-mono">{{ (int) ($diagnostics['operationRunId'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($diagnostics['flow'] ?? '') }}</span>
|
||||
</div>
|
||||
@if (filled($diagnostics['completedAt'] ?? null))
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
|
||||
<span>{{ $diagnostics['completedAt'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($diagnostics['fingerprint'] ?? null))
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $diagnostics['fingerprint'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($diagnostics['previousRunUrl'] ?? null))
|
||||
<div>
|
||||
<a
|
||||
href="{{ $diagnostics['previousRunUrl'] }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous operation
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,226 @@
|
||||
@php
|
||||
$issueGroups = collect($surface['issueGroups'] ?? [])
|
||||
->filter(static fn (mixed $group): bool => is_array($group))
|
||||
->values();
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
$ackAction = $ackAction ?? null;
|
||||
$showAssist = (bool) ($showAssist ?? false);
|
||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||
? trim((string) $assistActionName)
|
||||
: 'wizardVerificationRequiredPermissionsAssist';
|
||||
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="issues">
|
||||
@if ($issueGroups->isEmpty())
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = is_string($group['label'] ?? null) ? (string) $group['label'] : 'Issues';
|
||||
$checks = collect($group['checks'] ?? [])->filter(static fn (mixed $check): bool => is_array($check))->values();
|
||||
$acknowledged = (bool) ($group['acknowledged'] ?? false);
|
||||
@endphp
|
||||
|
||||
@if ($acknowledged)
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
|
||||
$message = is_string($check['message'] ?? null) && trim((string) $check['message']) !== '' ? trim((string) $check['message']) : null;
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
$acknowledgement = is_array($check['acknowledgement'] ?? null) ? $check['acknowledgement'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($acknowledgement)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if (filled($acknowledgement['ack_reason'] ?? null))
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $acknowledgement['ack_reason'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($acknowledgement['acknowledged_by']['name'] ?? null) || filled($acknowledgement['acknowledged_at'] ?? null))
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if (filled($acknowledgement['acknowledged_by']['name'] ?? null))
|
||||
{{ $acknowledgement['acknowledged_by']['name'] }}
|
||||
@endif
|
||||
@if (filled($acknowledgement['acknowledged_at'] ?? null))
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $acknowledgement['acknowledged_at'] }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (filled($acknowledgement['expires_at'] ?? null))
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $acknowledgement['expires_at'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
|
||||
$message = is_string($check['message'] ?? null) && trim((string) $check['message']) !== '' ? trim((string) $check['message']) : null;
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
$nextSteps = collect($check['next_steps'] ?? [])->filter(static fn (mixed $step): bool => is_array($step))->take(2)->values();
|
||||
$blocking = (bool) ($check['blocking'] ?? false);
|
||||
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
|
||||
{{ ($ackAction)(['check_key' => $checkKey]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps->isNotEmpty())
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
$testId = $label !== '' ? 'verification-next-step-'.\Illuminate\Support\Str::slug($label) : null;
|
||||
$behavior = $routeNextStepsThroughAssist
|
||||
? null
|
||||
: $linkBehavior->describe($label, $url);
|
||||
@endphp
|
||||
|
||||
@if ($label !== '' && $url !== '')
|
||||
<li>
|
||||
@if ($routeNextStepsThroughAssist)
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
wire:click="mountAction('{{ $assistActionName }}')"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Open in assist
|
||||
</span>
|
||||
</button>
|
||||
@else
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
@if ((bool) ($behavior['opens_in_new_tab'] ?? false))
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
@if ((bool) ($behavior['show_new_tab_hint'] ?? false))
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Opens in new tab
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,34 @@
|
||||
@php
|
||||
$passedChecks = collect($surface['passedChecks'] ?? [])
|
||||
->filter(static fn (mixed $check): bool => is_array($check))
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="passed">
|
||||
@if ($passedChecks->isEmpty())
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passedChecks as $check)
|
||||
@php
|
||||
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,64 @@
|
||||
@php
|
||||
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
$changeIndicator = is_array($summary['changeIndicator'] ?? null) ? $summary['changeIndicator'] : null;
|
||||
$redactionNotes = is_array($redactionNotes ?? null)
|
||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-shared-zone="summary"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if (($changeIndicator['state'] ?? null) === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif (($changeIndicator['state'] ?? null) === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
|
||||
@if ($redactionNotes !== [])
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
@foreach ($redactionNotes as $note)
|
||||
<div>{{ $note }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,70 +1,26 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$runUrl = $runUrl ?? null;
|
||||
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$advancedRunUrl = $advancedRunUrl ?? null;
|
||||
$advancedRunUrl = is_string($advancedRunUrl) && $advancedRunUrl !== '' ? $advancedRunUrl : null;
|
||||
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
|
||||
$assistVisibility = $assistVisibility ?? [];
|
||||
$assistVisibility = is_array($assistVisibility) ? $assistVisibility : [];
|
||||
|
||||
$assistActionName = $assistActionName ?? 'wizardVerificationRequiredPermissionsAssist';
|
||||
$assistActionName = is_string($assistActionName) && trim($assistActionName) !== ''
|
||||
? trim($assistActionName)
|
||||
$run = is_array($run ?? null) ? $run : null;
|
||||
$runUrl = is_string($runUrl ?? null) && trim((string) $runUrl) !== '' ? trim((string) $runUrl) : null;
|
||||
$surface = is_array($surface ?? null) ? $surface : [];
|
||||
$redactionNotes = is_array($redactionNotes ?? null)
|
||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||
: [];
|
||||
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||
? trim((string) $assistActionName)
|
||||
: 'wizardVerificationRequiredPermissionsAssist';
|
||||
|
||||
$technicalDetailsActionName = $technicalDetailsActionName ?? 'wizardVerificationTechnicalDetails';
|
||||
$technicalDetailsActionName = is_string($technicalDetailsActionName) && trim($technicalDetailsActionName) !== ''
|
||||
? trim($technicalDetailsActionName)
|
||||
$technicalDetailsActionName = is_string($technicalDetailsActionName ?? null) && trim((string) $technicalDetailsActionName) !== ''
|
||||
? trim((string) $technicalDetailsActionName)
|
||||
: 'wizardVerificationTechnicalDetails';
|
||||
|
||||
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
||||
$assistReason = $assistVisibility['reason'] ?? 'hidden_irrelevant';
|
||||
$assistReason = is_string($assistReason) ? $assistReason : 'hidden_irrelevant';
|
||||
|
||||
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
|
||||
$assistDescription = match ($assistReason) {
|
||||
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
|
||||
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
|
||||
default => 'Review required permissions without leaving onboarding.',
|
||||
};
|
||||
|
||||
$status = $run['status'] ?? null;
|
||||
$status = is_string($status) ? $status : null;
|
||||
|
||||
$outcome = $run['outcome'] ?? null;
|
||||
$outcome = is_string($outcome) ? $outcome : null;
|
||||
|
||||
$targetScope = $run['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = $run['failures'] ?? [];
|
||||
$failures = is_array($failures) ? $failures : [];
|
||||
|
||||
$completedAt = $run['completed_at'] ?? null;
|
||||
$completedAt = is_string($completedAt) && $completedAt !== '' ? $completedAt : null;
|
||||
|
||||
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
||||
$completedAtLabel = null;
|
||||
|
||||
if ($completedAt !== null) {
|
||||
@ -75,81 +31,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
$status = is_string($run['status'] ?? null) ? (string) $run['status'] : null;
|
||||
$runState = is_string($runState ?? null) ? (string) $runState : null;
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
|
||||
$runState = $run === null
|
||||
? 'no_run'
|
||||
: ($status === 'completed' ? 'completed' : 'active');
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
|
||||
$ackAction = null;
|
||||
|
||||
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
|
||||
@ -157,15 +47,6 @@
|
||||
}
|
||||
|
||||
$linkBehavior = app(\App\Support\Verification\VerificationLinkBehavior::class);
|
||||
|
||||
$runState = $runState ?? null;
|
||||
$runState = is_string($runState) ? $runState : null;
|
||||
|
||||
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
|
||||
$runState = $run === null
|
||||
? 'no_run'
|
||||
: ($status === 'completed' ? 'completed' : 'active');
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@ -211,65 +92,6 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
@php
|
||||
$overallSpec = $summary === null
|
||||
? null
|
||||
: \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($overallSpec)
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($runUrl)
|
||||
<x-filament::button
|
||||
@ -315,338 +137,15 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This operation doesn’t have a report yet. If it already completed, start verification again.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="{ tab: 'issues' }"
|
||||
class="space-y-4"
|
||||
>
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
alpine-active="tab === 'issues'"
|
||||
x-on:click="tab = 'issues'"
|
||||
>
|
||||
Issues
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'passed'"
|
||||
x-on:click="tab = 'passed'"
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
|
||||
{{ ($ackAction)(['check_key' => $checkKey]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$testId = is_string($label) && $label !== ''
|
||||
? 'verification-next-step-'.\Illuminate\Support\Str::slug($label)
|
||||
: null;
|
||||
$behavior = $routeNextStepsThroughAssist
|
||||
? null
|
||||
: $linkBehavior->describe(
|
||||
is_string($label) ? $label : null,
|
||||
is_string($url) ? $url : null,
|
||||
);
|
||||
$opensInNewTab = (bool) ($behavior['opens_in_new_tab'] ?? false);
|
||||
$showNewTabHint = (bool) ($behavior['show_new_tab_hint'] ?? false);
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
@if ($routeNextStepsThroughAssist)
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
wire:click="mountAction('{{ $assistActionName }}')"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Open in assist
|
||||
</span>
|
||||
</button>
|
||||
@else
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($testId)
|
||||
data-testid="{{ $testId }}"
|
||||
@endif
|
||||
@if ($opensInNewTab)
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
@endif
|
||||
>
|
||||
<span>{{ $label }}</span>
|
||||
@if ($showNewTabHint)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Opens in new tab
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('filament.components.verification-report-viewer', [
|
||||
'surface' => $surface,
|
||||
'redactionNotes' => $redactionNotes,
|
||||
'canAcknowledge' => (bool) ($canAcknowledge ?? false),
|
||||
'ackAction' => $ackAction,
|
||||
'showAssist' => $showAssist,
|
||||
'assistActionName' => $assistActionName,
|
||||
'linkBehavior' => $linkBehavior,
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
@ -1,787 +1,2 @@
|
||||
@php
|
||||
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
$summary = $diff['summary'] ?? [];
|
||||
$policyType = $diff['policy_type'] ?? null;
|
||||
|
||||
$groupByBlock = static function (array $items): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
[$group, $label] = $parts;
|
||||
} else {
|
||||
$group = 'Other';
|
||||
$label = $path;
|
||||
}
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
};
|
||||
|
||||
$stringify = static function (mixed $value): string {
|
||||
if ($value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||
};
|
||||
|
||||
$isExpandable = static function (mixed $value): bool {
|
||||
if (is_array($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && strlen($value) > 160;
|
||||
};
|
||||
|
||||
$isScriptKey = static function (mixed $name): bool {
|
||||
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
|
||||
};
|
||||
|
||||
$canHighlightScripts = static function (?string $policyType): bool {
|
||||
return (bool) config('tenantpilot.display.show_script_content', false)
|
||||
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
|
||||
};
|
||||
|
||||
$selectGrammar = static function (?string $policyType, string $code): string {
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
return 'zsh';
|
||||
}
|
||||
|
||||
if (str_contains($shebang, 'bash')) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'powershell';
|
||||
};
|
||||
|
||||
$highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: false,
|
||||
);
|
||||
|
||||
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
|
||||
|
||||
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[0] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$splitLines = static function (string $text): array {
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
return $text === '' ? [] : explode("\n", $text);
|
||||
};
|
||||
|
||||
$myersLineDiff = static function (array $a, array $b): array {
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
$max = $n + $m;
|
||||
|
||||
$v = [1 => 0];
|
||||
$trace = [];
|
||||
|
||||
for ($d = 0; $d <= $max; $d++) {
|
||||
$trace[$d] = $v;
|
||||
|
||||
for ($k = -$d; $k <= $d; $k += 2) {
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$x = $kPlus;
|
||||
} else {
|
||||
$x = $kMinus + 1;
|
||||
}
|
||||
|
||||
$y = $x - $k;
|
||||
|
||||
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
|
||||
$x++;
|
||||
$y++;
|
||||
}
|
||||
|
||||
$v[$k] = $x;
|
||||
|
||||
if ($x >= $n && $y >= $m) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ops = [];
|
||||
$x = $n;
|
||||
$y = $m;
|
||||
|
||||
for ($d = count($trace) - 1; $d >= 0; $d--) {
|
||||
$v = $trace[$d];
|
||||
$k = $x - $y;
|
||||
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$prevK = $k + 1;
|
||||
} else {
|
||||
$prevK = $k - 1;
|
||||
}
|
||||
|
||||
$prevX = $v[$prevK] ?? 0;
|
||||
$prevY = $prevX - $prevK;
|
||||
|
||||
while ($x > $prevX && $y > $prevY) {
|
||||
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
|
||||
$x--;
|
||||
$y--;
|
||||
}
|
||||
|
||||
if ($d === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($x === $prevX) {
|
||||
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
|
||||
$y--;
|
||||
} else {
|
||||
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
|
||||
$x--;
|
||||
}
|
||||
}
|
||||
|
||||
return array_reverse($ops);
|
||||
};
|
||||
|
||||
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
|
||||
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Normalized diff"
|
||||
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@foreach (['changed' => ['label' => 'Changed', 'collapsed' => false], 'added' => ['label' => 'Added', 'collapsed' => true], 'removed' => ['label' => 'Removed', 'collapsed' => true]] as $key => $meta)
|
||||
@php
|
||||
$items = $diff[$key] ?? [];
|
||||
$groups = $groupByBlock(is_array($items) ? $items : []);
|
||||
@endphp
|
||||
|
||||
@if ($groups !== [])
|
||||
<x-filament::section
|
||||
:heading="$meta['label']"
|
||||
collapsible
|
||||
:collapsed="$meta['collapsed']"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
@foreach ($groups as $group => $groupItems)
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $group }}
|
||||
</div>
|
||||
<x-filament::badge size="sm" color="gray">
|
||||
{{ count($groupItems) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
@foreach ($groupItems as $name => $value)
|
||||
<div class="px-4 py-3">
|
||||
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
|
||||
@php
|
||||
$from = $value['from'];
|
||||
$to = $value['to'];
|
||||
$fromText = $stringify($from);
|
||||
$toText = $stringify($to);
|
||||
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
|
||||
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
|
||||
|
||||
$rows = [];
|
||||
if ($isScriptContent) {
|
||||
$count = count($ops);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$op = $ops[$i];
|
||||
$next = $ops[$i + 1] ?? null;
|
||||
$type = $op['type'] ?? null;
|
||||
$line = (string) ($op['line'] ?? '');
|
||||
|
||||
if ($type === 'equal') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'equal', 'line' => $line],
|
||||
'right' => ['type' => 'equal', 'line' => $line],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
|
||||
];
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'blank', 'line' => ''],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'blank', 'line' => ''],
|
||||
'right' => ['type' => 'insert', 'line' => $line],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
|
||||
@if ($isScriptContent)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
|
||||
<details class="mt-1" x-data="{ fullscreenOpen: false }">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
|
||||
⤢ Fullscreen
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="fullscreenOpen"
|
||||
x-cloak
|
||||
x-on:keydown.escape.window="fullscreenOpen = false"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50"></div>
|
||||
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
|
||||
Close
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
<div
|
||||
x-data="{
|
||||
tab: 'diff',
|
||||
syncing: false,
|
||||
syncHorizontal: true,
|
||||
sync(from, to) {
|
||||
if (this.syncing) return;
|
||||
this.syncing = true;
|
||||
|
||||
to.scrollTop = from.scrollTop;
|
||||
|
||||
const bothHorizontal = this.syncHorizontal
|
||||
&& from.scrollWidth > from.clientWidth
|
||||
&& to.scrollWidth > to.clientWidth;
|
||||
|
||||
if (bothHorizontal) {
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => { this.syncing = false; });
|
||||
},
|
||||
}"
|
||||
x-init="$nextTick(() => {
|
||||
const left = $refs.left;
|
||||
const right = $refs.right;
|
||||
|
||||
if (!left || !right) return;
|
||||
|
||||
left.addEventListener('scroll', () => sync(left, right), { passive: true });
|
||||
right.addEventListener('scroll', () => sync(right, left), { passive: true });
|
||||
})"
|
||||
class="h-full space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre x-ref="left" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre x-ref="right" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
|
||||
@if ($isExpandable($from))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
|
||||
@if ($isExpandable($to))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $toText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$text = $stringify($value);
|
||||
@endphp
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
|
||||
@if ($isExpandable($value))
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
@php
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlighted) && $highlighted !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
|
||||
@endif
|
||||
</details>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- NormalizedDiffSurface normalized-diff wrapper --}}
|
||||
@include('filament.infolists.entries.normalized-diff.wrapper', ['state' => $getState() ?? []])
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
@php
|
||||
$emptyState = is_array($emptyState ?? null) ? $emptyState : [];
|
||||
$availabilityState = is_string($availabilityState ?? null) ? (string) $availabilityState : 'available';
|
||||
$toneClasses = match ($availabilityState) {
|
||||
'unavailable' => 'border-danger-200 bg-danger-50 text-danger-900 dark:border-danger-500/30 dark:bg-danger-500/10 dark:text-danger-100',
|
||||
'partial' => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
|
||||
default => 'border-gray-200 bg-white text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-200',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="empty" class="rounded-lg border px-4 py-3 text-sm {{ $toneClasses }}">
|
||||
<div class="font-medium">
|
||||
{{ $emptyState['title'] ?? 'No normalized changes' }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ $emptyState['message'] ?? 'No normalized changes were found.' }}
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,772 @@
|
||||
@php
|
||||
$diff = is_array($surface['raw'] ?? null) ? $surface['raw'] : ['changed' => [], 'added' => [], 'removed' => []];
|
||||
$groupMeta = collect($surface['groups'] ?? [])
|
||||
->filter(static fn (mixed $group): bool => is_array($group))
|
||||
->values();
|
||||
$policyType = is_string($surface['scriptRendering']['policyType'] ?? null) ? (string) $surface['scriptRendering']['policyType'] : null;
|
||||
|
||||
$groupByBlock = static function (array $items): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
[$group, $label] = $parts;
|
||||
} else {
|
||||
$group = 'Other';
|
||||
$label = $path;
|
||||
}
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
};
|
||||
|
||||
$stringify = static function (mixed $value): string {
|
||||
if ($value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||
};
|
||||
|
||||
$isExpandable = static function (mixed $value): bool {
|
||||
if (is_array($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && strlen($value) > 160;
|
||||
};
|
||||
|
||||
$isScriptKey = static function (mixed $name): bool {
|
||||
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
|
||||
};
|
||||
|
||||
$canHighlightScripts = static function (?string $policyType): bool {
|
||||
return (bool) config('tenantpilot.display.show_script_content', false)
|
||||
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
|
||||
};
|
||||
|
||||
$selectGrammar = static function (?string $policyType, string $code): string {
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
return 'zsh';
|
||||
}
|
||||
|
||||
if (str_contains($shebang, 'bash')) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
return 'powershell';
|
||||
};
|
||||
|
||||
$highlight = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
|
||||
if (! class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $selectGrammar($policyType, $code),
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: false,
|
||||
);
|
||||
|
||||
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
|
||||
|
||||
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[0] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$splitLines = static function (string $text): array {
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
return $text === '' ? [] : explode("\n", $text);
|
||||
};
|
||||
|
||||
$myersLineDiff = static function (array $a, array $b): array {
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
$max = $n + $m;
|
||||
$v = [1 => 0];
|
||||
$trace = [];
|
||||
|
||||
for ($d = 0; $d <= $max; $d++) {
|
||||
$trace[$d] = $v;
|
||||
|
||||
for ($k = -$d; $k <= $d; $k += 2) {
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$x = $kPlus;
|
||||
} else {
|
||||
$x = $kMinus + 1;
|
||||
}
|
||||
|
||||
$y = $x - $k;
|
||||
|
||||
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
|
||||
$x++;
|
||||
$y++;
|
||||
}
|
||||
|
||||
$v[$k] = $x;
|
||||
|
||||
if ($x >= $n && $y >= $m) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ops = [];
|
||||
$x = $n;
|
||||
$y = $m;
|
||||
|
||||
for ($d = count($trace) - 1; $d >= 0; $d--) {
|
||||
$v = $trace[$d];
|
||||
$k = $x - $y;
|
||||
|
||||
$kPlus = $v[$k + 1] ?? 0;
|
||||
$kMinus = $v[$k - 1] ?? 0;
|
||||
|
||||
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
|
||||
$prevK = $k + 1;
|
||||
} else {
|
||||
$prevK = $k - 1;
|
||||
}
|
||||
|
||||
$prevX = $v[$prevK] ?? 0;
|
||||
$prevY = $prevX - $prevK;
|
||||
|
||||
while ($x > $prevX && $y > $prevY) {
|
||||
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
|
||||
$x--;
|
||||
$y--;
|
||||
}
|
||||
|
||||
if ($d === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($x === $prevX) {
|
||||
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
|
||||
$y--;
|
||||
} else {
|
||||
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
|
||||
$x--;
|
||||
}
|
||||
}
|
||||
|
||||
return array_reverse($ops);
|
||||
};
|
||||
|
||||
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
|
||||
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="groups" class="space-y-4">
|
||||
@foreach ($groupMeta as $group)
|
||||
@php
|
||||
$key = is_string($group['key'] ?? null) ? (string) $group['key'] : 'changed';
|
||||
$label = is_string($group['label'] ?? null) ? (string) $group['label'] : ucfirst($key);
|
||||
$collapsed = (bool) ($group['collapsed'] ?? false);
|
||||
$items = is_array($diff[$key] ?? null) ? $diff[$key] : [];
|
||||
$groups = $groupByBlock($items);
|
||||
@endphp
|
||||
|
||||
@if ($groups !== [])
|
||||
<x-filament::section
|
||||
:heading="$label"
|
||||
collapsible
|
||||
:collapsed="$collapsed"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
@foreach ($groups as $groupLabel => $groupItems)
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $groupLabel }}
|
||||
</div>
|
||||
<x-filament::badge size="sm" color="gray">
|
||||
{{ count($groupItems) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
@foreach ($groupItems as $name => $value)
|
||||
<div class="px-4 py-3">
|
||||
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
|
||||
@php
|
||||
$from = $value['from'];
|
||||
$to = $value['to'];
|
||||
$fromText = $stringify($from);
|
||||
$toText = $stringify($to);
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
|
||||
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
|
||||
|
||||
$rows = [];
|
||||
if ($isScriptContent) {
|
||||
$count = count($ops);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$op = $ops[$i];
|
||||
$next = $ops[$i + 1] ?? null;
|
||||
$type = $op['type'] ?? null;
|
||||
$line = (string) ($op['line'] ?? '');
|
||||
|
||||
if ($type === 'equal') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'equal', 'line' => $line],
|
||||
'right' => ['type' => 'equal', 'line' => $line],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
|
||||
];
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'delete') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'delete', 'line' => $line],
|
||||
'right' => ['type' => 'blank', 'line' => ''],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'insert') {
|
||||
$rows[] = [
|
||||
'left' => ['type' => 'blank', 'line' => ''],
|
||||
'right' => ['type' => 'insert', 'line' => $line],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
|
||||
@if ($isScriptContent)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
|
||||
<details class="mt-1" x-data="{ fullscreenOpen: false }">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
|
||||
⤢ Fullscreen
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="fullscreenOpen"
|
||||
x-cloak
|
||||
x-on:keydown.escape.window="fullscreenOpen = false"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50"></div>
|
||||
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
|
||||
Close
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
<div
|
||||
x-data="{
|
||||
tab: 'diff',
|
||||
syncing: false,
|
||||
syncHorizontal: true,
|
||||
sync(from, to) {
|
||||
if (this.syncing) return;
|
||||
this.syncing = true;
|
||||
to.scrollTop = from.scrollTop;
|
||||
|
||||
const bothHorizontal = this.syncHorizontal
|
||||
&& from.scrollWidth > from.clientWidth
|
||||
&& to.scrollWidth > to.clientWidth;
|
||||
|
||||
if (bothHorizontal) {
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => { this.syncing = false; });
|
||||
},
|
||||
}"
|
||||
x-init="$nextTick(() => {
|
||||
const left = $refs.left;
|
||||
const right = $refs.right;
|
||||
|
||||
if (! left || ! right) {
|
||||
return;
|
||||
}
|
||||
|
||||
left.addEventListener('scroll', () => sync(left, right), { passive: true });
|
||||
right.addEventListener('scroll', () => sync(right, left), { passive: true });
|
||||
})"
|
||||
class="h-full space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre x-ref="left" class="mt-2 flex-1 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$left = $row['left'];
|
||||
$leftType = $left['type'];
|
||||
$leftLine = (string) ($left['line'] ?? '');
|
||||
|
||||
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
|
||||
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
|
||||
|
||||
if ($leftType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre x-ref="right" class="mt-2 flex-1 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
|
||||
foreach ($rows as $row) {
|
||||
$right = $row['right'];
|
||||
$rightType = $right['type'];
|
||||
$rightLine = (string) ($right['line'] ?? '');
|
||||
|
||||
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
|
||||
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
|
||||
|
||||
if ($rightType === 'equal') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
|
||||
@if ($isExpandable($from))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
|
||||
@if ($isExpandable($to))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $toText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$text = $stringify($value);
|
||||
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
|
||||
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
|
||||
@if ($isExpandable($value))
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
@if (is_string($highlighted) && $highlighted !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
|
||||
@endif
|
||||
</details>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@ -0,0 +1,30 @@
|
||||
@php
|
||||
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
|
||||
$availabilityState = is_string($surface['availabilityState'] ?? null) ? (string) $surface['availabilityState'] : 'available';
|
||||
@endphp
|
||||
|
||||
<div data-shared-zone="summary">
|
||||
<x-filament::section heading="Normalized diff">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($availabilityState === 'unavailable')
|
||||
<x-filament::badge color="danger">
|
||||
Unavailable
|
||||
</x-filament::badge>
|
||||
@elseif ($availabilityState === 'partial')
|
||||
<x-filament::badge color="warning">
|
||||
Partial
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@ -0,0 +1,47 @@
|
||||
@php
|
||||
$state = is_array($state ?? null) ? $state : [];
|
||||
|
||||
$surface = array_key_exists('renderExpectations', $state)
|
||||
? $state
|
||||
: \App\Filament\Support\NormalizedDiffSurface::build($state, 'unknown');
|
||||
|
||||
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
|
||||
$availabilityState = is_string($surface['availabilityState'] ?? null) ? (string) $surface['availabilityState'] : 'available';
|
||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
||||
$hasChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
|
||||
|
||||
if ($emptyState === null && ! $hasChanges && $availabilityState === 'available') {
|
||||
$message = is_string($summary['message'] ?? null) && trim((string) $summary['message']) !== ''
|
||||
? trim((string) $summary['message'])
|
||||
: 'No normalized changes were found.';
|
||||
|
||||
$emptyState = [
|
||||
'title' => 'No normalized changes',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="space-y-4"
|
||||
data-shared-detail-family="normalized-diff"
|
||||
data-shared-normalized-diff-host="{{ $surface['hostKind'] ?? 'unknown' }}"
|
||||
data-shared-normalized-diff-state="{{ $availabilityState }}"
|
||||
>
|
||||
@include('filament.infolists.entries.normalized-diff.summary', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
|
||||
@if ($emptyState !== null)
|
||||
@include('filament.infolists.entries.normalized-diff.empty-state', [
|
||||
'emptyState' => $emptyState,
|
||||
'availabilityState' => $availabilityState,
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($hasChanges)
|
||||
@include('filament.infolists.entries.normalized-diff.groups', [
|
||||
'surface' => $surface,
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
@ -1,233 +1,2 @@
|
||||
@php
|
||||
$normalized = $getState() ?? [];
|
||||
$warnings = $normalized['warnings'] ?? [];
|
||||
$settings = $normalized['settings'] ?? [];
|
||||
$settingsTable = $normalized['settings_table'] ?? null;
|
||||
$settingsTableRows = is_array($settingsTable) ? ($settingsTable['rows'] ?? []) : [];
|
||||
$context = $normalized['context'] ?? 'policy';
|
||||
$recordId = $normalized['record_id'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@if (! empty($warnings))
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<div class="font-semibold">Warnings</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
@foreach ($warnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (empty($settings) && empty($settingsTableRows))
|
||||
<p class="text-sm text-gray-600">No settings available.</p>
|
||||
@endif
|
||||
|
||||
@if (! empty($settingsTableRows))
|
||||
@php
|
||||
$settingsTableTitle = is_array($settingsTable) ? ($settingsTable['title'] ?? null) : null;
|
||||
$shouldShowTitle = is_string($settingsTableTitle)
|
||||
&& $settingsTableTitle !== ''
|
||||
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2">
|
||||
@if ($shouldShowTitle)
|
||||
<div class="text-sm font-semibold text-gray-800">{{ $settingsTableTitle }}</div>
|
||||
@endif
|
||||
|
||||
<livewire:settings-catalog-settings-table
|
||||
:settings-rows="$settingsTableRows"
|
||||
:context="$context"
|
||||
:key="$recordId ? ('sc-settings-'.$context.'-'.$recordId) : ('sc-settings-'.$context)"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach ($settings as $block)
|
||||
@php
|
||||
$title = $block['title'] ?? 'Settings';
|
||||
$isGeneral = is_string($title) && strtolower($title) === 'general';
|
||||
@endphp
|
||||
|
||||
@if ($isGeneral)
|
||||
<x-filament::section
|
||||
:heading="$title"
|
||||
collapsible
|
||||
:collapsed="true"
|
||||
data-block="general"
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['entries'] ?? []) }} fields
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
@if (($block['type'] ?? 'keyValue') === 'table')
|
||||
@php
|
||||
$columns = $block['columns'] ?? null;
|
||||
$hasColumns = is_array($columns) && ! empty($columns);
|
||||
$columnMeta = [
|
||||
'definitionId' => ['width' => 'w-[35%]', 'style' => 'width: 35%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'instanceType' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'value' => ['width' => 'w-[25%]', 'style' => 'width: 25%;', 'cell' => 'break-words whitespace-pre-wrap', 'cellStyle' => 'overflow-wrap: anywhere; white-space: pre-wrap;'],
|
||||
'path' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
];
|
||||
@endphp
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200" style="overflow-x: auto;">
|
||||
<table class="min-w-[900px] w-full table-fixed text-left text-sm" style="table-layout: fixed; width: 100%; min-width: 900px;">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<th class="px-3 py-2 {{ $meta['width'] ?? '' }}" style="{{ $meta['style'] ?? '' }}">{{ $column['label'] ?? $column['key'] ?? '-' }}</th>
|
||||
@endforeach
|
||||
@else
|
||||
<th class="px-3 py-2">Path</th>
|
||||
<th class="px-3 py-2">Value</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($block['rows'] ?? [] as $row)
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$cell = is_string($key) ? ($row[$key] ?? null) : null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<td class="px-3 py-2 align-top text-gray-800 {{ $meta['cell'] ?? 'whitespace-pre-wrap' }}" style="{{ $meta['cellStyle'] ?? '' }}">
|
||||
@if (is_array($cell))
|
||||
<pre class="overflow-x-auto text-xs">{{ json_encode($cell, JSON_PRETTY_PRINT) }}</pre>
|
||||
@elseif (is_bool($cell))
|
||||
<span>{{ $cell ? 'true' : 'false' }}</span>
|
||||
@else
|
||||
<span title="{{ is_string($cell) ? $cell : '' }}">{{ $cell ?? '-' }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@else
|
||||
<td class="px-3 py-2 align-top">
|
||||
<div class="font-mono text-xs font-medium text-gray-800 break-all whitespace-normal" style="word-break: break-all; overflow-wrap: anywhere; white-space: normal;">{{ $row['path'] ?? '-' }}</div>
|
||||
@if (! empty($row['label']))
|
||||
<div class="text-xs text-gray-600">{{ $row['label'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top text-gray-800 break-words whitespace-pre-wrap" style="overflow-wrap: anywhere; white-space: pre-wrap;">
|
||||
{{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($block['entries'] ?? [] as $entry)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] ?? '-' }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words">
|
||||
{{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@else
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="text-sm font-semibold text-gray-800">{{ $title }}</div>
|
||||
|
||||
@if (($block['type'] ?? 'keyValue') === 'table')
|
||||
@php
|
||||
$columns = $block['columns'] ?? null;
|
||||
$hasColumns = is_array($columns) && ! empty($columns);
|
||||
$columnMeta = [
|
||||
'definitionId' => ['width' => 'w-[35%]', 'style' => 'width: 35%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'instanceType' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
'value' => ['width' => 'w-[25%]', 'style' => 'width: 25%;', 'cell' => 'break-words whitespace-pre-wrap', 'cellStyle' => 'overflow-wrap: anywhere; white-space: pre-wrap;'],
|
||||
'path' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
|
||||
];
|
||||
@endphp
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200" style="overflow-x: auto;">
|
||||
<table class="min-w-[900px] w-full table-fixed text-left text-sm" style="table-layout: fixed; width: 100%; min-width: 900px;">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<th class="px-3 py-2 {{ $meta['width'] ?? '' }}" style="{{ $meta['style'] ?? '' }}">{{ $column['label'] ?? $column['key'] ?? '-' }}</th>
|
||||
@endforeach
|
||||
@else
|
||||
<th class="px-3 py-2">Path</th>
|
||||
<th class="px-3 py-2">Value</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($block['rows'] ?? [] as $row)
|
||||
<tr>
|
||||
@if ($hasColumns)
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$key = $column['key'] ?? null;
|
||||
$cell = is_string($key) ? ($row[$key] ?? null) : null;
|
||||
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
|
||||
@endphp
|
||||
<td class="px-3 py-2 align-top text-gray-800 {{ $meta['cell'] ?? 'whitespace-pre-wrap' }}" style="{{ $meta['cellStyle'] ?? '' }}">
|
||||
@if (is_array($cell))
|
||||
<pre class="overflow-x-auto text-xs">{{ json_encode($cell, JSON_PRETTY_PRINT) }}</pre>
|
||||
@elseif (is_bool($cell))
|
||||
<span>{{ $cell ? 'true' : 'false' }}</span>
|
||||
@else
|
||||
<span title="{{ is_string($cell) ? $cell : '' }}">{{ $cell ?? '-' }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@else
|
||||
<td class="px-3 py-2 align-top">
|
||||
<div class="font-mono text-xs font-medium text-gray-800 break-all whitespace-normal" style="word-break: break-all; overflow-wrap: anywhere; white-space: normal;">{{ $row['path'] ?? '-' }}</div>
|
||||
@if (! empty($row['label']))
|
||||
<div class="text-xs text-gray-600">{{ $row['label'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top text-gray-800 break-words whitespace-pre-wrap" style="overflow-wrap: anywhere; white-space: pre-wrap;">
|
||||
{{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
@foreach ($block['entries'] ?? [] as $entry)
|
||||
<div class="rounded border border-gray-100 bg-gray-50 p-3">
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">{{ $entry['key'] ?? '-' }}</dt>
|
||||
<dd class="whitespace-pre-wrap text-sm text-gray-800">
|
||||
{{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- NormalizedSettingsSurface normalized-settings wrapper --}}
|
||||
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
@php
|
||||
$settingsTable = is_array($settingsTable ?? null) ? $settingsTable : null;
|
||||
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
|
||||
$context = is_string($context ?? null) && $context !== '' ? $context : 'policy';
|
||||
$recordId = is_scalar($recordId ?? null) ? (string) $recordId : null;
|
||||
$settingsTableTitle = is_string($settingsTable['title'] ?? null) ? $settingsTable['title'] : null;
|
||||
$shouldShowTitle = is_string($settingsTableTitle)
|
||||
&& $settingsTableTitle !== ''
|
||||
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2" data-shared-zone="settings-table">
|
||||
@if ($shouldShowTitle)
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $settingsTableTitle }}</div>
|
||||
@endif
|
||||
|
||||
<livewire:settings-catalog-settings-table
|
||||
:settings-rows="$settingsTableRows"
|
||||
:context="$context"
|
||||
:key="$recordId ? ('sc-settings-'.$context.'-'.$recordId) : ('sc-settings-'.$context)"
|
||||
/>
|
||||
</div>
|
||||
@ -0,0 +1,273 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$blocks = is_array($blocks ?? null) ? $blocks : [];
|
||||
$policyType = is_string($policyType ?? null) ? $policyType : null;
|
||||
|
||||
$stringifyValue = function (mixed $value): string {
|
||||
if (is_null($value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (method_exists($value, '__toString')) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
$shouldRenderBadges = function (mixed $value): bool {
|
||||
if (! is_array($value) || $value === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (! is_scalar($item) && ! is_null($item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
|
||||
return $spec->label === 'Unknown' ? null : $spec;
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4" data-shared-zone="blocks">
|
||||
@foreach ($blocks as $block)
|
||||
@php
|
||||
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@if ($blockType === 'table')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['rows'] ?? []) }} {{ Str::plural('item', count($block['rows'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($block['rows'] ?? [] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
|
||||
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
|
||||
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
|
||||
<p class="mt-0.5 break-all text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
{{ (string) $row['path'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (! empty($row['description']))
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ Str::limit($row['description'], 80) }}</p>
|
||||
@endif
|
||||
</dt>
|
||||
<dd class="mt-1 sm:col-span-2 sm:mt-0">
|
||||
@php
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if ($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif (is_numeric($row['value'] ?? null))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
@elseif ($shouldRenderBadges($row['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach (($row['value'] ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if ($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="break-words text-sm text-gray-900 dark:text-white">
|
||||
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@elseif ($blockType === 'keyValue')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['entries'] ?? []) }} {{ Str::plural('entry', count($block['entries'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($block['entries'] ?? [] as $entry)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] ?? 'Setting' }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:col-span-2 sm:mt-0">
|
||||
@php
|
||||
$rawValue = $entry['value'] ?? null;
|
||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
|
||||
@endphp
|
||||
|
||||
@if ($isScriptContent)
|
||||
@php
|
||||
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
$grammar = 'powershell';
|
||||
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
$grammar = 'zsh';
|
||||
} elseif (str_contains($shebang, 'bash')) {
|
||||
$grammar = 'bash';
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
|
||||
$grammar = 'powershell';
|
||||
}
|
||||
|
||||
$highlightedHtml = null;
|
||||
|
||||
if (class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
try {
|
||||
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $grammar,
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$highlightedHtml = null;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div x-data="{ open: false }" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button
|
||||
size="xs"
|
||||
color="gray"
|
||||
type="button"
|
||||
x-on:click="open = !open"
|
||||
>
|
||||
<span x-cloak x-show="!open">Show</span>
|
||||
<span x-cloak x-show="open">Hide</span>
|
||||
</x-filament::button>
|
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ number_format(Str::length($code)) }} chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="open">
|
||||
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
|
||||
@else
|
||||
<pre class="whitespace-pre-wrap break-words text-xs font-mono text-gray-900 dark:text-white">{{ $code }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($shouldRenderBadges($rawValue))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach (($rawValue ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if ($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="break-words text-sm text-gray-900 dark:text-white">
|
||||
{{ Str::limit($stringifyValue($rawValue), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@ -0,0 +1,56 @@
|
||||
@php
|
||||
$state = is_array($state ?? null) ? $state : [];
|
||||
|
||||
$surface = array_key_exists('renderExpectations', $state)
|
||||
? $state
|
||||
: \App\Filament\Support\NormalizedSettingsSurface::build(
|
||||
$state,
|
||||
($state['context'] ?? 'policy') === 'policy' ? 'policy' : 'policy_version'
|
||||
);
|
||||
|
||||
$warnings = is_array($surface['warnings'] ?? null) ? $surface['warnings'] : [];
|
||||
$settingsTable = is_array($surface['settingsTable'] ?? null) ? $surface['settingsTable'] : null;
|
||||
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
|
||||
$blocks = is_array($surface['blocks'] ?? null) ? $surface['blocks'] : [];
|
||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="space-y-4"
|
||||
data-shared-detail-family="normalized-settings"
|
||||
data-shared-normalized-settings-host="{{ $surface['hostKind'] ?? 'unknown' }}"
|
||||
data-shared-normalized-settings-variant="{{ $surface['variant'] ?? 'standard_blocks' }}"
|
||||
>
|
||||
@if ($warnings !== [])
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800" data-shared-zone="warnings">
|
||||
<div class="font-semibold">Warnings</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
@foreach ($warnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($settingsTableRows !== [])
|
||||
@include('filament.infolists.entries.normalized-settings.catalog-table', [
|
||||
'settingsTable' => $settingsTable,
|
||||
'context' => $surface['context'] ?? 'policy',
|
||||
'recordId' => $surface['recordId'] ?? null,
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($blocks !== [])
|
||||
@include('filament.infolists.entries.normalized-settings.standard-blocks', [
|
||||
'blocks' => $blocks,
|
||||
'policyType' => $surface['policyType'] ?? null,
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($settingsTableRows === [] && $blocks === [] && $emptyState !== null)
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-8 text-center dark:border-white/15 dark:bg-gray-900/40" data-shared-zone="empty">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $emptyState['title'] ?? 'No settings available.' }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['message'] ?? 'No normalized settings payload is available for this host.' }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,355 +1,2 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// Extract state from Filament ViewEntry
|
||||
$state = $getState();
|
||||
$status = $state['status'] ?? 'success';
|
||||
$warnings = $state['warnings'] ?? [];
|
||||
$settings = $state['settings'] ?? [];
|
||||
$settingsTable = $state['settings_table'] ?? null;
|
||||
|
||||
$policyType = $state['policy_type'] ?? null;
|
||||
|
||||
$stringifyValue = function (mixed $value): string {
|
||||
if (is_null($value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (method_exists($value, '__toString')) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($encoded) ? $encoded : 'N/A';
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
$shouldRenderBadges = function (mixed $value): bool {
|
||||
if (! is_array($value) || $value === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (! is_scalar($item) && ! is_null($item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
|
||||
return $spec->label === 'Unknown' ? null : $spec;
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Warnings --}}
|
||||
@if(!empty($warnings))
|
||||
<x-filament::section>
|
||||
<div class="space-y-2">
|
||||
@foreach($warnings as $warning)
|
||||
<div class="flex items-start gap-2 text-sm text-warning-600 dark:text-warning-400">
|
||||
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<span>{{ $warning }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Settings Table (for Settings Catalog legacy format) --}}
|
||||
@if($settingsTable && !empty($settingsTable['rows']))
|
||||
<x-filament::section
|
||||
:heading="$settingsTable['title'] ?? 'Settings'"
|
||||
:description="$settingsTable['description'] ?? null"
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($settingsTable['rows']) }} {{ Str::plural('setting', count($settingsTable['rows'])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($settingsTable['rows'] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $row['definition'] ?? $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
@php
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
|
||||
@else
|
||||
{{ $row['value'] ?? 'N/A' }}
|
||||
@endif
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}}
|
||||
@foreach($settings as $block)
|
||||
@php
|
||||
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@if($blockType === 'table')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['rows'] ?? []) }} {{ Str::plural('item', count($block['rows'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($block['rows'] ?? [] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
|
||||
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
|
||||
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
|
||||
<p class="mt-0.5 text-xs font-mono text-gray-400 dark:text-gray-500 break-all">
|
||||
{{ (string) $row['path'] }}
|
||||
</p>
|
||||
@endif
|
||||
@if(!empty($row['description']))
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
|
||||
@endif
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@php
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
@elseif($shouldRenderBadges($row['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($row['value'] ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@elseif($blockType === 'keyValue')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ count($block['entries'] ?? []) }} {{ Str::plural('entry', count($block['entries'] ?? [])) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($block['entries'] ?? [] as $entry)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['key'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@php
|
||||
$rawValue = $entry['value'] ?? null;
|
||||
|
||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
|
||||
@endphp
|
||||
|
||||
@if($isScriptContent)
|
||||
@php
|
||||
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
|
||||
$firstLine = strtok($code, "\n") ?: '';
|
||||
|
||||
$grammar = 'powershell';
|
||||
|
||||
if ($policyType === 'deviceShellScript') {
|
||||
$shebang = trim($firstLine);
|
||||
|
||||
if (str_starts_with($shebang, '#!')) {
|
||||
if (str_contains($shebang, 'zsh')) {
|
||||
$grammar = 'zsh';
|
||||
} elseif (str_contains($shebang, 'bash')) {
|
||||
$grammar = 'bash';
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} else {
|
||||
$grammar = 'sh';
|
||||
}
|
||||
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
|
||||
$grammar = 'powershell';
|
||||
}
|
||||
|
||||
$highlightedHtml = null;
|
||||
|
||||
if (class_exists(\Torchlight\Engine\Engine::class)) {
|
||||
try {
|
||||
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
|
||||
code: $code,
|
||||
grammar: $grammar,
|
||||
theme: [
|
||||
'light' => 'github-light',
|
||||
'dark' => 'github-dark',
|
||||
],
|
||||
withGutter: false,
|
||||
withWrapper: true,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$highlightedHtml = null;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div x-data="{ open: false }" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button
|
||||
size="xs"
|
||||
color="gray"
|
||||
type="button"
|
||||
x-on:click="open = !open"
|
||||
>
|
||||
<span x-show="!open" x-cloak>Show</span>
|
||||
<span x-show="open" x-cloak>Hide</span>
|
||||
</x-filament::button>
|
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ number_format(Str::length($code)) }} chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-cloak>
|
||||
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
|
||||
@else
|
||||
<pre class="text-xs font-mono text-gray-900 dark:text-white whitespace-pre-wrap break-words">{{ $code }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif($shouldRenderBadges($rawValue))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($rawValue ?? []) as $item)
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($rawValue), 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Empty state --}}
|
||||
@if(empty($settings) && (!$settingsTable || empty($settingsTable['rows'])))
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
No settings data available
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
This policy may not contain settings, or they are in an unsupported format
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
{{-- NormalizedSettingsSurface policy-settings-standard compatibility wrapper --}}
|
||||
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])
|
||||
|
||||
@ -29,6 +29,20 @@
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if (filled($openCompareMatrixUrl ?? null))
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Launch the compare matrix with the currently known baseline profile and any carried subject focus from this tenant landing.
|
||||
</div>
|
||||
|
||||
<x-filament::button tag="a" :href="$openCompareMatrixUrl" color="gray" icon="heroicon-o-squares-2x2">
|
||||
Open compare matrix
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($arrivedFromCompareMatrix)
|
||||
<x-filament::section>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@ -265,7 +265,7 @@
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
|
||||
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
|
||||
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
|
||||
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix. Refreshing the page discards these unapplied draft edits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -305,6 +305,10 @@
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
||||
|
||||
@ -15,6 +15,10 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Actor, outcome, target, and readable context stay visible even when the original record changes or disappears later.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
The selected event is URL-addressable through the <span class="font-mono text-xs">event</span> query parameter. If the event is no longer visible in the current history view, the page quietly falls back to the unselected log.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
|
||||
@ -1,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>
|
||||
|
||||
@ -10,10 +10,14 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review pending requests, expiring governance, and lapsed exception coverage across entitled tenants without leaving the Monitoring area.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
The focused review lane is bound to the <span class="font-mono text-xs">exception</span> query parameter. If that exception drops out of the current queue view, the page falls back to quiet monitoring mode without stale decision state.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($this->showSelectedExceptionSummary && $selectedException)
|
||||
@if ($selectedException)
|
||||
<x-filament::section heading="Focused review lane">
|
||||
<x-slot name="description">
|
||||
Selection-bound decisions now define the active work lane. Scope, filters, and drilldowns stay visible without competing with the current review step.
|
||||
|
||||
@ -43,48 +43,66 @@
|
||||
<x-filament::tabs label="Operations tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'all'"
|
||||
wire:click="$set('activeTab', 'all')"
|
||||
:href="$this->tabUrl('all')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
All
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'active'"
|
||||
wire:click="$set('activeTab', 'active')"
|
||||
:href="$this->tabUrl('active')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Active
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === $staleAttentionTab"
|
||||
wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
|
||||
:href="$this->tabUrl($staleAttentionTab)"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Likely stale
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === $terminalFollowUpTab"
|
||||
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
|
||||
:href="$this->tabUrl($terminalFollowUpTab)"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Terminal follow-up
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
wire:click="$set('activeTab', 'succeeded')"
|
||||
:href="$this->tabUrl('succeeded')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Succeeded
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'partial'"
|
||||
wire:click="$set('activeTab', 'partial')"
|
||||
:href="$this->tabUrl('partial')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Partial
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'failed'"
|
||||
wire:click="$set('activeTab', 'failed')"
|
||||
:href="$this->tabUrl('failed')"
|
||||
tag="a"
|
||||
:spa-mode="true"
|
||||
>
|
||||
Failed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Tenant prefilters and the selected operations tab remain shareable through the URL. Additional table filters still restore from the last compatible session state.
|
||||
</p>
|
||||
|
||||
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
|
||||
|
||||
@ -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 doesn’t 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 doesn’t 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>
|
||||
|
||||
@ -94,8 +94,7 @@
|
||||
@endif
|
||||
@else
|
||||
@include('filament.components.verification-report-viewer', [
|
||||
'run' => $runData,
|
||||
'report' => $report,
|
||||
'surface' => $surface ?? [],
|
||||
'redactionNotes' => $redactionNotes ?? [],
|
||||
])
|
||||
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<div>
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
@ -50,6 +50,7 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
||||
->assertSee('Dense multi-tenant scan')
|
||||
->assertSee('Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.')
|
||||
->assertSee('Grouped legend')
|
||||
->assertSee('Open finding')
|
||||
->assertSee('More follow-up')
|
||||
@ -106,6 +107,7 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
||||
->assertSee('Compact compare results')
|
||||
->assertSee('Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.')
|
||||
->assertSee('Open finding');
|
||||
});
|
||||
|
||||
@ -142,6 +144,7 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('No rows match the current filters')
|
||||
->assertSee('Passive auto-refresh every 5 seconds')
|
||||
->assertSee('Applied filters and the focused subject are carried by the URL so the current matrix scan can be reopened or shared.')
|
||||
->click('Reset filters')
|
||||
->waitForText('Dense multi-tenant scan')
|
||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||
|
||||
@ -160,6 +160,7 @@ function spec194SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = '')
|
||||
->waitForText('Focused review lane')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Selection-bound decisions now define the active work lane.')
|
||||
->assertSee('Approve exception')
|
||||
->assertSee('Reject exception');
|
||||
|
||||
|
||||
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
it('smokes monitoring deeplinks for operations, audit log, finding exceptions queue, and evidence overview', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$secondTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Second Evidence Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $secondTenant, user: $user, role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$activeRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $tenant->workspace_id,
|
||||
'target_label' => 'Workspace '.$tenant->workspace_id,
|
||||
'summary' => 'Workspace selected for Workspace '.$tenant->workspace_id,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Spec198 browser queue smoke.',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
foreach ([$tenant, $secondTenant] as $snapshotTenant) {
|
||||
EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $snapshotTenant->getKey(),
|
||||
'workspace_id' => (int) $snapshotTenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
visit(route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'activeTab' => 'active',
|
||||
]))
|
||||
->waitForText('Monitoring landing')
|
||||
->assertSee('Tenant prefilters and the selected operations tab remain shareable through the URL.')
|
||||
->assertSee('Open run detail')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(route('admin.monitoring.audit-log', ['event' => (int) $audit->getKey()]))
|
||||
->waitForText('Summary-first audit history')
|
||||
->assertSee('Close details')
|
||||
->assertSee('Readable context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: ['exception' => (int) $exception->getKey()]))
|
||||
->waitForText('Focused review lane')
|
||||
->assertSee('Approve exception')
|
||||
->assertSee('Reject exception')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(route('admin.evidence.overview', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'search' => $tenant->name,
|
||||
]))
|
||||
->waitForText('Tenant and search query seeds can reopen this overview in a specific monitoring slice.')
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Clear filters')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('smokes compare landing to compare matrix handoff with carried subject focus', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$subjectKey = 'wifi-corp-profile';
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Spec198 Matrix Profile',
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$matrixUrl = BaselineProfileResource::compareMatrixUrl($profile).'?subject_key='.urlencode($subjectKey);
|
||||
|
||||
visit(BaselineCompareLanding::getUrl(
|
||||
parameters: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'subject_key' => $subjectKey,
|
||||
],
|
||||
panel: 'tenant',
|
||||
tenant: $tenant,
|
||||
))
|
||||
->waitForText('Open compare matrix')
|
||||
->assertSee('Launch the compare matrix with the currently known baseline profile and any carried subject focus from this tenant landing.');
|
||||
|
||||
visit($matrixUrl)
|
||||
->waitForText('Focused subject')
|
||||
->assertSee($subjectKey)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
@ -39,11 +39,13 @@
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Drift finding #'.$finding->getKey())
|
||||
->assertMountedActionModalSee('Open finding');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Drift finding #'.$finding->getKey())
|
||||
->assertActionVisible('open_selected_audit_target');
|
||||
});
|
||||
|
||||
it('keeps deleted findings readable while suppressing finding drill-down links', function (): void {
|
||||
@ -78,11 +80,13 @@
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Permission posture finding #'.$findingId)
|
||||
->assertMountedActionModalDontSee('Open finding');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Permission posture finding #'.$findingId)
|
||||
->assertActionDoesNotExist('open_selected_audit_target');
|
||||
});
|
||||
|
||||
it('does not render internal audit bookkeeping metadata in the inspection view', function (): void {
|
||||
@ -116,13 +120,15 @@
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalDontSee('_dedupe_key')
|
||||
->assertMountedActionModalDontSee('internal-bookkeeping-marker')
|
||||
->assertMountedActionModalDontSee('_actor_type')
|
||||
->assertMountedActionModalDontSee('hidden-actor-marker');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertDontSee('_dedupe_key')
|
||||
->assertDontSee('internal-bookkeeping-marker')
|
||||
->assertDontSee('_actor_type')
|
||||
->assertDontSee('hidden-actor-marker');
|
||||
});
|
||||
|
||||
it('hides finding audit rows for tenants outside the viewer entitlement scope', function (): void {
|
||||
@ -178,13 +184,17 @@
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||
->assertNotFound();
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Finding reopened for Drift finding #'.$findingB->getKey());
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
Livewire::withQueryParams(['event' => (int) $hidden->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -86,7 +86,7 @@
|
||||
'display_name' => 'My Policy 123',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Normalized diff')
|
||||
@ -96,4 +96,10 @@
|
||||
->assertSee('To')
|
||||
->assertSee('Old value')
|
||||
->assertSee('New value');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="groups"');
|
||||
});
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
@ -45,6 +45,11 @@
|
||||
->assertOk()
|
||||
->assertSee('Diff unavailable')
|
||||
->assertDontSee('No normalized changes were found');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-normalized-diff-state="unavailable"');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty baseline for unexpected_policy findings with a current policy version reference', function (): void {
|
||||
@ -101,7 +106,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
@ -110,6 +115,11 @@
|
||||
->assertDontSee('Diff unavailable')
|
||||
->assertSee('1 added')
|
||||
->assertSee('Password required');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-normalized-diff-state="available"');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty current side for missing_policy findings with a baseline policy version reference', function (): void {
|
||||
@ -166,7 +176,7 @@
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
@ -175,4 +185,9 @@
|
||||
->assertDontSee('Diff unavailable')
|
||||
->assertSee('1 removed')
|
||||
->assertSee('Password required');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"')
|
||||
->toContain('data-shared-normalized-diff-state="available"');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -85,5 +85,13 @@ function auditLogAuthorizationTestRecord(Tenant $tenant, array $attributes = [])
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||
->assertNotFound();
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Tenant B audit event');
|
||||
|
||||
Livewire::withQueryParams(['event' => (int) $hidden->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
});
|
||||
|
||||
@ -11,11 +11,17 @@
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null): Testable
|
||||
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null, ?int $selectedAuditLogId = null): Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
if ($selectedAuditLogId !== null) {
|
||||
return Livewire::withQueryParams(['event' => $selectedAuditLogId])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
@ -52,8 +58,8 @@ function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): Audit
|
||||
'summary' => 'Backup set created for Nightly iOS backup',
|
||||
]);
|
||||
|
||||
auditLogDetailTestComponent($user)
|
||||
->callTableAction('inspect', $audit)
|
||||
auditLogDetailTestComponent($user, selectedAuditLogId: (int) $audit->getKey())
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Readable context')
|
||||
->assertSee('Technical metadata')
|
||||
@ -78,8 +84,8 @@ function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): Audit
|
||||
'summary' => 'Backup set archived for Archived backup',
|
||||
]);
|
||||
|
||||
auditLogDetailTestComponent($user)
|
||||
->callTableAction('inspect', $audit)
|
||||
auditLogDetailTestComponent($user, selectedAuditLogId: (int) $audit->getKey())
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Archived backup')
|
||||
->assertSee('Technical metadata')
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BaselineProfile;
|
||||
@ -263,6 +264,41 @@
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('exposes a compare-matrix handoff that preserves carried subject focus from launch context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])->test(BaselineCompareLanding::class);
|
||||
|
||||
expect($component->instance()->openCompareMatrixUrl())
|
||||
->toBe(BaselineProfileResource::compareMatrixUrl($profile).'?subject_key=wifi-corp-profile');
|
||||
|
||||
$component->assertSee('Open compare matrix');
|
||||
});
|
||||
|
||||
it('exposes full coverage + fidelity context in stats', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -325,3 +325,37 @@
|
||||
->assertSee('No rows match the current filters')
|
||||
->assertSee('Reset filters');
|
||||
});
|
||||
|
||||
it('drops draft-only filter edits on remount while preserving the applied focus subject from the query', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
$this->makeBaselineCompareMatrixRun(
|
||||
$fixture['visibleTenant'],
|
||||
$fixture['profile'],
|
||||
$fixture['snapshot'],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertSet('focusedSubjectKey', 'wifi-corp-profile')
|
||||
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
|
||||
->set('draftSelectedStates', ['match'])
|
||||
->assertSee('Draft filters are staged');
|
||||
|
||||
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertSet('focusedSubjectKey', 'wifi-corp-profile')
|
||||
->assertSet('draftSelectedPolicyTypes', [])
|
||||
->assertSet('draftSelectedStates', [])
|
||||
->assertDontSee('Draft filters are staged');
|
||||
});
|
||||
|
||||
@ -40,6 +40,8 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(EvidenceOverview::class)
|
||||
->assertCountTableRecords(1)
|
||||
->assertCanSeeTableRecords([(string) $snapshot->getKey()])
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Artifact truth');
|
||||
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('group policy configuration normalized diff keys use definition display names', function () {
|
||||
$flat = app(PolicyNormalizer::class)->flattenForDiff(
|
||||
@ -27,3 +35,77 @@
|
||||
expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)');
|
||||
expect(implode("\n", $keys))->not->toContain('graph.microsoft.com');
|
||||
});
|
||||
|
||||
test('group policy configuration policy-version detail renders the shared normalized diff family', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'gpo-policy-1',
|
||||
'policy_type' => 'groupPolicyConfiguration',
|
||||
'display_name' => 'Admin Templates Alpha',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'id' => 'gpo-1',
|
||||
'displayName' => 'Admin Templates Alpha',
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => [
|
||||
[
|
||||
'enabled' => false,
|
||||
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
|
||||
'#Definition_Id' => 'def-1',
|
||||
'#Definition_displayName' => 'Block legacy auth',
|
||||
'#Definition_categoryPath' => 'Windows Components\\Security Options',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'id' => 'gpo-1',
|
||||
'displayName' => 'Admin Templates Alpha',
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => [
|
||||
[
|
||||
'enabled' => true,
|
||||
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
|
||||
'#Definition_Id' => 'def-1',
|
||||
'#Definition_displayName' => 'Block legacy auth',
|
||||
'#Definition_categoryPath' => 'Windows Components\\Security Options',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Block legacy auth');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"')
|
||||
->toContain('data-shared-zone="groups"');
|
||||
});
|
||||
|
||||
@ -0,0 +1,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();
|
||||
});
|
||||
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders shared normalized settings and diff families on policy and policy-version detail hosts', function (): void {
|
||||
$tenant = \App\Models\Tenant::factory()->create([
|
||||
'name' => 'Tenant One',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy A',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Policy A',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Policy A',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$policyResponse = $this->actingAs($user)
|
||||
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
|
||||
|
||||
$policyResponse->assertSuccessful()->assertSee('Enable feature');
|
||||
|
||||
expect($policyResponse->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy"');
|
||||
|
||||
$versionResponse = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||
|
||||
$versionResponse->assertSuccessful()->assertSee('Normalized diff');
|
||||
|
||||
expect($versionResponse->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"');
|
||||
});
|
||||
|
||||
it('renders the shared normalized diff family on finding detail hosts', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'shared-detail-contract'),
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-123',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'My Policy',
|
||||
'customSettingFoo' => 'Old value',
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'My Policy',
|
||||
'customSettingFoo' => 'New value',
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'snapshot_hash' => 'baseline-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'snapshot_hash' => 'current-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'My Policy 123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Normalized diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"');
|
||||
});
|
||||
@ -4,8 +4,10 @@
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -35,3 +37,68 @@
|
||||
->assertCanSeeTableRecords([$policyA])
|
||||
->assertCanNotSeeTableRecords([$policyB]);
|
||||
});
|
||||
|
||||
it('renders remembered canonical tenant policy detail with shared normalized settings markers', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered tenant policy']);
|
||||
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other tenant policy']);
|
||||
|
||||
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
|
||||
'omaSettings' => [
|
||||
[
|
||||
'displayName' => 'Setting A',
|
||||
'omaUri' => './Vendor/MSFT/SettingA',
|
||||
'value' => 'Enabled',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyB->policy_type,
|
||||
'platform' => $policyB->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
|
||||
'omaSettings' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withSession($session)
|
||||
->get(\App\Filament\Resources\PolicyResource::getUrl('view', ['record' => $policyA], panel: 'admin'));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Setting A');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy"');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(\App\Filament\Resources\PolicyResource::getUrl('view', ['record' => $policyB], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -73,3 +74,78 @@
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $versionB], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders remembered canonical tenant policy-version detail with shared normalized detail markers', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered policy']);
|
||||
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other policy']);
|
||||
|
||||
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Remembered policy',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$versionA = PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Remembered policy',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyB->policy_type,
|
||||
'platform' => $policyB->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Other policy',
|
||||
'settings' => [
|
||||
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withSession($session)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $versionA], panel: 'admin'));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Enable feature');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"');
|
||||
});
|
||||
|
||||
@ -50,6 +50,12 @@
|
||||
$response->assertSee('Normalized settings');
|
||||
$response->assertSee('Enable feature');
|
||||
$response->assertSee('Normalized diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="policy_version"');
|
||||
});
|
||||
|
||||
test('policy version detail shows enrollment notification template settings', function () {
|
||||
@ -139,4 +145,8 @@
|
||||
$response->assertSee('Push Subject');
|
||||
$response->assertSee('Push (en-us) Message');
|
||||
$response->assertSee('Push Body');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"');
|
||||
});
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\SettingsCatalogCategory;
|
||||
use App\Models\SettingsCatalogDefinition;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -53,3 +58,69 @@
|
||||
expect($keys)->toContain('Settings > Account Management > Deletion Policy');
|
||||
expect(implode("\n", $keys))->not->toContain('device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy');
|
||||
});
|
||||
|
||||
test('settings catalog policy version detail renders the shared normalized settings family', function () {
|
||||
SettingsCatalogCategory::create([
|
||||
'category_id' => 'cat-1',
|
||||
'display_name' => 'Account Management',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
SettingsCatalogDefinition::create([
|
||||
'definition_id' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
|
||||
'display_name' => 'Deletion Policy',
|
||||
'description' => null,
|
||||
'help_text' => null,
|
||||
'category_id' => 'cat-1',
|
||||
'ux_behavior' => null,
|
||||
'raw' => [],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'settings-catalog-policy-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 's1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
|
||||
'settingDefinitionId' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
|
||||
'choiceSettingValue' => [
|
||||
'value' => 'enabled',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Deletion Policy');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"');
|
||||
});
|
||||
|
||||
@ -111,10 +111,11 @@
|
||||
$versionResponse->assertSee('Enabled');
|
||||
$versionResponse->assertSee('device_vendor_msft_policy_config_system_child');
|
||||
|
||||
$versionGeneralSection = [];
|
||||
preg_match('/<section[^>]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection);
|
||||
expect($versionGeneralSection)->not->toBeEmpty();
|
||||
expect($versionGeneralSection[0])->toContain('x-cloak');
|
||||
expect($versionResponse->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-settings"')
|
||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
||||
->toContain('data-shared-normalized-settings-variant="settings_catalog_table"')
|
||||
->toContain('data-shared-zone="settings-table"');
|
||||
})->with([
|
||||
'settingsCatalogPolicy',
|
||||
'endpointSecurityPolicy',
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders the shared verification family on central operation detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'provider_connection',
|
||||
'title' => 'Provider connection preflight',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'provider_connection_missing',
|
||||
'message' => 'No provider connection configured.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Verification report');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="operation_run_detail"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('renders the shared verification family on onboarding verification', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'onboarding_permissions',
|
||||
'title' => 'Graph permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding');
|
||||
|
||||
$response->assertSuccessful()->assertSee('Graph permissions');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('renders the shared verification family on the tenant widget host', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'tenant_widget_report',
|
||||
'title' => 'Tenant widget verification',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'provider_permission_denied',
|
||||
'message' => 'Insufficient permission — ask a tenant Owner.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
],
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(TenantVerificationReport::class, ['record' => $tenant])
|
||||
->assertSee('Tenant widget verification');
|
||||
|
||||
expect($component->html())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="tenant_widget"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
@ -15,6 +17,9 @@
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -34,8 +39,10 @@ function spec125AssertPersistedTableState(
|
||||
string $sortDirection,
|
||||
string $filterPath,
|
||||
mixed $filterValue,
|
||||
array $queryParams = [],
|
||||
): void {
|
||||
$component = Livewire::test($componentClass, $parameters)
|
||||
$component = Livewire::withQueryParams($queryParams)
|
||||
->test($componentClass, $parameters)
|
||||
->searchTable($search)
|
||||
->call('sortTable', $sortColumn, $sortDirection)
|
||||
->set($filterPath, $filterValue);
|
||||
@ -46,7 +53,8 @@ function spec125AssertPersistedTableState(
|
||||
expect(session()->get($instance->getTableSortSessionKey()))->toBe("{$sortColumn}:{$sortDirection}");
|
||||
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), str($filterPath)->after('tableFilters.')->value()))->toBe($filterValue);
|
||||
|
||||
Livewire::test($componentClass, $parameters)
|
||||
Livewire::withQueryParams($queryParams)
|
||||
->test($componentClass, $parameters)
|
||||
->assertSet('tableSearch', $search)
|
||||
->assertSet('tableSort', "{$sortColumn}:{$sortDirection}")
|
||||
->assertSet($filterPath, $filterValue);
|
||||
@ -284,6 +292,132 @@ function spec125AssertPersistedTableState(
|
||||
);
|
||||
});
|
||||
|
||||
it('restores operations table state while the requested tab stays query-driven', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
spec125AssertPersistedTableState(
|
||||
Operations::class,
|
||||
[],
|
||||
'baseline',
|
||||
'created_at',
|
||||
'desc',
|
||||
'tableFilters.status.value',
|
||||
'failed',
|
||||
['activeTab' => 'failed'],
|
||||
);
|
||||
|
||||
Livewire::withQueryParams(['activeTab' => 'failed'])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('activeTab', 'failed');
|
||||
});
|
||||
|
||||
it('clears selected audit event state when persisted filters no longer contain the record', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$selectedAudit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $tenant->workspace_id,
|
||||
'target_label' => 'Workspace '.$tenant->workspace_id,
|
||||
'summary' => 'Selected audit event',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'operation_run.failed',
|
||||
'status' => 'failure',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => '1',
|
||||
'target_label' => 'Run #1',
|
||||
'summary' => 'Failure event',
|
||||
'recorded_at' => now()->addSecond(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$auditComponent = Livewire::withQueryParams(['event' => (int) $selectedAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class);
|
||||
|
||||
session()->put($auditComponent->instance()->getTableFiltersSessionKey(), [
|
||||
'outcome' => ['value' => 'failure'],
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['event' => (int) $selectedAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null);
|
||||
});
|
||||
|
||||
it('clears selected exception state when persisted queue filters no longer contain the record', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$selectedException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Selected queue exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$rejectedFinding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $rejectedFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_REJECTED,
|
||||
'current_validity_state' => FindingException::VALIDITY_REJECTED,
|
||||
'request_reason' => 'Rejected queue exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'rejected_at' => now()->subHour(),
|
||||
'rejection_reason' => 'No longer needed',
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$queueComponent = Livewire::withQueryParams(['exception' => (int) $selectedException->getKey()])
|
||||
->test(FindingExceptionsQueue::class);
|
||||
|
||||
session()->put($queueComponent->instance()->getTableFiltersSessionKey(), [
|
||||
'status' => ['value' => FindingException::STATUS_REJECTED],
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['exception' => (int) $selectedException->getKey()])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null);
|
||||
});
|
||||
|
||||
it('reseeds the provider-connections tenant filter when the remembered admin tenant changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -155,14 +155,6 @@ static function (Tenant $tenant, string $label): RestoreRun {
|
||||
return RestoreRun::factory()->for($tenant)->for($backupSet)->create();
|
||||
},
|
||||
],
|
||||
'inventory-item view' => [
|
||||
InventoryItemResource::class,
|
||||
'view',
|
||||
static fn (Tenant $tenant, string $label): InventoryItem => InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => $label,
|
||||
]),
|
||||
],
|
||||
'finding view' => [
|
||||
FindingResource::class,
|
||||
'view',
|
||||
@ -244,3 +236,34 @@ static function (Tenant $tenant, string $label): BackupSchedule {
|
||||
->get($resourceClass::getUrl($page, ['record' => $blocked], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
})->with('tenant-owned-detail-pages');
|
||||
|
||||
it('returns not found for admin inventory item detail pages outside the explicit tenant query scope', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$allowed = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'display_name' => 'Allowed inventory item',
|
||||
]);
|
||||
|
||||
$blocked = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'display_name' => 'Blocked inventory item',
|
||||
]);
|
||||
|
||||
$session = tenantOwnedAdminSession($tenantA);
|
||||
$allowedUrl = InventoryItemResource::getUrl('view', ['record' => $allowed], panel: 'admin').'?tenant='.(string) $tenantA->external_id;
|
||||
$blockedUrl = InventoryItemResource::getUrl('view', ['record' => $blocked], panel: 'admin').'?tenant='.(string) $tenantA->external_id;
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession($session)
|
||||
->get($allowedUrl)
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession($session)
|
||||
->get($blockedUrl)
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
@ -108,7 +108,7 @@
|
||||
bindFailHardGraphClient();
|
||||
|
||||
assertNoOutboundHttp(function () use ($user): void {
|
||||
Livewire::actingAs($user)
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(TenantVerificationReport::class)
|
||||
->assertSee('Provider connection preflight')
|
||||
->assertSee(OperationRunLinks::openLabel())
|
||||
@ -116,6 +116,13 @@
|
||||
->assertSee(OperationRunLinks::identifierLabel().':')
|
||||
->assertSee('Read-only:')
|
||||
->assertSee('Insufficient permission — ask a tenant Owner.');
|
||||
|
||||
expect($component->html())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="tenant_widget"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -116,3 +119,89 @@
|
||||
expect($exception->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders finding detail with shared normalized diff markers for entitled members', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'finding-rbac-shared-diff'),
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-finding-rbac',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'snapshot' => [
|
||||
'displayName' => 'RBAC Policy',
|
||||
'customSettingFoo' => 'Old value',
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'snapshot' => [
|
||||
'displayName' => 'RBAC Policy',
|
||||
'customSettingFoo' => 'New value',
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'snapshot_hash' => 'baseline-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'snapshot_hash' => 'current-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'RBAC Policy',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Normalized diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="normalized-diff"')
|
||||
->toContain('data-shared-normalized-diff-host="finding"');
|
||||
});
|
||||
|
||||
@ -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' => [
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
@ -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(');
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('opens the selected audit event in a slideover inspection surface', function (): void {
|
||||
it('hydrates the selected audit event from the query and renders inline detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
@ -36,16 +36,20 @@
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $audit->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Workspace selected for Workspace 1')
|
||||
->assertMountedActionModalSee('Readable context')
|
||||
->assertMountedActionModalSee('Technical metadata');
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Workspace selected for Workspace 1')
|
||||
->assertSee('Readable context')
|
||||
->assertSee('Technical metadata')
|
||||
->assertActionVisible('close_selected_audit_event');
|
||||
});
|
||||
|
||||
it('shows operation-run navigation only for the currently inspected operation run event', function (): void {
|
||||
it('shows related navigation only for the currently selected operation-run event', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -89,20 +93,24 @@
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $withRunLink->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$withRunLink, $withoutRunLink])
|
||||
->mountTableAction('inspect', $withRunLink)
|
||||
->assertMountedActionModalSee('Open operation');
|
||||
->assertActionVisible('open_selected_audit_target');
|
||||
|
||||
$component
|
||||
->call('replaceMountedTableAction', 'inspect', (string) $withoutRunLink->getKey())
|
||||
->assertMountedActionModalSee('Workspace selected for Workspace 1')
|
||||
->assertMountedActionModalDontSee('Open operation')
|
||||
->assertMountedActionModalDontSee('Baseline compare completed for Operation run');
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $withoutRunLink->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSee('Workspace selected for Workspace 1')
|
||||
->assertActionDoesNotExist('open_selected_audit_target');
|
||||
});
|
||||
|
||||
it('clearing the slideover closes the inspection surface cleanly', function (): void {
|
||||
it('falls back to the unselected history when the requested event is invalid or unavailable', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
@ -120,19 +128,40 @@
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$foreignTenant = \App\Models\Tenant::factory()->create();
|
||||
|
||||
$foreignAudit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $foreignTenant->workspace_id,
|
||||
'target_label' => 'Workspace 2',
|
||||
'summary' => 'Foreign workspace selected',
|
||||
'recorded_at' => now()->addSecond(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
Livewire::withQueryParams(['event' => 999999])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->mountTableAction('inspect', $audit)
|
||||
->unmountTableAction()
|
||||
->assertTableActionNotMounted('inspect');
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertActionDoesNotExist('close_selected_audit_event');
|
||||
|
||||
expect($component->instance()->getMountedTableAction())->toBeNull();
|
||||
Livewire::withQueryParams(['event' => (int) $foreignAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertActionDoesNotExist('close_selected_audit_event');
|
||||
});
|
||||
|
||||
it('keeps record inspection actions out of the global page header', function (): void {
|
||||
it('keeps selected-event actions out of the page header until an event is selected', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -143,7 +172,7 @@
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
@ -164,6 +193,13 @@
|
||||
->assertOk()
|
||||
->assertDontSee('Close details')
|
||||
->assertDontSee('Open operation');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log', ['event' => (int) $audit->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Close details')
|
||||
->assertSee('Open operation');
|
||||
});
|
||||
|
||||
it('surfaces origin context quietly when deep-linked to a selected audit event', function (): void {
|
||||
|
||||
@ -69,10 +69,12 @@
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
|
||||
->assertSee('Focused review lane')
|
||||
->assertSee('Decision lane')
|
||||
->assertSee('Related drilldown')
|
||||
->assertDontSee('Quiet monitoring mode')
|
||||
->assertActionVisible('clear_selected_exception')
|
||||
->assertActionVisible('approve_selected_exception')
|
||||
->assertActionVisible('reject_selected_exception')
|
||||
->mountAction('approve_selected_exception')
|
||||
@ -89,3 +91,42 @@
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['rejection_reason']);
|
||||
});
|
||||
|
||||
it('falls back to quiet monitoring when the requested exception is invalid or unauthorized', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$foreignTenant = \App\Models\Tenant::factory()->create();
|
||||
[$foreignRequester] = createUserWithTenant(tenant: $foreignTenant, role: 'owner');
|
||||
|
||||
$foreignFinding = Finding::factory()->for($foreignTenant)->create();
|
||||
|
||||
$foreignException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'finding_id' => (int) $foreignFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $foreignRequester->getKey(),
|
||||
'owner_user_id' => (int) $foreignRequester->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Foreign queue exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['exception' => 999999])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null)
|
||||
->assertSee('Quiet monitoring mode')
|
||||
->assertActionHidden('clear_selected_exception');
|
||||
|
||||
Livewire::withQueryParams(['exception' => (int) $foreignException->getKey()])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null)
|
||||
->assertSee('Quiet monitoring mode')
|
||||
->assertActionHidden('clear_selected_exception');
|
||||
});
|
||||
|
||||
@ -84,20 +84,24 @@
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||
->assertActionVisible('view_tenant_register');
|
||||
|
||||
$filtersComponent = Livewire::test(FindingExceptionsQueue::class);
|
||||
$queueInstance = $filtersComponent->instance();
|
||||
session()->forget([
|
||||
$queueInstance->getTableFiltersSessionKey(),
|
||||
$queueInstance->getTableSearchSessionKey(),
|
||||
$queueInstance->getTableSortSessionKey(),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'exception' => (int) $expiring->getKey(),
|
||||
])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', (int) $expiring->getKey())
|
||||
->assertSet('showSelectedExceptionSummary', true)
|
||||
->assertActionVisible('clear_selected_exception')
|
||||
->assertActionVisible('open_selected_exception')
|
||||
->assertActionVisible('open_selected_finding')
|
||||
->assertSee('Queue visibility test')
|
||||
->assertSee('Expiring')
|
||||
->assertSee($tenantA->name);
|
||||
|
||||
Livewire::test(FindingExceptionsQueue::class)
|
||||
->mountTableAction('inspect_exception', (string) $expiring->getKey())
|
||||
->assertMountedActionModalSee('Finding exception #'.$expiring->getKey())
|
||||
->assertMountedActionModalSee('Queue visibility test')
|
||||
->assertMountedActionModalSee('Close details');
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee('Focused review lane');
|
||||
});
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Pages\Monitoring\AuditLog;
|
||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\FindingException;
|
||||
|
||||
function monitoringPageStateFieldSummary(array $contract): array
|
||||
{
|
||||
return collect($contract['stateFields'])
|
||||
->mapWithKeys(static fn (array $field): array => [
|
||||
(string) $field['stateKey'] => [
|
||||
'stateClass' => $field['stateClass'],
|
||||
'queryRole' => $field['queryRole'],
|
||||
'shareable' => $field['shareable'],
|
||||
'restorableOnRefresh' => $field['restorableOnRefresh'],
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
it('declares the bounded page-state contract for each monitoring surface', function (string $pageClass, array $expected): void {
|
||||
$contract = $pageClass::monitoringPageStateContract();
|
||||
|
||||
expect($contract['surfaceKey'])->toBe($expected['surfaceKey'])
|
||||
->and($contract['surfaceType'])->toBe($expected['surfaceType'])
|
||||
->and($contract['shareableStateKeys'])->toBe($expected['shareableStateKeys'])
|
||||
->and($contract['localOnlyStateKeys'])->toBe($expected['localOnlyStateKeys'])
|
||||
->and($contract['inspectContract'])->toMatchArray($expected['inspectContract'])
|
||||
->and(monitoringPageStateFieldSummary($contract))->toEqual($expected['stateFields']);
|
||||
})->with([
|
||||
'operations' => [
|
||||
Operations::class,
|
||||
[
|
||||
'surfaceKey' => 'operations',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'shareableStateKeys' => ['tenant_id', 'tenant_scope', 'problemClass', 'activeTab'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'presentation' => 'none',
|
||||
'shareable' => false,
|
||||
],
|
||||
'stateFields' => [
|
||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant_scope' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'problemClass' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'activeTab' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'audit log' => [
|
||||
AuditLog::class,
|
||||
[
|
||||
'surfaceKey' => 'audit_log',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'shareableStateKeys' => ['event'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => AuditLogModel::class,
|
||||
'selectedStateKey' => 'selectedAuditLogId',
|
||||
'presentation' => 'inline_detail',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'event' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'finding exceptions queue' => [
|
||||
FindingExceptionsQueue::class,
|
||||
[
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'surfaceType' => 'selected_record_monitoring',
|
||||
'shareableStateKeys' => ['tenant', 'exception'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => FindingException::class,
|
||||
'selectedStateKey' => 'selectedFindingExceptionId',
|
||||
'presentation' => 'summary_plus_related_actions',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'exception' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'evidence overview' => [
|
||||
EvidenceOverview::class,
|
||||
[
|
||||
'surfaceKey' => 'evidence_overview',
|
||||
'surfaceType' => 'simple_monitoring',
|
||||
'shareableStateKeys' => ['tenant_id', 'search'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'presentation' => 'navigate_to_canonical_detail',
|
||||
'shareable' => false,
|
||||
],
|
||||
'stateFields' => [
|
||||
'tenant_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'search' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
'tableSort' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'baseline compare landing' => [
|
||||
BaselineCompareLanding::class,
|
||||
[
|
||||
'surfaceKey' => 'baseline_compare_landing',
|
||||
'surfaceType' => 'launch_context_support',
|
||||
'shareableStateKeys' => ['baseline_profile_id', 'subject_key', 'nav'],
|
||||
'localOnlyStateKeys' => [],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'none',
|
||||
'selectedStateKey' => null,
|
||||
'presentation' => 'none',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'baseline_profile_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'subject_key' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'nav' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
'baseline compare matrix' => [
|
||||
BaselineCompareMatrix::class,
|
||||
[
|
||||
'surfaceKey' => 'baseline_compare_matrix',
|
||||
'surfaceType' => 'draft_apply_analysis',
|
||||
'shareableStateKeys' => ['mode', 'policy_type', 'state', 'severity', 'tenant_sort', 'subject_sort', 'subject_key'],
|
||||
'localOnlyStateKeys' => [
|
||||
'draftSelectedPolicyTypes',
|
||||
'draftSelectedStates',
|
||||
'draftSelectedSeverities',
|
||||
'draftTenantSort',
|
||||
'draftSubjectSort',
|
||||
],
|
||||
'inspectContract' => [
|
||||
'primaryModel' => 'baseline_subject',
|
||||
'selectedStateKey' => 'focusedSubjectKey',
|
||||
'presentation' => 'focused_matrix',
|
||||
'shareable' => true,
|
||||
],
|
||||
'stateFields' => [
|
||||
'mode' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'policy_type' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'state' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'severity' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'tenant_sort' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'subject_sort' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
'subject_key' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
it('keeps the selected-record monitoring surfaces on a single inspect identifier', function (): void {
|
||||
$contracts = [
|
||||
AuditLog::monitoringPageStateContract(),
|
||||
FindingExceptionsQueue::monitoringPageStateContract(),
|
||||
];
|
||||
|
||||
expect(collect($contracts)->pluck('inspectContract.selectedStateKey')->all())
|
||||
->toEqual(['selectedAuditLogId', 'selectedFindingExceptionId'])
|
||||
->and(collect($contracts)->pluck('inspectContract.shareable')->unique()->all())
|
||||
->toEqual([true]);
|
||||
});
|
||||
|
||||
it('keeps compare matrix draft state local while applied filters stay query-driven', function (): void {
|
||||
$contract = BaselineCompareMatrix::monitoringPageStateContract();
|
||||
|
||||
expect($contract['surfaceType'])->toBe('draft_apply_analysis')
|
||||
->and($contract['localOnlyStateKeys'])->toEqual([
|
||||
'draftSelectedPolicyTypes',
|
||||
'draftSelectedStates',
|
||||
'draftSelectedSeverities',
|
||||
'draftTenantSort',
|
||||
'draftSubjectSort',
|
||||
])
|
||||
->and(collect($contract['shareableStateKeys'])->contains('subject_key'))->toBeTrue()
|
||||
->and($contract['inspectContract']['presentation'])->toBe('focused_matrix');
|
||||
});
|
||||
@ -273,3 +273,40 @@
|
||||
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
]));
|
||||
});
|
||||
|
||||
it('ignores unauthorized requested tenant filters while keeping canonical tab continuity', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$foreignTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenantA, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'tenant_id' => (string) $foreignTenant->getKey(),
|
||||
'activeTab' => 'active',
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertSet('activeTab', 'active');
|
||||
|
||||
expect(urldecode($component->instance()->tabUrl(OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)))
|
||||
->toContain('activeTab='.OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
->toContain('problemClass='.OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
->not->toContain('tenant_id='.(int) $foreignTenant->getKey());
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
@ -142,3 +143,45 @@
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders shared verification family markers on monitoring operation detail for workspace members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'initiator_name' => 'System',
|
||||
'run_identity_hash' => 'hash123',
|
||||
'context' => [
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'provider_connection',
|
||||
'title' => 'Provider connection preflight',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'provider_connection_missing',
|
||||
'message' => 'No provider connection configured.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Provider connection preflight');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="operation_run_detail"');
|
||||
});
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -148,6 +150,81 @@
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('renders shared verification family markers for an entitled requested verify draft', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Requested verify connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'requested_verify_draft',
|
||||
'title' => 'Requested verify draft check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'missing_configuration',
|
||||
'message' => 'Draft needs attention.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->followingRedirects()
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
|
||||
|
||||
$response->assertSuccessful()->assertSee('Requested verify draft check');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"');
|
||||
});
|
||||
|
||||
it('mounts the requested draft with canonical persisted continuity state even when other drafts exist', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
|
||||
@ -319,7 +319,7 @@
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
@ -331,6 +331,13 @@
|
||||
->assertSee('Missing required Graph permissions.')
|
||||
->assertSee('Graph permissions')
|
||||
->assertSee($entraTenantId);
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void {
|
||||
|
||||
@ -246,7 +246,7 @@
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
@ -262,4 +262,11 @@
|
||||
->assertSee('First step')
|
||||
->assertSee('Second step')
|
||||
->assertDontSee('Third step');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('data-shared-detail-family="verification-report"')
|
||||
->toContain('data-host-kind="onboarding_wizard"')
|
||||
->toContain('data-shared-zone="summary"')
|
||||
->toContain('data-shared-zone="issues"')
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
@ -26,5 +26,6 @@
|
||||
|
||||
expect($html)->toContain('Enabled')
|
||||
->and($html)->toContain('Disabled')
|
||||
->and($html)->toContain('fi-badge');
|
||||
->and($html)->toContain('fi-badge')
|
||||
->and($html)->toContain('data-shared-detail-family="normalized-settings"');
|
||||
});
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
@ -10,6 +13,8 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -85,3 +90,101 @@
|
||||
->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('drops unauthorized requested tenant filters on operations instead of honoring cross-tenant query state', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$unauthorizedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenantA, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant_id' => (string) $unauthorizedTenant->getKey(),
|
||||
'activeTab' => 'active',
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertSet('activeTab', 'active');
|
||||
});
|
||||
|
||||
it('falls back to an unselected audit history when the requested event is outside the accessible scope', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$foreignTenant = Tenant::factory()->create();
|
||||
|
||||
$foreignAudit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $foreignTenant->workspace_id,
|
||||
'target_label' => 'Foreign workspace',
|
||||
'summary' => 'Foreign workspace selected',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['event' => (int) $foreignAudit->getKey()])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', null)
|
||||
->assertActionDoesNotExist('close_selected_audit_event');
|
||||
});
|
||||
|
||||
it('falls back to an unselected queue state when the requested exception is outside the accessible tenant set', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$foreignTenant = Tenant::factory()->create();
|
||||
[$foreignRequester] = createUserWithTenant(tenant: $foreignTenant, role: 'owner');
|
||||
$foreignFinding = Finding::factory()->for($foreignTenant)->create();
|
||||
|
||||
$foreignException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $foreignTenant->workspace_id,
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
'finding_id' => (int) $foreignFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $foreignRequester->getKey(),
|
||||
'owner_user_id' => (int) $foreignRequester->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Foreign exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['exception' => (int) $foreignException->getKey()])
|
||||
->actingAs($approver)
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSet('selectedFindingExceptionId', null)
|
||||
->assertActionHidden('clear_selected_exception')
|
||||
->assertActionHidden('approve_selected_exception')
|
||||
->assertActionHidden('reject_selected_exception');
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||
@ -122,3 +123,21 @@
|
||||
], tenant: $fixture['visibleTenant']))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('drops foreign compare-context launch data instead of leaking another workspace profile', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
$foreignProfile = \App\Models\BaselineProfile::factory()->create();
|
||||
|
||||
$this->actingAs($fixture['user']);
|
||||
$fixture['visibleTenant']->makeCurrent();
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'baseline_profile_id' => (int) $foreignProfile->getKey(),
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])->test(BaselineCompareLanding::class);
|
||||
|
||||
expect($component->instance()->openCompareMatrixUrl())
|
||||
->toStartWith(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||
->not->toContain((string) $foreignProfile->getKey());
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user