""" 네트워크 토폴로지 시각화 API CMDB CI 의존관계를 D3.js force-directed graph 형식으로 반환. 프론트엔드에서 /api/topology/graph 데이터를 받아 D3.js로 렌더링. 엔드포인트: GET /api/topology/graph — 전체 CI 의존관계 그래프 (nodes/links) GET /api/topology/graph/{ci_id} — 특정 CI 중심 서브그래프 GET /api/topology/health — 서버별 헬스 오버레이 데이터 GET /api/topology/page — D3.js 토폴로지 뷰어 HTML """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import HTMLResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ConfigItem, CIRelation, Server, User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/topology", tags=["topology"]) # 노드 타입별 색상 NODE_COLORS = { "SERVER": "#60a5fa", "WAS": "#34d399", "DB": "#f59e0b", "NETWORK": "#a78bfa", "STORAGE": "#fb923c","LOAD_BALANCER": "#f472b6", "FIREWALL": "#f87171","CDN": "#6ee7b7", "DEFAULT": "#94a3b8", } async def _build_graph(db: AsyncSession, root_ci_id: Optional[int] = None, max_depth: int = 3) -> dict: """CMDB CI 관계에서 그래프 데이터 생성.""" # 전체 CI 조회 if root_ci_id: # BFS로 root에서 max_depth 깊이까지 visited = set() queue = [root_ci_id] ci_ids = set() depth = {root_ci_id: 0} while queue: current = queue.pop(0) if current in visited: continue visited.add(current) ci_ids.add(current) if depth.get(current, 0) >= max_depth: continue rels = (await db.execute( select(CIRelation).where( (CIRelation.from_ci_id == current) | (CIRelation.to_ci_id == current) ) )).scalars().all() for rel in rels: nxt = rel.to_ci_id if rel.from_ci_id == current else rel.from_ci_id if nxt not in visited: queue.append(nxt) depth[nxt] = depth.get(current, 0) + 1 cis = (await db.execute(select(ConfigItem).where(ConfigItem.id.in_(ci_ids)))).scalars().all() rels = (await db.execute( select(CIRelation).where( CIRelation.from_ci_id.in_(ci_ids), CIRelation.to_ci_id.in_(ci_ids) ) )).scalars().all() else: cis = (await db.execute(select(ConfigItem).limit(200))).scalars().all() rels = (await db.execute(select(CIRelation).limit(500))).scalars().all() # 서버 헬스 데이터 server_status: dict = {} if cis: servers = (await db.execute(select(Server))).scalars().all() for s in servers: server_status[s.server_name] = s.status if hasattr(s, "status") else "ACTIVE" # nodes nodes = [] for ci in cis: ci_type = (ci.ci_type or "DEFAULT").upper() color = NODE_COLORS.get(ci_type, NODE_COLORS["DEFAULT"]) srv_stat = server_status.get(ci.name, "UNKNOWN") nodes.append({ "id": ci.id, "name": ci.name, "type": ci_type, "category": ci.category or "", "status": ci.status or "ACTIVE", "color": color, "health": srv_stat, "owner": ci.owner or "", "is_root": ci.id == root_ci_id, }) # links rel_type_labels = { "DEPENDS_ON": "의존", "RUNS_ON": "실행", "CONNECTS_TO": "연결", "BACKS_UP": "백업", "MONITORS": "모니터", } links = [ { "source": r.from_ci_id, "target": r.to_ci_id, "type": r.relation_type if hasattr(r, "relation_type") else "CONNECTS_TO", "label": rel_type_labels.get( r.relation_type if hasattr(r, "relation_type") else "", "연결" ), } for r in rels ] return { "nodes": nodes, "links": links, "node_count": len(nodes), "link_count": len(links), "root_ci_id": root_ci_id, } @router.get("/graph") async def full_graph( db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """전체 CI 의존관계 그래프.""" return await _build_graph(db) @router.get("/graph/{ci_id}") async def subgraph( ci_id: int, depth: int = Query(2, ge=1, le=4), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """특정 CI 중심 서브그래프.""" ci = await db.get(ConfigItem, ci_id) if not ci: raise HTTPException(404, f"CI ID {ci_id}를 찾을 수 없습니다.") return await _build_graph(db, root_ci_id=ci_id, max_depth=depth) @router.get("/health") async def topology_health( db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """서버 헬스 오버레이 데이터 (실시간).""" servers = (await db.execute(select(Server))).scalars().all() return { "servers": [ { "name": s.server_name, "status": getattr(s, "status", "UNKNOWN"), "os": s.os_type if hasattr(s, "os_type") else "", "inst": s.inst_id, } for s in servers ] } @router.get("/page", response_class=HTMLResponse) async def topology_page(_u: User = Depends(get_current_user)): """D3.js 인터랙티브 토폴로지 뷰어.""" html = """ GUARDiA 네트워크 토폴로지

🌐 네트워크 토폴로지

노드: 0 | 링크: 0
노드 유형
서버
WAS
DB
네트워크
스토리지
헬스 상태
정상
주의
위험
""" return HTMLResponse(html)