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>
252 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|