TenantAtlas/app/Filament/Resources/InventoryItemResource.php
ahmido da18d3cb14 feat/042-inventory-dependencies-graph (#50)
Dieses PR liefert den Inventory Dependencies Graph end-to-end: Abhängigkeiten (Edges) werden aus Inventory-Sync-Daten extrahiert, tenant-sicher gespeichert und in der Inventory Item Detailansicht angezeigt.

Ziel: Admins können Prerequisites + Blast Radius (direct) schnell erkennen, ohne Snapshot/Restore anzufassen.

⸻

Was ist drin?

Dependency Graph (Edges)
	•	inventory_links Schema + Indizes + idempotentes Upsert (Unique Key)
	•	Relationship Types (u.a.):
	•	assigned_to_include, assigned_to_exclude
	•	uses_assignment_filter
	•	scoped_by_scope_tag
	•	UI: Inventory Item → Dependencies Section
	•	Direction Filter: All / Inbound / Outbound
	•	Relationship Filter: All + spezifische Relationship Types
	•	Missing-Badge + sicheres Tooltip (safe subset)

Safety / Observability
	•	Unknown/unsupported Shapes erzeugen keine Edges, sondern:
	•	Warning in InventorySyncRun.error_context.warnings[]
	•	optional info-log (ohne Secrets)
	•	Limit-only Semantik (MVP): bis zu 50 Edges pro Richtung (max 100 bei “All”)
	•	Blast Radius in MVP = direct only (kein depth>1 traversal)

Name Resolution (lokal, ohne Entra Calls)
	•	Resolver/DTO Layer für deterministische Labels (kein “Unknown” mehr)
	•	Auflösung aus lokaler DB nur für Foundations, wenn vorhanden:
	•	scope_tag → roleScopeTag
	•	assignment_filter → assignmentFilter
	•	aad_group bleibt bewusst external ref: “Group (external): …” (keine Graph/Entra Lookups im UI)
	•	Zentraler FoundationTypeMap als Source-of-Truth (keine Hardcodings)

⸻

Out of Scope / Follow-up
	•	Entra Group Name Resolution (braucht eigenes “Group Inventory” Modul + Permissions)
	•	Foundations als Inventory Items / Coverage Tab (Scope Tags / Assignment Filters sichtbar & syncbar)
→ folgt als separater PR (Inventory Core/UI), damit 042 sauber “Edges-only” bleibt.

⸻

Tests / Verifikation
	•	Targeted Pest Tests (Unit + Feature + UI smoke) für:
	•	deterministische Edge-Erzeugung + idempotent upsert
	•	tenant isolation (UI/Query)
	•	warnings auf Run Record
	•	resolver/name rendering + links (wo möglich)
	•	pint --dirty ausgeführt

⸻

Manual QA (UI)
	1.	Inventory Sync Run mit include_dependencies=true starten
	2.	Inventory Item öffnen → Dependencies prüfen:
	•	include/exclude + filter + scoped_by sichtbar (wenn vorhanden)
	•	Relationship/Direction Filter funktionieren
	•	keine “Unknown” Labels mehr, sondern deterministische Labels

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #50
2026-01-10 12:50:08 +00:00

201 lines
8.6 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\InventoryItemResource\Pages;
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 BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class InventoryItemResource extends Resource
{
protected static ?string $model = InventoryItem::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->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('Dependencies')
->schema([
ViewEntry::make('dependencies')
->label('')
->view('filament.components.dependency-edges')
->state(function (InventoryItem $record) {
$direction = request()->query('direction', 'all');
$relationshipType = request()->query('relationship_type', 'all');
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
$relationshipType = $relationshipType === 'all'
? null
: RelationshipType::tryFrom($relationshipType)?->value;
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$tenant = Tenant::current();
$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
return $edges->take(100); // both directions combined
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
})
->columnSpanFull(),
])
->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<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
}