- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
12 KiB
Python
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)
|