From 4466e59684742903b7b76866dc462cb628feda7b Mon Sep 17 00:00:00 2001 From: Perplexity Bot Date: Tue, 4 Aug 2026 19:20:19 +0000 Subject: [PATCH] Add inventory relations model and API structure --- backend/app/main.py | 4 ++ backend/app/routers/inventory.py | 48 ++++++++++++++ backend/app/routers/relations.py | 37 +++++++++++ backend/app/schemas.py | 48 ++++++++++++++ backend/app/services/inventory.py | 103 ++++++++++++++++++++++++++++++ backend/app/services/store.py | 13 +++- 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/inventory.py create mode 100644 backend/app/routers/relations.py create mode 100644 backend/app/services/inventory.py diff --git a/backend/app/main.py b/backend/app/main.py index d3be8c1..09b0992 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,8 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware from app.routers.discoveries import router as discoveries_router from app.routers.health import router as health_router +from app.routers.inventory import router as inventory_router from app.routers.jobs import router as jobs_router from app.routers.objects import router as objects_router +from app.routers.relations import router as relations_router app = FastAPI(title='NetMapper Backend', version='0.1.0') @@ -20,3 +22,5 @@ app.include_router(health_router) app.include_router(objects_router) app.include_router(jobs_router) app.include_router(discoveries_router) +app.include_router(inventory_router) +app.include_router(relations_router) diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py new file mode 100644 index 0000000..410738a --- /dev/null +++ b/backend/app/routers/inventory.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.schemas import EntityResponse, InventoryTreeNode, ListResponse, NodeContext, Relation +from app.services.inventory import build_inventory_tree, build_node_context, get_badges, get_node_relations + +router = APIRouter(prefix='/inventory', tags=['inventory']) + + +@router.get('/tree', response_model=ListResponse[InventoryTreeNode]) +def inventory_tree( + root_id: str | None = None, + depth: int | None = Query(default=None, ge=0), +) -> ListResponse[InventoryTreeNode]: + items = build_inventory_tree(root_id=root_id, depth=depth) + return ListResponse(count=len(items), items=items) + + +@router.get('/tree/{item_id}', response_model=EntityResponse[InventoryTreeNode]) +def inventory_subtree(item_id: str, depth: int | None = Query(default=None, ge=0)) -> EntityResponse[InventoryTreeNode]: + items = build_inventory_tree(root_id=item_id, depth=depth) + if not items: + raise HTTPException(status_code=404, detail='Inventory node not found') + return EntityResponse(item=items[0]) + + +@router.get('/nodes/{item_id}/context', response_model=EntityResponse[NodeContext]) +def node_context(item_id: str) -> EntityResponse[NodeContext]: + item = build_node_context(item_id) + if not item: + raise HTTPException(status_code=404, detail='Inventory node not found') + return EntityResponse(item=item) + + +@router.get('/nodes/{item_id}/badges', response_model=ListResponse[str]) +def node_badges(item_id: str) -> ListResponse[str]: + badges = get_badges(item_id) + return ListResponse(count=len(badges), items=badges) + + +@router.get('/nodes/{item_id}/relations', response_model=ListResponse[Relation]) +def node_relations( + item_id: str, + layer: str | None = None, + type: str | None = Query(default=None), + direction: str = Query(default='both', pattern='^(incoming|outgoing|both)$'), +) -> ListResponse[Relation]: + items = get_node_relations(item_id, layer=layer, rel_type=type, direction=direction) + return ListResponse(count=len(items), items=items) diff --git a/backend/app/routers/relations.py b/backend/app/routers/relations.py new file mode 100644 index 0000000..3d6d2a7 --- /dev/null +++ b/backend/app/routers/relations.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.schemas import EntityResponse, ListResponse, Relation +from app.services.inventory import get_node_relations +from app.services.store import RELATIONS + +router = APIRouter(prefix='/relations', tags=['relations']) + + +@router.get('', response_model=ListResponse[Relation]) +def list_relations( + source_id: str | None = None, + target_id: str | None = None, + layer: str | None = None, + type: str | None = Query(default=None), + min_confidence: float | None = None, +) -> ListResponse[Relation]: + items = RELATIONS + if source_id: + items = [r for r in items if r.source_id == source_id] + if target_id: + items = [r for r in items if r.target_id == target_id] + if layer: + items = [r for r in items if r.layer == layer] + if type: + items = [r for r in items if r.type == type] + if min_confidence is not None: + items = [r for r in items if r.confidence >= min_confidence] + return ListResponse(count=len(items), items=items) + + +@router.get('/{relation_id}', response_model=EntityResponse[Relation]) +def get_relation(relation_id: str) -> EntityResponse[Relation]: + for item in RELATIONS: + if item.id == relation_id: + return EntityResponse(item=item) + raise HTTPException(status_code=404, detail='Relation not found') diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6d4600e..fb58ec8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -11,6 +11,14 @@ ObjectType = Literal[ 'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container', 'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other' ] +RelationType = Literal[ + 'contains', 'mounted_in', 'attached_to', 'connects_to', 'uplink_to', + 'routes_via', 'bridges_to', 'depends_on', 'hosts_service', 'managed_by' +] +RelationLayer = Literal['physical', 'network', 'logical', 'service'] +RelationSource = Literal['manual', 'snmp', 'lldp', 'arp', 'nmap', 'inference', 'import'] +RelationStatus = Literal['active', 'stale', 'planned', 'deleted'] +RelationDirection = Literal['forward'] class HealthCheck(BaseModel): @@ -75,6 +83,43 @@ class Link(BaseModel): source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference'] +class Relation(BaseModel): + id: str + type: RelationType + layer: RelationLayer + source_id: str + target_id: str + direction: RelationDirection = 'forward' + source: RelationSource + confidence: float = Field(ge=0, le=1, default=1.0) + status: RelationStatus = 'active' + attributes: Dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class InventoryTreeNode(BaseModel): + id: str + name: str + type: ObjectType + status: ObjectStatus + badges: List[str] = Field(default_factory=list) + children_count: int = 0 + children: List['InventoryTreeNode'] = Field(default_factory=list) + + +class NodeContextRelations(BaseModel): + incoming: List[Relation] = Field(default_factory=list) + outgoing: List[Relation] = Field(default_factory=list) + + +class NodeContext(BaseModel): + item: NetObject + parent: Optional[NetObject] = None + children: List[NetObject] = Field(default_factory=list) + relations: NodeContextRelations = Field(default_factory=NodeContextRelations) + + class Job(BaseModel): id: str type: str @@ -123,3 +168,6 @@ class ListResponse(BaseModel, Generic[T]): class EntityResponse(BaseModel, Generic[T]): item: T + + +InventoryTreeNode.model_rebuild() diff --git a/backend/app/services/inventory.py b/backend/app/services/inventory.py new file mode 100644 index 0000000..222e9d2 --- /dev/null +++ b/backend/app/services/inventory.py @@ -0,0 +1,103 @@ +from collections import defaultdict + +from app.schemas import InventoryTreeNode, NetObject, NodeContext, NodeContextRelations, Relation +from app.services.store import OBJECTS, RELATIONS + + +def get_object_map() -> dict[str, NetObject]: + return {item.id: item for item in OBJECTS} + + +def get_children_map() -> dict[str, list[NetObject]]: + object_map = get_object_map() + children: dict[str, list[NetObject]] = defaultdict(list) + for rel in RELATIONS: + if rel.type == 'contains' and rel.status == 'active' and rel.target_id in object_map: + children[rel.source_id].append(object_map[rel.target_id]) + return children + + +def get_badges(object_id: str) -> list[str]: + obj = get_object_map()[object_id] + badges: list[str] = [] + if obj.type == 'physical_host': + badges.append('virtualization-host') + if obj.type in {'vm', 'router', 'firewall'}: + badges.append('network-node') + outgoing = [r for r in RELATIONS if r.source_id == object_id and r.status == 'active'] + if any(r.type == 'routes_via' for r in outgoing): + badges.append('gateway-dependent') + if any(r.type in {'routes_via', 'bridges_to'} for r in outgoing) or obj.name.lower().startswith('openwrt'): + badges.append('gateway') + return badges + + +def build_tree_node(object_id: str, depth: int | None = None) -> InventoryTreeNode: + object_map = get_object_map() + children_map = get_children_map() + obj = object_map[object_id] + child_objects = children_map.get(object_id, []) + child_nodes = [] + if depth is None or depth > 0: + next_depth = None if depth is None else depth - 1 + child_nodes = [build_tree_node(child.id, next_depth) for child in child_objects] + return InventoryTreeNode( + id=obj.id, + name=obj.name, + type=obj.type, + status=obj.status, + badges=get_badges(obj.id), + children_count=len(child_objects), + children=child_nodes, + ) + + +def build_inventory_tree(root_id: str | None = None, depth: int | None = None) -> list[InventoryTreeNode]: + object_map = get_object_map() + if root_id: + return [build_tree_node(root_id, depth)] if root_id in object_map else [] + roots = [obj for obj in OBJECTS if obj.parent_id is None] + return [build_tree_node(obj.id, depth) for obj in roots] + + +def get_parent(item: NetObject) -> NetObject | None: + object_map = get_object_map() + if not item.parent_id: + return None + return object_map.get(item.parent_id) + + +def get_children(item_id: str) -> list[NetObject]: + return get_children_map().get(item_id, []) + + +def get_relations(item_id: str) -> NodeContextRelations: + incoming = [rel for rel in RELATIONS if rel.target_id == item_id and rel.status == 'active'] + outgoing = [rel for rel in RELATIONS if rel.source_id == item_id and rel.status == 'active'] + return NodeContextRelations(incoming=incoming, outgoing=outgoing) + + +def build_node_context(item_id: str) -> NodeContext | None: + object_map = get_object_map() + item = object_map.get(item_id) + if not item: + return None + return NodeContext( + item=item, + parent=get_parent(item), + children=get_children(item_id), + relations=get_relations(item_id), + ) + + +def get_node_relations(item_id: str, layer: str | None = None, rel_type: str | None = None, direction: str = 'both') -> list[Relation]: + result: list[Relation] = [] + if direction in {'incoming', 'both'}: + result.extend([r for r in RELATIONS if r.target_id == item_id and r.status == 'active']) + if direction in {'outgoing', 'both'}: + result.extend([r for r in RELATIONS if r.source_id == item_id and r.status == 'active']) + if layer: + result = [r for r in result if r.layer == layer] + if rel_type: + result = [r for r in result if r.type == rel_type] + return result diff --git a/backend/app/services/store.py b/backend/app/services/store.py index 19787fb..0c33267 100644 --- a/backend/app/services/store.py +++ b/backend/app/services/store.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject +from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject, Relation NOW = datetime.now(timezone.utc) @@ -8,6 +8,17 @@ OBJECTS = [ NetObject(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW), NetObject(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW), NetObject(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW), + NetObject(id='obj_4', name='OpenWRT VM', slug='openwrt-vm', type='vm', parent_id='obj_3', status='active', created_at=NOW, updated_at=NOW), + NetObject(id='obj_5', name='Core Switch', slug='core-switch', type='switch', parent_id='obj_2', status='active', created_at=NOW, updated_at=NOW), +] + +RELATIONS = [ + Relation(id='rel_1', type='contains', layer='physical', source_id='obj_1', target_id='obj_2', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW), + Relation(id='rel_2', type='contains', layer='physical', source_id='obj_2', target_id='obj_3', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW), + Relation(id='rel_3', type='contains', layer='physical', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW), + Relation(id='rel_4', type='contains', layer='physical', source_id='obj_2', target_id='obj_5', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW), + Relation(id='rel_5', type='routes_via', layer='network', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW), + Relation(id='rel_6', type='connects_to', layer='network', source_id='obj_4', target_id='obj_5', source='manual', confidence=0.9, created_at=NOW, updated_at=NOW), ] JOBS = [