## Summary - replace the inventory dependency GET/apply flow with an embedded native Filament `TableComponent` - convert tenant required permissions and evidence overview to native page-owned Filament tables with mount-only query seeding and preserved scope authority - extend focused Pest, Livewire, RBAC, and guard coverage, and update the Spec 196 artifacts and release close-out notes ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php tests/Feature/Filament/TenantRequiredPermissionsPageTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Unit/TenantRequiredPermissionsFilteringTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` (`45` tests, `177` assertions) - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - integrated-browser smoke on localhost for inventory detail dependencies, tenant required permissions, and evidence overview Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #236
490 lines
18 KiB
PHP
490 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Monitoring;
|
|
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\User;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\TenantReviewStatus;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Pages\Page;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Auth\AuthenticationException;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use UnitEnum;
|
|
|
|
class EvidenceOverview extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
|
|
protected static ?string $title = 'Evidence Overview';
|
|
|
|
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
|
|
|
/**
|
|
* @var list<array<string, mixed>>
|
|
*/
|
|
public array $rows = [];
|
|
|
|
/**
|
|
* @var array<int, Tenant>|null
|
|
*/
|
|
private ?array $accessibleTenants = null;
|
|
|
|
private ?Collection $cachedSnapshots = null;
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
|
}
|
|
|
|
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->resetPage();
|
|
}
|
|
|
|
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();
|
|
|
|
if (! $user instanceof User) {
|
|
throw new AuthenticationException;
|
|
}
|
|
|
|
app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Tenant>
|
|
*/
|
|
private function accessibleTenants(): array
|
|
{
|
|
if (is_array($this->accessibleTenants)) {
|
|
return $this->accessibleTenants;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return $this->accessibleTenants = [];
|
|
}
|
|
|
|
$workspaceId = $this->workspaceId();
|
|
|
|
return $this->accessibleTenants = $user->tenants()
|
|
->where('tenants.workspace_id', $workspaceId)
|
|
->orderBy('tenants.name')
|
|
->get()
|
|
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function tenantFilterOptions(): array
|
|
{
|
|
return collect($this->accessibleTenants())
|
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
(string) $tenant->getKey() => $tenant->name,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function rowsForState(array $filters = [], ?string $search = null): Collection
|
|
{
|
|
$rows = $this->baseRows();
|
|
$tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value'));
|
|
$normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch)));
|
|
|
|
if ($tenantFilter !== null) {
|
|
$rows = $rows->where('tenant_id', $tenantFilter);
|
|
}
|
|
|
|
if ($normalizedSearch === '') {
|
|
return $rows;
|
|
}
|
|
|
|
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
|
$haystack = implode(' ', [
|
|
(string) ($row['tenant_name'] ?? ''),
|
|
(string) ($row['artifact_truth_label'] ?? ''),
|
|
(string) ($row['artifact_truth_explanation'] ?? ''),
|
|
(string) ($row['freshness_label'] ?? ''),
|
|
(string) ($row['next_step'] ?? ''),
|
|
]);
|
|
|
|
return str_contains(Str::lower($haystack), $normalizedSearch);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function baseRows(): Collection
|
|
{
|
|
$snapshots = $this->latestAccessibleSnapshots();
|
|
$currentReviewTenantIds = $this->currentReviewTenantIds($snapshots);
|
|
|
|
return $snapshots->mapWithKeys(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
|
|
return [(string) $snapshot->getKey() => $this->rowForSnapshot($snapshot, $currentReviewTenantIds)];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, EvidenceSnapshot>
|
|
*/
|
|
private function latestAccessibleSnapshots(): Collection
|
|
{
|
|
if ($this->cachedSnapshots instanceof Collection) {
|
|
return $this->cachedSnapshots;
|
|
}
|
|
|
|
$tenantIds = collect($this->accessibleTenants())
|
|
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
|
->all();
|
|
|
|
$query = EvidenceSnapshot::query()
|
|
->with('tenant')
|
|
->where('workspace_id', $this->workspaceId())
|
|
->where('status', 'active')
|
|
->latest('generated_at');
|
|
|
|
if ($tenantIds === []) {
|
|
$query->whereRaw('1 = 0');
|
|
} else {
|
|
$query->whereIn('tenant_id', $tenantIds);
|
|
}
|
|
|
|
return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, EvidenceSnapshot> $snapshots
|
|
* @return array<int, bool>
|
|
*/
|
|
private function currentReviewTenantIds(Collection $snapshots): array
|
|
{
|
|
return TenantReview::query()
|
|
->where('workspace_id', $this->workspaceId())
|
|
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
|
->whereIn('status', [
|
|
TenantReviewStatus::Draft->value,
|
|
TenantReviewStatus::Ready->value,
|
|
TenantReviewStatus::Published->value,
|
|
])
|
|
->pluck('tenant_id')
|
|
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, bool> $currentReviewTenantIds
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array
|
|
{
|
|
$truth = $this->snapshotTruth($snapshot);
|
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
|
$tenantId = (int) $snapshot->tenant_id;
|
|
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
|
|
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
|
|
? 'Create a current review from this evidence snapshot'
|
|
: $truth->nextStepText();
|
|
|
|
return [
|
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
|
'tenant_id' => $tenantId,
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
|
'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0),
|
|
'stale_dimensions' => (int) ($snapshot->summary['stale_dimensions'] ?? 0),
|
|
'artifact_truth_label' => $truth->primaryLabel,
|
|
'artifact_truth_color' => $truth->primaryBadgeSpec()->color,
|
|
'artifact_truth_icon' => $truth->primaryBadgeSpec()->icon,
|
|
'artifact_truth_explanation' => $truth->primaryExplanation,
|
|
'artifact_truth' => [
|
|
'label' => $truth->primaryLabel,
|
|
'color' => $truth->primaryBadgeSpec()->color,
|
|
'icon' => $truth->primaryBadgeSpec()->icon,
|
|
'explanation' => $truth->primaryExplanation,
|
|
],
|
|
'freshness_label' => $freshnessSpec->label,
|
|
'freshness_color' => $freshnessSpec->color,
|
|
'freshness_icon' => $freshnessSpec->icon,
|
|
'freshness' => [
|
|
'label' => $freshnessSpec->label,
|
|
'color' => $freshnessSpec->color,
|
|
'icon' => $freshnessSpec->icon,
|
|
],
|
|
'next_step' => $nextStep,
|
|
'view_url' => $snapshot->tenant
|
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
|
: null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, array<string, mixed>> $rows
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
|
{
|
|
$sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true)
|
|
? $sortColumn
|
|
: 'tenant_name';
|
|
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
|
|
|
$records = $rows->all();
|
|
|
|
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
|
$comparison = in_array($sortColumn, ['missing_dimensions', 'stale_dimensions'], true)
|
|
? ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0))
|
|
: strnatcasecmp((string) ($left[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? ''));
|
|
|
|
if ($comparison === 0) {
|
|
$comparison = strnatcasecmp((string) ($left['tenant_name'] ?? ''), (string) ($right['tenant_name'] ?? ''));
|
|
}
|
|
|
|
return $descending ? ($comparison * -1) : $comparison;
|
|
});
|
|
|
|
return collect($records);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, array<string, mixed>> $rows
|
|
*/
|
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
|
{
|
|
return new LengthAwarePaginator(
|
|
items: $rows->forPage($page, $recordsPerPage),
|
|
total: $rows->count(),
|
|
perPage: $recordsPerPage,
|
|
currentPage: $page,
|
|
);
|
|
}
|
|
|
|
private function seedTableStateFromQuery(): void
|
|
{
|
|
$query = request()->query();
|
|
|
|
if (array_key_exists('search', $query)) {
|
|
$this->tableSearch = trim((string) request()->query('search', ''));
|
|
}
|
|
|
|
if (! array_key_exists('tenant_id', $query)) {
|
|
return;
|
|
}
|
|
|
|
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
|
|
|
|
if ($tenantFilter === null) {
|
|
return;
|
|
}
|
|
|
|
$this->tableFilters = [
|
|
'tenant_id' => ['value' => (string) $tenantFilter],
|
|
];
|
|
$this->tableDeferredFilters = $this->tableFilters;
|
|
}
|
|
|
|
private function normalizeTenantFilter(mixed $value): ?int
|
|
{
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
$requestedTenantId = (int) $value;
|
|
$allowedTenantIds = collect($this->accessibleTenants())
|
|
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
|
->all();
|
|
|
|
return in_array($requestedTenantId, $allowedTenantIds, true)
|
|
? $requestedTenantId
|
|
: null;
|
|
}
|
|
|
|
private function hasActiveOverviewFilters(): bool
|
|
{
|
|
return filled(data_get($this->tableFilters, 'tenant_id.value'))
|
|
|| trim((string) $this->tableSearch) !== '';
|
|
}
|
|
|
|
private function workspaceId(): int
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
throw new AuthenticationException;
|
|
}
|
|
|
|
return (int) app(WorkspaceContext::class)
|
|
->currentWorkspaceForMemberOrFail($user, request())
|
|
->getKey();
|
|
}
|
|
}
|