kcg-ai-monitoring/frontend/src/flow/components/NodeDetailPanel.tsx
htlee a6f6003c5f feat: System Flow 뷰어 추가 (system-flow.html) — 102 노드, 133 엣지
iran 프로젝트의 gear-parent-flow 패턴을 차용하여 시스템 전체 데이터 흐름을
노드/엣지로 시각화하는 별도 React 앱 추가. 메인 SPA와 완전 분리.

## 인프라
- @xyflow/react 추가
- frontend/system-flow.html (별도 entry HTML)
- frontend/src/systemFlowMain.tsx (React entry)
- vite.config.ts: rollupOptions.input에 systemFlow 추가
- 빌드 산출물: dist/system-flow.html + dist/assets/systemFlow-*.js (231kB, 메인과 분리)

## 매니페스트 (frontend/src/flow/manifest/)
카테고리별 JSON 분할 + 빌드 시 병합:
- 01-ingest.json (6) — snpdb, vessel_store, refresh
- 02-pipeline.json (7) — 7단계 분류 파이프라인
- 03-algorithms.json (12) — zone/dark/spoofing/risk/transship 등
- 04-fleet.json (9) — fleet_tracker, polygon_builder, gear_correlation, parent_inference
- 05-output.json (8) — event/violation/kpi/stats/alert/redis
- 06-storage.json (18) — 핵심 DB 테이블
- 07-backend.json (15) — Spring Boot 컨트롤러 + endpoint
- 08-frontend.json (17) — 프론트 화면 (menu 매핑 포함)
- 09-decision.json (8) — 운영자 의사결정 액션
- 10-external.json (2) — iran, redis
- edges.json (133) — data/trigger/feedback 분류

## 뷰어 컴포넌트
- SystemFlowViewer.tsx — 3단 레이아웃 + React Flow + 상태 관리
- components/FilterBar.tsx — 검색/단계/메뉴/상세필터 + 레이아웃 토글
- components/NodeListSidebar.tsx — 좌측 카테고리별 노드 리스트
- components/NodeDetailPanel.tsx — 우측 선택 정보 + incoming/outgoing 흐름
- components/nodeShapes.ts — kind별 모양/색상 헬퍼
- SystemFlowViewer.css — 전용 다크 테마 스타일

## 기능
- stage(단계) ⇄ menu(메뉴) 두 가지 그룹화 토글
- 통합 검색 (label/file/symbol/tag)
- 다중 필터 (kind/trigger/status)
- 노드 모양: kind별 (algorithm=다이아몬드, decision=마름모, api=6각형 등)
- 엣지 색상: data=회색, trigger=녹색, feedback=노란 점선
- 딥링크: /system-flow.html#node=<id> (산출문서에서 직접 참조)

## /version 스킬 통합
- CLAUDE.md에 "/version 스킬 사후 처리" 섹션 추가
  Claude가 /version 호출 후 자동으로 manifest.meta version/updatedAt/releaseDate 갱신
- .gitea/workflows/deploy.yml에 archive 보존 단계 추가
  /deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/ 영구 누적
  (nginx 노출 X, 서버 로컬 보존)
- docs/system-flow-guide.md 작성 (URL, 노드 ID 명명, 산출문서 참조법, 갱신 절차)

## URL
- 운영: https://kcg-ai-monitoring.gc-si.dev/system-flow.html
- 메인 SPA에 링크 노출 없음 (개발 단계 페이지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:10:22 +09:00

252 lines
7.9 KiB
TypeScript

import type { FlowEdge, FlowNode } from '../manifest';
import { KIND_LABELS, STATUS_LABELS, TRIGGER_LABELS } from '../manifest';
import { stageColor } from './nodeShapes';
interface Props {
selectedNode: FlowNode | null;
selectedEdge: FlowEdge | null;
allNodes: FlowNode[];
allEdges: FlowEdge[];
onSelectNode: (id: string) => void;
}
export function NodeDetailPanel({
selectedNode,
selectedEdge,
allNodes,
allEdges,
onSelectNode,
}: Props) {
if (selectedEdge) {
const source = allNodes.find((n) => n.id === selectedEdge.source);
const target = allNodes.find((n) => n.id === selectedEdge.target);
return (
<div className="sf-detail">
<div className="sf-detail-label"></div>
<h2>{selectedEdge.label || selectedEdge.id}</h2>
<div className="sf-detail-id">{selectedEdge.id}</div>
<div className="sf-badges">
<span
className="sf-badge"
data-tone={
selectedEdge.kind === 'feedback'
? 'warn'
: selectedEdge.kind === 'trigger'
? 'success'
: 'accent'
}
>
{selectedEdge.kind ?? 'data'}
</span>
</div>
{selectedEdge.detail && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<div className="sf-detail-text">{selectedEdge.detail}</div>
</div>
)}
<div className="sf-detail-section">
<div className="sf-detail-label">Source Target</div>
{source && (
<button className="sf-link-button" onClick={() => onSelectNode(source.id)}>
{source.label}{' '}
<span style={{ color: '#64748b', fontSize: 9 }}>{source.id}</span>
</button>
)}
{target && (
<button className="sf-link-button" onClick={() => onSelectNode(target.id)}>
{target.label}{' '}
<span style={{ color: '#64748b', fontSize: 9 }}>{target.id}</span>
</button>
)}
</div>
</div>
);
}
if (!selectedNode) {
return (
<div className="sf-detail">
<div className="sf-empty">
<br />
.
</div>
</div>
);
}
const accent = stageColor(selectedNode.stage);
const incoming = allEdges.filter((e) => e.target === selectedNode.id);
const outgoing = allEdges.filter((e) => e.source === selectedNode.id);
return (
<div className="sf-detail" style={{ ['--accent' as never]: accent }}>
<div className="sf-detail-label">{selectedNode.stage}</div>
<h2>{selectedNode.label}</h2>
<div className="sf-detail-id">{selectedNode.id}</div>
<div className="sf-badges">
<span className="sf-badge" data-tone="accent">
{KIND_LABELS[selectedNode.kind]}
</span>
<span className="sf-badge">{TRIGGER_LABELS[selectedNode.trigger]}</span>
<span
className="sf-badge"
data-tone={
selectedNode.status === 'implemented'
? 'success'
: selectedNode.status === 'planned'
? 'warn'
: undefined
}
>
{STATUS_LABELS[selectedNode.status]}
</span>
{selectedNode.menu && (
<span className="sf-badge">: {selectedNode.menu}</span>
)}
</div>
{selectedNode.shortDescription && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<div className="sf-detail-text">{selectedNode.shortDescription}</div>
</div>
)}
{selectedNode.notes && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<div className="sf-detail-text">{selectedNode.notes}</div>
</div>
)}
{selectedNode.file && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<code className="sf-code">{selectedNode.file}</code>
{selectedNode.symbol && (
<code className="sf-code" style={{ marginTop: 4 }}>
{selectedNode.symbol}
</code>
)}
</div>
)}
{selectedNode.actor && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<div className="sf-detail-text">{selectedNode.actor}</div>
</div>
)}
{selectedNode.triggers && selectedNode.triggers.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"> </div>
<ul className="sf-list">
{selectedNode.triggers.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
</div>
)}
{selectedNode.inputs && selectedNode.inputs.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"> (Inputs)</div>
<ul className="sf-list">
{selectedNode.inputs.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
{selectedNode.outputs && selectedNode.outputs.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"> (Outputs)</div>
<ul className="sf-list">
{selectedNode.outputs.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
{selectedNode.params && selectedNode.params.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<ul className="sf-list">
{selectedNode.params.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
{selectedNode.rules && selectedNode.rules.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<ul className="sf-list">
{selectedNode.rules.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
{incoming.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"> ({incoming.length})</div>
{incoming.map((e) => {
const src = allNodes.find((n) => n.id === e.source);
return (
<button
key={e.id}
className="sf-link-button"
onClick={() => src && onSelectNode(src.id)}
>
{src?.label ?? e.source}
{e.label && <span style={{ color: '#94a3b8' }}> · {e.label}</span>}
</button>
);
})}
</div>
)}
{outgoing.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"> ({outgoing.length})</div>
{outgoing.map((e) => {
const tgt = allNodes.find((n) => n.id === e.target);
return (
<button
key={e.id}
className="sf-link-button"
onClick={() => tgt && onSelectNode(tgt.id)}
>
{tgt?.label ?? e.target}
{e.label && <span style={{ color: '#94a3b8' }}> · {e.label}</span>}
</button>
);
})}
</div>
)}
{selectedNode.tags && selectedNode.tags.length > 0 && (
<div className="sf-detail-section">
<div className="sf-detail-label"></div>
<div className="sf-filter-row">
{selectedNode.tags.map((t) => (
<span key={t} className="sf-chip">
{t}
</span>
))}
</div>
</div>
)}
</div>
);
}