Compare commits
1 Commits
dev
...
205-compar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
663a10ace3 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -186,10 +186,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
|
- 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)
|
- 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)
|
- 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)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -224,8 +220,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 198-monitoring-page-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages
|
|
||||||
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
|
|
||||||
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||||
|
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
|
||||||
|
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -5,9 +5,7 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\BaselineProfile;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -38,60 +36,6 @@ class BaselineCompareLanding extends Page
|
|||||||
{
|
{
|
||||||
use ResolvesPanelTenantContext;
|
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|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
@ -193,14 +137,6 @@ public static function canAccess(): bool
|
|||||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
@ -330,7 +266,6 @@ protected function getViewData(): array
|
|||||||
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
||||||
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
||||||
'matrixSubjectKey' => $this->matrixSubjectKey,
|
'matrixSubjectKey' => $this->matrixSubjectKey,
|
||||||
'openCompareMatrixUrl' => $this->openCompareMatrixUrl(),
|
|
||||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
@ -530,26 +465,6 @@ public function getRunUrl(): ?string
|
|||||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
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
|
private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||||
{
|
{
|
||||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
@ -567,33 +482,8 @@ private function navigationContext(): ?CanonicalNavigationContext
|
|||||||
return CanonicalNavigationContext::fromRequest(request());
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
}
|
}
|
||||||
|
|
||||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveCompareMatrixProfile(): ?BaselineProfile
|
return CanonicalNavigationContext::fromRequest($request);
|
||||||
{
|
|
||||||
$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,106 +39,6 @@ class BaselineCompareMatrix extends Page implements HasForms
|
|||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
use InteractsWithRecord;
|
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 $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -207,14 +107,6 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
|
->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
|
public function mount(int|string $record): void
|
||||||
{
|
{
|
||||||
$this->record = $this->resolveRecord($record);
|
$this->record = $this->resolveRecord($record);
|
||||||
|
|||||||
@ -36,8 +36,8 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -45,60 +45,6 @@ class AuditLog extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
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;
|
public ?int $selectedAuditLogId = null;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
@ -136,14 +82,6 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
|
->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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
@ -154,7 +92,8 @@ public function mount(): void
|
|||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
if ($requestedEventId !== null) {
|
if ($requestedEventId !== null) {
|
||||||
$this->selectedAuditLogId = $this->resolveSelectedAuditLogId($requestedEventId);
|
$this->resolveAuditLog($requestedEventId);
|
||||||
|
$this->selectedAuditLogId = $requestedEventId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,41 +119,9 @@ 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;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedTableFilters(): void
|
|
||||||
{
|
|
||||||
$this->normalizeSelectedAuditLogId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedTableSearch(): void
|
|
||||||
{
|
|
||||||
$this->normalizeSelectedAuditLogId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -285,7 +192,19 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (AuditLogModel $record): string => $this->auditLogUrl(['event' => (int) $record->getKey()])),
|
->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),
|
||||||
|
])),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No audit events match this view')
|
->emptyStateHeading('No audit events match this view')
|
||||||
@ -297,7 +216,6 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->selectedAuditLogId = null;
|
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@ -394,12 +312,6 @@ public function selectedAuditRecord(): ?AuditLogModel
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->normalizeSelectedAuditLogId();
|
|
||||||
|
|
||||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->resolveAuditLog($this->selectedAuditLogId);
|
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||||
} catch (NotFoundHttpException) {
|
} catch (NotFoundHttpException) {
|
||||||
@ -429,137 +341,6 @@ private function auditTargetLink(AuditLogModel $record): ?array
|
|||||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -20,89 +20,14 @@
|
|||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Pages\Page;
|
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\Auth\AuthenticationException;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EvidenceOverview extends Page implements HasTable
|
class EvidenceOverview extends Page
|
||||||
{
|
{
|
||||||
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 $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -120,12 +45,7 @@ class EvidenceOverview extends Page implements HasTable
|
|||||||
*/
|
*/
|
||||||
public array $rows = [];
|
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
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
@ -137,143 +57,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
->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
|
public function mount(): void
|
||||||
{
|
|
||||||
$this->authorizeWorkspaceAccess();
|
|
||||||
$this->seedTableStateFromQuery();
|
|
||||||
$this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all();
|
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return $this->paginateRows($rows, $page, $recordsPerPage);
|
|
||||||
})
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->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()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->color('gray')
|
|
||||||
->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);
|
|
||||||
|
|
||||||
return $fresh
|
|
||||||
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
|
||||||
: $presenter->forEvidenceSnapshot($snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeWorkspaceAccess(): void
|
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -281,127 +65,35 @@ private function authorizeWorkspaceAccess(): void
|
|||||||
throw new AuthenticationException;
|
throw new AuthenticationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
}
|
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||||
|
$workspaceId = (int) $workspace->getKey();
|
||||||
|
|
||||||
/**
|
$accessibleTenants = $user->tenants()
|
||||||
* @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)
|
->where('tenants.workspace_id', $workspaceId)
|
||||||
->orderBy('tenants.name')
|
->orderBy('tenants.name')
|
||||||
->get()
|
->get()
|
||||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||||
->values()
|
->values();
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return collect($this->accessibleTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->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()
|
$query = EvidenceSnapshot::query()
|
||||||
->with('tenant')
|
->with('tenant')
|
||||||
->where('workspace_id', $this->workspaceId())
|
->where('workspace_id', $workspaceId)
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->latest('generated_at');
|
->latest('generated_at');
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
if ($this->tenantFilter !== null) {
|
||||||
$query->whereRaw('1 = 0');
|
$query->where('tenant_id', $this->tenantFilter);
|
||||||
} else {
|
|
||||||
$query->whereIn('tenant_id', $tenantIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
}
|
$currentReviewTenantIds = TenantReview::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
/**
|
|
||||||
* @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('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
TenantReviewStatus::Draft->value,
|
TenantReviewStatus::Draft->value,
|
||||||
@ -411,14 +103,8 @@ private function currentReviewTenantIds(Collection $snapshots): array
|
|||||||
->pluck('tenant_id')
|
->pluck('tenant_id')
|
||||||
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
||||||
->all();
|
->all();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
|
||||||
* @param array<int, bool> $currentReviewTenantIds
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array
|
|
||||||
{
|
|
||||||
$truth = $this->snapshotTruth($snapshot);
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
$tenantId = (int) $snapshot->tenant_id;
|
$tenantId = (int) $snapshot->tenant_id;
|
||||||
@ -431,22 +117,16 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
|||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'completeness_state' => (string) $snapshot->completeness_state,
|
||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0),
|
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||||
'stale_dimensions' => (int) ($snapshot->summary['stale_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' => [
|
'artifact_truth' => [
|
||||||
'label' => $truth->primaryLabel,
|
'label' => $truth->primaryLabel,
|
||||||
'color' => $truth->primaryBadgeSpec()->color,
|
'color' => $truth->primaryBadgeSpec()->color,
|
||||||
'icon' => $truth->primaryBadgeSpec()->icon,
|
'icon' => $truth->primaryBadgeSpec()->icon,
|
||||||
'explanation' => $truth->primaryExplanation,
|
'explanation' => $truth->primaryExplanation,
|
||||||
],
|
],
|
||||||
'freshness_label' => $freshnessSpec->label,
|
|
||||||
'freshness_color' => $freshnessSpec->color,
|
|
||||||
'freshness_icon' => $freshnessSpec->icon,
|
|
||||||
'freshness' => [
|
'freshness' => [
|
||||||
'label' => $freshnessSpec->label,
|
'label' => $freshnessSpec->label,
|
||||||
'color' => $freshnessSpec->color,
|
'color' => $freshnessSpec->color,
|
||||||
@ -457,113 +137,29 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
|
|||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
: null,
|
: null,
|
||||||
];
|
];
|
||||||
|
})->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<string, array<string, mixed>> $rows
|
* @return array<Action>
|
||||||
* @return Collection<string, array<string, mixed>>
|
|
||||||
*/
|
*/
|
||||||
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true)
|
return [
|
||||||
? $sortColumn
|
Action::make('clear_filters')
|
||||||
: 'tenant_name';
|
->label('Clear filters')
|
||||||
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->tenantFilter !== null)
|
||||||
$records = $rows->all();
|
->url(route('admin.evidence.overview')),
|
||||||
|
|
||||||
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
|
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
if (! is_numeric($value)) {
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestedTenantId = (int) $value;
|
return $fresh
|
||||||
$allowedTenantIds = collect($this->accessibleTenants())
|
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
: $presenter->forEvidenceSnapshot($snapshot);
|
||||||
->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,10 +16,8 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
@ -42,9 +40,9 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -52,72 +50,10 @@ class FindingExceptionsQueue extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
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 ?int $selectedFindingExceptionId = null;
|
public ?int $selectedFindingExceptionId = null;
|
||||||
|
|
||||||
|
public bool $showSelectedExceptionSummary = false;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||||
@ -151,14 +87,6 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
->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
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
@ -192,12 +120,13 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||||
|
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
$this->applyRequestedTenantPrefilter();
|
$this->applyRequestedTenantPrefilter();
|
||||||
$requestedExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
|
||||||
|
|
||||||
if ($requestedExceptionId !== null) {
|
if ($this->selectedFindingExceptionId !== null) {
|
||||||
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($requestedExceptionId);
|
$this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,6 +147,7 @@ protected function getHeaderActions(): array
|
|||||||
$this->removeTableFilter('status');
|
$this->removeTableFilter('status');
|
||||||
$this->removeTableFilter('current_validity_state');
|
$this->removeTableFilter('current_validity_state');
|
||||||
$this->selectedFindingExceptionId = null;
|
$this->selectedFindingExceptionId = null;
|
||||||
|
$this->showSelectedExceptionSummary = false;
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -240,21 +170,23 @@ protected function getHeaderActions(): array
|
|||||||
Action::make('clear_selected_exception')
|
Action::make('clear_selected_exception')
|
||||||
->label('Close details')
|
->label('Close details')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
->url(fn (): string => $this->queueUrl(['exception' => null])),
|
->action(function (): void {
|
||||||
|
$this->clearSelectedException();
|
||||||
|
}),
|
||||||
|
|
||||||
Action::make('open_selected_exception')
|
Action::make('open_selected_exception')
|
||||||
->label('Open tenant detail')
|
->label('Open tenant detail')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
->url(fn (): ?string => $this->selectedExceptionUrl()),
|
->url(fn (): ?string => $this->selectedExceptionUrl()),
|
||||||
|
|
||||||
Action::make('open_selected_finding')
|
Action::make('open_selected_finding')
|
||||||
->label('Open finding')
|
->label('Open finding')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
->url(fn (): ?string => $this->selectedFindingUrl()),
|
->url(fn (): ?string => $this->selectedFindingUrl()),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -338,7 +270,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Selected context')
|
->label('Selected context')
|
||||||
->icon('heroicon-o-rectangle-stack')
|
->icon('heroicon-o-rectangle-stack')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException);
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null);
|
||||||
|
|
||||||
$actions[] = ActionGroup::make($selectedDecisionActions)
|
$actions[] = ActionGroup::make($selectedDecisionActions)
|
||||||
->label('Review selected')
|
->label('Review selected')
|
||||||
@ -421,7 +353,32 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect exception')
|
->label('Inspect exception')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (FindingException $record): string => $this->queueUrl(['exception' => (int) $record->getKey()])),
|
->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,
|
||||||
|
]);
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No exceptions match this queue')
|
->emptyStateHeading('No exceptions match this queue')
|
||||||
@ -437,38 +394,19 @@ public function table(Table $table): Table
|
|||||||
$this->removeTableFilter('status');
|
$this->removeTableFilter('status');
|
||||||
$this->removeTableFilter('current_validity_state');
|
$this->removeTableFilter('current_validity_state');
|
||||||
$this->selectedFindingExceptionId = null;
|
$this->selectedFindingExceptionId = null;
|
||||||
|
$this->showSelectedExceptionSummary = false;
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedTableFilters(): void
|
|
||||||
{
|
|
||||||
$this->normalizeSelectedFindingExceptionId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedTableSearch(): void
|
|
||||||
{
|
|
||||||
$this->normalizeSelectedFindingExceptionId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedFindingException(): ?FindingException
|
public function selectedFindingException(): ?FindingException
|
||||||
{
|
{
|
||||||
if (! is_int($this->selectedFindingExceptionId)) {
|
if (! is_int($this->selectedFindingExceptionId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->normalizeSelectedFindingExceptionId();
|
|
||||||
|
|
||||||
if (! is_int($this->selectedFindingExceptionId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||||
} catch (NotFoundHttpException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectedExceptionUrl(): ?string
|
public function selectedExceptionUrl(): ?string
|
||||||
@ -496,6 +434,7 @@ public function selectedFindingUrl(): ?string
|
|||||||
public function clearSelectedException(): void
|
public function clearSelectedException(): void
|
||||||
{
|
{
|
||||||
$this->selectedFindingExceptionId = null;
|
$this->selectedFindingExceptionId = null;
|
||||||
|
$this->showSelectedExceptionSummary = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -603,11 +542,11 @@ private function filteredTenant(): ?Tenant
|
|||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
private function currentTenantFilterId(): ?int
|
||||||
{
|
{
|
||||||
$tenantFilter = app(CanonicalAdminTenantFilterState::class)->currentFilterValue(
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
$this->tableFilters ?? [],
|
if (! is_numeric($tenantFilter)) {
|
||||||
request(),
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
);
|
}
|
||||||
|
|
||||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||||
}
|
}
|
||||||
@ -632,126 +571,15 @@ private function resolveSelectedFindingException(int $findingExceptionId): Findi
|
|||||||
return $record;
|
return $record;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function queueUrl(array $overrides = []): string
|
private function inspectedFindingException(): ?FindingException
|
||||||
{
|
{
|
||||||
$parameters = array_merge(
|
$mountedRecord = $this->getMountedTableActionRecord();
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
|
||||||
[
|
|
||||||
'tenant' => $this->filteredTenant()?->getKey(),
|
|
||||||
'exception' => $this->selectedFindingExceptionId,
|
|
||||||
],
|
|
||||||
$overrides,
|
|
||||||
);
|
|
||||||
|
|
||||||
return static::getUrl(
|
if ($mountedRecord instanceof FindingException) {
|
||||||
panel: 'admin',
|
return $mountedRecord;
|
||||||
parameters: array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function navigationContext(): ?CanonicalNavigationContext
|
return $this->selectedFindingException();
|
||||||
{
|
|
||||||
return CanonicalNavigationContext::fromRequest(request());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeSelectedFindingExceptionId(): void
|
|
||||||
{
|
|
||||||
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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
|
private function governanceWarning(FindingException $record): ?string
|
||||||
|
|||||||
@ -21,7 +21,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use App\Models\User;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -39,80 +38,6 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
use InteractsWithTable;
|
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';
|
public string $activeTab = 'all';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -145,14 +70,6 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.');
|
->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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
@ -268,23 +185,15 @@ 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
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
{
|
{
|
||||||
if (! is_array($this->navigationContextPayload)) {
|
if (! is_array($this->navigationContextPayload)) {
|
||||||
return CanonicalNavigationContext::fromRequest(request());
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
}
|
}
|
||||||
|
|
||||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::fromRequest($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedActiveTab(): void
|
public function updatedActiveTab(): void
|
||||||
@ -297,7 +206,11 @@ public function table(Table $table): Table
|
|||||||
return OperationRunResource::table($table)
|
return OperationRunResource::table($table)
|
||||||
->query(function (): Builder {
|
->query(function (): Builder {
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantFilter = $this->currentTenantFilterId();
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->with('user')
|
->with('user')
|
||||||
@ -311,8 +224,8 @@ public function table(Table $table): Table
|
|||||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$tenantFilter !== null,
|
is_numeric($tenantFilter),
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->applyActiveTab($query);
|
return $this->applyActiveTab($query);
|
||||||
@ -387,22 +300,26 @@ private function scopedSummaryQuery(): ?Builder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantFilter = $this->currentTenantFilterId();
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('workspace_id', (int) $workspaceId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
->when(
|
->when(
|
||||||
$tenantFilter !== null,
|
is_numeric($tenantFilter),
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', $tenantFilter),
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyRequestedDashboardPrefilter(): void
|
private function applyRequestedDashboardPrefilter(): void
|
||||||
{
|
{
|
||||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||||
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('tenant_id'));
|
$requestedTenantId = request()->query('tenant_id');
|
||||||
|
|
||||||
if ($requestedTenantId !== null) {
|
if (is_numeric($requestedTenantId)) {
|
||||||
$tenantId = (string) $requestedTenantId;
|
$tenantId = (string) $requestedTenantId;
|
||||||
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
||||||
@ -411,7 +328,10 @@ private function applyRequestedDashboardPrefilter(): void
|
|||||||
|
|
||||||
$requestedProblemClass = request()->query('problemClass');
|
$requestedProblemClass = request()->query('problemClass');
|
||||||
|
|
||||||
if (in_array($requestedProblemClass, self::problemClassTabs(), true)) {
|
if (in_array($requestedProblemClass, [
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
], true)) {
|
||||||
$this->activeTab = (string) $requestedProblemClass;
|
$this->activeTab = (string) $requestedProblemClass;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -419,7 +339,16 @@ private function applyRequestedDashboardPrefilter(): void
|
|||||||
|
|
||||||
$requestedTab = request()->query('activeTab');
|
$requestedTab = request()->query('activeTab');
|
||||||
|
|
||||||
if (in_array($requestedTab, self::supportedTabs(), true)) {
|
if (in_array($requestedTab, [
|
||||||
|
'all',
|
||||||
|
'active',
|
||||||
|
'blocked',
|
||||||
|
'succeeded',
|
||||||
|
'partial',
|
||||||
|
'failed',
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
], true)) {
|
||||||
$this->activeTab = (string) $requestedTab;
|
$this->activeTab = (string) $requestedTab;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -428,94 +357,4 @@ private function shouldForceWorkspaceWideTenantScope(): bool
|
|||||||
{
|
{
|
||||||
return request()->query('tenant_scope') === 'all';
|
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,30 +10,16 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
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\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
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 Livewire\Attributes\Locked;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class TenantRequiredPermissions extends Page implements HasTable
|
class TenantRequiredPermissions extends Page
|
||||||
{
|
{
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -54,15 +40,24 @@ 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.');
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Locked]
|
public string $status = 'missing';
|
||||||
public ?int $scopedTenantId = null;
|
|
||||||
|
public string $type = 'all';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, mixed>|null
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
private ?array $cachedViewModel = null;
|
public array $features = [];
|
||||||
|
|
||||||
private ?string $cachedViewModelStateKey = null;
|
public string $search = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $viewModel = [];
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?int $scopedTenantId = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
@ -74,9 +69,9 @@ public function currentTenant(): ?Tenant
|
|||||||
return $this->trustedScopedTenant();
|
return $this->trustedScopedTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(Tenant|string|null $tenant = null): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant($tenant);
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -86,120 +81,109 @@ public function mount(Tenant|string|null $tenant = null): void
|
|||||||
$this->heading = $tenant->getFilamentName();
|
$this->heading = $tenant->getFilamentName();
|
||||||
$this->subheading = 'Required permissions';
|
$this->subheading = 'Required permissions';
|
||||||
|
|
||||||
$this->seedTableStateFromQuery();
|
$queryFeatures = request()->query('features', $this->features);
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
{
|
'status' => request()->query('status', $this->status),
|
||||||
return $table
|
'type' => request()->query('type', $this->type),
|
||||||
->defaultSort('sort_priority')
|
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||||
->defaultPaginationPageOption(25)
|
'search' => request()->query('search', $this->search),
|
||||||
->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()),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->status = $state['status'];
|
||||||
|
$this->type = $state['type'];
|
||||||
|
$this->features = $state['features'];
|
||||||
|
$this->search = $state['search'];
|
||||||
|
|
||||||
|
$this->refreshViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function updatedStatus(): void
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function viewModel(): array
|
|
||||||
{
|
{
|
||||||
return $this->viewModelForState($this->filterState());
|
$this->refreshViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearPermissionFilters(): void
|
public function updatedType(): void
|
||||||
{
|
{
|
||||||
$this->tableFilters = [
|
$this->refreshViewModel();
|
||||||
'status' => ['value' => 'missing'],
|
}
|
||||||
'type' => ['value' => 'all'],
|
|
||||||
'features' => ['values' => []],
|
|
||||||
];
|
|
||||||
$this->tableDeferredFilters = $this->tableFilters;
|
|
||||||
$this->tableSearch = '';
|
|
||||||
$this->cachedViewModel = null;
|
|
||||||
$this->cachedViewModelStateKey = null;
|
|
||||||
|
|
||||||
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
|
public function updatedFeatures(): void
|
||||||
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
|
{
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
$this->resetPage();
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reRunVerificationUrl(): string
|
public function reRunVerificationUrl(): string
|
||||||
@ -224,18 +208,8 @@ public function manageProviderConnectionUrl(): ?string
|
|||||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant
|
protected static function resolveScopedTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($tenant) && $tenant !== '') {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', $tenant)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$routeTenant = request()->route('tenant');
|
$routeTenant = request()->route('tenant');
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
if ($routeTenant instanceof Tenant) {
|
||||||
@ -248,14 +222,6 @@ protected static function resolveScopedTenant(Tenant|string|null $tenant = null)
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
$queryTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (is_string($queryTenant) && $queryTenant !== '') {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', $queryTenant)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,216 +293,4 @@ private function trustedScopedTenant(): ?Tenant
|
|||||||
return null;
|
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,8 +1760,6 @@ private function verificationReportViewData(): array
|
|||||||
'previousRunUrl' => null,
|
'previousRunUrl' => null,
|
||||||
'canAcknowledge' => false,
|
'canAcknowledge' => false,
|
||||||
'acknowledgements' => [],
|
'acknowledgements' => [],
|
||||||
'surface' => [],
|
|
||||||
'redactionNotes' => [],
|
|
||||||
'assistVisibility' => $assistVisibility,
|
'assistVisibility' => $assistVisibility,
|
||||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||||
@ -1811,28 +1809,7 @@ private function verificationReportViewData(): array
|
|||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||||
$surface = VerificationReportViewer::surface($run, $acknowledgements, [
|
$verificationReport = VerificationReportViewer::report($run);
|
||||||
'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 [
|
return [
|
||||||
'run' => [
|
'run' => [
|
||||||
@ -1855,8 +1832,6 @@ private function verificationReportViewData(): array
|
|||||||
'previousRunUrl' => $previousRunUrl,
|
'previousRunUrl' => $previousRunUrl,
|
||||||
'canAcknowledge' => $canAcknowledge,
|
'canAcknowledge' => $canAcknowledge,
|
||||||
'acknowledgements' => $acknowledgements,
|
'acknowledgements' => $acknowledgements,
|
||||||
'surface' => $surface,
|
|
||||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
|
||||||
'assistVisibility' => $assistVisibility,
|
'assistVisibility' => $assistVisibility,
|
||||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
use App\Filament\Resources\FindingResource\Pages;
|
||||||
use App\Filament\Support\NormalizedDiffSurface;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
@ -413,6 +412,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Section::make('Diff')
|
Section::make('Diff')
|
||||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||||
->schema([
|
->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')
|
ViewEntry::make('rbac_role_definition_diff')
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.rbac-role-definition-diff')
|
->view('filament.infolists.entries.rbac-role-definition-diff')
|
||||||
@ -425,13 +429,13 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->state(function (Finding $record): array {
|
->state(function (Finding $record): array {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return NormalizedDiffSurface::build(static::unavailableDiffState('No tenant context'), 'finding');
|
return static::unavailableDiffState('No tenant context');
|
||||||
}
|
}
|
||||||
|
|
||||||
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
||||||
|
|
||||||
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
||||||
return NormalizedDiffSurface::build(static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'), 'finding');
|
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||||
@ -448,9 +452,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NormalizedDiffSurface::build($diff, 'finding');
|
return $diff;
|
||||||
})
|
})
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
ViewEntry::make('scope_tags_diff')
|
ViewEntry::make('scope_tags_diff')
|
||||||
|
|||||||
@ -10,11 +10,14 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Enums\RelationshipType;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -176,6 +179,29 @@ public static function infolist(Schema $schema): Schema
|
|||||||
ViewEntry::make('dependencies')
|
ViewEntry::make('dependencies')
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.components.dependency-edges')
|
->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(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|||||||
@ -1178,18 +1178,6 @@ private static function verificationReportViewData(OperationRun $record): array
|
|||||||
'changeIndicator' => $changeIndicator,
|
'changeIndicator' => $changeIndicator,
|
||||||
'previousRunUrl' => $previousRunUrl,
|
'previousRunUrl' => $previousRunUrl,
|
||||||
'acknowledgements' => $acknowledgements,
|
'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),
|
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\PolicyResource\Pages;
|
use App\Filament\Resources\PolicyResource\Pages;
|
||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
use App\Filament\Support\NormalizedSettingsSurface;
|
|
||||||
use App\Jobs\BulkPolicyDeleteJob;
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
use App\Jobs\BulkPolicyExportJob;
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
use App\Jobs\BulkPolicyUnignoreJob;
|
use App\Jobs\BulkPolicyUnignoreJob;
|
||||||
@ -239,13 +238,25 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Tab::make('Settings')
|
Tab::make('Settings')
|
||||||
->id('settings')
|
->id('settings')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('settings')
|
ViewEntry::make('settings_catalog')
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.normalized-settings')
|
->view('filament.infolists.entries.normalized-settings')
|
||||||
->state(function (Policy $record) {
|
->state(function (Policy $record) {
|
||||||
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
|
return static::settingsTabState($record);
|
||||||
})
|
})
|
||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->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()
|
||||||
|
),
|
||||||
|
|
||||||
TextEntry::make('no_settings_available')
|
TextEntry::make('no_settings_available')
|
||||||
->label('Settings')
|
->label('Settings')
|
||||||
@ -290,7 +301,16 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.normalized-settings')
|
->view('filament.infolists.entries.normalized-settings')
|
||||||
->state(function (Policy $record) {
|
->state(function (Policy $record) {
|
||||||
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
|
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||||
|
static::latestSnapshot($record),
|
||||||
|
$record->policy_type ?? '',
|
||||||
|
$record->platform
|
||||||
|
);
|
||||||
|
|
||||||
|
$normalized['context'] = 'policy';
|
||||||
|
$normalized['record_id'] = (string) $record->getKey();
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
|
|||||||
@ -6,8 +6,6 @@
|
|||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||||
use App\Filament\Support\NormalizedDiffSurface;
|
|
||||||
use App\Filament\Support\NormalizedSettingsSurface;
|
|
||||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
use App\Jobs\BulkPolicyVersionRestoreJob;
|
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||||
@ -182,7 +180,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Tab::make('Normalized settings')
|
Tab::make('Normalized settings')
|
||||||
->id('normalized-settings')
|
->id('normalized-settings')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
Infolists\Components\ViewEntry::make('normalized_settings_catalog')
|
||||||
->view('filament.infolists.entries.normalized-settings')
|
->view('filament.infolists.entries.normalized-settings')
|
||||||
->state(function (PolicyVersion $record) {
|
->state(function (PolicyVersion $record) {
|
||||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||||
@ -191,12 +189,29 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$record->platform
|
$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['context'] = 'version';
|
||||||
$normalized['record_id'] = (string) $record->getKey();
|
$normalized['record_id'] = (string) $record->getKey();
|
||||||
$normalized['policy_type'] = $record->policy_type;
|
$normalized['policy_type'] = $record->policy_type;
|
||||||
|
|
||||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
return $normalized;
|
||||||
}),
|
})
|
||||||
|
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||||
]),
|
]),
|
||||||
Tab::make('Raw JSON')
|
Tab::make('Raw JSON')
|
||||||
->id('raw-json')
|
->id('raw-json')
|
||||||
@ -223,7 +238,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$result = $diff->compare($from, $to);
|
$result = $diff->compare($from, $to);
|
||||||
$result['policy_type'] = $record->policy_type;
|
$result['policy_type'] = $record->policy_type;
|
||||||
|
|
||||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
return $result;
|
||||||
}),
|
}),
|
||||||
Infolists\Components\ViewEntry::make('diff_json')
|
Infolists\Components\ViewEntry::make('diff_json')
|
||||||
->label('Raw diff (advanced)')
|
->label('Raw diff (advanced)')
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<?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,8 +5,6 @@
|
|||||||
namespace App\Filament\Support;
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Verification\VerificationReportFingerprint;
|
use App\Support\Verification\VerificationReportFingerprint;
|
||||||
use App\Support\Verification\VerificationReportSanitizer;
|
use App\Support\Verification\VerificationReportSanitizer;
|
||||||
@ -93,276 +91,6 @@ public static function shouldRenderForRun(OperationRun $run): bool
|
|||||||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
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
|
* @param array<string, mixed>|null $report
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
|
||||||
use App\Filament\Support\VerificationReportViewer;
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -190,12 +189,6 @@ protected function getViewData(): array
|
|||||||
$report = $run instanceof OperationRun
|
$report = $run instanceof OperationRun
|
||||||
? VerificationReportViewer::report($run)
|
? VerificationReportViewer::report($run)
|
||||||
: null;
|
: 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
|
$isInProgress = $run instanceof OperationRun
|
||||||
&& (string) $run->status !== OperationRunStatus::Completed->value;
|
&& (string) $run->status !== OperationRunStatus::Completed->value;
|
||||||
@ -237,20 +230,6 @@ protected function getViewData(): array
|
|||||||
'runData' => $runData,
|
'runData' => $runData,
|
||||||
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
|
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
|
||||||
'report' => $report,
|
'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),
|
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||||
'isInProgress' => $isInProgress,
|
'isInProgress' => $isInProgress,
|
||||||
'showStartAction' => ! ($run instanceof OperationRun) && $isTenantMember && $canOperate,
|
'showStartAction' => ! ($run instanceof OperationRun) && $isTenantMember && $canOperate,
|
||||||
|
|||||||
@ -1,264 +0,0 @@
|
|||||||
<?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,8 +53,6 @@ public function build(Tenant $tenant, array $filters = []): array
|
|||||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||||
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
||||||
|
|
||||||
$summaryPermissions = $filteredPermissions;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant' => [
|
'tenant' => [
|
||||||
'id' => (int) $tenant->getKey(),
|
'id' => (int) $tenant->getKey(),
|
||||||
@ -62,9 +60,9 @@ public function build(Tenant $tenant, array $filters = []): array
|
|||||||
'name' => (string) $tenant->name,
|
'name' => (string) $tenant->name,
|
||||||
],
|
],
|
||||||
'overview' => [
|
'overview' => [
|
||||||
'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)),
|
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
|
||||||
'counts' => self::deriveCounts($summaryPermissions),
|
'counts' => self::deriveCounts($allPermissions),
|
||||||
'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions),
|
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
||||||
'freshness' => $freshness,
|
'freshness' => $freshness,
|
||||||
],
|
],
|
||||||
'permissions' => $filteredPermissions,
|
'permissions' => $filteredPermissions,
|
||||||
|
|||||||
@ -16,32 +16,6 @@ final class CanonicalAdminTenantFilterState
|
|||||||
|
|
||||||
public function __construct(private readonly OperateHubShell $operateHubShell) {}
|
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
|
* @param array<int, string> $tenantSensitiveFilters
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -260,9 +260,7 @@ public function firstSlice(): array
|
|||||||
usedForProtectedAction: false,
|
usedForProtectedAction: false,
|
||||||
revalidationRequired: false,
|
revalidationRequired: false,
|
||||||
implementationMarkers: [
|
implementationMarkers: [
|
||||||
"SelectFilter::make('status')",
|
"public string \$status = 'missing';",
|
||||||
"'status' => ['value' => 'missing'],",
|
|
||||||
"'status' => \$filters['status']['value'] ?? data_get(\$this->tableFilters, 'status.value'),",
|
|
||||||
],
|
],
|
||||||
notes: 'Filter-only state for the permissions view model.',
|
notes: 'Filter-only state for the permissions view model.',
|
||||||
),
|
),
|
||||||
@ -274,9 +272,7 @@ public function firstSlice(): array
|
|||||||
usedForProtectedAction: false,
|
usedForProtectedAction: false,
|
||||||
revalidationRequired: false,
|
revalidationRequired: false,
|
||||||
implementationMarkers: [
|
implementationMarkers: [
|
||||||
"SelectFilter::make('type')",
|
"public string \$type = 'all';",
|
||||||
"'type' => ['value' => 'all'],",
|
|
||||||
"'type' => \$filters['type']['value'] ?? data_get(\$this->tableFilters, 'type.value'),",
|
|
||||||
],
|
],
|
||||||
notes: 'Filter-only state for the permissions view model.',
|
notes: 'Filter-only state for the permissions view model.',
|
||||||
),
|
),
|
||||||
@ -288,9 +284,7 @@ public function firstSlice(): array
|
|||||||
usedForProtectedAction: false,
|
usedForProtectedAction: false,
|
||||||
revalidationRequired: false,
|
revalidationRequired: false,
|
||||||
implementationMarkers: [
|
implementationMarkers: [
|
||||||
"SelectFilter::make('features')",
|
'public array $features = [];',
|
||||||
"'features' => ['values' => []],",
|
|
||||||
"'features' => \$filters['features']['values'] ?? data_get(\$this->tableFilters, 'features.values', []),",
|
|
||||||
],
|
],
|
||||||
notes: 'Filter-only state for the permissions view model.',
|
notes: 'Filter-only state for the permissions view model.',
|
||||||
),
|
),
|
||||||
@ -302,9 +296,7 @@ public function firstSlice(): array
|
|||||||
usedForProtectedAction: false,
|
usedForProtectedAction: false,
|
||||||
revalidationRequired: false,
|
revalidationRequired: false,
|
||||||
implementationMarkers: [
|
implementationMarkers: [
|
||||||
'->searchable()',
|
"public string \$search = '';",
|
||||||
"'search' => \$search ?? \$this->tableSearch,",
|
|
||||||
"\$this->tableSearch = '';",
|
|
||||||
],
|
],
|
||||||
notes: 'Filter-only state for the permissions view model.',
|
notes: 'Filter-only state for the permissions view model.',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -23,18 +23,6 @@ public function __construct(
|
|||||||
public array $filterPayload = [],
|
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
|
public static function fromRequest(Request $request): ?self
|
||||||
{
|
{
|
||||||
$payload = $request->query('nav');
|
$payload = $request->query('nav');
|
||||||
@ -68,19 +56,17 @@ public static function fromRequest(Request $request): ?self
|
|||||||
public function toQuery(): array
|
public function toQuery(): array
|
||||||
{
|
{
|
||||||
$query = $this->filterPayload;
|
$query = $this->filterPayload;
|
||||||
$query['nav'] = $this->navPayload();
|
$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 !== '');
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{nav: array<string, mixed>}
|
|
||||||
*/
|
|
||||||
public function navQuery(): array
|
|
||||||
{
|
|
||||||
return ['nav' => $this->navPayload()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
*/
|
*/
|
||||||
@ -107,18 +93,4 @@ public static function forBaselineCompareMatrix(
|
|||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function navPayload(): array
|
|
||||||
{
|
|
||||||
return array_filter([
|
|
||||||
'source_surface' => $this->sourceSurface,
|
|
||||||
'canonical_route_name' => $this->canonicalRouteName,
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'back_label' => $this->backLinkLabel,
|
|
||||||
'back_url' => $this->backLinkUrl,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,85 @@
|
|||||||
|
@php /** @var callable $getState */ @endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<livewire:inventory-item-dependency-edges-table
|
<form method="GET" class="flex items-center gap-2">
|
||||||
:inventory-item-id="(int) $getRecord()->getKey()"
|
<label for="direction" class="text-sm text-gray-600">Direction</label>
|
||||||
:key="'inventory-item-dependency-edges-'.$getRecord()->getKey()"
|
<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
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,38 +1,108 @@
|
|||||||
@php
|
@php
|
||||||
$surface = is_array($surface ?? null) ? $surface : [];
|
$report = $report ?? null;
|
||||||
$coreState = is_string($surface['coreState'] ?? null) ? (string) $surface['coreState'] : 'unavailable';
|
$report = is_array($report) ? $report : null;
|
||||||
$hostVariation = is_array($surface['hostVariation'] ?? null) ? $surface['hostVariation'] : [];
|
|
||||||
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
|
$run = $run ?? null;
|
||||||
$showDiagnosticsZone = (bool) ($diagnostics['hasTechnicalZone'] ?? true)
|
$run = is_array($run) ? $run : null;
|
||||||
&& ! (bool) ($hostVariation['supportsTechnicalDetailsTrigger'] ?? false);
|
|
||||||
$redactionNotes = is_array($redactionNotes ?? null)
|
$fingerprint = $fingerprint ?? null;
|
||||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||||
: [];
|
|
||||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
$changeIndicator = $changeIndicator ?? null;
|
||||||
$ackAction = $ackAction ?? null;
|
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||||
$showAssist = (bool) ($showAssist ?? false);
|
|
||||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
$previousRunUrl = $previousRunUrl ?? null;
|
||||||
? trim((string) $assistActionName)
|
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||||
: 'wizardVerificationRequiredPermissionsAssist';
|
|
||||||
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
|
$acknowledgements = $acknowledgements ?? [];
|
||||||
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
|
$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);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div class="space-y-4">
|
||||||
data-shared-detail-family="verification-report"
|
@if ($report === null || $summary === null)
|
||||||
data-host-kind="{{ (string) ($surface['hostKind'] ?? 'operation_run_detail') }}"
|
<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">
|
||||||
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">
|
<div class="font-medium text-gray-900 dark:text-white">
|
||||||
{{ $emptyState['title'] ?? 'Verification report unavailable' }}
|
Verification report unavailable
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
{{ $emptyState['message'] ?? 'This operation does not have a report yet.' }}
|
This operation doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
<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.
|
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||||
@ -47,10 +117,68 @@ class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shad
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@include('filament.components.verification-report.summary', [
|
@php
|
||||||
'surface' => $surface,
|
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
'redactionNotes' => $redactionNotes,
|
\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>
|
||||||
|
|
||||||
<div x-data="{ tab: 'issues' }" class="space-y-4">
|
<div x-data="{ tab: 'issues' }" class="space-y-4">
|
||||||
<x-filament::tabs label="Verification report tabs">
|
<x-filament::tabs label="Verification report tabs">
|
||||||
@ -68,30 +196,313 @@ class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shad
|
|||||||
>
|
>
|
||||||
Passed
|
Passed
|
||||||
</x-filament::tabs.item>
|
</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>
|
</x-filament::tabs>
|
||||||
|
|
||||||
<div x-show="tab === 'issues'">
|
<div x-show="tab === 'issues'">
|
||||||
@include('filament.components.verification-report.issues', [
|
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||||
'surface' => $surface,
|
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
'canAcknowledge' => $canAcknowledge,
|
No issues found in this report.
|
||||||
'ackAction' => $ackAction,
|
</div>
|
||||||
'showAssist' => $showAssist,
|
@else
|
||||||
'assistActionName' => $assistActionName,
|
<div class="space-y-3">
|
||||||
'linkBehavior' => $linkBehavior,
|
@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
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="tab === 'passed'" style="display: none;">
|
<div x-show="tab === 'passed'" style="display: none;">
|
||||||
@include('filament.components.verification-report.passed', [
|
@if ($passed === [])
|
||||||
'surface' => $surface,
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($showDiagnosticsZone)
|
@if ($previousRunUrl !== null)
|
||||||
@include('filament.components.verification-report.diagnostics', [
|
<div>
|
||||||
'surface' => $surface,
|
<a
|
||||||
])
|
href="{{ $previousRunUrl }}"
|
||||||
|
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||||
|
>
|
||||||
|
Open previous operation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
@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,26 +1,70 @@
|
|||||||
@php
|
@php
|
||||||
$fieldWrapperView = $getFieldWrapperView();
|
$fieldWrapperView = $getFieldWrapperView();
|
||||||
$run = is_array($run ?? null) ? $run : null;
|
|
||||||
$runUrl = is_string($runUrl ?? null) && trim((string) $runUrl) !== '' ? trim((string) $runUrl) : null;
|
$run = $run ?? null;
|
||||||
$surface = is_array($surface ?? null) ? $surface : [];
|
$run = is_array($run) ? $run : null;
|
||||||
$redactionNotes = is_array($redactionNotes ?? null)
|
|
||||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
$runUrl = $runUrl ?? null;
|
||||||
: [];
|
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||||
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
|
||||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
$report = $report ?? null;
|
||||||
? trim((string) $assistActionName)
|
$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)
|
||||||
: 'wizardVerificationRequiredPermissionsAssist';
|
: 'wizardVerificationRequiredPermissionsAssist';
|
||||||
$technicalDetailsActionName = is_string($technicalDetailsActionName ?? null) && trim((string) $technicalDetailsActionName) !== ''
|
|
||||||
? trim((string) $technicalDetailsActionName)
|
$technicalDetailsActionName = $technicalDetailsActionName ?? 'wizardVerificationTechnicalDetails';
|
||||||
|
$technicalDetailsActionName = is_string($technicalDetailsActionName) && trim($technicalDetailsActionName) !== ''
|
||||||
|
? trim($technicalDetailsActionName)
|
||||||
: 'wizardVerificationTechnicalDetails';
|
: 'wizardVerificationTechnicalDetails';
|
||||||
|
|
||||||
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
||||||
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
|
$assistReason = $assistVisibility['reason'] ?? 'hidden_irrelevant';
|
||||||
|
$assistReason = is_string($assistReason) ? $assistReason : 'hidden_irrelevant';
|
||||||
|
|
||||||
$assistDescription = match ($assistReason) {
|
$assistDescription = match ($assistReason) {
|
||||||
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
|
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
|
||||||
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
|
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
|
||||||
default => 'Review required permissions without leaving onboarding.',
|
default => 'Review required permissions without leaving onboarding.',
|
||||||
};
|
};
|
||||||
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
|
||||||
|
$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;
|
||||||
|
|
||||||
$completedAtLabel = null;
|
$completedAtLabel = null;
|
||||||
|
|
||||||
if ($completedAt !== null) {
|
if ($completedAt !== null) {
|
||||||
@ -31,15 +75,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = is_string($run['status'] ?? null) ? (string) $run['status'] : null;
|
$summary = $report['summary'] ?? null;
|
||||||
$runState = is_string($runState ?? null) ? (string) $runState : null;
|
$summary = is_array($summary) ? $summary : null;
|
||||||
|
|
||||||
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
|
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||||
$runState = $run === null
|
|
||||||
? 'no_run'
|
$checks = $report['checks'] ?? null;
|
||||||
: ($status === 'completed' ? 'completed' : 'active');
|
$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);
|
||||||
|
|
||||||
$ackAction = null;
|
$ackAction = null;
|
||||||
|
|
||||||
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
|
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
|
||||||
@ -47,6 +157,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$linkBehavior = app(\App\Support\Verification\VerificationLinkBehavior::class);
|
$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
|
@endphp
|
||||||
|
|
||||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||||
@ -92,6 +211,65 @@
|
|||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-4">
|
<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">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@if ($runUrl)
|
@if ($runUrl)
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
@ -137,15 +315,338 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@include('filament.components.verification-report-viewer', [
|
@if ($report === null || $summary === null)
|
||||||
'surface' => $surface,
|
<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">
|
||||||
'redactionNotes' => $redactionNotes,
|
<div class="font-medium text-gray-900 dark:text-white">
|
||||||
'canAcknowledge' => (bool) ($canAcknowledge ?? false),
|
Verification report unavailable
|
||||||
'ackAction' => $ackAction,
|
</div>
|
||||||
'showAssist' => $showAssist,
|
<div class="mt-1">
|
||||||
'assistActionName' => $assistActionName,
|
This operation doesn’t have a report yet. If it already completed, start verification again.
|
||||||
'linkBehavior' => $linkBehavior,
|
</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
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -1,2 +1,787 @@
|
|||||||
{{-- NormalizedDiffSurface normalized-diff wrapper --}}
|
@php
|
||||||
@include('filament.infolists.entries.normalized-diff.wrapper', ['state' => $getState() ?? []])
|
$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>
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,772 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
@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,2 +1,233 @@
|
|||||||
{{-- NormalizedSettingsSurface normalized-settings wrapper --}}
|
@php
|
||||||
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])
|
$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>
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
@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>
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
@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,2 +1,355 @@
|
|||||||
{{-- NormalizedSettingsSurface policy-settings-standard compatibility wrapper --}}
|
@php
|
||||||
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])
|
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>
|
||||||
|
|||||||
@ -29,20 +29,6 @@
|
|||||||
};
|
};
|
||||||
@endphp
|
@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)
|
@if ($arrivedFromCompareMatrix)
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@ -265,7 +265,7 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
|
<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">
|
<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. Refreshing the page discards these unapplied draft edits.
|
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -305,10 +305,6 @@
|
|||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
||||||
|
|||||||
@ -15,10 +15,6 @@
|
|||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<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.
|
Actor, outcome, target, and readable context stay visible even when the original record changes or disappears later.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,59 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<x-filament::section>
|
@if ($rows === [])
|
||||||
<div class="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
|
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
|
||||||
<p>Tenant and search query seeds can reopen this overview in a specific monitoring slice.</p>
|
<h2 class="text-lg font-semibold text-gray-950">No evidence snapshots in this scope</h2>
|
||||||
<p>Compatible filters and sorting still restore from the last session, but row inspection always leaves the page for the canonical evidence detail.</p>
|
<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>
|
</div>
|
||||||
</x-filament::section>
|
</div>
|
||||||
|
@else
|
||||||
{{ $this->table }}
|
<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
|
||||||
</div>
|
</div>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@ -10,14 +10,10 @@
|
|||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<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.
|
Review pending requests, expiring governance, and lapsed exception coverage across entitled tenants without leaving the Monitoring area.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($selectedException)
|
@if ($this->showSelectedExceptionSummary && $selectedException)
|
||||||
<x-filament::section heading="Focused review lane">
|
<x-filament::section heading="Focused review lane">
|
||||||
<x-slot name="description">
|
<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.
|
Selection-bound decisions now define the active work lane. Scope, filters, and drilldowns stay visible without competing with the current review step.
|
||||||
|
|||||||
@ -43,66 +43,48 @@
|
|||||||
<x-filament::tabs label="Operations tabs">
|
<x-filament::tabs label="Operations tabs">
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'all'"
|
:active="$this->activeTab === 'all'"
|
||||||
:href="$this->tabUrl('all')"
|
wire:click="$set('activeTab', 'all')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'active'"
|
:active="$this->activeTab === 'active'"
|
||||||
:href="$this->tabUrl('active')"
|
wire:click="$set('activeTab', 'active')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
Active
|
Active
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === $staleAttentionTab"
|
:active="$this->activeTab === $staleAttentionTab"
|
||||||
:href="$this->tabUrl($staleAttentionTab)"
|
wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
Likely stale
|
Likely stale
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === $terminalFollowUpTab"
|
:active="$this->activeTab === $terminalFollowUpTab"
|
||||||
:href="$this->tabUrl($terminalFollowUpTab)"
|
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
Terminal follow-up
|
Terminal follow-up
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'succeeded'"
|
:active="$this->activeTab === 'succeeded'"
|
||||||
:href="$this->tabUrl('succeeded')"
|
wire:click="$set('activeTab', 'succeeded')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
Succeeded
|
Succeeded
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'partial'"
|
:active="$this->activeTab === 'partial'"
|
||||||
:href="$this->tabUrl('partial')"
|
wire:click="$set('activeTab', 'partial')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
Partial
|
Partial
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'failed'"
|
:active="$this->activeTab === 'failed'"
|
||||||
:href="$this->tabUrl('failed')"
|
wire:click="$set('activeTab', 'failed')"
|
||||||
tag="a"
|
|
||||||
:spa-mode="true"
|
|
||||||
>
|
>
|
||||||
Failed
|
Failed
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
</x-filament::tabs>
|
</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)
|
@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">
|
<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.
|
{{ ($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();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
$vm = $this->viewModel();
|
$vm = is_array($viewModel ?? null) ? $viewModel : [];
|
||||||
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
||||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||||
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
|
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
|
||||||
@ -14,6 +14,20 @@
|
|||||||
|
|
||||||
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
||||||
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
|
$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;
|
$overall = $overview['overall'] ?? null;
|
||||||
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
|
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
|
||||||
@ -212,8 +226,10 @@ class="text-primary-600 hover:underline dark:text-primary-400"
|
|||||||
$selected = in_array($featureKey, $selectedFeatures, true);
|
$selected = in_array($featureKey, $selectedFeatures, true);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<button
|
||||||
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' }}"
|
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="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@ -229,9 +245,17 @@ class="rounded-xl border p-4 text-left {{ $selected ? 'border-primary-300 bg-pri
|
|||||||
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
|
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($selectedFeatures !== [])
|
||||||
|
<div>
|
||||||
|
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
|
||||||
|
Clear feature filter
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -451,14 +475,182 @@ class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800
|
|||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<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="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="font-semibold text-gray-950 dark:text-white">Native permission matrix</div>
|
<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">
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
Search doesn’t affect copy actions. Feature filters do.
|
Search doesn’t affect copy actions. Feature filters do.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ $this->table }}
|
<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>
|
||||||
|
</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
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -94,7 +94,8 @@
|
|||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
@include('filament.components.verification-report-viewer', [
|
@include('filament.components.verification-report-viewer', [
|
||||||
'surface' => $surface ?? [],
|
'run' => $runData,
|
||||||
|
'report' => $report,
|
||||||
'redactionNotes' => $redactionNotes ?? [],
|
'redactionNotes' => $redactionNotes ?? [],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
<div>
|
|
||||||
{{ $this->table }}
|
|
||||||
</div>
|
|
||||||
@ -50,7 +50,6 @@
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
||||||
->assertSee('Dense multi-tenant scan')
|
->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('Grouped legend')
|
||||||
->assertSee('Open finding')
|
->assertSee('Open finding')
|
||||||
->assertSee('More follow-up')
|
->assertSee('More follow-up')
|
||||||
@ -107,7 +106,6 @@
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
||||||
->assertSee('Compact compare results')
|
->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');
|
->assertSee('Open finding');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -144,7 +142,6 @@
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->waitForText('No rows match the current filters')
|
->waitForText('No rows match the current filters')
|
||||||
->assertSee('Passive auto-refresh every 5 seconds')
|
->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')
|
->click('Reset filters')
|
||||||
->waitForText('Dense multi-tenant scan')
|
->waitForText('Dense multi-tenant scan')
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
|||||||
@ -160,7 +160,6 @@ function spec194SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = '')
|
|||||||
->waitForText('Focused review lane')
|
->waitForText('Focused review lane')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertNoConsoleLogs()
|
->assertNoConsoleLogs()
|
||||||
->assertSee('Selection-bound decisions now define the active work lane.')
|
|
||||||
->assertSee('Approve exception')
|
->assertSee('Approve exception')
|
||||||
->assertSee('Reject exception');
|
->assertSee('Reject exception');
|
||||||
|
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
<?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,13 +39,11 @@
|
|||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
|
||||||
->assertCanSeeTableRecords([$audit])
|
->assertCanSeeTableRecords([$audit])
|
||||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
->mountTableAction('inspect', $audit)
|
||||||
->assertSee('Drift finding #'.$finding->getKey())
|
->assertMountedActionModalSee('Drift finding #'.$finding->getKey())
|
||||||
->assertActionVisible('open_selected_audit_target');
|
->assertMountedActionModalSee('Open finding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps deleted findings readable while suppressing finding drill-down links', function (): void {
|
it('keeps deleted findings readable while suppressing finding drill-down links', function (): void {
|
||||||
@ -80,13 +78,11 @@
|
|||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
|
||||||
->assertCanSeeTableRecords([$audit])
|
->assertCanSeeTableRecords([$audit])
|
||||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
->mountTableAction('inspect', $audit)
|
||||||
->assertSee('Permission posture finding #'.$findingId)
|
->assertMountedActionModalSee('Permission posture finding #'.$findingId)
|
||||||
->assertActionDoesNotExist('open_selected_audit_target');
|
->assertMountedActionModalDontSee('Open finding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render internal audit bookkeeping metadata in the inspection view', function (): void {
|
it('does not render internal audit bookkeeping metadata in the inspection view', function (): void {
|
||||||
@ -120,15 +116,13 @@
|
|||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => (int) $audit->getKey()])
|
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
|
||||||
->assertCanSeeTableRecords([$audit])
|
->assertCanSeeTableRecords([$audit])
|
||||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
->mountTableAction('inspect', $audit)
|
||||||
->assertDontSee('_dedupe_key')
|
->assertMountedActionModalDontSee('_dedupe_key')
|
||||||
->assertDontSee('internal-bookkeeping-marker')
|
->assertMountedActionModalDontSee('internal-bookkeeping-marker')
|
||||||
->assertDontSee('_actor_type')
|
->assertMountedActionModalDontSee('_actor_type')
|
||||||
->assertDontSee('hidden-actor-marker');
|
->assertMountedActionModalDontSee('hidden-actor-marker');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides finding audit rows for tenants outside the viewer entitlement scope', function (): void {
|
it('hides finding audit rows for tenants outside the viewer entitlement scope', function (): void {
|
||||||
@ -184,17 +178,13 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||||
->assertSuccessful()
|
->assertNotFound();
|
||||||
->assertDontSee('Finding reopened for Drift finding #'.$findingB->getKey());
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => (int) $hidden->getKey()])
|
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
|
||||||
->assertSet('selectedAuditLogId', null)
|
|
||||||
->assertCanSeeTableRecords([$visible])
|
->assertCanSeeTableRecords([$visible])
|
||||||
->assertCanNotSeeTableRecords([$hidden]);
|
->assertCanNotSeeTableRecords([$hidden]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -86,7 +86,7 @@
|
|||||||
'display_name' => 'My Policy 123',
|
'display_name' => 'My Policy 123',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Normalized diff')
|
->assertSee('Normalized diff')
|
||||||
@ -96,10 +96,4 @@
|
|||||||
->assertSee('To')
|
->assertSee('To')
|
||||||
->assertSee('Old value')
|
->assertSee('Old value')
|
||||||
->assertSee('New 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 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||||
filamentTenantRouteParams($tenant),
|
filamentTenantRouteParams($tenant),
|
||||||
['record' => $finding],
|
['record' => $finding],
|
||||||
@ -45,11 +45,6 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Diff unavailable')
|
->assertSee('Diff unavailable')
|
||||||
->assertDontSee('No normalized changes were found');
|
->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 {
|
it('renders a diff against an empty baseline for unexpected_policy findings with a current policy version reference', function (): void {
|
||||||
@ -106,7 +101,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||||
filamentTenantRouteParams($tenant),
|
filamentTenantRouteParams($tenant),
|
||||||
['record' => $finding],
|
['record' => $finding],
|
||||||
@ -115,11 +110,6 @@
|
|||||||
->assertDontSee('Diff unavailable')
|
->assertDontSee('Diff unavailable')
|
||||||
->assertSee('1 added')
|
->assertSee('1 added')
|
||||||
->assertSee('Password required');
|
->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 {
|
it('renders a diff against an empty current side for missing_policy findings with a baseline policy version reference', function (): void {
|
||||||
@ -176,7 +166,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||||
filamentTenantRouteParams($tenant),
|
filamentTenantRouteParams($tenant),
|
||||||
['record' => $finding],
|
['record' => $finding],
|
||||||
@ -185,9 +175,4 @@
|
|||||||
->assertDontSee('Diff unavailable')
|
->assertDontSee('Diff unavailable')
|
||||||
->assertSee('1 removed')
|
->assertSee('1 removed')
|
||||||
->assertSee('Password required');
|
->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,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -11,7 +10,6 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
@ -124,56 +122,3 @@
|
|||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
|
||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), 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,13 +85,5 @@ function auditLogAuthorizationTestRecord(Tenant $tenant, array $attributes = [])
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||||
->assertSuccessful()
|
->assertNotFound();
|
||||||
->assertDontSee('Tenant B audit event');
|
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => (int) $hidden->getKey()])
|
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
|
||||||
->assertSet('selectedAuditLogId', null)
|
|
||||||
->assertCanSeeTableRecords([$visible])
|
|
||||||
->assertCanNotSeeTableRecords([$hidden]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,17 +11,11 @@
|
|||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null, ?int $selectedAuditLogId = null): Testable
|
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null): Testable
|
||||||
{
|
{
|
||||||
test()->actingAs($user);
|
test()->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
if ($selectedAuditLogId !== null) {
|
|
||||||
return Livewire::withQueryParams(['event' => $selectedAuditLogId])
|
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,8 +52,8 @@ function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): Audit
|
|||||||
'summary' => 'Backup set created for Nightly iOS backup',
|
'summary' => 'Backup set created for Nightly iOS backup',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
auditLogDetailTestComponent($user, selectedAuditLogId: (int) $audit->getKey())
|
auditLogDetailTestComponent($user)
|
||||||
->assertCanSeeTableRecords([$audit])
|
->callTableAction('inspect', $audit)
|
||||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||||
->assertSee('Readable context')
|
->assertSee('Readable context')
|
||||||
->assertSee('Technical metadata')
|
->assertSee('Technical metadata')
|
||||||
@ -84,8 +78,8 @@ function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): Audit
|
|||||||
'summary' => 'Backup set archived for Archived backup',
|
'summary' => 'Backup set archived for Archived backup',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
auditLogDetailTestComponent($user, selectedAuditLogId: (int) $audit->getKey())
|
auditLogDetailTestComponent($user)
|
||||||
->assertCanSeeTableRecords([$audit])
|
->callTableAction('inspect', $audit)
|
||||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||||
->assertSee('Archived backup')
|
->assertSee('Archived backup')
|
||||||
->assertSee('Technical metadata')
|
->assertSee('Technical metadata')
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Livewire\BulkOperationProgress;
|
use App\Livewire\BulkOperationProgress;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
@ -264,41 +263,6 @@
|
|||||||
->assertStatus(200);
|
->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 {
|
it('exposes full coverage + fidelity context in stats', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
@ -325,37 +325,3 @@
|
|||||||
->assertSee('No rows match the current filters')
|
->assertSee('No rows match the current filters')
|
||||||
->assertSee('Reset 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,8 +40,6 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EvidenceOverview::class)
|
->test(EvidenceOverview::class)
|
||||||
->assertCountTableRecords(1)
|
|
||||||
->assertCanSeeTableRecords([(string) $snapshot->getKey()])
|
|
||||||
->assertSee($tenant->name)
|
->assertSee($tenant->name)
|
||||||
->assertSee('Artifact truth');
|
->assertSee('Artifact truth');
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
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 () {
|
test('group policy configuration normalized diff keys use definition display names', function () {
|
||||||
$flat = app(PolicyNormalizer::class)->flattenForDiff(
|
$flat = app(PolicyNormalizer::class)->flattenForDiff(
|
||||||
@ -35,77 +27,3 @@
|
|||||||
expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)');
|
expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)');
|
||||||
expect(implode("\n", $keys))->not->toContain('graph.microsoft.com');
|
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"');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
<?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();
|
|
||||||
});
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
<?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,10 +4,8 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -37,68 +35,3 @@
|
|||||||
->assertCanSeeTableRecords([$policyA])
|
->assertCanSeeTableRecords([$policyA])
|
||||||
->assertCanNotSeeTableRecords([$policyB]);
|
->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,7 +8,6 @@
|
|||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -74,78 +73,3 @@
|
|||||||
->get(PolicyVersionResource::getUrl('view', ['record' => $versionB], panel: 'admin'))
|
->get(PolicyVersionResource::getUrl('view', ['record' => $versionB], panel: 'admin'))
|
||||||
->assertNotFound();
|
->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,12 +50,6 @@
|
|||||||
$response->assertSee('Normalized settings');
|
$response->assertSee('Normalized settings');
|
||||||
$response->assertSee('Enable feature');
|
$response->assertSee('Enable feature');
|
||||||
$response->assertSee('Normalized diff');
|
$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 () {
|
test('policy version detail shows enrollment notification template settings', function () {
|
||||||
@ -145,8 +139,4 @@
|
|||||||
$response->assertSee('Push Subject');
|
$response->assertSee('Push Subject');
|
||||||
$response->assertSee('Push (en-us) Message');
|
$response->assertSee('Push (en-us) Message');
|
||||||
$response->assertSee('Push Body');
|
$response->assertSee('Push Body');
|
||||||
|
|
||||||
expect($response->getContent())
|
|
||||||
->toContain('data-shared-detail-family="normalized-settings"')
|
|
||||||
->toContain('data-shared-normalized-settings-host="policy_version"');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Models\SettingsCatalogCategory;
|
use App\Models\SettingsCatalogCategory;
|
||||||
use App\Models\SettingsCatalogDefinition;
|
use App\Models\SettingsCatalogDefinition;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -58,69 +53,3 @@
|
|||||||
expect($keys)->toContain('Settings > Account Management > Deletion Policy');
|
expect($keys)->toContain('Settings > Account Management > Deletion Policy');
|
||||||
expect(implode("\n", $keys))->not->toContain('device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy');
|
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,11 +111,10 @@
|
|||||||
$versionResponse->assertSee('Enabled');
|
$versionResponse->assertSee('Enabled');
|
||||||
$versionResponse->assertSee('device_vendor_msft_policy_config_system_child');
|
$versionResponse->assertSee('device_vendor_msft_policy_config_system_child');
|
||||||
|
|
||||||
expect($versionResponse->getContent())
|
$versionGeneralSection = [];
|
||||||
->toContain('data-shared-detail-family="normalized-settings"')
|
preg_match('/<section[^>]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection);
|
||||||
->toContain('data-shared-normalized-settings-host="policy_version"')
|
expect($versionGeneralSection)->not->toBeEmpty();
|
||||||
->toContain('data-shared-normalized-settings-variant="settings_catalog_table"')
|
expect($versionGeneralSection[0])->toContain('x-cloak');
|
||||||
->toContain('data-shared-zone="settings-table"');
|
|
||||||
})->with([
|
})->with([
|
||||||
'settingsCatalogPolicy',
|
'settingsCatalogPolicy',
|
||||||
'endpointSecurityPolicy',
|
'endpointSecurityPolicy',
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
<?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,8 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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\Pages\Monitoring\Operations;
|
||||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||||
@ -17,9 +15,6 @@
|
|||||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
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\Models\Tenant;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -39,10 +34,8 @@ function spec125AssertPersistedTableState(
|
|||||||
string $sortDirection,
|
string $sortDirection,
|
||||||
string $filterPath,
|
string $filterPath,
|
||||||
mixed $filterValue,
|
mixed $filterValue,
|
||||||
array $queryParams = [],
|
|
||||||
): void {
|
): void {
|
||||||
$component = Livewire::withQueryParams($queryParams)
|
$component = Livewire::test($componentClass, $parameters)
|
||||||
->test($componentClass, $parameters)
|
|
||||||
->searchTable($search)
|
->searchTable($search)
|
||||||
->call('sortTable', $sortColumn, $sortDirection)
|
->call('sortTable', $sortColumn, $sortDirection)
|
||||||
->set($filterPath, $filterValue);
|
->set($filterPath, $filterValue);
|
||||||
@ -53,8 +46,7 @@ function spec125AssertPersistedTableState(
|
|||||||
expect(session()->get($instance->getTableSortSessionKey()))->toBe("{$sortColumn}:{$sortDirection}");
|
expect(session()->get($instance->getTableSortSessionKey()))->toBe("{$sortColumn}:{$sortDirection}");
|
||||||
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), str($filterPath)->after('tableFilters.')->value()))->toBe($filterValue);
|
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), str($filterPath)->after('tableFilters.')->value()))->toBe($filterValue);
|
||||||
|
|
||||||
Livewire::withQueryParams($queryParams)
|
Livewire::test($componentClass, $parameters)
|
||||||
->test($componentClass, $parameters)
|
|
||||||
->assertSet('tableSearch', $search)
|
->assertSet('tableSearch', $search)
|
||||||
->assertSet('tableSort', "{$sortColumn}:{$sortDirection}")
|
->assertSet('tableSort', "{$sortColumn}:{$sortDirection}")
|
||||||
->assertSet($filterPath, $filterValue);
|
->assertSet($filterPath, $filterValue);
|
||||||
@ -292,132 +284,6 @@ 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 {
|
it('reseeds the provider-connections tenant filter when the remembered admin tenant changes', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|||||||
@ -155,6 +155,14 @@ static function (Tenant $tenant, string $label): RestoreRun {
|
|||||||
return RestoreRun::factory()->for($tenant)->for($backupSet)->create();
|
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' => [
|
'finding view' => [
|
||||||
FindingResource::class,
|
FindingResource::class,
|
||||||
'view',
|
'view',
|
||||||
@ -236,34 +244,3 @@ static function (Tenant $tenant, string $label): BackupSchedule {
|
|||||||
->get($resourceClass::getUrl($page, ['record' => $blocked], panel: 'admin'))
|
->get($resourceClass::getUrl($page, ['record' => $blocked], panel: 'admin'))
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
})->with('tenant-owned-detail-pages');
|
})->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();
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,126 +0,0 @@
|
|||||||
<?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();
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($user): void {
|
assertNoOutboundHttp(function () use ($user): void {
|
||||||
$component = Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(TenantVerificationReport::class)
|
->test(TenantVerificationReport::class)
|
||||||
->assertSee('Provider connection preflight')
|
->assertSee('Provider connection preflight')
|
||||||
->assertSee(OperationRunLinks::openLabel())
|
->assertSee(OperationRunLinks::openLabel())
|
||||||
@ -116,13 +116,6 @@
|
|||||||
->assertSee(OperationRunLinks::identifierLabel().':')
|
->assertSee(OperationRunLinks::identifierLabel().':')
|
||||||
->assertSee('Read-only:')
|
->assertSee('Read-only:')
|
||||||
->assertSee('Insufficient permission — ask a tenant Owner.');
|
->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,9 +5,6 @@
|
|||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -119,89 +116,3 @@
|
|||||||
expect($exception->status())->toBe(404);
|
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,9 +29,7 @@
|
|||||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||||
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
||||||
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
|
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
|
||||||
'app/Filament/Pages/TenantRequiredPermissions.php',
|
|
||||||
'app/Filament/Pages/InventoryCoverage.php',
|
'app/Filament/Pages/InventoryCoverage.php',
|
||||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
|
|
||||||
'app/Filament/System/Pages/Directory/Tenants.php',
|
'app/Filament/System/Pages/Directory/Tenants.php',
|
||||||
'app/Filament/System/Pages/Directory/Workspaces.php',
|
'app/Filament/System/Pages/Directory/Workspaces.php',
|
||||||
'app/Filament/System/Pages/Ops/Runs.php',
|
'app/Filament/System/Pages/Ops/Runs.php',
|
||||||
@ -41,7 +39,6 @@
|
|||||||
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
|
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
|
||||||
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
|
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
|
||||||
'app/Filament/Widgets/Dashboard/RecentOperations.php',
|
'app/Filament/Widgets/Dashboard/RecentOperations.php',
|
||||||
'app/Livewire/InventoryItemDependencyEdgesTable.php',
|
|
||||||
'app/Livewire/BackupSetPolicyPickerTable.php',
|
'app/Livewire/BackupSetPolicyPickerTable.php',
|
||||||
'app/Livewire/EntraGroupCachePickerTable.php',
|
'app/Livewire/EntraGroupCachePickerTable.php',
|
||||||
'app/Livewire/SettingsCatalogSettingsTable.php',
|
'app/Livewire/SettingsCatalogSettingsTable.php',
|
||||||
@ -84,9 +81,7 @@
|
|||||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
|
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
|
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.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/InventoryCoverage.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => ['->emptyStateHeading('],
|
|
||||||
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
|
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
|
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
|
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
|
||||||
@ -96,7 +91,6 @@
|
|||||||
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
|
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
|
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
|
||||||
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
|
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
|
||||||
'app/Livewire/InventoryItemDependencyEdgesTable.php' => ['->emptyStateHeading('],
|
|
||||||
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
|
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
|
||||||
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
|
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
|
||||||
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
|
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
|
||||||
@ -140,8 +134,6 @@
|
|||||||
'app/Filament/Resources/EntraGroupResource.php',
|
'app/Filament/Resources/EntraGroupResource.php',
|
||||||
'app/Filament/Resources/OperationRunResource.php',
|
'app/Filament/Resources/OperationRunResource.php',
|
||||||
'app/Filament/Resources/BaselineSnapshotResource.php',
|
'app/Filament/Resources/BaselineSnapshotResource.php',
|
||||||
'app/Filament/Pages/TenantRequiredPermissions.php',
|
|
||||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
|
|
||||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -318,9 +310,7 @@
|
|||||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||||
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
||||||
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
|
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
|
||||||
'app/Filament/Pages/TenantRequiredPermissions.php',
|
|
||||||
'app/Filament/Pages/InventoryCoverage.php',
|
'app/Filament/Pages/InventoryCoverage.php',
|
||||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
|
|
||||||
'app/Filament/System/Pages/Directory/Tenants.php',
|
'app/Filament/System/Pages/Directory/Tenants.php',
|
||||||
'app/Filament/System/Pages/Directory/Workspaces.php',
|
'app/Filament/System/Pages/Directory/Workspaces.php',
|
||||||
'app/Filament/System/Pages/Ops/Runs.php',
|
'app/Filament/System/Pages/Ops/Runs.php',
|
||||||
@ -330,7 +320,6 @@
|
|||||||
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
|
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
|
||||||
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
|
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
|
||||||
'app/Filament/Widgets/Dashboard/RecentOperations.php',
|
'app/Filament/Widgets/Dashboard/RecentOperations.php',
|
||||||
'app/Livewire/InventoryItemDependencyEdgesTable.php',
|
|
||||||
'app/Livewire/BackupSetPolicyPickerTable.php',
|
'app/Livewire/BackupSetPolicyPickerTable.php',
|
||||||
'app/Livewire/EntraGroupCachePickerTable.php',
|
'app/Livewire/EntraGroupCachePickerTable.php',
|
||||||
'app/Livewire/SettingsCatalogSettingsTable.php',
|
'app/Livewire/SettingsCatalogSettingsTable.php',
|
||||||
@ -348,85 +337,6 @@
|
|||||||
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
|
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 {
|
it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
|
||||||
$patternByPath = [
|
$patternByPath = [
|
||||||
'app/Filament/Resources/TenantResource.php' => [
|
'app/Filament/Resources/TenantResource.php' => [
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
<?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([]);
|
|
||||||
});
|
|
||||||
@ -41,7 +41,7 @@
|
|||||||
->assertSee('Last known: Ghost Target');
|
->assertSee('Last known: Ghost Target');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders native dependency controls in place instead of a GET apply workflow', function () {
|
it('direction filter limits to outbound or inbound', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
@ -51,48 +51,34 @@
|
|||||||
'external_id' => (string) Str::uuid(),
|
'external_id' => (string) Str::uuid(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$inboundSource = InventoryItem::factory()->create([
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'external_id' => (string) Str::uuid(),
|
|
||||||
'display_name' => 'Inbound Source',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Outbound only
|
// Outbound only
|
||||||
InventoryLink::factory()->create([
|
InventoryLink::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'source_type' => 'inventory_item',
|
'source_type' => 'inventory_item',
|
||||||
'source_id' => $item->external_id,
|
'source_id' => $item->external_id,
|
||||||
'target_type' => 'missing',
|
'target_type' => 'foundation_object',
|
||||||
'target_id' => null,
|
'target_id' => (string) Str::uuid(),
|
||||||
'relationship_type' => 'assigned_to',
|
'relationship_type' => 'assigned_to',
|
||||||
'metadata' => [
|
|
||||||
'last_known_name' => 'Assigned Target',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Inbound only
|
// Inbound only
|
||||||
InventoryLink::factory()->create([
|
InventoryLink::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'source_type' => 'inventory_item',
|
'source_type' => 'inventory_item',
|
||||||
'source_id' => $inboundSource->external_id,
|
'source_id' => (string) Str::uuid(),
|
||||||
'target_type' => 'inventory_item',
|
'target_type' => 'inventory_item',
|
||||||
'target_id' => $item->external_id,
|
'target_id' => $item->external_id,
|
||||||
'relationship_type' => 'depends_on',
|
'relationship_type' => 'depends_on',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
|
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
|
||||||
|
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
|
||||||
|
|
||||||
$this->get($url)
|
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound';
|
||||||
->assertOk()
|
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
|
||||||
->assertSee('Direction')
|
|
||||||
->assertSee('Inbound')
|
|
||||||
->assertSee('Outbound')
|
|
||||||
->assertSee('Relationship')
|
|
||||||
->assertSee('Assigned Target')
|
|
||||||
->assertDontSee('No dependencies found');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores legacy relationship query state while preserving visible target safety', function () {
|
it('relationship filter limits edges by type', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
@ -129,7 +115,7 @@
|
|||||||
$this->get($url)
|
$this->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Scoped Target')
|
->assertSee('Scoped Target')
|
||||||
->assertSee('Assigned Target');
|
->assertDontSee('Assigned Target');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show edges from other tenants (tenant isolation)', function () {
|
it('does not show edges from other tenants (tenant isolation)', function () {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('hydrates the selected audit event from the query and renders inline detail', function (): void {
|
it('opens the selected audit event in a slideover inspection surface', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$audit = AuditLog::query()->create([
|
$audit = AuditLog::query()->create([
|
||||||
@ -36,20 +36,16 @@
|
|||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
Livewire::actingAs($user)
|
||||||
'event' => (int) $audit->getKey(),
|
|
||||||
])
|
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
->test(AuditLogPage::class)
|
||||||
->assertCanSeeTableRecords([$audit])
|
->assertCanSeeTableRecords([$audit])
|
||||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
->mountTableAction('inspect', $audit)
|
||||||
->assertSee('Workspace selected for Workspace 1')
|
->assertMountedActionModalSee('Workspace selected for Workspace 1')
|
||||||
->assertSee('Readable context')
|
->assertMountedActionModalSee('Readable context')
|
||||||
->assertSee('Technical metadata')
|
->assertMountedActionModalSee('Technical metadata');
|
||||||
->assertActionVisible('close_selected_audit_event');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows related navigation only for the currently selected operation-run event', function (): void {
|
it('shows operation-run navigation only for the currently inspected operation run event', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
@ -93,24 +89,20 @@
|
|||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
$component = Livewire::actingAs($user)
|
||||||
'event' => (int) $withRunLink->getKey(),
|
|
||||||
])
|
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
->test(AuditLogPage::class)
|
||||||
->assertCanSeeTableRecords([$withRunLink, $withoutRunLink])
|
->assertCanSeeTableRecords([$withRunLink, $withoutRunLink])
|
||||||
->assertActionVisible('open_selected_audit_target');
|
->mountTableAction('inspect', $withRunLink)
|
||||||
|
->assertMountedActionModalSee('Open operation');
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
$component
|
||||||
'event' => (int) $withoutRunLink->getKey(),
|
->call('replaceMountedTableAction', 'inspect', (string) $withoutRunLink->getKey())
|
||||||
])
|
->assertMountedActionModalSee('Workspace selected for Workspace 1')
|
||||||
->actingAs($user)
|
->assertMountedActionModalDontSee('Open operation')
|
||||||
->test(AuditLogPage::class)
|
->assertMountedActionModalDontSee('Baseline compare completed for Operation run');
|
||||||
->assertSee('Workspace selected for Workspace 1')
|
|
||||||
->assertActionDoesNotExist('open_selected_audit_target');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the unselected history when the requested event is invalid or unavailable', function (): void {
|
it('clearing the slideover closes the inspection surface cleanly', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$audit = AuditLog::query()->create([
|
$audit = AuditLog::query()->create([
|
||||||
@ -128,40 +120,19 @@
|
|||||||
'recorded_at' => now(),
|
'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);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => 999999])
|
$component = Livewire::actingAs($user)
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
->test(AuditLogPage::class)
|
||||||
->assertSet('selectedAuditLogId', null)
|
->mountTableAction('inspect', $audit)
|
||||||
->assertActionDoesNotExist('close_selected_audit_event');
|
->unmountTableAction()
|
||||||
|
->assertTableActionNotMounted('inspect');
|
||||||
|
|
||||||
Livewire::withQueryParams(['event' => (int) $foreignAudit->getKey()])
|
expect($component->instance()->getMountedTableAction())->toBeNull();
|
||||||
->actingAs($user)
|
|
||||||
->test(AuditLogPage::class)
|
|
||||||
->assertSet('selectedAuditLogId', null)
|
|
||||||
->assertActionDoesNotExist('close_selected_audit_event');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps selected-event actions out of the page header until an event is selected', function (): void {
|
it('keeps record inspection actions out of the global page header', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
@ -172,7 +143,7 @@
|
|||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$audit = AuditLog::query()->create([
|
AuditLog::query()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'actor_email' => 'owner@example.com',
|
'actor_email' => 'owner@example.com',
|
||||||
@ -193,13 +164,6 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertDontSee('Close details')
|
->assertDontSee('Close details')
|
||||||
->assertDontSee('Open operation');
|
->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 {
|
it('surfaces origin context quietly when deep-linked to a selected audit event', function (): void {
|
||||||
|
|||||||
@ -69,12 +69,10 @@
|
|||||||
'exception' => (int) $exception->getKey(),
|
'exception' => (int) $exception->getKey(),
|
||||||
])
|
])
|
||||||
->test(FindingExceptionsQueue::class)
|
->test(FindingExceptionsQueue::class)
|
||||||
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
|
|
||||||
->assertSee('Focused review lane')
|
->assertSee('Focused review lane')
|
||||||
->assertSee('Decision lane')
|
->assertSee('Decision lane')
|
||||||
->assertSee('Related drilldown')
|
->assertSee('Related drilldown')
|
||||||
->assertDontSee('Quiet monitoring mode')
|
->assertDontSee('Quiet monitoring mode')
|
||||||
->assertActionVisible('clear_selected_exception')
|
|
||||||
->assertActionVisible('approve_selected_exception')
|
->assertActionVisible('approve_selected_exception')
|
||||||
->assertActionVisible('reject_selected_exception')
|
->assertActionVisible('reject_selected_exception')
|
||||||
->mountAction('approve_selected_exception')
|
->mountAction('approve_selected_exception')
|
||||||
@ -91,42 +89,3 @@
|
|||||||
->callMountedAction()
|
->callMountedAction()
|
||||||
->assertHasActionErrors(['rejection_reason']);
|
->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,24 +84,20 @@
|
|||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||||
->assertActionVisible('view_tenant_register');
|
->assertActionVisible('view_tenant_register');
|
||||||
|
|
||||||
$filtersComponent = Livewire::test(FindingExceptionsQueue::class);
|
|
||||||
$queueInstance = $filtersComponent->instance();
|
|
||||||
session()->forget([
|
|
||||||
$queueInstance->getTableFiltersSessionKey(),
|
|
||||||
$queueInstance->getTableSearchSessionKey(),
|
|
||||||
$queueInstance->getTableSortSessionKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
Livewire::withQueryParams([
|
||||||
'exception' => (int) $expiring->getKey(),
|
'exception' => (int) $expiring->getKey(),
|
||||||
])
|
])
|
||||||
->test(FindingExceptionsQueue::class)
|
->test(FindingExceptionsQueue::class)
|
||||||
->assertSet('selectedFindingExceptionId', (int) $expiring->getKey())
|
->assertSet('selectedFindingExceptionId', (int) $expiring->getKey())
|
||||||
|
->assertSet('showSelectedExceptionSummary', true)
|
||||||
->assertActionVisible('clear_selected_exception')
|
->assertActionVisible('clear_selected_exception')
|
||||||
->assertActionVisible('open_selected_exception')
|
|
||||||
->assertActionVisible('open_selected_finding')
|
|
||||||
->assertSee('Queue visibility test')
|
->assertSee('Queue visibility test')
|
||||||
->assertSee('Expiring')
|
->assertSee('Expiring')
|
||||||
->assertSee($tenantA->name)
|
->assertSee($tenantA->name);
|
||||||
->assertSee('Focused review lane');
|
|
||||||
|
Livewire::test(FindingExceptionsQueue::class)
|
||||||
|
->mountTableAction('inspect_exception', (string) $expiring->getKey())
|
||||||
|
->assertMountedActionModalSee('Finding exception #'.$expiring->getKey())
|
||||||
|
->assertMountedActionModalSee('Queue visibility test')
|
||||||
|
->assertMountedActionModalSee('Close details');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,199 +0,0 @@
|
|||||||
<?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,40 +273,3 @@
|
|||||||
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
'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,7 +6,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
@ -143,45 +142,3 @@
|
|||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertNotFound();
|
->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,13 +3,11 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -150,81 +148,6 @@
|
|||||||
->assertForbidden();
|
->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 {
|
it('mounts the requested draft with canonical persisted continuity state even when other drafts exist', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
|
|||||||
@ -319,7 +319,7 @@
|
|||||||
'updated_by_user_id' => (int) $user->getKey(),
|
'updated_by_user_id' => (int) $user->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->followingRedirects()
|
->followingRedirects()
|
||||||
->get('/admin/onboarding')
|
->get('/admin/onboarding')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
@ -331,13 +331,6 @@
|
|||||||
->assertSee('Missing required Graph permissions.')
|
->assertSee('Missing required Graph permissions.')
|
||||||
->assertSee('Graph permissions')
|
->assertSee('Graph permissions')
|
||||||
->assertSee($entraTenantId);
|
->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 {
|
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(),
|
'updated_by_user_id' => (int) $user->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->followingRedirects()
|
->followingRedirects()
|
||||||
->get('/admin/onboarding')
|
->get('/admin/onboarding')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
@ -262,11 +262,4 @@
|
|||||||
->assertSee('First step')
|
->assertSee('First step')
|
||||||
->assertSee('Second step')
|
->assertSee('Second step')
|
||||||
->assertDontSee('Third 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,6 +26,5 @@
|
|||||||
|
|
||||||
expect($html)->toContain('Enabled')
|
expect($html)->toContain('Enabled')
|
||||||
->and($html)->toContain('Disabled')
|
->and($html)->toContain('Disabled')
|
||||||
->and($html)->toContain('fi-badge')
|
->and($html)->toContain('fi-badge');
|
||||||
->and($html)->toContain('data-shared-detail-family="normalized-settings"');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
use App\Filament\Pages\Monitoring\Operations;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -13,8 +10,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -90,101 +85,3 @@
|
|||||||
->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||||
->assertForbidden();
|
->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,7 +12,6 @@
|
|||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
@ -123,21 +122,3 @@
|
|||||||
], tenant: $fixture['visibleTenant']))
|
], tenant: $fixture['visibleTenant']))
|
||||||
->assertForbidden();
|
->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());
|
|
||||||
});
|
|
||||||
|
|||||||
@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
|
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
@ -56,7 +54,7 @@
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertSee($tenant->getFilamentName())
|
->assertSee($tenant->getFilamentName())
|
||||||
->assertSee('Tenant.Read.All');
|
->assertSee('data-permission-key="Tenant.Read.All"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 when the current workspace no longer matches the tenant route scope', function (): void {
|
it('returns 404 when the current workspace no longer matches the tenant route scope', function (): void {
|
||||||
@ -88,52 +86,3 @@
|
|||||||
->get('/admin/tenants/'.$tenant->external_id.'/required-permissions')
|
->get('/admin/tenants/'.$tenant->external_id.'/required-permissions')
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds native table state from deeplink filters without letting query values redefine the route tenant', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'name' => 'Ignored Query Tenant',
|
|
||||||
'external_id' => 'ignored-query-tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
config()->set('intune_permissions.permissions', [
|
|
||||||
[
|
|
||||||
'key' => 'Tenant.Read.All',
|
|
||||||
'type' => 'application',
|
|
||||||
'description' => 'Tenant read permission',
|
|
||||||
'features' => ['backup'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
config()->set('entra_permissions.permissions', []);
|
|
||||||
|
|
||||||
TenantPermission::query()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'permission_key' => 'Tenant.Read.All',
|
|
||||||
'status' => 'granted',
|
|
||||||
'details' => ['source' => 'db'],
|
|
||||||
'last_checked_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
$component = Livewire::withQueryParams([
|
|
||||||
'tenant' => $tenant->external_id,
|
|
||||||
'tenant_id' => (string) $otherTenant->getKey(),
|
|
||||||
'status' => 'present',
|
|
||||||
'type' => 'application',
|
|
||||||
'features' => ['backup'],
|
|
||||||
'search' => 'Tenant',
|
|
||||||
])->test(TenantRequiredPermissions::class);
|
|
||||||
|
|
||||||
$component
|
|
||||||
->assertSet('tableFilters.status.value', 'present')
|
|
||||||
->assertSet('tableFilters.type.value', 'application')
|
|
||||||
->assertSet('tableFilters.features.values', ['backup'])
|
|
||||||
->assertSet('tableSearch', 'Tenant');
|
|
||||||
|
|
||||||
expect($component->instance()->currentTenant()?->is($tenant))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('narrows required permissions results using filters and search', function (): void {
|
it('narrows required permissions results using filters and search', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
@ -56,71 +51,55 @@
|
|||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$missingResponse = $this->actingAs($user)
|
||||||
setAdminPanelContext();
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
->assertSuccessful()
|
||||||
|
->assertSee('All required permissions are present', false);
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
$missingResponse
|
||||||
'tenant' => (string) $tenant->external_id,
|
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||||
])
|
->assertDontSee('data-permission-key="Beta.Read.All"', false)
|
||||||
->test(TenantRequiredPermissions::class)
|
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||||
->assertSet('tableFilters.status.value', 'missing')
|
|
||||||
->assertSee('All required permissions are present')
|
|
||||||
->assertCanNotSeeTableRecords([
|
|
||||||
'Alpha.Read.All',
|
|
||||||
'Beta.Read.All',
|
|
||||||
'Gamma.Manage.All',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
$presentResponse = $this->actingAs($user)
|
||||||
'tenant' => (string) $tenant->external_id,
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present")
|
||||||
'status' => 'present',
|
->assertSuccessful()
|
||||||
])
|
->assertSee('wire:model.live="status"', false);
|
||||||
->test(TenantRequiredPermissions::class)
|
|
||||||
->assertSet('tableFilters.status.value', 'present')
|
|
||||||
->assertCanSeeTableRecords([
|
|
||||||
'Alpha.Read.All',
|
|
||||||
'Beta.Read.All',
|
|
||||||
'Gamma.Manage.All',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
$presentResponse
|
||||||
'tenant' => (string) $tenant->external_id,
|
->assertSee('data-permission-key="Alpha.Read.All"', false)
|
||||||
'status' => 'present',
|
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||||
'type' => 'delegated',
|
->assertSee('data-permission-key="Gamma.Manage.All"', false);
|
||||||
])
|
|
||||||
->test(TenantRequiredPermissions::class)
|
|
||||||
->assertSet('tableFilters.status.value', 'present')
|
|
||||||
->assertSet('tableFilters.type.value', 'delegated')
|
|
||||||
->assertCanSeeTableRecords(['Beta.Read.All'])
|
|
||||||
->assertCanNotSeeTableRecords([
|
|
||||||
'Alpha.Read.All',
|
|
||||||
'Gamma.Manage.All',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
$delegatedResponse = $this->actingAs($user)
|
||||||
'tenant' => (string) $tenant->external_id,
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present&type=delegated")
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$delegatedResponse
|
||||||
|
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||||
|
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||||
|
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||||
|
|
||||||
|
$featureQuery = http_build_query([
|
||||||
'status' => 'all',
|
'status' => 'all',
|
||||||
'features' => ['backup'],
|
'features' => ['backup'],
|
||||||
])
|
|
||||||
->test(TenantRequiredPermissions::class)
|
|
||||||
->assertSet('tableFilters.features.values', ['backup'])
|
|
||||||
->assertCanSeeTableRecords([
|
|
||||||
'Alpha.Read.All',
|
|
||||||
'Gamma.Manage.All',
|
|
||||||
])
|
|
||||||
->assertCanNotSeeTableRecords(['Beta.Read.All']);
|
|
||||||
|
|
||||||
Livewire::withQueryParams([
|
|
||||||
'tenant' => (string) $tenant->external_id,
|
|
||||||
'status' => 'all',
|
|
||||||
'search' => 'delegated',
|
|
||||||
])
|
|
||||||
->test(TenantRequiredPermissions::class)
|
|
||||||
->assertSet('tableSearch', 'delegated')
|
|
||||||
->assertCanSeeTableRecords(['Beta.Read.All'])
|
|
||||||
->assertCanNotSeeTableRecords([
|
|
||||||
'Alpha.Read.All',
|
|
||||||
'Gamma.Manage.All',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$featureResponse = $this->actingAs($user)
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$featureResponse
|
||||||
|
->assertSee('data-permission-key="Alpha.Read.All"', false)
|
||||||
|
->assertSee('data-permission-key="Gamma.Manage.All"', false)
|
||||||
|
->assertDontSee('data-permission-key="Beta.Read.All"', false);
|
||||||
|
|
||||||
|
$searchResponse = $this->actingAs($user)
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all&search=delegated")
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$searchResponse
|
||||||
|
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||||
|
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||||
|
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@ -40,21 +39,6 @@
|
|||||||
'type' => 'provider.connection.check',
|
'type' => 'provider.connection.check',
|
||||||
'status' => OperationRunStatus::Queued->value,
|
'status' => OperationRunStatus::Queued->value,
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
'context' => [
|
|
||||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
|
||||||
[
|
|
||||||
'key' => 'provider_connection',
|
|
||||||
'title' => 'Provider connection preflight',
|
|
||||||
'status' => 'fail',
|
|
||||||
'severity' => 'critical',
|
|
||||||
'blocking' => true,
|
|
||||||
'reason_code' => 'provider_connection_missing',
|
|
||||||
'message' => 'No provider connection configured.',
|
|
||||||
'evidence' => [],
|
|
||||||
'next_steps' => [],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
|
|||||||
@ -50,13 +50,6 @@
|
|||||||
->assertSee('Blocked')
|
->assertSee('Blocked')
|
||||||
->assertSee('Token acquisition works');
|
->assertSee('Token acquisition works');
|
||||||
|
|
||||||
expect($component->html())
|
|
||||||
->toContain('data-shared-detail-family="verification-report"')
|
|
||||||
->toContain('data-host-kind="operation_run_detail"')
|
|
||||||
->toContain('data-shared-zone="summary"')
|
|
||||||
->toContain('data-shared-zone="issues"')
|
|
||||||
->toContain('data-shared-zone="diagnostics"');
|
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->call('$refresh')
|
->call('$refresh')
|
||||||
->assertSee('Token acquisition works');
|
->assertSee('Token acquisition works');
|
||||||
@ -191,18 +184,11 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($user): void {
|
assertNoOutboundHttp(function () use ($user): void {
|
||||||
$response = $this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->followingRedirects()
|
->followingRedirects()
|
||||||
->get('/admin/onboarding')
|
->get('/admin/onboarding')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Onboarding check');
|
->assertSee('Onboarding check');
|
||||||
|
|
||||||
expect($response->getContent())
|
|
||||||
->toContain('data-shared-detail-family="verification-report"')
|
|
||||||
->toContain('data-host-kind="onboarding_wizard"')
|
|
||||||
->toContain('data-shared-zone="summary"')
|
|
||||||
->toContain('data-shared-zone="issues"')
|
|
||||||
->not->toContain('data-shared-zone="diagnostics"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Bus::assertNothingDispatched();
|
Bus::assertNothingDispatched();
|
||||||
|
|||||||
@ -377,18 +377,11 @@ x-spec-196-notes:
|
|||||||
- route tenant stays authoritative on required-permissions
|
- route tenant stays authoritative on required-permissions
|
||||||
- evidence overview only exposes entitled tenant rows
|
- evidence overview only exposes entitled tenant rows
|
||||||
- dependency rendering remains tenant-isolated and DB-only
|
- dependency rendering remains tenant-isolated and DB-only
|
||||||
- cleaned surfaces remain read-only and introduce no new remote runtime calls at render time
|
|
||||||
- native Filament or Livewire state remains the primary contract, with no new wrapper or presenter layer introduced only to translate pseudo-native state
|
|
||||||
- required-permissions summary counts, freshness, guidance visibility, and copy payload remain derived from the same normalized filter state as the visible rows
|
|
||||||
- evidence overview preserves a meaningful empty state, clear-filter affordance when scoped, and one workspace-safe inspect path per authorized row
|
|
||||||
- query values may seed initial state but not stay the primary contract
|
- query values may seed initial state but not stay the primary contract
|
||||||
nonGoals:
|
nonGoals:
|
||||||
- runtime API exposure
|
- runtime API exposure
|
||||||
- new persistence
|
- new persistence
|
||||||
- new polling behavior
|
|
||||||
- new provider or route families
|
- new provider or route families
|
||||||
- new global or on-demand asset requirements
|
|
||||||
- shared wrapper or presenter framework
|
|
||||||
- global context shell redesign
|
- global context shell redesign
|
||||||
- monitoring page-state architecture rewrite
|
- monitoring page-state architecture rewrite
|
||||||
- audit log selected-record or inspect duality cleanup
|
- audit log selected-record or inspect duality cleanup
|
||||||
|
|||||||
@ -64,7 +64,7 @@ ## Phase 0 Research
|
|||||||
- Keep `TenantRequiredPermissions` and `EvidenceOverview` on derived data and current services instead of adding new projections, tables, or materialized helper models.
|
- Keep `TenantRequiredPermissions` and `EvidenceOverview` on derived data and current services instead of adding new projections, tables, or materialized helper models.
|
||||||
- Replace inventory dependency GET-form controls with an embedded Livewire `TableComponent` because the surface is detail-context and not a true relation manager or a standalone page.
|
- Replace inventory dependency GET-form controls with an embedded Livewire `TableComponent` because the surface is detail-context and not a true relation manager or a standalone page.
|
||||||
- Treat query parameters as one-time seed or deeplink inputs only; after mount, native page or component state owns filter interaction.
|
- Treat query parameters as one-time seed or deeplink inputs only; after mount, native page or component state owns filter interaction.
|
||||||
- No additional same-class extra hit is confirmed in planning; default implementation scope stays fixed at the three named core surfaces unless the setup audit records a candidate that passes every FR-196-015 admission check without widening architecture or adding new persistence, routes, or shared abstractions.
|
- No additional low-risk same-class hit is confirmed in planning; default implementation scope stays at the three named core surfaces unless implementation audit finds one trivial match that does not widen scope.
|
||||||
- Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer.
|
- Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer.
|
||||||
|
|
||||||
## Phase 1 Design
|
## Phase 1 Design
|
||||||
@ -169,8 +169,7 @@ ### Phase 0.5 - Establish shared test and guard scaffolding
|
|||||||
Changes:
|
Changes:
|
||||||
|
|
||||||
- Create the new focused test entry points for the dependency table component and required-permissions page table.
|
- Create the new focused test entry points for the dependency table component and required-permissions page table.
|
||||||
- Record the pre-implementation scope gate: unless the setup audit documents an FR-196-015 pass, scope is frozen to the three named core surfaces and no optional extra hit may begin.
|
- Extend shared guard coverage for new native page-table expectations and faux-control regressions.
|
||||||
- Extend shared guard coverage for new native page-table expectations, faux-control regressions, and no-new-wrapper drift.
|
|
||||||
- Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview.
|
- Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview.
|
||||||
|
|
||||||
Tests:
|
Tests:
|
||||||
@ -246,7 +245,6 @@ ### Phase D - Verification, guard alignment, and explicit scope stop
|
|||||||
- Run focused Sail verification for the modified feature, RBAC, and guard tests.
|
- Run focused Sail verification for the modified feature, RBAC, and guard tests.
|
||||||
- Record the release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md`, including cleaned surfaces, deferred themes, optional extra hits, and touched follow-up specs.
|
- Record the release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md`, including cleaned surfaces, deferred themes, optional extra hits, and touched follow-up specs.
|
||||||
- Document any optional additional same-class hit only if it was truly included; otherwise record that no extra candidate was confirmed.
|
- Document any optional additional same-class hit only if it was truly included; otherwise record that no extra candidate was confirmed.
|
||||||
- Verify the final implementation still satisfies the non-functional constraints: DB-only rendering, no new remote calls, no new persistence, and no asset or provider drift.
|
|
||||||
- Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture.
|
- Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture.
|
||||||
|
|
||||||
Tests:
|
Tests:
|
||||||
@ -295,4 +293,4 @@ ## Implementation Order Recommendation
|
|||||||
2. Replace inventory dependencies second, with the focused story tests landing before the implementation changes.
|
2. Replace inventory dependencies second, with the focused story tests landing before the implementation changes.
|
||||||
3. Convert `TenantRequiredPermissions` third, again extending the story tests before code changes.
|
3. Convert `TenantRequiredPermissions` third, again extending the story tests before code changes.
|
||||||
4. Convert `EvidenceOverview` fourth, with its focused page and derived-state tests updated before the refactor lands.
|
4. Convert `EvidenceOverview` fourth, with its focused page and derived-state tests updated before the refactor lands.
|
||||||
5. Run the final focused verification pack, formatting, and release close-out last, and record whether the setup audit admitted any optional same-class extra hit. No new optional extra hit may enter scope after implementation has started.
|
5. Run the final focused verification pack, formatting, and release close-out last, and only then consider whether any optional same-class extra hit truly qualifies.
|
||||||
@ -19,7 +19,6 @@ ### 1. Prepare shared test and guard scaffolding
|
|||||||
Do:
|
Do:
|
||||||
|
|
||||||
- create the new focused surface-test entry points before story implementation starts
|
- create the new focused surface-test entry points before story implementation starts
|
||||||
- perform the setup audit that decides whether any optional same-class extra hit passes every FR-196-015 admission check before any extra hit begins; otherwise lock scope to the three named surfaces
|
|
||||||
- add the shared guard expectations for new native page-table and faux-control regressions
|
- add the shared guard expectations for new native page-table and faux-control regressions
|
||||||
- add the shared mount-only query-seeding regression coverage that later story work depends on
|
- add the shared mount-only query-seeding regression coverage that later story work depends on
|
||||||
|
|
||||||
@ -125,7 +124,7 @@ ### 7. Record the release close-out in this quickstart
|
|||||||
When implementation is complete, update this file with a short close-out note that records:
|
When implementation is complete, update this file with a short close-out note that records:
|
||||||
|
|
||||||
- which surfaces were actually cleaned
|
- which surfaces were actually cleaned
|
||||||
- whether any optional same-class extra hit was included or explicitly rejected, and if included, which FR-196-015 admission checks it satisfied
|
- whether any optional same-class extra hit was included or explicitly rejected
|
||||||
- which related themes stayed out of scope and were deferred
|
- which related themes stayed out of scope and were deferred
|
||||||
- which follow-up specs or artifacts were touched
|
- which follow-up specs or artifacts were touched
|
||||||
|
|
||||||
@ -152,26 +151,15 @@ ## Suggested Test Pack
|
|||||||
|
|
||||||
## Manual Smoke Checklist
|
## Manual Smoke Checklist
|
||||||
|
|
||||||
1. Open an inventory item detail page and confirm current record context stays visible, dependency direction and relationship changes happen in place without a foreign apply-and-reload workflow, missing-target markers remain visible where applicable, and empty-state copy still explains the no-results case.
|
1. Open an inventory item detail page and confirm dependency direction and relationship changes happen without a foreign apply-and-reload workflow.
|
||||||
2. Open tenant required permissions and confirm current tenant scope and active filter state remain visible, the filter surface feels native, summary counts and freshness stay consistent with the visible rows, guidance remains available, and copy flows still use the same filtered state.
|
2. Open tenant required permissions and confirm the filter surface feels native, while summary counts, guidance, and copy flows remain correct.
|
||||||
3. Open evidence overview and confirm workspace scope and any active entitled-tenant filter remain visible, the table behaves like a native Filament report, artifact truth, freshness, and next-step context remain visible by default, `Clear filters` behaves correctly, and each authorized row still has one workspace-safe inspect path.
|
3. Open evidence overview and confirm the table behaves like a native Filament report with clear empty state and row inspect behavior.
|
||||||
4. Confirm no cleaned surface leaks scope, rows, counts, or drilldown targets through query manipulation.
|
4. Confirm no cleaned surface leaks scope through query manipulation.
|
||||||
5. Confirm no implementation expanded into monitoring-state, shell, shared micro-UI redesign work, new wrapper layers, or new persistence created only to support the native controls.
|
5. Confirm no implementation expanded into monitoring-state, shell, or shared micro-UI redesign work.
|
||||||
|
|
||||||
## Deployment Notes
|
## Deployment Notes
|
||||||
|
|
||||||
- No migration is expected.
|
- No migration is expected.
|
||||||
- No polling change is expected.
|
|
||||||
- No provider registration change is expected.
|
- No provider registration change is expected.
|
||||||
- No new assets are expected.
|
- No new assets are expected.
|
||||||
- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged.
|
- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged.
|
||||||
|
|
||||||
## Release Close-Out
|
|
||||||
|
|
||||||
- Cleaned surfaces: inventory item dependency edges now run through the embedded `InventoryItemDependencyEdgesTable` component; tenant required permissions now uses one page-owned native table contract; evidence overview now uses one page-owned native workspace table.
|
|
||||||
- Optional same-class extra hit decision: rejected. No additional nativity-bypass candidate passed every FR-196-015 admission check during setup, so scope remained frozen to the three named surfaces.
|
|
||||||
- Deferred themes kept out of scope: monitoring page-state architecture, global shell or context redesign, shared micro-UI or wrapper abstractions, verification viewer families, and any new persistence or asset work.
|
|
||||||
- Follow-up artifacts touched: this quickstart note, the Spec 196 task ledger, and the existing logical contract remained aligned without widening consumer scope.
|
|
||||||
- Focused Sail verification pack: passed on 2026-04-14 with 45 tests and 177 assertions across the Spec 196 feature, guard, and unit coverage set.
|
|
||||||
- Integrated-browser smoke sign-off: passed on `http://localhost` against tenant `19000000-0000-4000-8000-000000000191`, including an inventory detail fixture (`inventory-items/383`) and evidence fixture (`evidence/20`). Verified in-place dependency filters with visible active filter chips and missing-target hints, native required-permissions search plus technical-details matrix continuity, and evidence overview tenant prefilter plus `Clear filters` behavior with workspace-safe drilldown links.
|
|
||||||
- Browser-log note: the integrated-browser session still contains old historical 419 and aborted-request noise from prior sessions, but no new Spec 196 surface-specific JavaScript failure blocked the smoke flow above.
|
|
||||||
@ -177,19 +177,12 @@ ### Functional Requirements
|
|||||||
- **FR-196-012**: Evidence overview MUST provide one consistent inspect or open model for authorized rows and MUST preserve the current workspace-safe drilldown into tenant evidence.
|
- **FR-196-012**: Evidence overview MUST provide one consistent inspect or open model for authorized rows and MUST preserve the current workspace-safe drilldown into tenant evidence.
|
||||||
- **FR-196-013**: Evidence overview MUST remove manual page-body query and Blade wiring that exists only because the report table is hand-built, while preserving entitled tenant prefilter behavior.
|
- **FR-196-013**: Evidence overview MUST remove manual page-body query and Blade wiring that exists only because the report table is hand-built, while preserving entitled tenant prefilter behavior.
|
||||||
- **FR-196-014**: Evidence overview MUST preserve workspace boundary enforcement, entitled-tenant filtering, and deny-as-not-found behavior for users outside the workspace boundary.
|
- **FR-196-014**: Evidence overview MUST preserve workspace boundary enforcement, entitled-tenant filtering, and deny-as-not-found behavior for users outside the workspace boundary.
|
||||||
- **FR-196-015**: Any additional cleanup hit included under this spec MUST pass all of the following admission checks before implementation starts on that hit: it removes the same confirmed nativity-bypass problem class as the three core surfaces (primary GET-form controls, request-driven page-body state, or a hand-built primary table imitating native Filament behavior); it stays read-only and preserves current scope semantics; it can be completed by modifying existing files only, or by adding at most one narrow sibling view or component file with no new route, persistence, enum, or shared abstraction; it does not enter shared-family, shell, monitoring-state, diff, verification-report, or special-visualization work; and it is explicitly recorded in release close-out. If any admission check fails, the candidate is out of scope.
|
- **FR-196-015**: Any additional cleanup hit included under this spec MUST share the same unnecessary nativity bypass, remain low to medium complexity, add no new product semantics, and avoid shared-family, shell, monitoring-state, and special-visualization work.
|
||||||
- **FR-196-016**: Any discovered related surface that crosses into shared detail micro-UI, monitoring state, context shell, verification report, diff or settings viewer, restore preview or result layouts, or other declared non-goal families MUST be documented and deferred instead of partially refactored here.
|
- **FR-196-016**: Any discovered related surface that crosses into shared detail micro-UI, monitoring state, context shell, verification report, diff or settings viewer, restore preview or result layouts, or other declared non-goal families MUST be documented and deferred instead of partially refactored here.
|
||||||
- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, cross-page UI abstraction, or service whose main purpose is to translate bespoke pseudo-native page state into native Filament primitives. If native Filament or Livewire page state can express the behavior directly, direct state wins.
|
- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, or cross-page UI abstraction whose main purpose is to hide the same non-native contract.
|
||||||
- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before. This requirement is satisfied only when the cleaned surface preserves all of the following, in the same primary surface where the operator is already working: inventory dependencies still shows current record context, direction and relationship scope labels, missing-target markers, meaningful empty-state copy, and safe inspect links; tenant required permissions still shows current tenant scope, active filter state, overall counts, freshness, guidance visibility, and copy payload behavior derived from the same normalized filter state; evidence overview still shows workspace scope, active entitled-tenant filter state, artifact truth, freshness, next-step context, clear-filter affordance, and one workspace-safe inspect path for authorized rows.
|
- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before, with no loss of empty-state meaning, next-step clarity, scope signals, or inspect navigation.
|
||||||
- **FR-196-019**: Release close-out MUST list which surfaces were actually cleaned, which optional same-class low-risk hits were included, which related themes remained out of scope, and which follow-up specs were touched.
|
- **FR-196-019**: Release close-out MUST list which surfaces were actually cleaned, which optional same-class low-risk hits were included, which related themes remained out of scope, and which follow-up specs were touched.
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
|
|
||||||
- **NFR-196-001**: Render-time behavior for inventory dependencies, tenant required permissions, and evidence overview MUST remain DB-only and MUST NOT introduce new Microsoft Graph calls, external HTTP requests, or other remote runtime dependencies.
|
|
||||||
- **NFR-196-002**: This cleanup MUST NOT add new persistence artifacts, including tables, persisted UI-state mirrors, materialized helper projections, or helper models whose only purpose is to support the new native controls.
|
|
||||||
- **NFR-196-003**: This cleanup MUST NOT add polling, provider registration changes, or new global or on-demand asset requirements. Existing `bootstrap/providers.php` registration and current `filament:assets` deployment handling remain unchanged.
|
|
||||||
- **NFR-196-004**: Implementation MUST stay inside the current Filament v5 and Livewire v4 page layer and current derived services unless a touched existing service needs a narrow adapter to keep one authoritative normalized filter state.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
@ -215,9 +208,6 @@ ### Measurable Outcomes
|
|||||||
- **SC-196-004**: Release validation finds zero primary plain HTML control surfaces on the three core pages whose only purpose is to imitate native admin controls.
|
- **SC-196-004**: Release validation finds zero primary plain HTML control surfaces on the three core pages whose only purpose is to imitate native admin controls.
|
||||||
- **SC-196-005**: Deeplink and prefilter behaviors continue to work for the targeted routes without allowing unauthorized tenant scope changes or cross-tenant row leakage.
|
- **SC-196-005**: Deeplink and prefilter behaviors continue to work for the targeted routes without allowing unauthorized tenant scope changes or cross-tenant row leakage.
|
||||||
- **SC-196-006**: Final close-out documentation explicitly records completed surfaces, deferred related themes, and any optional extra hits that were admitted under the shared rule.
|
- **SC-196-006**: Final close-out documentation explicitly records completed surfaces, deferred related themes, and any optional extra hits that were admitted under the shared rule.
|
||||||
- **SC-196-007**: Inventory dependencies preserves current-record context, direction and relationship labels, missing-target markers, safe inspect links, and meaningful empty-state copy after native state adoption.
|
|
||||||
- **SC-196-008**: Tenant required permissions preserves visible active filter state, counts, freshness, guidance, and copy payload behavior that stay internally consistent for the same tenant and the same normalized filter state.
|
|
||||||
- **SC-196-009**: Evidence overview preserves visible workspace scope signals, entitled-tenant clear-filter behavior, artifact truth, freshness, next-step context, and one workspace-safe inspect path for every authorized row.
|
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ # Tasks: Hard Filament Nativity Cleanup
|
|||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/`
|
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/`
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/filament-nativity-cleanup.logical.openapi.yaml`
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/filament-nativity-cleanup.logical.openapi.yaml`
|
||||||
|
|
||||||
**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces and their DB-only, no-wrapper, and scope-safety constraints.
|
**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces.
|
||||||
**Operations**: This cleanup does not introduce new queued work or `OperationRun` flows. Existing linked follow-up paths remain unchanged.
|
**Operations**: This cleanup does not introduce new queued work or `OperationRun` flows. Existing linked follow-up paths remain unchanged.
|
||||||
**RBAC**: Tenant-context, route-tenant, workspace-membership, and entitled-tenant boundaries remain authoritative. Non-members stay `404`, and no new destructive action is added.
|
**RBAC**: Tenant-context, route-tenant, workspace-membership, and entitled-tenant boundaries remain authoritative. Non-members stay `404`, and no new destructive action is added.
|
||||||
**UI Naming**: Keep existing operator terms stable: `Dependencies`, `Direction`, `Relationship`, `Required permissions`, `Status`, `Type`, `Search`, `Evidence overview`, `Artifact truth`, `Freshness`, and `Next step`.
|
**UI Naming**: Keep existing operator terms stable: `Dependencies`, `Direction`, `Relationship`, `Required permissions`, `Status`, `Type`, `Search`, `Evidence overview`, `Artifact truth`, `Freshness`, and `Next step`.
|
||||||
@ -18,8 +18,8 @@ ## Phase 1: Setup (Shared Review Inputs)
|
|||||||
|
|
||||||
**Purpose**: Confirm the exact implementation entry points, native reference patterns, and focused regression baselines before editing the three in-scope surfaces.
|
**Purpose**: Confirm the exact implementation entry points, native reference patterns, and focused regression baselines before editing the three in-scope surfaces.
|
||||||
|
|
||||||
- [X] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`, and record whether any optional extra candidate passes every FR-196-015 admission check; otherwise freeze scope to the three named surfaces
|
- [ ] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||||
- [X] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
- [ ] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -29,11 +29,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
|||||||
|
|
||||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
- [X] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
- [ ] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
||||||
- [X] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables, faux-control regressions, and no-new-wrapper drift in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
- [ ] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables and faux-control regressions in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||||
- [X] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
- [ ] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||||
|
|
||||||
**Checkpoint**: The shared Spec 196 test harness and scope gate are in place, and later surface work can prove native state ownership without reopening scope or guard assumptions.
|
**Checkpoint**: The shared Spec 196 test harness is in place, and later surface work can prove native state ownership without reopening scope or guard assumptions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -47,13 +47,13 @@ ### Tests for User Story 1
|
|||||||
|
|
||||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||||
|
|
||||||
- [X] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety
|
- [ ] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety
|
||||||
- [X] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation
|
- [ ] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation
|
||||||
|
|
||||||
### Implementation for User Story 1
|
### Implementation for User Story 1
|
||||||
|
|
||||||
- [X] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services
|
- [ ] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services
|
||||||
- [X] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints
|
- [ ] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is complete when inventory detail keeps the same dependency meaning and target safety without switching operators into a foreign apply-and-reload workflow.
|
**Checkpoint**: User Story 1 is complete when inventory detail keeps the same dependency meaning and target safety without switching operators into a foreign apply-and-reload workflow.
|
||||||
|
|
||||||
@ -69,14 +69,14 @@ ### Tests for User Story 2
|
|||||||
|
|
||||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||||
|
|
||||||
- [X] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values
|
- [ ] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values
|
||||||
- [X] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states
|
- [ ] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states
|
||||||
- [X] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
- [ ] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||||
|
|
||||||
### Implementation for User Story 2
|
### Implementation for User Story 2
|
||||||
|
|
||||||
- [X] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding
|
- [ ] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding
|
||||||
- [X] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state
|
- [ ] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is complete when required permissions behaves like one native Filament page without losing tenant authority, summary clarity, or follow-up guidance.
|
**Checkpoint**: User Story 2 is complete when required permissions behaves like one native Filament page without losing tenant authority, summary clarity, or follow-up guidance.
|
||||||
|
|
||||||
@ -92,13 +92,13 @@ ### Tests for User Story 3
|
|||||||
|
|
||||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||||
|
|
||||||
- [X] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, visible workspace scope and active entitled-tenant filter state, artifact-truth and freshness and next-step row fields, entitled-tenant seed and clear behavior, workspace-safe row drilldown, clear-filter behavior, empty states, and deny-as-not-found enforcement
|
- [ ] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, entitled-tenant seed and clear behavior, workspace-safe row drilldown, empty states, and deny-as-not-found enforcement
|
||||||
- [X] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract
|
- [ ] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract
|
||||||
|
|
||||||
### Implementation for User Story 3
|
### Implementation for User Story 3
|
||||||
|
|
||||||
- [X] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks that preserve visible workspace scope, active entitled-tenant filter state, artifact-truth, freshness, and next-step row context, native filter and search state, entitled-tenant query seeding, clear-filter behavior, and one inspect model
|
- [ ] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks, native filter and search state, entitled-tenant query seeding, and one inspect model
|
||||||
- [X] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy
|
- [ ] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is complete when evidence overview reads like one native workspace review table without leaking unauthorized tenant scope or losing the current drilldown path.
|
**Checkpoint**: User Story 3 is complete when evidence overview reads like one native workspace review table without leaking unauthorized tenant scope or losing the current drilldown path.
|
||||||
|
|
||||||
@ -108,11 +108,11 @@ ## Phase 6: Polish & Cross-Cutting Verification
|
|||||||
|
|
||||||
**Purpose**: Run the focused verification pack, format the touched files, and record the final bounded scope outcome for Spec 196.
|
**Purpose**: Run the focused verification pack, format the touched files, and record the final bounded scope outcome for Spec 196.
|
||||||
|
|
||||||
- [X] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and confirm the pack still proves DB-only rendering, no-wrapper drift protection, and scope-safety invariants
|
- [ ] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||||
- [X] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/`
|
- [ ] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/`
|
||||||
- [X] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces, explicitly verifying current-context visibility, active filter or scope signals, empty-state meaning, guidance or next-step clarity, and inspect navigation, and capture any sign-off notes needed for release close-out
|
- [ ] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces and capture any sign-off notes needed for release close-out
|
||||||
- [X] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs
|
- [ ] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs
|
||||||
- [X] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, non-goals, and non-functional constraints in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope
|
- [ ] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, and non-goals in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
# Specification Quality Checklist: Shared Detail Micro-UI Contract
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
||||||
**Created**: 2026-04-15
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] No implementation details (languages, frameworks, APIs)
|
|
||||||
- [x] Focused on user value and business needs
|
|
||||||
- [x] Written for non-technical stakeholders
|
|
||||||
- [x] All mandatory sections completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Success criteria are measurable
|
|
||||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
||||||
- [x] All acceptance scenarios are defined
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded
|
|
||||||
- [x] Dependencies and assumptions identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] All functional requirements have clear acceptance criteria
|
|
||||||
- [x] User scenarios cover primary flows
|
|
||||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
||||||
- [x] No implementation details leak into specification
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Validation completed in one pass.
|
|
||||||
- The spec stays bounded to two already proven shared detail families and explicitly excludes shell, monitoring-state, and broad custom-view consolidation themes.
|
|
||||||
- Repository truth from current host usage informed the in-scope family definitions without embedding file-level implementation detail into the spec body.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user