feat(042): relationship filter + MVP clarifications
This commit is contained in:
parent
5e70cdaad4
commit
667ebc619b
@ -6,6 +6,7 @@
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@ -78,14 +79,21 @@ public static function infolist(Schema $schema): Schema
|
||||
->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);
|
||||
|
||||
$edges = collect();
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getInboundEdges($record));
|
||||
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
|
||||
}
|
||||
if ($direction === 'outbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getOutboundEdges($record));
|
||||
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
|
||||
}
|
||||
|
||||
return $edges->take(100); // both directions combined
|
||||
|
||||
@ -8,4 +8,28 @@ enum RelationshipType: string
|
||||
case ScopedBy = 'scoped_by';
|
||||
case Targets = 'targets';
|
||||
case DependsOn = 'depends_on';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AssignedTo => 'Assigned to',
|
||||
self::ScopedBy => 'Scoped by',
|
||||
self::Targets => 'Targets',
|
||||
self::DependsOn => 'Depends on',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
$options[$case->value] = $case->label();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,14 @@
|
||||
<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>
|
||||
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
# Requirements Checklist — Inventory Dependencies Graph (042)
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] This checklist applies only to Spec 042 (Inventory Dependencies Graph).
|
||||
- [ ] MVP scope: show **direct** inbound/outbound edges only (no depth>1 traversal / transitive blast radius).
|
||||
|
||||
## Constitution Gates
|
||||
|
||||
- [ ] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects)
|
||||
- [ ] Read/write separation: no Intune write paths introduced
|
||||
- [ ] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used)
|
||||
- [ ] Tenant isolation: all reads/writes tenant-scoped
|
||||
- [ ] Automation is idempotent & observable: unique key + upsert + run records + stable error codes
|
||||
- [ ] Data minimization & safe logging: no secrets/tokens; avoid storing raw payloads outside allowed fields
|
||||
- [ ] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[]
|
||||
|
||||
## Functional Requirements Coverage
|
||||
|
||||
- [ ] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions)
|
||||
- [ ] FR-002 Dependency edges stored in `inventory_links` with unique key (idempotent upsert)
|
||||
- [ ] FR-003 Inbound/outbound query services tenant-scoped, limited (MVP: limit-only unless pagination is explicitly implemented)
|
||||
- [ ] FR-004 Missing prerequisites represented as `target_type='missing'` with safe metadata + UI badge/tooltip
|
||||
- [ ] FR-005 Relationship-type filtering available in UI (single-select, default “All”)
|
||||
|
||||
## Non-Functional Requirements Coverage
|
||||
|
||||
- [ ] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically
|
||||
- [ ] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types
|
||||
|
||||
## Tests (Pest)
|
||||
|
||||
- [ ] Extraction determinism + unique key (re-run equality)
|
||||
- [ ] Missing edges show “Missing” badge and safe tooltip
|
||||
- [ ] 50-edge limit enforced and truncation behavior is observable (if specified)
|
||||
- [ ] Tenant isolation for queries and UI
|
||||
- [ ] UI smoke: relationship-type filter limits visible edges
|
||||
@ -7,6 +7,12 @@ ## Summary
|
||||
|
||||
Add dependency edge model, extraction logic, and UI views to explain relationships between inventory items and prerequisite/foundation objects.
|
||||
|
||||
**MVP constraints (explicit):**
|
||||
- **Limit-only** queries (no pagination/cursors in this iteration).
|
||||
- UI shows up to **50 edges per direction** (up to 100 total when showing both directions).
|
||||
- Unknown/unsupported reference shapes: **warning-only** (no edge created).
|
||||
- Warnings are persisted on the **sync run record** at `InventorySyncRun.error_context.warnings[]` (no new tables).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Inventory items and stable identifiers (Spec 040)
|
||||
@ -63,6 +69,8 @@ ### Error & Warning Logic
|
||||
- **Unresolved target**: Create edge with `target_type='missing'` (not an error)
|
||||
- **All targets missing**: Extraction still emits `missing` edges; UI must handle zero resolvable targets without error
|
||||
|
||||
**MVP clarification:** Unknown/unsupported reference shapes are **warning-only** (no edge created). Warnings are stored at `InventorySyncRun.error_context.warnings[]`.
|
||||
|
||||
### Hard Limits
|
||||
- **Max 50 edges per item per direction** (outbound/inbound)
|
||||
- Enforced at extraction: sort by priority (assigned_to > scoped_by > targets > depends_on), keep top 50, log truncation warning
|
||||
@ -126,6 +134,12 @@ ### Component Structure
|
||||
->options(['all' => 'All', 'inbound' => 'Inbound', 'outbound' => 'Outbound'])
|
||||
->default('all')
|
||||
->live(),
|
||||
|
||||
// Filter: Relationship Type (default: All)
|
||||
Forms\Components\Select::make('relationship_type')
|
||||
->options(['all' => 'All'] + /* RelationshipType enum options */ [])
|
||||
->default('all')
|
||||
->live(),
|
||||
|
||||
// Edges table (or custom Blade component)
|
||||
ViewEntry::make('edges')
|
||||
@ -145,9 +159,8 @@ ### Blade View: `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- **Privacy**: If display name unavailable, render masked identifier (e.g., `ID: abcd12…`), no cross-tenant lookups
|
||||
|
||||
### Filter Behavior
|
||||
- Single-select dropdown (not multi-select)
|
||||
- Default: "All" (both inbound + outbound shown)
|
||||
- Empty/null selection → treated as "All"
|
||||
- Direction: single-select dropdown; default "All" (both inbound + outbound shown); empty/null treated as "All"
|
||||
- Relationship type: single-select dropdown; default "All"; empty/null treated as "All"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -8,8 +8,20 @@ ## Purpose
|
||||
|
||||
Represent and surface dependency relationships between inventory items and foundational Intune objects so admins can understand blast radius and prerequisites.
|
||||
|
||||
MVP shows direct inbound/outbound edges only; depth > 1 traversal is out of scope for this iteration.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-01-10
|
||||
|
||||
- Q: Should FR3 be paginated or limit-only for MVP? → A: Limit-only (no pagination).
|
||||
- Q: Where should unknown/unsupported reference warnings be persisted? → A: On the inventory sync run record (e.g., `InventorySyncRun.error_context.warnings[]`).
|
||||
- Q: For unknown assignment target shapes, should we create a missing edge or warning-only? → A: Warning-only (no edge created).
|
||||
- Q: Should `foundation_object` edges always store `metadata.foundation_type`? → A: Yes (required).
|
||||
- Q: Should the UI show 50 edges total or 50 per direction? → A: 50 per direction (up to 100 total when showing both directions).
|
||||
|
||||
**Definitions**:
|
||||
- **Blast radius**: All resources directly or transitively affected by a change to a given item (outbound edges up to depth 2).
|
||||
- **Blast radius**: All resources directly affected by a change to a given item (outbound edges only; no transitive traversal in MVP).
|
||||
- **Prerequisite**: A hard dependency required for an item to function; missing prerequisites are explicitly surfaced.
|
||||
- **Inbound edge**: A relationship pointing TO this item (e.g., "Policy A is assigned to Group X" → Group X has inbound edge from Policy A).
|
||||
- **Outbound edge**: A relationship pointing FROM this item (e.g., "Policy A is scoped by ScopeTag Y" → Policy A has outbound edge to ScopeTag Y).
|
||||
@ -66,7 +78,7 @@ ## Functional Requirements
|
||||
- `target_type` (string: `inventory_item`, `foundation_object`, `missing`)
|
||||
- `target_id` (UUID or stable ref, nullable if missing)
|
||||
- `relationship_type` (FK to taxonomy or enum)
|
||||
- `metadata` (JSONB, optional: last_known_name, raw_ref, etc.)
|
||||
- `metadata` (JSONB, optional: last_known_name, raw_ref, etc.; for `target_type='foundation_object'`, `metadata.foundation_type` is required)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**In-scope foundation object types (MVP)**:
|
||||
@ -81,7 +93,9 @@ ## Functional Requirements
|
||||
- `getOutboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is source
|
||||
- `getInboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is target
|
||||
|
||||
Both return paginated, ordered by `created_at DESC`.
|
||||
Both return up to `limit` edges, ordered by `created_at DESC`.
|
||||
|
||||
UI supports filtering by `relationship_type` via a single-select dropdown (default: "All"; empty selection behaves as "All").
|
||||
|
||||
- **FR4: Missing prerequisites**
|
||||
When a target reference cannot be resolved:
|
||||
@ -91,6 +105,8 @@ ## Functional Requirements
|
||||
|
||||
No separate "deleted" or "archived" state in core inventory; missing is purely an edge property.
|
||||
|
||||
Unknown/unsupported reference shapes do not create edges; they are handled via warnings (see NFR2).
|
||||
|
||||
- **FR5: Tenant scoping and access control**
|
||||
- All edges filtered by `tenant_id` matching `Tenant::current()`
|
||||
- Read access: any authenticated tenant user
|
||||
@ -107,23 +123,23 @@ ## Non-Functional Requirements
|
||||
- **NFR2: Graceful unknown-reference handling**
|
||||
If an unknown/unsupported reference shape is encountered:
|
||||
- Log warning with severity `info` (not `error`)
|
||||
- Do NOT create an edge for unsupported types
|
||||
- Record warning in sync run metadata: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
|
||||
- Do NOT create an edge for unsupported types (including unknown assignment target shapes)
|
||||
- Record warning in sync run metadata at `InventorySyncRun.error_context.warnings[]` with shape: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
|
||||
- Sync run continues without failure
|
||||
|
||||
## Graph Traversal & Cycles
|
||||
## Graph Traversal & Cycles (Out of Scope for MVP)
|
||||
|
||||
- UI blast radius view is limited to depth ≤ 2 (direct neighbors and their neighbors).
|
||||
- Traversal uses visited-node tracking to prevent revisiting nodes (cycle break); cycles are implicitly handled by not re-visiting.
|
||||
- No special UI cycle annotation in MVP; future work may visualize cycles explicitly.
|
||||
- Depth > 1 traversal (transitive “blast radius”) is out of scope for this iteration.
|
||||
- The UI shows only direct inbound/outbound edges.
|
||||
- Future work may add depth-capped traversal with cycle handling and explicit cycle visualization.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- **SC1: Blast radius determination**
|
||||
Admins can determine prerequisites (inbound edges) and blast radius (outbound edges, depth ≤2) for any item in under 2 minutes:
|
||||
Admins can determine prerequisites (inbound edges) and blast radius (outbound edges; direct only) for any item in under 2 minutes:
|
||||
- Measured from: clicking "View Dependencies" on an item detail page
|
||||
- To: able to answer "What would break if I delete this?" and "What does this depend on?"
|
||||
- Acceptance: <2s page load, ≤50 edges per direction shown initially, clear visual grouping by relationship type
|
||||
- Acceptance: <2s page load, ≤50 edges per direction shown initially (≤100 total when showing both directions), clear visual grouping by relationship type
|
||||
|
||||
- **SC2: Deterministic output**
|
||||
For supported relationship types, dependency edges are consistent across re-runs:
|
||||
|
||||
@ -35,3 +35,14 @@ ## Finalization
|
||||
- [x] T022 Run full test suite (`php artisan test`)
|
||||
- [x] T023 Run Pint (`vendor/bin/pint`)
|
||||
- [x] T024 Update checklist items in `checklists/pr-gate.md`
|
||||
|
||||
## 追加 Tasks (MVP Remediation)
|
||||
- [ ] T025 Implement relationship-type filter (single-select dropdown, default: all)
|
||||
- [ ] T026 UI Smoke: relationship-type filter limits edges
|
||||
- [ ] T027 Create and complete `checklists/requirements.md` (Constitution gate)
|
||||
|
||||
## MVP Constraints (Non-Tasks)
|
||||
- MVP is limit-only (no pagination/cursors).
|
||||
- Show up to 50 edges per direction (up to 100 total for "all").
|
||||
- Unknown/unsupported shapes are warning-only; persist warnings on run record (`InventorySyncRun.error_context.warnings[]`).
|
||||
- No new tables for warnings.
|
||||
|
||||
@ -69,3 +69,43 @@
|
||||
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound';
|
||||
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
|
||||
});
|
||||
|
||||
it('relationship filter limits edges by type', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Two outbound edges with different relationship types.
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $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'],
|
||||
]);
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $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'],
|
||||
]);
|
||||
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant)
|
||||
.'?direction=outbound&relationship_type=scoped_by';
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('Scoped Target')
|
||||
->assertDontSee('Assigned Target');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user