This commit is contained in:
@@ -3,8 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.routers.discoveries import router as discoveries_router
|
from app.routers.discoveries import router as discoveries_router
|
||||||
from app.routers.health import router as health_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.jobs import router as jobs_router
|
||||||
from app.routers.objects import router as objects_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')
|
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(objects_router)
|
||||||
app.include_router(jobs_router)
|
app.include_router(jobs_router)
|
||||||
app.include_router(discoveries_router)
|
app.include_router(discoveries_router)
|
||||||
|
app.include_router(inventory_router)
|
||||||
|
app.include_router(relations_router)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -11,6 +11,14 @@ ObjectType = Literal[
|
|||||||
'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container',
|
'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container',
|
||||||
'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other'
|
'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):
|
class HealthCheck(BaseModel):
|
||||||
@@ -75,6 +83,43 @@ class Link(BaseModel):
|
|||||||
source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference']
|
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):
|
class Job(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
@@ -123,3 +168,6 @@ class ListResponse(BaseModel, Generic[T]):
|
|||||||
|
|
||||||
class EntityResponse(BaseModel, Generic[T]):
|
class EntityResponse(BaseModel, Generic[T]):
|
||||||
item: T
|
item: T
|
||||||
|
|
||||||
|
|
||||||
|
InventoryTreeNode.model_rebuild()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
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)
|
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_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_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_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 = [
|
JOBS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user