guardia-itsm/routers/topology.py
DESKTOP-TKLFCPRython 6c85fba90f feat(itsm): 추가 기능 7개 + API 명세서 완성
[고객 셀프서비스 포털]
- routers/customer_portal.py: SR접수/추적/AI FAQ자가해결/카탈로그/만족도/통계
  POST /api/portal/faq/suggest — KB+Ollama 기반 SR 접수 전 자가해결 유도

[그룹웨어 전자결재 연동]
- routers/groupware.py: 카카오워크/네이버웍스/한컴/Custom 웹훅
  POST /api/groupware/send-approval → 결재 발송
  POST /api/groupware/callback → 승인/반려 콜백 → SR 상태 자동 갱신

[SIEM 보안 이벤트 연동]
- routers/siem.py: Elasticsearch/Splunk HEC/OpenSearch
  POST /api/siem/alert/receive → SIEM 경보 → 인시던트 자동 생성

[네트워크 토폴로지 시각화]
- routers/topology.py: CMDB CI 의존관계 D3.js 인터랙티브 그래프
  GET /api/topology/page — 드래그/줌/헬스오버레이 뷰어

[포트폴리오 + 리소스/인력 관리]
- routers/portfolio.py: 다중 프로젝트 포트폴리오 대시보드
  + 인원 배치(M/M) + 역량 매핑

[Zero Trust + Kubernetes + ERP]
- routers/infra_ext.py:
  - Zero Trust 세션 재검증 (위험점수 70 이상 → 강제 재인증)
  - K8s pods/services/nodes API 연동
  - ERP 예산 동기화

[API 명세서]
- manual/16_API_명세서.md: 전체 588개 라우트 도메인별 정리

[버그 수정]
- customer_portal.py: ServiceCatalog→ServiceItem, KBDocument.content→solution/symptoms
- customer_portal.py: catalog is_active→status="ACTIVE"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 07:37:52 +09:00

318 lines
12 KiB
Python

"""
네트워크 토폴로지 시각화 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 = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>GUARDiA 네트워크 토폴로지</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
body { margin:0; background:#0f172a; color:#e2e8f0; font-family:Arial,sans-serif; }
#topology-header { padding:12px 20px; background:#1e293b; border-bottom:1px solid #334155; display:flex; align-items:center; gap:12px; }
#topology-header h1 { margin:0; font-size:16px; color:#818cf8; }
#controls { display:flex; gap:8px; margin-left:auto; }
.btn { padding:5px 12px; border-radius:6px; border:1px solid #334155; background:#1e293b; color:#e2e8f0; cursor:pointer; font-size:12px; }
.btn:hover { background:#334155; }
#tooltip { position:fixed; background:#1e293b; border:1px solid #334155; border-radius:8px; padding:10px 14px; font-size:12px; pointer-events:none; opacity:0; transition:opacity .15s; max-width:220px; }
#legend { position:fixed; bottom:20px; left:20px; background:#1e293b; border:1px solid #334155; border-radius:8px; padding:12px 16px; font-size:11px; }
.legend-item { display:flex; align-items:center; gap:8px; margin:4px 0; }
.legend-dot { width:12px; height:12px; border-radius:50%; }
#stats { position:fixed; top:60px; right:20px; background:#1e293b; border:1px solid #334155; border-radius:8px; padding:12px 16px; font-size:12px; }
svg { width:100vw; height:calc(100vh - 52px); }
</style>
</head>
<body>
<div id="topology-header">
<h1>🌐 네트워크 토폴로지</h1>
<div id="controls">
<button class="btn" onclick="resetZoom()">리셋</button>
<button class="btn" onclick="toggleLabels()">레이블</button>
<button class="btn" onclick="refreshData()">새로고침</button>
<button class="btn" onclick="history.back()">← 닫기</button>
</div>
</div>
<div id="tooltip"></div>
<div id="stats">노드: <b id="node-count">0</b> | 링크: <b id="link-count">0</b></div>
<div id="legend">
<div style="font-weight:700;margin-bottom:6px">노드 유형</div>
<div class="legend-item"><div class="legend-dot" style="background:#60a5fa"></div>서버</div>
<div class="legend-item"><div class="legend-dot" style="background:#34d399"></div>WAS</div>
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div>DB</div>
<div class="legend-item"><div class="legend-dot" style="background:#a78bfa"></div>네트워크</div>
<div class="legend-item"><div class="legend-dot" style="background:#fb923c"></div>스토리지</div>
<div style="margin-top:8px;font-weight:700">헬스 상태</div>
<div class="legend-item"><div class="legend-dot" style="background:#22c55e;border:2px solid #16a34a"></div>정상</div>
<div class="legend-item"><div class="legend-dot" style="background:#eab308;border:2px solid #ca8a04"></div>주의</div>
<div class="legend-item"><div class="legend-dot" style="background:#ef4444;border:2px solid #dc2626"></div>위험</div>
</div>
<svg id="topo-svg"></svg>
<script>
const token = localStorage.getItem('access_token') || '';
let showLabels = true;
let simulation, svg, g;
async function loadData() {
const r = await fetch('/api/topology/graph', { headers: { 'Authorization': 'Bearer ' + token } });
return r.json();
}
async function refreshData() { render(await loadData()); }
function resetZoom() { svg.call(zoom.transform, d3.zoomIdentity); }
function toggleLabels() {
showLabels = !showLabels;
g.selectAll('.node-label').attr('opacity', showLabels ? 1 : 0);
}
const healthColor = { ACTIVE:'#22c55e', MAINTENANCE:'#eab308', INACTIVE:'#ef4444', UNKNOWN:'#94a3b8' };
async function render(data) {
document.getElementById('node-count').textContent = data.node_count;
document.getElementById('link-count').textContent = data.link_count;
const svgEl = document.getElementById('topo-svg');
const W = svgEl.clientWidth, H = svgEl.clientHeight;
d3.select('#topo-svg').selectAll('*').remove();
svg = d3.select('#topo-svg');
const zoom = d3.zoom().scaleExtent([0.1, 4]).on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
g = svg.append('g');
const nodes = data.nodes.map(d => ({...d}));
const links = data.links.map(d => ({...d}));
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(W/2, H/2))
.force('collision', d3.forceCollide(30));
// 링크
const link = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', '#334155').attr('stroke-width', 1.5).attr('stroke-opacity', 0.6);
// 노드 그룹
const node = g.append('g').selectAll('g').data(nodes).join('g')
.call(d3.drag()
.on('start', (e,d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if (!e.active) simulation.alphaTarget(0); d.fx=null; d.fy=null; }));
node.append('circle')
.attr('r', 16)
.attr('fill', d => d.color)
.attr('stroke', d => healthColor[d.health] || '#94a3b8')
.attr('stroke-width', 2.5)
.attr('opacity', 0.9);
// 루트 노드 강조
node.filter(d => d.is_root).append('circle').attr('r', 22)
.attr('fill', 'none').attr('stroke', '#f59e0b').attr('stroke-width', 2).attr('stroke-dasharray', '4 2');
node.append('text').attr('class','node-label')
.attr('text-anchor', 'middle').attr('dy', 26)
.attr('fill', '#cbd5e1').attr('font-size', 10)
.text(d => d.name.substring(0,16));
// 툴팁
const tooltip = document.getElementById('tooltip');
node.on('mouseover', (e, d) => {
tooltip.style.opacity = 1;
tooltip.innerHTML = `<b>${d.name}</b><br>유형: ${d.type}<br>상태: ${d.health || d.status}<br>담당: ${d.owner || '미지정'}`;
}).on('mousemove', e => {
tooltip.style.left = (e.clientX+12)+'px';
tooltip.style.top = (e.clientY-8)+'px';
}).on('mouseout', () => { tooltip.style.opacity = 0; });
simulation.on('tick', () => {
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
.attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
loadData().then(render);
</script>
</body>
</html>"""
return HTMLResponse(html)