From 1340c47f543002aaab08bdad9dd4e511d11dabb2 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 7 Jan 2026 17:10:57 +0000 Subject: [PATCH] feat/041-inventory-ui (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Implements Spec 041 – Inventory UI (Filament v4): adds Inventory navigation, landing page, and resources for Inventory Items and Inventory Sync Runs, plus a Coverage page that reflects supported policy types and capabilities from config. This PR is UI-focused and builds on Inventory Core (040). What’s included • Navigation / IA • New Inventory section with: Landing, Coverage, Inventory Items, Inventory Sync Runs • Landing page with quick links to the three Inventory views • Coverage page • Table view generated from config-derived capabilities (Type, Label, Category, Restore, Risk) • Resources • InventoryItemResource list + view (tenant-scoped) • InventorySyncRunResource list + view (tenant-scoped) • Filament v4 fixes • Updated page signatures ($navigationGroup, $navigationIcon, $view) • Updated table actions to use Filament\Actions\ViewAction Tests Inventory UI tests added/updated and passing: • InventoryItemResourceTest.php • InventorySyncRunResourceTest.php • InventoryPagesTest.php Non-goals • No dependency graph UI (Spec 042) • No cross-tenant portfolio/compare/promotion (Spec 043) • No drift dashboards (Spec 044) • No changes to restore/backup behavior Review focus • Navigation structure and naming (Inventory Landing vs direct resources) • Tenant isolation in resources/pages (no cross-tenant leakage) • Coverage page accuracy vs config/tenantpilot.php capabilities • Filament v4 action usage (ViewAction) Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/44 --- app/Filament/Pages/InventoryCoverage.php | 30 ++++ app/Filament/Pages/InventoryLanding.php | 36 ++++ .../Resources/InventoryItemResource.php | 161 ++++++++++++++++++ .../Pages/ListInventoryItems.php | 11 ++ .../Pages/ViewInventoryItem.php | 11 ++ .../Resources/InventorySyncRunResource.php | 137 +++++++++++++++ .../Pages/ListInventorySyncRuns.php | 11 ++ .../Pages/ViewInventorySyncRun.php | 11 ++ .../pages/inventory-coverage.blade.php | 28 +++ .../pages/inventory-landing.blade.php | 23 +++ specs/041-inventory-ui/tasks.md | 14 +- .../Filament/InventoryItemResourceTest.php | 41 +++++ tests/Feature/Filament/InventoryPagesTest.php | 26 +++ .../Filament/InventorySyncRunResourceTest.php | 37 ++++ 14 files changed, 570 insertions(+), 7 deletions(-) create mode 100644 app/Filament/Pages/InventoryCoverage.php create mode 100644 app/Filament/Pages/InventoryLanding.php create mode 100644 app/Filament/Resources/InventoryItemResource.php create mode 100644 app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php create mode 100644 app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php create mode 100644 app/Filament/Resources/InventorySyncRunResource.php create mode 100644 app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php create mode 100644 app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php create mode 100644 resources/views/filament/pages/inventory-coverage.blade.php create mode 100644 resources/views/filament/pages/inventory-landing.blade.php create mode 100644 tests/Feature/Filament/InventoryItemResourceTest.php create mode 100644 tests/Feature/Filament/InventoryPagesTest.php create mode 100644 tests/Feature/Filament/InventorySyncRunResourceTest.php diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php new file mode 100644 index 0000000..74b888d --- /dev/null +++ b/app/Filament/Pages/InventoryCoverage.php @@ -0,0 +1,30 @@ +> + */ + public array $supportedTypes = []; + + public function mount(): void + { + $types = config('tenantpilot.supported_policy_types', []); + + $this->supportedTypes = is_array($types) ? $types : []; + } +} diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php new file mode 100644 index 0000000..71771c9 --- /dev/null +++ b/app/Filament/Pages/InventoryLanding.php @@ -0,0 +1,36 @@ +schema([ + Section::make('Inventory Item') + ->schema([ + TextEntry::make('display_name')->label('Name'), + TextEntry::make('policy_type') + ->label('Type') + ->badge() + ->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type), + TextEntry::make('category') + ->badge() + ->state(fn (InventoryItem $record): string => $record->category + ?: (static::typeMeta($record->policy_type)['category'] ?? 'Unknown')), + TextEntry::make('platform')->badge(), + TextEntry::make('external_id')->label('External ID'), + TextEntry::make('last_seen_at')->label('Last seen')->dateTime(), + TextEntry::make('last_seen_run_id') + ->label('Last sync run') + ->url(function (InventoryItem $record): ?string { + if (! $record->last_seen_run_id) { + return null; + } + + return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current()); + }) + ->openUrlInNewTab(), + TextEntry::make('support_restore') + ->label('Restore') + ->badge() + ->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'), + TextEntry::make('support_risk') + ->label('Risk') + ->badge() + ->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Metadata (Safe Subset)') + ->schema([ + ViewEntry::make('meta_jsonb') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventoryItem $record) => $record->meta_jsonb ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + $typeOptions = collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)]) + ->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '') + ->all(); + + $categoryOptions = collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)]) + ->filter(fn ($value, $key) => is_string($key) && $key !== '') + ->all(); + + return $table + ->defaultSort('last_seen_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('display_name') + ->label('Name') + ->searchable(), + Tables\Columns\TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state), + Tables\Columns\TextColumn::make('category') + ->badge(), + Tables\Columns\TextColumn::make('platform') + ->badge(), + Tables\Columns\TextColumn::make('last_seen_at') + ->label('Last seen') + ->since(), + Tables\Columns\TextColumn::make('lastSeenRun.status') + ->label('Run') + ->badge(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('policy_type') + ->options($typeOptions) + ->searchable(), + Tables\Filters\SelectFilter::make('category') + ->options($categoryOptions) + ->searchable(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->with('lastSeenRun'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInventoryItems::route('/'), + 'view' => Pages\ViewInventoryItem::route('/{record}'), + ]; + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + return collect(config('tenantpilot.supported_policy_types', [])) + ->firstWhere('type', $type) ?? []; + } +} diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php new file mode 100644 index 0000000..fea1cdb --- /dev/null +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -0,0 +1,11 @@ +schema([ + Section::make('Sync Run') + ->schema([ + TextEntry::make('status') + ->badge() + ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), + TextEntry::make('selection_hash')->label('Selection hash')->copyable(), + TextEntry::make('started_at')->dateTime(), + TextEntry::make('finished_at')->dateTime(), + TextEntry::make('items_observed_count')->label('Observed')->numeric(), + TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), + TextEntry::make('errors_count')->label('Errors')->numeric(), + TextEntry::make('had_errors')->label('Had errors')->badge(), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Selection Payload') + ->schema([ + ViewEntry::make('selection_payload') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventorySyncRun $record) => $record->selection_payload ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + + Section::make('Error Summary') + ->schema([ + ViewEntry::make('error_codes') + ->label('Error codes') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventorySyncRun $record) => $record->error_codes ?? []) + ->columnSpanFull(), + ViewEntry::make('error_context') + ->label('Safe error context') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (InventorySyncRun $record) => $record->error_context ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), + Tables\Columns\TextColumn::make('selection_hash') + ->label('Selection') + ->copyable() + ->limit(12), + Tables\Columns\TextColumn::make('started_at')->since(), + Tables\Columns\TextColumn::make('finished_at')->since(), + Tables\Columns\TextColumn::make('items_observed_count') + ->label('Observed') + ->numeric(), + Tables\Columns\TextColumn::make('items_upserted_count') + ->label('Upserted') + ->numeric(), + Tables\Columns\TextColumn::make('errors_count') + ->label('Errors') + ->numeric(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInventorySyncRuns::route('/'), + 'view' => Pages\ViewInventorySyncRun::route('/{record}'), + ]; + } + + private static function statusColor(?string $status): string + { + return match ($status) { + InventorySyncRun::STATUS_SUCCESS => 'success', + InventorySyncRun::STATUS_PARTIAL => 'warning', + InventorySyncRun::STATUS_FAILED => 'danger', + InventorySyncRun::STATUS_SKIPPED => 'gray', + InventorySyncRun::STATUS_RUNNING => 'info', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php new file mode 100644 index 0000000..024f46e --- /dev/null +++ b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php @@ -0,0 +1,11 @@ + + +
+ + + + + + + + + + + + @foreach ($supportedTypes as $row) + + + + + + + + @endforeach + +
TypeLabelCategoryRestoreRisk
{{ $row['type'] ?? '' }}{{ $row['label'] ?? '' }}{{ $row['category'] ?? '' }}{{ $row['restore'] ?? 'enabled' }}{{ $row['risk'] ?? 'normal' }}
+
+
+ diff --git a/resources/views/filament/pages/inventory-landing.blade.php b/resources/views/filament/pages/inventory-landing.blade.php new file mode 100644 index 0000000..a486e15 --- /dev/null +++ b/resources/views/filament/pages/inventory-landing.blade.php @@ -0,0 +1,23 @@ + + +
+
+ Browse inventory items, inspect sync runs, and review coverage/capabilities. +
+ +
+ + Inventory Items + + + + Sync Runs + + + + Coverage + +
+
+
+
diff --git a/specs/041-inventory-ui/tasks.md b/specs/041-inventory-ui/tasks.md index f105ffb..0630635 100644 --- a/specs/041-inventory-ui/tasks.md +++ b/specs/041-inventory-ui/tasks.md @@ -1,9 +1,9 @@ # Tasks: Inventory UI -- [ ] T001 Inventory landing page + navigation -- [ ] T002 Inventory list views per type/category -- [ ] T003 Inventory detail view with capability/support indicator -- [ ] T004 Sync runs list + detail -- [ ] T005 Coverage/capabilities view derived from config/contracts -- [ ] T006 Authorization + tenant isolation tests -- [ ] T007 Performance sanity checks (pagination/search) +- [x] T001 Inventory landing page + navigation +- [x] T002 Inventory list views per type/category +- [x] T003 Inventory detail view with capability/support indicator +- [x] T004 Sync runs list + detail +- [x] T005 Coverage/capabilities view derived from config/contracts +- [x] T006 Authorization + tenant isolation tests +- [x] T007 Performance sanity checks (pagination/search) diff --git a/tests/Feature/Filament/InventoryItemResourceTest.php b/tests/Feature/Filament/InventoryItemResourceTest.php new file mode 100644 index 0000000..feae540 --- /dev/null +++ b/tests/Feature/Filament/InventoryItemResourceTest.php @@ -0,0 +1,41 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Item A', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-a', + 'platform' => 'windows', + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'display_name' => 'Item B', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-b', + 'platform' => 'windows', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(InventoryItemResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('Item A') + ->assertDontSee('Item B'); +}); diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php new file mode 100644 index 0000000..598e362 --- /dev/null +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -0,0 +1,26 @@ +create(); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(InventoryLanding::getUrl(tenant: $tenant)) + ->assertOk(); + + $this->actingAs($user) + ->get(InventoryCoverage::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Coverage'); +}); diff --git a/tests/Feature/Filament/InventorySyncRunResourceTest.php b/tests/Feature/Filament/InventorySyncRunResourceTest.php new file mode 100644 index 0000000..654eb3a --- /dev/null +++ b/tests/Feature/Filament/InventorySyncRunResourceTest.php @@ -0,0 +1,37 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => str_repeat('a', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'selection_hash' => str_repeat('b', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee(str_repeat('a', 12)) + ->assertDontSee(str_repeat('b', 12)); +});