"""Nexus — Platform & Node API Routes (Asset organization layer) All operations require JWT authentication. """ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request from server.api.dependencies import get_db from server.api.auth_jwt import get_current_admin from server.api.schemas import PlatformCreate, PlatformUpdate, NodeCreate, NodeUpdate from server.infrastructure.database.platform_node_repo import PlatformRepositoryImpl, NodeRepositoryImpl from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl from server.domain.models import Platform, Node, Admin, AuditLog from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter(prefix="/api/assets", tags=["assets"]) # ── Platforms ── @router.get("/platforms", response_model=list) async def list_platforms( admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """List all platform templates""" repo = PlatformRepositoryImpl(db) platforms = await repo.get_all() return [_platform_to_dict(p) for p in platforms] @router.get("/platforms/{id}", response_model=dict) async def get_platform( id: int, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Get a single platform by ID""" repo = PlatformRepositoryImpl(db) platform = await repo.get_by_id(id) if not platform: raise HTTPException(status_code=404, detail="Platform not found") return _platform_to_dict(platform) @router.post("/platforms", response_model=dict, status_code=201) async def create_platform( payload: PlatformCreate, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Create a new platform template""" repo = PlatformRepositoryImpl(db) platform = Platform(**payload.model_dump(exclude_none=True)) created = await repo.create(platform) audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="create_platform", target_type="platform", target_id=created.id, detail=f"name={created.name}", ip_address="", )) return _platform_to_dict(created) @router.put("/platforms/{id}", response_model=dict) async def update_platform( id: int, payload: PlatformUpdate, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Update a platform template""" repo = PlatformRepositoryImpl(db) platform = await repo.get_by_id(id) if not platform: raise HTTPException(status_code=404, detail="Platform not found") for key, value in payload.model_dump(exclude_unset=True).items(): if hasattr(platform, key) and key != "id": setattr(platform, key, value) updated = await repo.update(platform) audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="update_platform", target_type="platform", target_id=id, detail=f"name={updated.name}", ip_address="", )) return _platform_to_dict(updated) @router.delete("/platforms/{id}", status_code=204) async def delete_platform( id: int, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Delete a platform template""" repo = PlatformRepositoryImpl(db) platform = await repo.get_by_id(id) if not platform: raise HTTPException(status_code=404, detail="Platform not found") result = await repo.delete(id) if not result: raise HTTPException(status_code=404, detail="Platform not found") audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="delete_platform", target_type="platform", target_id=id, detail=f"name={platform.name}", ip_address="", )) # ── Nodes (Tree) ── @router.get("/nodes", response_model=list) async def list_nodes( parent_id: Optional[int] = None, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """List nodes — all, or children of a specific parent""" repo = NodeRepositoryImpl(db) if parent_id is not None: nodes = await repo.get_children(parent_id) else: nodes = await repo.get_all() return [_node_to_dict(n) for n in nodes] @router.get("/nodes/tree", response_model=list) async def get_node_tree( admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Get full node tree structure""" repo = NodeRepositoryImpl(db) all_nodes = await repo.get_all() return _build_tree(all_nodes) @router.post("/nodes", response_model=dict, status_code=201) async def create_node( payload: NodeCreate, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Create a new node""" repo = NodeRepositoryImpl(db) node = Node(**payload.model_dump(exclude_none=True)) created = await repo.create(node) audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="create_node", target_type="node", target_id=created.id, detail=f"name={created.name}", ip_address="", )) return _node_to_dict(created) @router.put("/nodes/{id}", response_model=dict) async def update_node( id: int, payload: NodeUpdate, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Update a node""" repo = NodeRepositoryImpl(db) node = await repo.get_by_id(id) if not node: raise HTTPException(status_code=404, detail="Node not found") for key, value in payload.model_dump(exclude_unset=True).items(): if hasattr(node, key) and key != "id": setattr(node, key, value) updated = await repo.update(node) audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="update_node", target_type="node", target_id=id, detail=f"name={updated.name}", ip_address="", )) return _node_to_dict(updated) @router.delete("/nodes/{id}", status_code=204) async def delete_node( id: int, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Delete a node""" repo = NodeRepositoryImpl(db) node = await repo.get_by_id(id) if not node: raise HTTPException(status_code=404, detail="Node not found") result = await repo.delete(id) if not result: raise HTTPException(status_code=404, detail="Node not found") audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="delete_node", target_type="node", target_id=id, detail=f"name={node.name}", ip_address="", )) # ── SSH Sessions & Command Logs ── @router.get("/ssh-sessions", response_model=list) async def list_ssh_sessions( server_id: Optional[int] = None, limit: int = 50, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """List SSH sessions""" from server.infrastructure.database.ssh_session_repo import SshSessionRepositoryImpl repo = SshSessionRepositoryImpl(db) if server_id: sessions = await repo.get_by_server(server_id, limit) else: from sqlalchemy import select from server.domain.models import SshSession result = await db.execute(select(SshSession).order_by(SshSession.started_at.desc()).limit(limit)) sessions = list(result.scalars().all()) return [_ssh_session_to_dict(s) for s in sessions] @router.get("/command-logs", response_model=list) async def list_command_logs( server_id: Optional[int] = None, session_id: Optional[str] = None, limit: int = 200, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """List command logs""" from server.infrastructure.database.ssh_session_repo import CommandLogRepositoryImpl repo = CommandLogRepositoryImpl(db) if session_id: logs = await repo.get_by_session(session_id, limit) elif server_id: logs = await repo.get_by_server(server_id, limit) else: from sqlalchemy import select from server.domain.models import CommandLog result = await db.execute(select(CommandLog).order_by(CommandLog.created_at.desc()).limit(limit)) logs = list(result.scalars().all()) return [_command_log_to_dict(l) for l in logs] # ── Helper functions ── def _platform_to_dict(platform: Platform) -> dict: return { "id": platform.id, "name": platform.name, "category": platform.category, "type": platform.type, "default_protocols": platform.default_protocols, "charset": platform.charset, "created_at": str(platform.created_at) if platform.created_at else None, } def _node_to_dict(node: Node) -> dict: return { "id": node.id, "name": node.name, "parent_id": node.parent_id, "sort_order": node.sort_order, "created_at": str(node.created_at) if node.created_at else None, } def _build_tree(nodes: list) -> list: """Build tree structure from flat node list""" node_map = {n.id: {**_node_to_dict(n), "children": []} for n in nodes} tree = [] for n in nodes: node_dict = node_map[n.id] if n.parent_id and n.parent_id in node_map: node_map[n.parent_id]["children"].append(node_dict) else: tree.append(node_dict) return tree def _ssh_session_to_dict(session) -> dict: return { "id": session.id, "server_id": session.server_id, "admin_id": session.admin_id, "remote_addr": session.remote_addr, "status": session.status, "started_at": str(session.started_at) if session.started_at else None, "closed_at": str(session.closed_at) if session.closed_at else None, } def _command_log_to_dict(log) -> dict: return { "id": log.id, "session_id": log.session_id, "server_id": log.server_id, "admin_id": log.admin_id, "command": log.command, "remote_addr": log.remote_addr, "created_at": str(log.created_at) if log.created_at else None, }