Merge pull request 'release: System Flow 뷰어 추가' (#9) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s

This commit is contained in:
htlee 2026-04-07 17:11:46 +09:00
커밋 1a6604d3b9
28개의 변경된 파일3276개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -40,3 +40,32 @@ jobs:
cp -r frontend/dist/* /deploy/kcg-ai-monitoring/ cp -r frontend/dist/* /deploy/kcg-ai-monitoring/
echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')" echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')"
ls -la /deploy/kcg-ai-monitoring/ ls -la /deploy/kcg-ai-monitoring/
- name: Archive system-flow snapshot (per version)
run: |
# system-flow.html을 manifest version별로 영구 보존 (서버 로컬, nginx 노출 X)
ARCHIVE=/deploy/kcg-ai-monitoring-archive/system-flow
mkdir -p $ARCHIVE
if [ ! -f "frontend/src/flow/manifest/meta.json" ]; then
echo "[archive] meta.json not found, skip"
exit 0
fi
VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('frontend/src/flow/manifest/meta.json')).version)")
DATE=$(date +%Y-%m-%d)
SNAPSHOT="$ARCHIVE/v${VERSION}_${DATE}"
if [ -d "$SNAPSHOT" ]; then
echo "[archive] v${VERSION} already exists, skip"
exit 0
fi
mkdir -p "$SNAPSHOT/assets"
cp /deploy/kcg-ai-monitoring/system-flow.html "$SNAPSHOT/" 2>/dev/null || true
cp /deploy/kcg-ai-monitoring/assets/systemFlow-*.* "$SNAPSHOT/assets/" 2>/dev/null || true
cp /deploy/kcg-ai-monitoring/assets/index-*.* "$SNAPSHOT/assets/" 2>/dev/null || true
# manifest 전체 스냅샷 (JSON 형태로 별도 참조 가능)
cp -r frontend/src/flow/manifest "$SNAPSHOT/manifest" 2>/dev/null || true
echo "[archive] system-flow v${VERSION} snapshot saved → $SNAPSHOT"
ls -la "$SNAPSHOT/"

파일 보기

@ -100,3 +100,29 @@ make format # 프론트 prettier
- 팀 규칙: `.claude/rules/` - 팀 규칙: `.claude/rules/`
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증 - 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증 - pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
## System Flow 뷰어 (개발 단계용)
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
- **소스**: `frontend/system-flow.html` + `frontend/src/systemFlowMain.tsx` + `frontend/src/flow/`
- **매니페스트**: `frontend/src/flow/manifest/` (10개 카테고리 JSON + meta.json + edges.json)
- **노드 ID 명명**: `<category>.<snake_case>` (예: `output.event_generator`, `ui.parent_review`)
- **딥링크**: `/system-flow.html#node=<node_id>` — 산출문서에서 노드 직접 참조
- **가이드**: `docs/system-flow-guide.md` 참조
### `/version` 스킬 사후 처리 (필수)
`/version` 스킬을 실행하여 새 SemVer 버전이 결정되면, Claude는 이어서 다음 작업을 **자동으로** 수행한다 (`/version` 스킬 자체는 팀 공통 파일이라 직접 수정하지 않음):
1. **manifest 동기화**: `/version`이 결정한 새 버전을 `frontend/src/flow/manifest/meta.json`에 반영
- `version`: 새 SemVer (예: `"1.2.0"`)
- `updatedAt`: 현재 ISO datetime (`new Date().toISOString()`)
- `releaseDate`: 오늘 날짜 (`YYYY-MM-DD`)
2. **같은 커밋에 포함**: `frontend/src/flow/manifest/meta.json``/version` 스킬이 만든 커밋에 amend하거나, `docs: VERSION-HISTORY 갱신 + system-flow manifest 동기화`로 통합 커밋
3. **서버 archive는 CI/CD가 자동 처리**: 별도 작업 불필요. main 머지 후 Gitea Actions가 빌드 + dist 배포 + `/deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/`에 스냅샷 영구 보존
### 노드 ID 안정성
- **노드 ID는 절대 변경 금지** (산출문서가 참조하므로 깨짐)
- 노드 제거 시 `status: 'deprecated'`로 마킹 (1~2 릴리즈 유지 후 삭제)
- 새 노드 추가 시 `status: 'implemented'` 또는 `'planned'`

파일 보기

@ -4,6 +4,14 @@
## [Unreleased] ## [Unreleased]
### 추가
- System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화
- 102 노드 + 133 엣지, 10개 카테고리 매니페스트
- stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
- CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존
## [2026-04-07] ## [2026-04-07]
### 추가 ### 추가

155
docs/system-flow-guide.md Normal file
파일 보기

@ -0,0 +1,155 @@
# System Flow 뷰어 가이드
KCG AI Monitoring 시스템 워크플로우 플로우차트 뷰어 사용법.
## 개요
`/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다.
- 102개 노드 + 133개 엣지 (v1.0.0 기준)
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
- 메뉴/링크 노출 없음 — 직접 URL 접근만
## 접근 URL
- **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html
- **로컬 개발**: http://localhost:5173/system-flow.html
별도 인증 없이 접근 가능 (개발 단계).
## 노드 ID 명명 규칙
```
<category>.<snake_case_name>
```
| Category | Stage | 예시 |
|---|---|---|
| `ingest` | 수집/캐시 | `ingest.snpdb_5min`, `ingest.vessel_store_cache` |
| `pipeline` | 파이프라인 | `pipeline.preprocess`, `pipeline.classify` |
| `algo` | 분석 | `algo.zone_classify`, `algo.risk_score` |
| `fleet` | 선단 | `fleet.parent_inference`, `fleet.gear_correlation` |
| `output` | 출력 | `output.event_generator`, `output.alert_dispatcher` |
| `storage` | 저장소 | `storage.prediction_events`, `storage.vessel_analysis_results` |
| `api` | API | `api.events_get`, `api.parent_inference_confirm` |
| `ui` | 화면 | `ui.dashboard`, `ui.parent_review` |
| `decision` | 의사결정 | `decision.parent_confirm`, `decision.enforcement_register` |
| `external` | 외부 | `external.iran_backend`, `external.redis` |
## 산출문서에서 노드 참조
### 마크다운 링크
```markdown
모선 추론 후보는 [`fleet.parent_inference`](https://kcg-ai-monitoring.gc-si.dev/system-flow.html#node=fleet.parent_inference)
에서 생성되며, 운영자 확정은 [`decision.parent_confirm`](https://kcg-ai-monitoring.gc-si.dev/system-flow.html#node=decision.parent_confirm)
을 거쳐 [`storage.gear_group_parent_resolution`](https://kcg-ai-monitoring.gc-si.dev/system-flow.html#node=storage.gear_group_parent_resolution)
에 저장됩니다.
```
### 짧게 표기 (URL 생략)
```markdown
이벤트 생성 단계(`output.event_generator`)에서 위반 카테고리를 포함한
`prediction_events` 레코드가 INSERT됩니다.
```
### 흐름 표기
```markdown
snpdb 5분 → vessel_store → 7단계 파이프라인 → 14개 알고리즘 → 분석 결과
└─ `ingest.snpdb_5min``ingest.vessel_store_cache``pipeline.*``algo.*``storage.vessel_analysis_results`
```
## UI 사용법
### 3단 레이아웃
- **좌측 (332px)**: 카테고리별로 그룹된 노드 목록 + 검색 결과
- **중앙 (가변)**: React Flow 캔버스 (노드/엣지 시각화)
- **우측 (392px)**: 선택된 노드/엣지의 상세 정보
### 헤더 컨트롤
- **검색**: label, file, symbol, tags 통합 검색 (공백 무시)
- **단계 필터**: 11개 stage 중 선택
- **메뉴 필터**: 프론트 메뉴(대시보드/탐지/단속 등)별 필터
- **레이아웃 토글**: `단계 기준``메뉴 기준` (노드 위치 자동 재배치)
- **상세 필터**: kind/trigger/status 다중 선택 (드롭다운)
### 노드 시각화
- **색상**: stage별 (수집=파랑, 분석=보라, 출력=주황 등)
- **모양**: kind별 (algorithm=다이아몬드, decision=마름모, api=6각형 등)
- **노란 글로우**: `trigger=user_action` 노드 (운영자 액션)
- **점선 테두리**: `status=planned` 노드
- **반투명**: `status=deprecated` 노드
### 엣지 종류
- **회색 (data)**: 일반 데이터 흐름
- **녹색 (trigger)**: 이벤트 트리거
- **노란 점선 (feedback)**: 피드백 루프 (예: label_input → 다음 사이클)
## 매니페스트 갱신 절차
### 노드 추가/수정 시
1. 해당 카테고리 JSON 파일 편집:
- `frontend/src/flow/manifest/01-ingest.json`
- `frontend/src/flow/manifest/02-pipeline.json`
- ... `10-external.json`
2. 새 엣지가 필요하면 `frontend/src/flow/manifest/edges.json`에 추가
3. 빌드 검증:
```bash
cd frontend && npx tsc --noEmit && npx vite build
```
4. 로컬 확인 (`http://localhost:5173/system-flow.html`)
### 릴리즈 시 (`/version` 스킬과 동기화)
`/version` 스킬을 실행하면 Claude가 자동으로:
1. `/version`이 결정한 새 SemVer를 `frontend/src/flow/manifest/meta.json`에 반영
- `version`: 새 버전 (예: `"1.2.0"`)
- `updatedAt`: 현재 ISO datetime
- `releaseDate`: 오늘 날짜 (`YYYY-MM-DD`)
2. `meta.json` 변경분을 같은 커밋에 포함
3. main 머지 → CI/CD가 자동으로:
- `dist/system-flow.html` 배포 (최신)
- `/deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/`에 영구 스냅샷 보존
상세는 `CLAUDE.md`의 "/version 스킬 사후 처리" 섹션 참조.
### 노드 ID 안정성 정책
- **노드 ID는 변경 금지** — 산출문서가 ID로 참조하므로 깨지면 추적 불가
- 노드 제거 시: `status: 'deprecated'`로 마킹 (1~2 릴리즈 유지 후 삭제)
- 새 노드: `status: 'implemented'` (이미 구현) 또는 `'planned'` (계획)
- 부분 구현: `status: 'partial'`
## 서버 archive 위치
```
/deploy/kcg-ai-monitoring-archive/system-flow/
├── v1.0.0_2026-04-07/
│ ├── system-flow.html
│ ├── assets/
│ │ ├── systemFlow-XXX.js
│ │ ├── systemFlow-XXX.css
│ │ └── index-XXX.js
│ └── manifest/
│ ├── meta.json
│ ├── 01-ingest.json
│ └── ...
├── v1.1.0_2026-05-15/
└── v1.2.0_2026-06-22/
```
- nginx에서 노출되지 않음 (서버 로컬 영구 보존)
- 직접 접근 필요 시: `ssh rocky-211 "ls /deploy/kcg-ai-monitoring-archive/system-flow/"`
- 향후 특정 버전 임시 노출이 필요하면 nginx 설정 추가
## 향후 확장 가능
- 노드별 라이브 메트릭 (prediction 사이클 결과 fetch)
- mermaid 산출 (manifest → 정적 markdown 다이어그램)
- 노드 변경 이력 (git log 기반)
- 자동 검증 (file:symbol 실재 확인)
- 운영자 의사결정 시뮬레이션

파일 보기

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@deck.gl/mapbox": "^9.2.11", "@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"deck.gl": "^9.2.11", "deck.gl": "^9.2.11",
"echarts": "^6.0.0", "echarts": "^6.0.0",
@ -2126,6 +2127,24 @@
"integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==", "integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-scale": { "node_modules/@types/d3-scale": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
@ -2135,12 +2154,37 @@
"@types/d3-time": "^2" "@types/d3-time": "^2"
} }
}, },
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-time": { "node_modules/@types/d3-time": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
"integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==", "integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -2493,6 +2537,66 @@
} }
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/a5-js": { "node_modules/a5-js": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz",
@ -2712,6 +2816,12 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2823,6 +2933,37 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@ -2866,6 +3007,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": { "node_modules/d3-time": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@ -2890,6 +3040,50 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

파일 보기

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@deck.gl/mapbox": "^9.2.11", "@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"deck.gl": "^9.2.11", "deck.gl": "^9.2.11",
"echarts": "^6.0.0", "echarts": "^6.0.0",

파일 보기

@ -0,0 +1,453 @@
/* SystemFlowViewer 전용 스타일 — 프로젝트 다크 테마와 일관 */
@import '@xyflow/react/dist/style.css';
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
background: #0b1220;
color: #e2e8f0;
font-family: 'Inter', 'Noto Sans KR', system-ui, sans-serif;
font-size: 13px;
}
* {
box-sizing: border-box;
}
.system-flow-shell {
display: grid;
grid-template-rows: 56px 1fr;
grid-template-columns: 332px minmax(880px, 1fr) 392px;
grid-template-areas:
'header header header'
'sidebar canvas detail';
height: 100vh;
overflow: hidden;
}
/* Header */
.sf-header {
grid-area: header;
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
background: #0f172a;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.sf-header h1 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #f1f5f9;
letter-spacing: 0.02em;
}
.sf-header .sf-meta {
font-size: 11px;
color: #94a3b8;
}
.sf-header .sf-spacer {
flex: 1;
}
.sf-header input[type='text'] {
background: #1e293b;
border: 1px solid rgba(148, 163, 184, 0.24);
color: #e2e8f0;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
width: 240px;
}
.sf-header input[type='text']:focus {
outline: none;
border-color: #38bdf8;
}
.sf-header select {
background: #1e293b;
border: 1px solid rgba(148, 163, 184, 0.24);
color: #e2e8f0;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.sf-toggle {
display: inline-flex;
background: #1e293b;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 6px;
overflow: hidden;
}
.sf-toggle button {
background: transparent;
border: 0;
color: #94a3b8;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.sf-toggle button[data-active='true'] {
background: #334155;
color: #f1f5f9;
}
/* 좌측 사이드바 */
.sf-sidebar {
grid-area: sidebar;
background: #0f172a;
border-right: 1px solid rgba(148, 163, 184, 0.18);
overflow-y: auto;
padding: 16px;
}
.sf-section-title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #64748b;
margin: 12px 0 8px;
}
.sf-node-card {
display: block;
width: 100%;
text-align: left;
background: #1e293b;
border: 1px solid rgba(148, 163, 184, 0.18);
border-left: 3px solid var(--accent, #38bdf8);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.15s;
}
.sf-node-card:hover {
background: #273449;
border-color: rgba(56, 189, 248, 0.4);
}
.sf-node-card[data-active='true'] {
background: #1e3a52;
border-color: #38bdf8;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
}
.sf-node-card .sf-card-stage {
font-size: 9px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.sf-node-card .sf-card-label {
font-size: 12px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 2px;
}
.sf-node-card .sf-card-desc {
font-size: 10px;
color: #94a3b8;
line-height: 1.4;
}
.sf-node-card .sf-card-id {
font-size: 9px;
color: #64748b;
font-family: 'Fira Code', 'Menlo', monospace;
margin-top: 4px;
}
/* 중앙 캔버스 */
.sf-canvas {
grid-area: canvas;
position: relative;
background: #0b1220;
}
.sf-canvas .react-flow__renderer {
background: #0b1220;
}
.sf-canvas .react-flow__node {
font-size: 11px;
color: #e2e8f0;
}
.sf-canvas .react-flow__node-default {
padding: 0;
border-radius: 8px;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.95), rgba(15, 23, 42, 0.8));
}
.sf-canvas .react-flow__handle {
background: #38bdf8;
border: 1px solid #0f172a;
width: 6px;
height: 6px;
}
.sf-canvas .react-flow__edge-path {
stroke-width: 1.6;
}
.sf-canvas .react-flow__edge.selected .react-flow__edge-path {
stroke-width: 2.6;
stroke: #f8fafc;
}
.sf-canvas .react-flow__edge-text {
font-size: 10px;
fill: #cbd5e1;
}
.sf-canvas .react-flow__minimap {
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 6px;
}
.sf-canvas .react-flow__controls {
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 6px;
}
.sf-canvas .react-flow__controls button {
background: #1e293b;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
color: #e2e8f0;
}
.sf-canvas .react-flow__controls button:hover {
background: #334155;
}
/* 노드 내부 컨텐츠 */
.sf-node-body {
padding: 10px 14px;
width: 220px;
border: 2px solid var(--accent, #38bdf8);
border-radius: 8px;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.84));
}
.sf-node-body[data-status='planned'] {
border-style: dashed;
opacity: 0.85;
}
.sf-node-body[data-status='deprecated'] {
border-style: dotted;
opacity: 0.55;
}
.sf-node-body[data-trigger='user_action'] {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
}
.sf-node-body[data-selected='true'] {
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.35), 0 12px 32px rgba(2, 6, 23, 0.4);
}
.sf-node-stage {
font-size: 9px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 4px;
}
.sf-node-label {
font-size: 12px;
font-weight: 600;
color: #f1f5f9;
line-height: 1.3;
margin-bottom: 4px;
}
.sf-node-id {
font-size: 9px;
font-family: 'Fira Code', 'Menlo', monospace;
color: #64748b;
}
/* 우측 상세 패널 */
.sf-detail {
grid-area: detail;
background: #0f172a;
border-left: 1px solid rgba(148, 163, 184, 0.18);
overflow-y: auto;
padding: 20px;
}
.sf-detail h2 {
font-size: 16px;
font-weight: 700;
color: #f1f5f9;
margin: 0 0 6px;
}
.sf-detail .sf-detail-id {
font-size: 11px;
font-family: 'Fira Code', 'Menlo', monospace;
color: #94a3b8;
background: #1e293b;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
margin-bottom: 12px;
}
.sf-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.sf-badge {
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
background: #1e293b;
color: #cbd5e1;
border: 1px solid rgba(148, 163, 184, 0.2);
}
.sf-badge[data-tone='accent'] {
background: rgba(56, 189, 248, 0.15);
color: #7dd3fc;
border-color: rgba(56, 189, 248, 0.35);
}
.sf-badge[data-tone='warn'] {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.35);
}
.sf-badge[data-tone='success'] {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
border-color: rgba(34, 197, 94, 0.35);
}
.sf-detail-section {
margin-top: 16px;
}
.sf-detail-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #64748b;
margin-bottom: 6px;
}
.sf-detail-text {
font-size: 12px;
color: #e2e8f0;
line-height: 1.6;
}
.sf-code {
font-family: 'Fira Code', 'Menlo', monospace;
font-size: 11px;
background: #1e293b;
color: #7dd3fc;
padding: 6px 10px;
border-radius: 4px;
word-break: break-all;
display: block;
}
.sf-list {
list-style: none;
padding: 0;
margin: 0;
}
.sf-list li {
font-size: 11px;
color: #cbd5e1;
background: #1e293b;
padding: 5px 10px;
border-radius: 4px;
margin-bottom: 4px;
border-left: 2px solid var(--accent, #38bdf8);
}
.sf-link-button {
display: block;
width: 100%;
text-align: left;
background: #1e293b;
border: 1px solid rgba(148, 163, 184, 0.18);
color: #e2e8f0;
padding: 6px 10px;
border-radius: 4px;
margin-bottom: 4px;
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
}
.sf-link-button:hover {
background: #273449;
border-color: rgba(56, 189, 248, 0.4);
}
.sf-empty {
text-align: center;
color: #64748b;
font-size: 11px;
padding: 40px 20px;
}
.sf-filter-row {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.sf-chip {
font-size: 10px;
padding: 3px 8px;
border-radius: 999px;
background: #1e293b;
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.2);
cursor: pointer;
transition: all 0.15s;
}
.sf-chip[data-active='true'] {
background: rgba(56, 189, 248, 0.18);
color: #7dd3fc;
border-color: rgba(56, 189, 248, 0.4);
}
.sf-chip:hover {
border-color: rgba(56, 189, 248, 0.4);
}

파일 보기

@ -0,0 +1,316 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Background,
Controls,
MarkerType,
MiniMap,
ReactFlow,
type Edge,
type EdgeMouseHandler,
type Node,
type NodeMouseHandler,
} from '@xyflow/react';
import { manifest, type FlowEdge, type FlowNode } from './manifest';
import { stageColor } from './components/nodeShapes';
import { NodeListSidebar } from './components/NodeListSidebar';
import { NodeDetailPanel } from './components/NodeDetailPanel';
import { FilterBar, type FilterState } from './components/FilterBar';
// ─── 검색 매칭 ──────────────────────────────────
function matchesSearch(node: FlowNode, query: string): boolean {
if (!query) return true;
const q = query.replace(/\s+/g, '').toLowerCase();
const haystack = [
node.id,
node.label,
node.shortDescription,
node.stage,
node.menu ?? '',
node.kind,
node.file ?? '',
node.symbol ?? '',
...(node.tags ?? []),
...(node.inputs ?? []),
...(node.outputs ?? []),
node.notes ?? '',
]
.join(' ')
.replace(/\s+/g, '')
.toLowerCase();
return haystack.includes(q);
}
// ─── 레이아웃 계산 (stage/menu 그리드) ──────────────────
const COL_WIDTH = 360;
const ROW_HEIGHT = 130;
const COL_PADDING_X = 80;
const COL_PADDING_Y = 80;
interface LayoutResult {
nodes: Node[];
edges: Edge[];
}
function buildLayout(
flowNodes: FlowNode[],
flowEdges: FlowEdge[],
groupBy: 'stage' | 'menu',
selectedNodeId: string | null,
selectedEdgeId: string | null,
): LayoutResult {
// 1. 그룹 키별로 노드 분류 (stage 모드는 정해진 순서, menu 모드는 정렬)
const STAGE_ORDER = [
'수집',
'캐시',
'파이프라인',
'분석',
'선단',
'출력',
'저장소',
'API',
'UI',
'의사결정',
'외부',
];
const groups: Record<string, FlowNode[]> = {};
flowNodes.forEach((n) => {
const key = (groupBy === 'menu' ? n.menu : n.stage) ?? '기타';
if (!groups[key]) groups[key] = [];
groups[key].push(n);
});
const groupKeys =
groupBy === 'stage'
? STAGE_ORDER.filter((s) => groups[s] && groups[s].length > 0).concat(
Object.keys(groups).filter((k) => !STAGE_ORDER.includes(k)),
)
: Object.keys(groups).sort();
// 2. 노드 위치 계산
const positionedNodes: Node[] = [];
groupKeys.forEach((key, colIdx) => {
const list = groups[key];
list.forEach((node, rowIdx) => {
const x = COL_PADDING_X + colIdx * COL_WIDTH;
const y = COL_PADDING_Y + rowIdx * ROW_HEIGHT;
const accent = stageColor(node.stage);
const isSelected = selectedNodeId === node.id;
positionedNodes.push({
id: node.id,
position: { x, y },
data: { label: node.label, node },
type: 'default',
style: {
padding: 0,
background: 'transparent',
border: 'none',
width: 240,
},
// 커스텀 노드를 사용하지 않고 기본 노드의 라벨을 React 요소로 전달
// (별도 nodeTypes 등록 없이 동작하도록 className으로 처리)
} as Node);
// 노드 라벨에 직접 JSX를 넣을 수 없으므로 className으로 처리하고 CSS로 dataset 사용
const last = positionedNodes[positionedNodes.length - 1];
last.data = {
label: (
<div
className="sf-node-body"
data-status={node.status}
data-trigger={node.trigger}
data-selected={isSelected}
style={{ ['--accent' as never]: accent }}
>
<div className="sf-node-stage">{node.stage} · {node.kind}</div>
<div className="sf-node-label">{node.label}</div>
<div className="sf-node-id">{node.id}</div>
</div>
),
node,
} as Record<string, unknown>;
});
});
// 3. 엣지 변환
const positionedEdges: Edge[] = flowEdges.map((edge) => {
const isSelected = selectedEdgeId === edge.id;
const color =
edge.kind === 'feedback'
? '#fbbf24'
: edge.kind === 'trigger'
? '#86efac'
: '#94a3b8';
return {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
type: 'smoothstep',
animated: isSelected || edge.kind === 'feedback',
markerEnd: {
type: MarkerType.ArrowClosed,
color: isSelected ? '#f8fafc' : color,
width: 18,
height: 18,
},
style: {
stroke: isSelected ? '#f8fafc' : color,
strokeWidth: isSelected ? 2.6 : 1.6,
strokeDasharray: edge.kind === 'feedback' ? '6 4' : undefined,
},
labelStyle: { fill: '#cbd5e1', fontSize: 10 },
labelBgStyle: { fill: '#0f172a', fillOpacity: 0.9 },
};
});
return { nodes: positionedNodes, edges: positionedEdges };
}
// ─── 메인 뷰어 ──────────────────────────────────
export function SystemFlowViewer() {
const [filter, setFilter] = useState<FilterState>({
search: '',
stage: '전체',
menu: '전체',
kinds: new Set(),
triggers: new Set(),
statuses: new Set(),
});
const [groupBy, setGroupBy] = useState<'stage' | 'menu'>('stage');
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
// URL hash deep link
useEffect(() => {
const hash = window.location.hash;
const m = hash.match(/^#node=(.+)$/);
if (m) {
const id = decodeURIComponent(m[1]);
if (manifest.nodes.find((n) => n.id === id)) {
setSelectedNodeId(id);
}
}
}, []);
// 선택된 노드 변경 시 hash 업데이트
useEffect(() => {
if (selectedNodeId) {
window.history.replaceState(null, '', `#node=${selectedNodeId}`);
}
}, [selectedNodeId]);
// 필터링된 노드
const filteredNodes = useMemo(() => {
return manifest.nodes.filter((n) => {
if (filter.stage !== '전체' && n.stage !== filter.stage) return false;
if (filter.menu !== '전체' && n.menu !== filter.menu) return false;
if (filter.kinds.size > 0 && !filter.kinds.has(n.kind)) return false;
if (filter.triggers.size > 0 && !filter.triggers.has(n.trigger)) return false;
if (filter.statuses.size > 0 && !filter.statuses.has(n.status)) return false;
if (!matchesSearch(n, filter.search)) return false;
return true;
});
}, [filter]);
// 필터링된 노드 ID 집합
const filteredNodeIds = useMemo(
() => new Set(filteredNodes.map((n) => n.id)),
[filteredNodes],
);
// 필터링된 엣지 (양 끝이 필터된 노드일 때만)
const filteredEdges = useMemo(() => {
return manifest.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
}, [filteredNodeIds]);
// React Flow 노드/엣지
const { nodes: rfNodes, edges: rfEdges } = useMemo(
() => buildLayout(filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId),
[filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId],
);
const selectedNode = useMemo(
() => manifest.nodes.find((n) => n.id === selectedNodeId) ?? null,
[selectedNodeId],
);
const selectedEdge = useMemo(
() => manifest.edges.find((e) => e.id === selectedEdgeId) ?? null,
[selectedEdgeId],
);
const onNodeClick: NodeMouseHandler = useCallback((_e, node) => {
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
}, []);
const onEdgeClick: EdgeMouseHandler = useCallback((_e, edge) => {
setSelectedEdgeId(edge.id);
setSelectedNodeId(null);
}, []);
const handleSelectNode = useCallback((id: string) => {
setSelectedNodeId(id);
setSelectedEdgeId(null);
}, []);
return (
<div className="system-flow-shell">
<FilterBar
filter={filter}
onChange={setFilter}
groupBy={groupBy}
onGroupByChange={setGroupBy}
meta={manifest.meta}
/>
<NodeListSidebar
nodes={filteredNodes}
selectedNodeId={selectedNodeId}
groupBy={groupBy}
onSelectNode={handleSelectNode}
/>
<div className="sf-canvas">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background color="#1e293b" gap={24} size={1.2} />
<Controls showInteractive={false} />
<MiniMap
pannable
zoomable
nodeColor={(node) => {
const meta = manifest.nodes.find((n) => n.id === node.id);
return meta ? `${stageColor(meta.stage)}aa` : '#334155';
}}
nodeStrokeColor={(node) => {
const meta = manifest.nodes.find((n) => n.id === node.id);
return meta ? stageColor(meta.stage) : '#94a3b8';
}}
maskColor="rgba(7, 17, 31, 0.7)"
/>
</ReactFlow>
</div>
<NodeDetailPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
allNodes={manifest.nodes}
allEdges={manifest.edges}
onSelectNode={handleSelectNode}
/>
</div>
);
}

파일 보기

@ -0,0 +1,192 @@
import type { NodeKind, NodeStatus, NodeTrigger } from '../manifest';
import { ALL_MENUS, ALL_STAGES, KIND_LABELS, STATUS_LABELS, TRIGGER_LABELS } from '../manifest';
export interface FilterState {
search: string;
stage: string; // '전체' or stage name
menu: string; // '전체' or menu name
kinds: Set<NodeKind>;
triggers: Set<NodeTrigger>;
statuses: Set<NodeStatus>;
}
interface Props {
filter: FilterState;
onChange: (next: FilterState) => void;
groupBy: 'stage' | 'menu';
onGroupByChange: (g: 'stage' | 'menu') => void;
meta: { version: string; releaseDate?: string; nodeCount?: number; edgeCount?: number };
}
const ALL_KINDS: NodeKind[] = [
'source',
'cache',
'pipeline',
'algorithm',
'output',
'storage',
'api',
'ui',
'decision',
'external',
];
const ALL_TRIGGERS: NodeTrigger[] = [
'scheduled',
'event',
'user_action',
'on_demand',
];
const ALL_STATUSES: NodeStatus[] = [
'implemented',
'partial',
'planned',
'deprecated',
];
export function FilterBar({ filter, onChange, groupBy, onGroupByChange, meta }: Props) {
const toggleKind = (k: NodeKind) => {
const next = new Set(filter.kinds);
if (next.has(k)) next.delete(k);
else next.add(k);
onChange({ ...filter, kinds: next });
};
const toggleTrigger = (t: NodeTrigger) => {
const next = new Set(filter.triggers);
if (next.has(t)) next.delete(t);
else next.add(t);
onChange({ ...filter, triggers: next });
};
const toggleStatus = (s: NodeStatus) => {
const next = new Set(filter.statuses);
if (next.has(s)) next.delete(s);
else next.add(s);
onChange({ ...filter, statuses: next });
};
return (
<header className="sf-header">
<h1>KCG AI Monitoring · System Flow</h1>
<div className="sf-meta">
v{meta.version} · {meta.releaseDate} · {meta.nodeCount} · {meta.edgeCount}
</div>
<div className="sf-spacer" />
<input
type="text"
placeholder="검색 (label, file, symbol, tag)"
value={filter.search}
onChange={(e) => onChange({ ...filter, search: e.target.value })}
/>
<select
value={filter.stage}
onChange={(e) => onChange({ ...filter, stage: e.target.value })}
>
<option value="전체">단계: 전체</option>
{ALL_STAGES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={filter.menu}
onChange={(e) => onChange({ ...filter, menu: e.target.value })}
>
<option value="전체">메뉴: 전체</option>
{ALL_MENUS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<div className="sf-toggle">
<button
data-active={groupBy === 'stage'}
onClick={() => onGroupByChange('stage')}
>
</button>
<button data-active={groupBy === 'menu'} onClick={() => onGroupByChange('menu')}>
</button>
</div>
<details style={{ position: 'relative' }}>
<summary
style={{
cursor: 'pointer',
fontSize: 12,
color: '#94a3b8',
padding: '6px 10px',
background: '#1e293b',
border: '1px solid rgba(148,163,184,0.24)',
borderRadius: 6,
listStyle: 'none',
}}
>
</summary>
<div
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: 6,
background: '#0f172a',
border: '1px solid rgba(148,163,184,0.24)',
borderRadius: 6,
padding: 12,
zIndex: 100,
minWidth: 280,
}}
>
<div className="sf-detail-label"></div>
<div className="sf-filter-row">
{ALL_KINDS.map((k) => (
<button
key={k}
className="sf-chip"
data-active={filter.kinds.has(k)}
onClick={() => toggleKind(k)}
>
{KIND_LABELS[k]}
</button>
))}
</div>
<div className="sf-detail-label" style={{ marginTop: 12 }}>
</div>
<div className="sf-filter-row">
{ALL_TRIGGERS.map((t) => (
<button
key={t}
className="sf-chip"
data-active={filter.triggers.has(t)}
onClick={() => toggleTrigger(t)}
>
{TRIGGER_LABELS[t]}
</button>
))}
</div>
<div className="sf-detail-label" style={{ marginTop: 12 }}>
</div>
<div className="sf-filter-row">
{ALL_STATUSES.map((s) => (
<button
key={s}
className="sf-chip"
data-active={filter.statuses.has(s)}
onClick={() => toggleStatus(s)}
>
{STATUS_LABELS[s]}
</button>
))}
</div>
</div>
</details>
</header>
);
}

파일 보기

@ -0,0 +1,251 @@
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>
);
}

파일 보기

@ -0,0 +1,59 @@
import { useMemo } from 'react';
import type { FlowNode } from '../manifest';
import { stageColor } from './nodeShapes';
interface Props {
nodes: FlowNode[];
selectedNodeId: string | null;
groupBy: 'stage' | 'menu';
onSelectNode: (id: string) => void;
}
export function NodeListSidebar({
nodes,
selectedNodeId,
groupBy,
onSelectNode,
}: Props) {
const grouped = useMemo(() => {
const map = new Map<string, FlowNode[]>();
nodes.forEach((n) => {
const key = (groupBy === 'menu' ? n.menu : n.stage) ?? '기타';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(n);
});
return Array.from(map.entries());
}, [nodes, groupBy]);
return (
<aside className="sf-sidebar">
<div className="sf-detail-label" style={{ marginBottom: 12 }}>
({nodes.length})
</div>
{grouped.map(([groupKey, groupNodes]) => (
<div key={groupKey}>
<div className="sf-section-title">
{groupKey} ({groupNodes.length})
</div>
{groupNodes.map((node) => (
<button
key={node.id}
className="sf-node-card"
data-active={selectedNodeId === node.id}
style={{ ['--accent' as never]: stageColor(node.stage) }}
onClick={() => onSelectNode(node.id)}
>
<div className="sf-card-stage">{node.stage}</div>
<div className="sf-card-label">{node.label}</div>
<div className="sf-card-desc">{node.shortDescription}</div>
<div className="sf-card-id">{node.id}</div>
</button>
))}
</div>
))}
{grouped.length === 0 && (
<div className="sf-empty"> .</div>
)}
</aside>
);
}

파일 보기

@ -0,0 +1,69 @@
/**
* kind별 (CSS clip-path) +
*/
import type { NodeKind, NodeStatus } from '../manifest';
import { STAGE_COLORS } from '../manifest';
export function shapeClipPath(kind: NodeKind): string | undefined {
switch (kind) {
case 'source':
// 사다리꼴 (입력 방향)
return 'polygon(0 0, 100% 0, 92% 100%, 8% 100%)';
case 'cache':
// 둥근 사각형은 border-radius로 처리
return undefined;
case 'pipeline':
// 파이프라인 단계 — 화살표 모양
return 'polygon(0 0, 92% 0, 100% 50%, 92% 100%, 0 100%, 8% 50%)';
case 'algorithm':
// 다이아몬드
return 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)';
case 'output':
// 사다리꼴 (출력 방향)
return 'polygon(8% 0, 92% 0, 100% 100%, 0 100%)';
case 'storage':
// 실린더 모양 (DB)
return undefined; // 별도 ::before로 처리
case 'api':
// 6각형
return 'polygon(7% 0, 93% 0, 100% 50%, 93% 100%, 7% 100%, 0 50%)';
case 'ui':
// 직사각형 (기본)
return undefined;
case 'decision':
// 마름모 (사용자 액션)
return 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)';
case 'external':
// 점선 테두리 사각형
return undefined;
default:
return undefined;
}
}
export function stageColor(stage: string): string {
return STAGE_COLORS[stage] ?? '#94a3b8';
}
export function statusBorderStyle(status: NodeStatus): string {
switch (status) {
case 'planned':
return 'dashed';
case 'deprecated':
return 'dotted';
default:
return 'solid';
}
}
export function statusOpacity(status: NodeStatus): number {
switch (status) {
case 'deprecated':
return 0.5;
case 'planned':
return 0.75;
default:
return 1;
}
}

파일 보기

@ -0,0 +1,80 @@
[
{
"id": "ingest.snpdb_5min",
"label": "SNPDB 5분 버킷",
"shortDescription": "AIS 원본 5분 집계 테이블",
"stage": "수집",
"kind": "source",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/db/snpdb.py",
"symbol": "signal.t_vessel_tracks_5min",
"outputs": ["raw_ais_tracks"],
"tags": ["AIS", "원본"]
},
{
"id": "ingest.snpdb_initial_load",
"label": "초기 24시간 적재",
"shortDescription": "기동 시 24시간 트랙 일괄 로드",
"stage": "수집",
"kind": "source",
"trigger": "on_demand",
"status": "implemented",
"file": "prediction/db/snpdb.py",
"symbol": "fetch_all_tracks",
"params": ["hours=24"],
"outputs": ["vessel_store"]
},
{
"id": "ingest.snpdb_incremental",
"label": "증분 로드",
"shortDescription": "마지막 bucket 이후 신규 트랙 조회",
"stage": "수집",
"kind": "source",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/db/snpdb.py",
"symbol": "fetch_incremental",
"params": ["last_bucket"],
"outputs": ["incremental_df"]
},
{
"id": "ingest.vessel_store_cache",
"label": "VesselStore 인메모리 캐시",
"shortDescription": "MMSI별 트랙/위치/메타 인메모리 저장",
"stage": "캐시",
"kind": "cache",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/cache/vessel_store.py",
"symbol": "VesselStore",
"inputs": ["raw_ais_tracks"],
"outputs": ["vessel_store"],
"params": ["CACHE_WINDOW_HOURS"]
},
{
"id": "ingest.static_info_refresh",
"label": "정적정보 갱신",
"shortDescription": "선박명/호출부호/IMO 등 메타 갱신",
"stage": "캐시",
"kind": "cache",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/cache/vessel_store.py",
"symbol": "refresh_static_info",
"inputs": ["snpdb.static_info"],
"outputs": ["vessel_store.static"]
},
{
"id": "ingest.permit_registry_refresh",
"label": "허가어선 레지스트리 갱신",
"shortDescription": "허가어선 MMSI 목록 주기 갱신",
"stage": "캐시",
"kind": "cache",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/cache/vessel_store.py",
"symbol": "refresh_permit_registry",
"outputs": ["vessel_store.permit_set"]
}
]

파일 보기

@ -0,0 +1,88 @@
[
{
"id": "pipeline.preprocess",
"label": "AIS 전처리",
"shortDescription": "노이즈 제거 + 좌표/속도 검증",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/preprocessor.py",
"symbol": "AISPreprocessor",
"inputs": ["vessel_store"],
"outputs": ["clean_tracks"]
},
{
"id": "pipeline.behavior_detect",
"label": "행동 분류 (3-class)",
"shortDescription": "STATIONARY / FISHING / SAILING",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/behavior.py",
"symbol": "BehaviorDetector",
"inputs": ["clean_tracks"],
"outputs": ["state_tagged_tracks"]
},
{
"id": "pipeline.resample",
"label": "트랙 리샘플링",
"shortDescription": "4분 간격 균등 리샘플링",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/resampler.py",
"symbol": "TrajectoryResampler",
"params": ["interval=4min"],
"outputs": ["resampled_tracks"]
},
{
"id": "pipeline.feature_extract",
"label": "특징 추출",
"shortDescription": "12개 동적 특징 (속도/회전/패턴)",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/features.py",
"symbol": "FeatureExtractor",
"outputs": ["feature_vectors"]
},
{
"id": "pipeline.classify",
"label": "어선 유형 분류",
"shortDescription": "TRAWL/PURSE/LONGLINE/TRAP",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/classifier.py",
"symbol": "VesselTypeClassifier",
"outputs": ["vessel_type"]
},
{
"id": "pipeline.cluster",
"label": "BIRCH 클러스터링",
"shortDescription": "유사 트랙 군집 + 리더 식별",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/clusterer.py",
"symbol": "EnhancedBIRCHClusterer",
"outputs": ["cluster_id", "is_leader"]
},
{
"id": "pipeline.seasonal_tag",
"label": "계절 태깅",
"shortDescription": "타임스탬프 → SPRING/SUMMER/FALL/WINTER",
"stage": "파이프라인",
"kind": "pipeline",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/pipeline/classifier.py",
"symbol": "get_season"
}
]

파일 보기

@ -0,0 +1,142 @@
[
{
"id": "algo.zone_classify",
"label": "구역 분류",
"shortDescription": "위경도 → 해양구역 + 기선거리",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/location.py",
"symbol": "classify_zone",
"outputs": ["zone", "dist_to_baseline_nm"]
},
{
"id": "algo.ucaf_score",
"label": "UCAF 점수",
"shortDescription": "어구별 어획 활동 패턴 점수",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/fishing_pattern.py",
"symbol": "compute_ucaf_score",
"outputs": ["ucaf_score"]
},
{
"id": "algo.ucft_score",
"label": "UCFT 점수",
"shortDescription": "조업시간 비율 점수",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/fishing_pattern.py",
"symbol": "compute_ucft_score",
"outputs": ["ucft_score"]
},
{
"id": "algo.trawl_uturn",
"label": "Trawl U-turn 검출",
"shortDescription": "트롤 어선 U턴 패턴 검출",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/fishing_pattern.py",
"symbol": "detect_trawl_uturn"
},
{
"id": "algo.dark_vessel",
"label": "다크베셀 검출",
"shortDescription": "AIS 신호 단절 시간 분석",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/dark_vessel.py",
"symbol": "is_dark_vessel",
"outputs": ["is_dark", "gap_duration_min"]
},
{
"id": "algo.spoofing_score",
"label": "스푸핑 점수",
"shortDescription": "위치 위변조 종합 점수",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/spoofing.py",
"symbol": "compute_spoofing_score",
"outputs": ["spoofing_score"]
},
{
"id": "algo.speed_jumps",
"label": "속도 점프 카운트",
"shortDescription": "비정상 속도 변동 횟수",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/spoofing.py",
"symbol": "count_speed_jumps",
"outputs": ["speed_jump_count"]
},
{
"id": "algo.bd09_offset",
"label": "BD-09 오프셋",
"shortDescription": "중국 좌표계 오차 보정 거리",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/location.py",
"symbol": "compute_bd09_offset",
"outputs": ["bd09_offset_m"]
},
{
"id": "algo.risk_score",
"label": "선박 위험도 점수",
"shortDescription": "구역/허가/조업 종합 위험 점수",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/risk.py",
"symbol": "compute_vessel_risk_score",
"outputs": ["risk_score", "risk_level"]
},
{
"id": "algo.lightweight_risk",
"label": "경량 위험도",
"shortDescription": "파이프라인 미통과 412* 선박용",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/risk.py",
"symbol": "compute_lightweight_risk_score"
},
{
"id": "algo.transship_detect",
"label": "환적 검출",
"shortDescription": "근접 페어 + 지속시간 추적",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/transshipment.py",
"symbol": "detect_transshipment",
"outputs": ["transship_pairs"]
},
{
"id": "algo.track_similarity",
"label": "트랙 유사도",
"shortDescription": "선박 간 트랙 유사도 측정",
"stage": "분석",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/track_similarity.py"
}
]

파일 보기

@ -0,0 +1,108 @@
[
{
"id": "fleet.load_registry",
"label": "선단 레지스트리 로드",
"shortDescription": "허가/회사 레지스트리 메모리 로드",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/fleet_tracker.py",
"symbol": "FleetTracker.load_registry",
"inputs": ["fleet_companies", "fleet_vessels"]
},
{
"id": "fleet.match_ais_to_registry",
"label": "AIS-레지스트리 매칭",
"shortDescription": "관측 MMSI를 등록 선박과 매칭",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/fleet_tracker.py",
"symbol": "FleetTracker.match_ais_to_registry"
},
{
"id": "fleet.track_gear_identity",
"label": "어구 식별 추적",
"shortDescription": "어구 명명 패턴 → identity 매핑",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/fleet_tracker.py",
"symbol": "FleetTracker.track_gear_identity",
"outputs": ["gear_identity_log"]
},
{
"id": "fleet.build_clusters",
"label": "선단 클러스터 생성",
"shortDescription": "근접 + 선장 그룹화",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/fleet_tracker.py",
"symbol": "FleetTracker.build_fleet_clusters",
"outputs": ["fleet_role"]
},
{
"id": "fleet.save_snapshot",
"label": "선단 스냅샷 저장",
"shortDescription": "현재 사이클 선단 상태 스냅샷",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/fleet_tracker.py",
"symbol": "FleetTracker.save_snapshot",
"outputs": ["fleet_tracking_snapshot"]
},
{
"id": "fleet.detect_gear_groups",
"label": "어구 그룹 검출",
"shortDescription": "어구 신호 군집 → 그룹 후보",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/polygon_builder.py",
"symbol": "detect_gear_groups"
},
{
"id": "fleet.build_polygons",
"label": "그룹 폴리곤 생성",
"shortDescription": "어구 그룹 → 영역 폴리곤 스냅샷",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/polygon_builder.py",
"symbol": "build_all_group_snapshots",
"outputs": ["group_polygon_snapshots"]
},
{
"id": "fleet.gear_correlation",
"label": "어구 연관성 분석",
"shortDescription": "멀티모델 패턴 추적 + 점수 갱신",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/gear_correlation.py",
"symbol": "run_gear_correlation",
"outputs": ["gear_correlation_scores", "gear_correlation_raw_metrics"]
},
{
"id": "fleet.parent_inference",
"label": "모선 추론",
"shortDescription": "어구 그룹 → 모선 후보 점수 + 확정",
"stage": "선단",
"kind": "algorithm",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/algorithms/gear_parent_inference.py",
"symbol": "run_gear_parent_inference",
"outputs": ["gear_group_parent_resolution"]
}
]

파일 보기

@ -0,0 +1,98 @@
[
{
"id": "output.violation_classifier",
"label": "위반 분류",
"shortDescription": "분석 결과 → 위반 카테고리 라벨",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/violation_classifier.py",
"symbol": "run_violation_classifier",
"outputs": ["violation_categories"]
},
{
"id": "output.event_generator",
"label": "이벤트 생성기",
"shortDescription": "위반 → 중복 제거 이벤트 발행",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/event_generator.py",
"symbol": "run_event_generator",
"outputs": ["prediction_events"]
},
{
"id": "output.kpi_writer",
"label": "KPI 갱신",
"shortDescription": "실시간 KPI 카운터 재계산",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/kpi_writer.py",
"symbol": "run_kpi_writer",
"outputs": ["prediction_kpi_realtime"]
},
{
"id": "output.stats_hourly",
"label": "시간별 집계",
"shortDescription": "시간 단위 통계 집계",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/stats_aggregator.py",
"symbol": "aggregate_hourly",
"outputs": ["prediction_stats_hourly"]
},
{
"id": "output.stats_daily",
"label": "일별 집계",
"shortDescription": "일 단위 통계 집계",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/stats_aggregator.py",
"symbol": "aggregate_daily",
"outputs": ["prediction_stats_daily"]
},
{
"id": "output.stats_monthly",
"label": "월별 집계",
"shortDescription": "월 단위 통계 집계",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/stats_aggregator.py",
"symbol": "aggregate_monthly",
"outputs": ["prediction_stats_monthly"]
},
{
"id": "output.alert_dispatcher",
"label": "경보 디스패처",
"shortDescription": "이벤트 → 경보 생성/발송",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/output/alert_dispatcher.py",
"symbol": "run_alert_dispatcher",
"outputs": ["prediction_alerts"]
},
{
"id": "output.redis_chat_cache",
"label": "AI 채팅 컨텍스트 캐싱",
"shortDescription": "분석 컨텍스트 Redis 저장 (채팅용)",
"stage": "출력",
"kind": "output",
"trigger": "scheduled",
"status": "implemented",
"file": "prediction/chat/cache.py",
"symbol": "cache_analysis_context",
"outputs": ["redis:analysis_context"]
}
]

파일 보기

@ -0,0 +1,183 @@
[
{
"id": "storage.vessel_analysis_results",
"label": "vessel_analysis_results",
"shortDescription": "선박별 분석 결과 (최신)",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql",
"tags": ["prediction"]
},
{
"id": "storage.prediction_events",
"label": "prediction_events",
"shortDescription": "이벤트 (위반 후보, 워크플로우 대상)",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.event_workflow",
"label": "event_workflow",
"shortDescription": "이벤트 처리 상태/이력",
"stage": "저장소",
"kind": "storage",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.prediction_alerts",
"label": "prediction_alerts",
"shortDescription": "운영자 경보 메시지",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.prediction_kpi_realtime",
"label": "prediction_kpi_realtime",
"shortDescription": "실시간 KPI 카운터",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.prediction_stats_hourly",
"label": "prediction_stats_hourly",
"shortDescription": "시간별 집계 통계",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.prediction_stats_daily",
"label": "prediction_stats_daily",
"shortDescription": "일별 집계 통계",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.prediction_stats_monthly",
"label": "prediction_stats_monthly",
"shortDescription": "월별 집계 통계",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V012__prediction_events_stats.sql"
},
{
"id": "storage.gear_group_parent_resolution",
"label": "gear_group_parent_resolution",
"shortDescription": "모선 추론 최종 상태",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V005__parent_workflow.sql"
},
{
"id": "storage.gear_parent_candidate_exclusions",
"label": "gear_parent_candidate_exclusions",
"shortDescription": "운영자 배제 후보 MMSI 목록",
"stage": "저장소",
"kind": "storage",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V005__parent_workflow.sql"
},
{
"id": "storage.gear_parent_label_sessions",
"label": "gear_parent_label_sessions",
"shortDescription": "정답 라벨링 세션",
"stage": "저장소",
"kind": "storage",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V005__parent_workflow.sql"
},
{
"id": "storage.fleet_companies",
"label": "fleet_companies",
"shortDescription": "회사/선단 마스터",
"stage": "저장소",
"kind": "storage",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V011__vessel_permit_patrol.sql"
},
{
"id": "storage.fleet_vessels",
"label": "fleet_vessels",
"shortDescription": "선단 소속 선박",
"stage": "저장소",
"kind": "storage",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V014__fleet_prediction_tables.sql"
},
{
"id": "storage.fleet_tracking_snapshot",
"label": "fleet_tracking_snapshot",
"shortDescription": "사이클별 선단 스냅샷",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V014__fleet_prediction_tables.sql"
},
{
"id": "storage.gear_correlation_scores",
"label": "gear_correlation_scores",
"shortDescription": "어구 연관성 누적 점수",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V014__fleet_prediction_tables.sql"
},
{
"id": "storage.group_polygon_snapshots",
"label": "group_polygon_snapshots",
"shortDescription": "어구 그룹 폴리곤 스냅샷",
"stage": "저장소",
"kind": "storage",
"trigger": "scheduled",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V014__fleet_prediction_tables.sql"
},
{
"id": "storage.enforcement_records",
"label": "enforcement_records",
"shortDescription": "단속 결과 기록",
"stage": "저장소",
"kind": "storage",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V013__enforcement_operations.sql"
},
{
"id": "storage.enforcement_plans",
"label": "enforcement_plans",
"shortDescription": "단속 계획",
"stage": "저장소",
"kind": "storage",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/resources/db/migration/V013__enforcement_operations.sql"
}
]

파일 보기

@ -0,0 +1,172 @@
[
{
"id": "api.events_get",
"label": "GET /api/events",
"shortDescription": "이벤트 목록 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/event/EventController.java",
"symbol": "EventController.list",
"inputs": ["prediction_events"]
},
{
"id": "api.events_ack",
"label": "PATCH /api/events/{id}/ack",
"shortDescription": "이벤트 확인 처리",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/event/EventController.java",
"symbol": "EventController.ack",
"outputs": ["event_workflow"]
},
{
"id": "api.events_status",
"label": "PATCH /api/events/{id}/status",
"shortDescription": "이벤트 상태 변경",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/event/EventController.java",
"symbol": "EventController.updateStatus",
"outputs": ["event_workflow"]
},
{
"id": "api.alerts_get",
"label": "GET /api/alerts",
"shortDescription": "경보 목록 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java",
"symbol": "AlertController.list",
"inputs": ["prediction_alerts"]
},
{
"id": "api.stats_kpi",
"label": "GET /api/stats/kpi",
"shortDescription": "실시간 KPI 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java",
"symbol": "StatsController.kpi",
"inputs": ["prediction_kpi_realtime"]
},
{
"id": "api.stats_hourly",
"label": "GET /api/stats/hourly",
"shortDescription": "시간별 통계 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java",
"symbol": "StatsController.hourly"
},
{
"id": "api.stats_daily",
"label": "GET /api/stats/daily",
"shortDescription": "일별 통계 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java",
"symbol": "StatsController.daily"
},
{
"id": "api.stats_monthly",
"label": "GET /api/stats/monthly",
"shortDescription": "월별 통계 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java",
"symbol": "StatsController.monthly"
},
{
"id": "api.enforcement_records",
"label": "GET/POST /api/enforcement/records",
"shortDescription": "단속 결과 조회/등록",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java",
"symbol": "EnforcementController.records"
},
{
"id": "api.enforcement_plans",
"label": "GET/POST /api/enforcement/plans",
"shortDescription": "단속 계획 조회/생성",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java",
"symbol": "EnforcementController.plans"
},
{
"id": "api.parent_inference_review",
"label": "GET /api/parent-inference/review",
"shortDescription": "모선 추론 리뷰 큐 조회",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
"symbol": "ParentInferenceWorkflowController.review"
},
{
"id": "api.parent_inference_confirm",
"label": "POST /api/parent-inference/groups/{key}/{sub}/review",
"shortDescription": "모선 후보 확정/반려",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
"symbol": "ParentInferenceWorkflowController.confirm"
},
{
"id": "api.parent_inference_exclusions",
"label": "GET/POST /api/parent-inference/exclusions",
"shortDescription": "후보 배제 목록 관리",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
"symbol": "ParentInferenceWorkflowController.exclusions"
},
{
"id": "api.parent_inference_label_sessions",
"label": "GET/POST /api/parent-inference/label-sessions",
"shortDescription": "정답 라벨링 세션 관리",
"stage": "API",
"kind": "api",
"trigger": "user_action",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
"symbol": "ParentInferenceWorkflowController.labelSessions"
},
{
"id": "api.vessel_analysis",
"label": "GET /api/vessel-analysis",
"shortDescription": "선박 분석 결과 (iran 프록시)",
"stage": "API",
"kind": "api",
"trigger": "on_demand",
"status": "implemented",
"file": "backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java",
"symbol": "VesselAnalysisProxyController.list"
}
]

파일 보기

@ -0,0 +1,189 @@
[
{
"id": "ui.dashboard",
"label": "대시보드",
"shortDescription": "전체 KPI + 이벤트 요약",
"stage": "UI",
"menu": "대시보드",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/dashboard/Dashboard.tsx"
},
{
"id": "ui.monitoring_dashboard",
"label": "모니터링 대시보드",
"shortDescription": "시스템 상태 + 분석 사이클",
"stage": "UI",
"menu": "모니터링",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/monitoring/MonitoringDashboard.tsx"
},
{
"id": "ui.live_map",
"label": "실시간 지도",
"shortDescription": "선박 위치/궤적 실시간 표시",
"stage": "UI",
"menu": "감시",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/surveillance/LiveMapView.tsx"
},
{
"id": "ui.gear_detection",
"label": "어구 탐지",
"shortDescription": "어구 그룹/모선 탐지 화면",
"stage": "UI",
"menu": "탐지",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/detection/GearDetection.tsx"
},
{
"id": "ui.dark_vessel",
"label": "다크베셀 탐지",
"shortDescription": "AIS 신호 단절 선박 목록",
"stage": "UI",
"menu": "탐지",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/detection/DarkVesselDetection.tsx"
},
{
"id": "ui.china_fishing",
"label": "중국어선 탐지",
"shortDescription": "412* 선박 위험도 화면",
"stage": "UI",
"menu": "탐지",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/detection/ChinaFishing.tsx"
},
{
"id": "ui.transfer_detection",
"label": "환적 탐지",
"shortDescription": "환적 의심 페어 화면",
"stage": "UI",
"menu": "선박",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/vessel/TransferDetection.tsx"
},
{
"id": "ui.vessel_detail",
"label": "선박 상세",
"shortDescription": "MMSI별 분석 결과 상세",
"stage": "UI",
"menu": "선박",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/vessel/VesselDetail.tsx"
},
{
"id": "ui.event_list",
"label": "이벤트 목록",
"shortDescription": "단속 후보 이벤트 워크큐",
"stage": "UI",
"menu": "단속",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/enforcement/EventList.tsx"
},
{
"id": "ui.enforcement_history",
"label": "단속 이력",
"shortDescription": "과거 단속 결과 조회",
"stage": "UI",
"menu": "단속",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/enforcement/EnforcementHistory.tsx"
},
{
"id": "ui.enforcement_plan",
"label": "단속 계획",
"shortDescription": "위험도 기반 단속 계획 수립",
"stage": "UI",
"menu": "단속",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/risk-assessment/EnforcementPlan.tsx"
},
{
"id": "ui.parent_review",
"label": "모선 추론 리뷰",
"shortDescription": "모선 후보 확정/반려",
"stage": "UI",
"menu": "모선워크플로우",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/parent-inference/ParentReview.tsx"
},
{
"id": "ui.parent_exclusion",
"label": "후보 배제 관리",
"shortDescription": "운영자 배제 후보 관리",
"stage": "UI",
"menu": "모선워크플로우",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/parent-inference/ParentExclusion.tsx"
},
{
"id": "ui.label_session",
"label": "라벨링 세션",
"shortDescription": "정답 라벨링 세션 관리",
"stage": "UI",
"menu": "모선워크플로우",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/parent-inference/LabelSession.tsx"
},
{
"id": "ui.statistics",
"label": "통계",
"shortDescription": "시간/일/월별 통계 차트",
"stage": "UI",
"menu": "통계",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/statistics/Statistics.tsx"
},
{
"id": "ui.ai_alert",
"label": "현장 AI 경보",
"shortDescription": "현장 단말용 경보 화면",
"stage": "UI",
"menu": "현장",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/field-ops/AIAlert.tsx"
},
{
"id": "ui.ai_assistant",
"label": "AI 어시스턴트",
"shortDescription": "분석 컨텍스트 기반 채팅",
"stage": "UI",
"menu": "AI",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/ai-operations/AIAssistant.tsx"
}
]

파일 보기

@ -0,0 +1,90 @@
[
{
"id": "decision.event_ack",
"label": "이벤트 확인",
"shortDescription": "운영자가 이벤트를 NEW → ACK",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "운영자",
"triggers": ["이벤트 NEW 상태 확인 클릭"]
},
{
"id": "decision.event_resolve",
"label": "이벤트 종료",
"shortDescription": "단속 완료 또는 처리 완료",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "운영자",
"triggers": ["단속 완료", "처리 완료"]
},
{
"id": "decision.event_false_positive",
"label": "오탐 판정",
"shortDescription": "분석가가 이벤트를 오탐 처리",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "분석가",
"triggers": ["오탐 판정 버튼"]
},
{
"id": "decision.parent_confirm",
"label": "모선 후보 확정",
"shortDescription": "모선 후보 운영자 확정",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "운영자",
"triggers": ["모선 후보 확정 버튼"]
},
{
"id": "decision.parent_reject",
"label": "모선 후보 반려",
"shortDescription": "모선 후보 거부 → 재학습",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "운영자",
"triggers": ["모선 후보 반려 버튼"]
},
{
"id": "decision.candidate_exclude",
"label": "후보 배제",
"shortDescription": "특정 MMSI를 후보에서 영구 배제",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "분석가",
"triggers": ["MMSI 후보 배제 버튼"]
},
{
"id": "decision.label_session_start",
"label": "라벨링 세션 시작",
"shortDescription": "정답 라벨링 세션 개시",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "분석가",
"triggers": ["정답 라벨링 시작"]
},
{
"id": "decision.enforcement_register",
"label": "단속 결과 등록",
"shortDescription": "현장 단속 결과 등록",
"stage": "의사결정",
"kind": "decision",
"trigger": "user_action",
"status": "implemented",
"actor": "운영자",
"triggers": ["단속 결과 등록"]
}
]

파일 보기

@ -0,0 +1,22 @@
[
{
"id": "external.iran_backend",
"label": "Iran 백엔드 (레거시)",
"shortDescription": "어구 그룹 read-only 프록시",
"stage": "외부",
"kind": "external",
"trigger": "on_demand",
"status": "partial",
"notes": "어구 그룹 read-only proxy (선택적, 향후 자체 prediction으로 대체 예정)"
},
{
"id": "external.redis",
"label": "Redis",
"shortDescription": "AI 채팅 컨텍스트 캐시",
"stage": "외부",
"kind": "external",
"trigger": "scheduled",
"status": "implemented",
"notes": "AI 채팅용 분석 컨텍스트 캐시"
}
]

파일 보기

@ -0,0 +1,149 @@
[
{ "id": "e_snpdb__initial_load", "source": "ingest.snpdb_5min", "target": "ingest.snpdb_initial_load", "label": "SELECT 24h", "kind": "data" },
{ "id": "e_snpdb__incremental", "source": "ingest.snpdb_5min", "target": "ingest.snpdb_incremental", "label": "SELECT > last_bucket", "kind": "data" },
{ "id": "e_initial__store", "source": "ingest.snpdb_initial_load", "target": "ingest.vessel_store_cache", "label": "초기 적재", "kind": "data" },
{ "id": "e_incremental__store", "source": "ingest.snpdb_incremental", "target": "ingest.vessel_store_cache", "label": "merge_incremental", "kind": "data" },
{ "id": "e_static__store", "source": "ingest.static_info_refresh", "target": "ingest.vessel_store_cache", "label": "정적정보", "kind": "data" },
{ "id": "e_permit__store", "source": "ingest.permit_registry_refresh", "target": "ingest.vessel_store_cache", "label": "허가 set", "kind": "data" },
{ "id": "e_store__preprocess", "source": "ingest.vessel_store_cache", "target": "pipeline.preprocess", "label": "select_targets", "kind": "data" },
{ "id": "e_preprocess__behavior", "source": "pipeline.preprocess", "target": "pipeline.behavior_detect", "kind": "data" },
{ "id": "e_behavior__resample", "source": "pipeline.behavior_detect", "target": "pipeline.resample", "kind": "data" },
{ "id": "e_resample__feature", "source": "pipeline.resample", "target": "pipeline.feature_extract", "kind": "data" },
{ "id": "e_feature__classify", "source": "pipeline.feature_extract", "target": "pipeline.classify", "kind": "data" },
{ "id": "e_classify__cluster", "source": "pipeline.classify", "target": "pipeline.cluster", "kind": "data" },
{ "id": "e_cluster__seasonal", "source": "pipeline.cluster", "target": "pipeline.seasonal_tag", "kind": "data" },
{ "id": "e_cluster__zone", "source": "pipeline.cluster", "target": "algo.zone_classify", "kind": "data" },
{ "id": "e_cluster__ucaf", "source": "pipeline.cluster", "target": "algo.ucaf_score", "kind": "data" },
{ "id": "e_cluster__ucft", "source": "pipeline.cluster", "target": "algo.ucft_score", "kind": "data" },
{ "id": "e_cluster__trawl", "source": "pipeline.cluster", "target": "algo.trawl_uturn", "kind": "data" },
{ "id": "e_cluster__dark", "source": "pipeline.cluster", "target": "algo.dark_vessel", "kind": "data" },
{ "id": "e_cluster__spoof", "source": "pipeline.cluster", "target": "algo.spoofing_score", "kind": "data" },
{ "id": "e_cluster__jumps", "source": "pipeline.cluster", "target": "algo.speed_jumps", "kind": "data" },
{ "id": "e_cluster__bd09", "source": "pipeline.cluster", "target": "algo.bd09_offset", "kind": "data" },
{ "id": "e_cluster__risk", "source": "pipeline.cluster", "target": "algo.risk_score", "kind": "data" },
{ "id": "e_store__lwrisk", "source": "ingest.vessel_store_cache", "target": "algo.lightweight_risk", "label": "412* 미통과 선박", "kind": "data" },
{ "id": "e_store__transship", "source": "ingest.vessel_store_cache", "target": "algo.transship_detect", "kind": "data" },
{ "id": "e_cluster__similarity", "source": "pipeline.cluster", "target": "algo.track_similarity", "kind": "data" },
{ "id": "e_zone__results", "source": "algo.zone_classify", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_ucaf__results", "source": "algo.ucaf_score", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_ucft__results", "source": "algo.ucft_score", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_dark__results", "source": "algo.dark_vessel", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_spoof__results", "source": "algo.spoofing_score", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_jumps__results", "source": "algo.speed_jumps", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_bd09__results", "source": "algo.bd09_offset", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_risk__results", "source": "algo.risk_score", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_lwrisk__results", "source": "algo.lightweight_risk", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_transship__results", "source": "algo.transship_detect", "target": "storage.vessel_analysis_results", "kind": "data" },
{ "id": "e_cluster__fleet_load", "source": "pipeline.cluster", "target": "fleet.load_registry", "kind": "trigger" },
{ "id": "e_fleet_load__match", "source": "fleet.load_registry", "target": "fleet.match_ais_to_registry", "kind": "data" },
{ "id": "e_match__gear_identity", "source": "fleet.match_ais_to_registry", "target": "fleet.track_gear_identity", "kind": "data" },
{ "id": "e_gear_identity__clusters", "source": "fleet.track_gear_identity", "target": "fleet.build_clusters", "kind": "data" },
{ "id": "e_clusters__snapshot", "source": "fleet.build_clusters", "target": "fleet.save_snapshot", "kind": "data" },
{ "id": "e_snapshot__store", "source": "fleet.save_snapshot", "target": "storage.fleet_tracking_snapshot", "label": "INSERT", "kind": "data" },
{ "id": "e_snapshot__detect_groups", "source": "fleet.save_snapshot", "target": "fleet.detect_gear_groups", "kind": "trigger" },
{ "id": "e_detect__polygons", "source": "fleet.detect_gear_groups", "target": "fleet.build_polygons", "kind": "data" },
{ "id": "e_polygons__store", "source": "fleet.build_polygons", "target": "storage.group_polygon_snapshots", "label": "INSERT", "kind": "data" },
{ "id": "e_polygons__correlation", "source": "fleet.build_polygons", "target": "fleet.gear_correlation", "kind": "data" },
{ "id": "e_correlation__scores", "source": "fleet.gear_correlation", "target": "storage.gear_correlation_scores", "label": "UPSERT", "kind": "data" },
{ "id": "e_correlation__inference", "source": "fleet.gear_correlation", "target": "fleet.parent_inference", "kind": "data" },
{ "id": "e_inference__resolution", "source": "fleet.parent_inference", "target": "storage.gear_group_parent_resolution", "label": "UPSERT", "kind": "data" },
{ "id": "e_companies__load", "source": "storage.fleet_companies", "target": "fleet.load_registry", "kind": "data" },
{ "id": "e_vessels__load", "source": "storage.fleet_vessels", "target": "fleet.load_registry", "kind": "data" },
{ "id": "e_identity__store", "source": "fleet.track_gear_identity", "target": "storage.fleet_tracking_snapshot", "kind": "data" },
{ "id": "e_results__violation", "source": "storage.vessel_analysis_results", "target": "output.violation_classifier", "kind": "data" },
{ "id": "e_violation__event", "source": "output.violation_classifier", "target": "output.event_generator", "kind": "data" },
{ "id": "e_event__events_store", "source": "output.event_generator", "target": "storage.prediction_events", "label": "INSERT", "kind": "data" },
{ "id": "e_event__workflow", "source": "output.event_generator", "target": "storage.event_workflow", "label": "INIT", "kind": "data" },
{ "id": "e_results__kpi", "source": "storage.vessel_analysis_results", "target": "output.kpi_writer", "kind": "data" },
{ "id": "e_kpi__store", "source": "output.kpi_writer", "target": "storage.prediction_kpi_realtime", "label": "UPSERT", "kind": "data" },
{ "id": "e_results__hourly", "source": "storage.vessel_analysis_results", "target": "output.stats_hourly", "kind": "data" },
{ "id": "e_hourly__store", "source": "output.stats_hourly", "target": "storage.prediction_stats_hourly", "label": "INSERT", "kind": "data" },
{ "id": "e_results__daily", "source": "storage.vessel_analysis_results", "target": "output.stats_daily", "kind": "data" },
{ "id": "e_daily__store", "source": "output.stats_daily", "target": "storage.prediction_stats_daily", "label": "INSERT", "kind": "data" },
{ "id": "e_daily__monthly", "source": "output.stats_daily", "target": "output.stats_monthly", "kind": "trigger" },
{ "id": "e_monthly__store", "source": "output.stats_monthly", "target": "storage.prediction_stats_monthly", "label": "INSERT", "kind": "data" },
{ "id": "e_events__alert", "source": "storage.prediction_events", "target": "output.alert_dispatcher", "kind": "data" },
{ "id": "e_alert__store", "source": "output.alert_dispatcher", "target": "storage.prediction_alerts", "label": "INSERT", "kind": "data" },
{ "id": "e_results__chat_cache", "source": "storage.vessel_analysis_results", "target": "output.redis_chat_cache", "kind": "data" },
{ "id": "e_chat_cache__redis", "source": "output.redis_chat_cache", "target": "external.redis", "label": "SET", "kind": "data" },
{ "id": "e_events__api_get", "source": "storage.prediction_events", "target": "api.events_get", "label": "SELECT", "kind": "data" },
{ "id": "e_alerts__api_get", "source": "storage.prediction_alerts", "target": "api.alerts_get", "label": "SELECT", "kind": "data" },
{ "id": "e_kpi__api", "source": "storage.prediction_kpi_realtime", "target": "api.stats_kpi", "label": "SELECT", "kind": "data" },
{ "id": "e_hourly__api", "source": "storage.prediction_stats_hourly", "target": "api.stats_hourly", "kind": "data" },
{ "id": "e_daily__api", "source": "storage.prediction_stats_daily", "target": "api.stats_daily", "kind": "data" },
{ "id": "e_monthly__api", "source": "storage.prediction_stats_monthly", "target": "api.stats_monthly", "kind": "data" },
{ "id": "e_resolution__api_review", "source": "storage.gear_group_parent_resolution", "target": "api.parent_inference_review", "kind": "data" },
{ "id": "e_exclusions__api", "source": "storage.gear_parent_candidate_exclusions", "target": "api.parent_inference_exclusions", "kind": "data" },
{ "id": "e_label_sessions__api", "source": "storage.gear_parent_label_sessions", "target": "api.parent_inference_label_sessions", "kind": "data" },
{ "id": "e_enf_records__api", "source": "storage.enforcement_records", "target": "api.enforcement_records", "kind": "data" },
{ "id": "e_enf_plans__api", "source": "storage.enforcement_plans", "target": "api.enforcement_plans", "kind": "data" },
{ "id": "e_iran__vessel_analysis_api", "source": "external.iran_backend", "target": "api.vessel_analysis", "label": "프록시", "kind": "data" },
{ "id": "e_api_events__dashboard", "source": "api.events_get", "target": "ui.dashboard", "kind": "data" },
{ "id": "e_api_events__event_list", "source": "api.events_get", "target": "ui.event_list", "kind": "data" },
{ "id": "e_api_events__china", "source": "api.events_get", "target": "ui.china_fishing", "kind": "data" },
{ "id": "e_api_events__transfer", "source": "api.events_get", "target": "ui.transfer_detection", "kind": "data" },
{ "id": "e_api_alerts__dashboard", "source": "api.alerts_get", "target": "ui.dashboard", "kind": "data" },
{ "id": "e_api_alerts__field", "source": "api.alerts_get", "target": "ui.ai_alert", "kind": "data" },
{ "id": "e_api_kpi__dashboard", "source": "api.stats_kpi", "target": "ui.dashboard", "kind": "data" },
{ "id": "e_api_kpi__monitoring", "source": "api.stats_kpi", "target": "ui.monitoring_dashboard", "kind": "data" },
{ "id": "e_api_kpi__statistics", "source": "api.stats_kpi", "target": "ui.statistics", "kind": "data" },
{ "id": "e_api_hourly__dashboard", "source": "api.stats_hourly", "target": "ui.dashboard", "kind": "data" },
{ "id": "e_api_hourly__monitoring", "source": "api.stats_hourly", "target": "ui.monitoring_dashboard", "kind": "data" },
{ "id": "e_api_hourly__stats", "source": "api.stats_hourly", "target": "ui.statistics", "kind": "data" },
{ "id": "e_api_daily__stats", "source": "api.stats_daily", "target": "ui.statistics", "kind": "data" },
{ "id": "e_api_monthly__stats", "source": "api.stats_monthly", "target": "ui.statistics", "kind": "data" },
{ "id": "e_api_review__ui", "source": "api.parent_inference_review", "target": "ui.parent_review", "kind": "data" },
{ "id": "e_api_exclusions__ui", "source": "api.parent_inference_exclusions", "target": "ui.parent_exclusion", "kind": "data" },
{ "id": "e_api_label__ui", "source": "api.parent_inference_label_sessions", "target": "ui.label_session", "kind": "data" },
{ "id": "e_api_enf_records__history", "source": "api.enforcement_records", "target": "ui.enforcement_history", "kind": "data" },
{ "id": "e_api_enf_plans__plan", "source": "api.enforcement_plans", "target": "ui.enforcement_plan", "kind": "data" },
{ "id": "e_api_vessel_analysis__detail", "source": "api.vessel_analysis", "target": "ui.vessel_detail", "kind": "data" },
{ "id": "e_api_vessel_analysis__live", "source": "api.vessel_analysis", "target": "ui.live_map", "kind": "data" },
{ "id": "e_api_vessel_analysis__gear", "source": "api.vessel_analysis", "target": "ui.gear_detection", "kind": "data" },
{ "id": "e_api_vessel_analysis__dark", "source": "api.vessel_analysis", "target": "ui.dark_vessel", "kind": "data" },
{ "id": "e_redis__ai_assistant", "source": "external.redis", "target": "ui.ai_assistant", "label": "GET context", "kind": "data" },
{ "id": "e_event_list__ack", "source": "ui.event_list", "target": "decision.event_ack", "kind": "trigger" },
{ "id": "e_event_list__resolve", "source": "ui.event_list", "target": "decision.event_resolve", "kind": "trigger" },
{ "id": "e_event_list__fp", "source": "ui.event_list", "target": "decision.event_false_positive", "kind": "trigger" },
{ "id": "e_ack__api", "source": "decision.event_ack", "target": "api.events_ack", "label": "PATCH", "kind": "trigger" },
{ "id": "e_resolve__api", "source": "decision.event_resolve", "target": "api.events_status", "label": "PATCH", "kind": "trigger" },
{ "id": "e_fp__api", "source": "decision.event_false_positive", "target": "api.events_status", "label": "PATCH", "kind": "trigger" },
{ "id": "e_ack_api__store", "source": "api.events_ack", "target": "storage.event_workflow", "label": "UPDATE", "kind": "data" },
{ "id": "e_status_api__store", "source": "api.events_status", "target": "storage.event_workflow", "label": "UPDATE", "kind": "data" },
{ "id": "e_parent_review__confirm", "source": "ui.parent_review", "target": "decision.parent_confirm", "kind": "trigger" },
{ "id": "e_parent_review__reject", "source": "ui.parent_review", "target": "decision.parent_reject", "kind": "trigger" },
{ "id": "e_confirm__api", "source": "decision.parent_confirm", "target": "api.parent_inference_confirm", "label": "POST", "kind": "trigger" },
{ "id": "e_reject__api", "source": "decision.parent_reject", "target": "api.parent_inference_confirm", "label": "POST", "kind": "trigger" },
{ "id": "e_confirm_api__store", "source": "api.parent_inference_confirm", "target": "storage.gear_group_parent_resolution", "label": "UPDATE", "kind": "data" },
{ "id": "e_exclusion_ui__decision", "source": "ui.parent_exclusion", "target": "decision.candidate_exclude", "kind": "trigger" },
{ "id": "e_exclude__api", "source": "decision.candidate_exclude", "target": "api.parent_inference_exclusions", "label": "POST", "kind": "trigger" },
{ "id": "e_exclude_api__store", "source": "api.parent_inference_exclusions", "target": "storage.gear_parent_candidate_exclusions", "label": "INSERT", "kind": "data" },
{ "id": "e_label_ui__start", "source": "ui.label_session", "target": "decision.label_session_start", "kind": "trigger" },
{ "id": "e_label_start__api", "source": "decision.label_session_start", "target": "api.parent_inference_label_sessions", "label": "POST", "kind": "trigger" },
{ "id": "e_label_api__store", "source": "api.parent_inference_label_sessions", "target": "storage.gear_parent_label_sessions", "label": "INSERT", "kind": "data" },
{ "id": "e_history_ui__register", "source": "ui.enforcement_history", "target": "decision.enforcement_register", "kind": "trigger" },
{ "id": "e_plan_ui__register", "source": "ui.enforcement_plan", "target": "decision.enforcement_register", "kind": "trigger" },
{ "id": "e_register__api", "source": "decision.enforcement_register", "target": "api.enforcement_records", "label": "POST", "kind": "trigger" },
{ "id": "e_register_api__store", "source": "api.enforcement_records", "target": "storage.enforcement_records", "label": "INSERT", "kind": "data" },
{ "id": "e_plan_api__store", "source": "api.enforcement_plans", "target": "storage.enforcement_plans", "label": "INSERT", "kind": "data" },
{ "id": "e_register__events_resolved", "source": "decision.enforcement_register", "target": "storage.prediction_events", "label": "RESOLVED 업데이트", "kind": "feedback" },
{ "id": "e_confirm__feedback_inference", "source": "decision.parent_confirm", "target": "fleet.parent_inference", "label": "다음 사이클 학습", "kind": "feedback" },
{ "id": "e_exclusions__feedback_inference", "source": "storage.gear_parent_candidate_exclusions", "target": "fleet.parent_inference", "label": "후보 필터링", "kind": "feedback" },
{ "id": "e_label__feedback_inference", "source": "storage.gear_parent_label_sessions", "target": "fleet.parent_inference", "label": "정답 라벨", "kind": "feedback" },
{ "id": "e_enforcement__feedback_results", "source": "storage.enforcement_records", "target": "storage.vessel_analysis_results", "label": "검증 라벨", "kind": "feedback" },
{ "id": "e_fp__feedback_classifier", "source": "decision.event_false_positive", "target": "output.violation_classifier", "label": "오탐 학습", "kind": "feedback" }
]

파일 보기

@ -0,0 +1,165 @@
/**
* System Flow Manifest JSON +
*
* / JSON ,
* import + concat manifest로 export.
*/
import metaJson from './meta.json';
import ingest from './01-ingest.json';
import pipeline from './02-pipeline.json';
import algorithms from './03-algorithms.json';
import fleet from './04-fleet.json';
import output from './05-output.json';
import storage from './06-storage.json';
import backend from './07-backend.json';
import frontend from './08-frontend.json';
import decision from './09-decision.json';
import external from './10-external.json';
import edgesJson from './edges.json';
// ─── 타입 정의 ──────────────────────────────────────────────
export type NodeKind =
| 'source' // 원천 데이터 (snpdb 등)
| 'cache' // 메모리/Redis 캐시
| 'pipeline' // 7단계 분류 파이프라인 단계
| 'algorithm' // 분석 알고리즘
| 'output' // 출력 모듈 (event_generator 등)
| 'storage' // DB 테이블
| 'api' // 백엔드 API 엔드포인트
| 'ui' // 프론트 화면
| 'decision' // 운영자 의사결정 액션
| 'external'; // 외부 시스템 (iran, GPKI 등)
export type NodeTrigger =
| 'scheduled' // 5분 주기 등 자동
| 'event' // 이벤트 체이닝
| 'user_action' // 운영자 클릭/입력
| 'on_demand'; // 사용자 조회 시
export type NodeStatus = 'implemented' | 'partial' | 'planned' | 'deprecated';
export type EdgeKind = 'data' | 'trigger' | 'feedback';
export interface FlowNode {
id: string;
label: string;
shortDescription: string;
stage: string;
menu?: string;
kind: NodeKind;
trigger: NodeTrigger;
status: NodeStatus;
file?: string;
symbol?: string;
lineRange?: [number, number];
inputs?: string[];
outputs?: string[];
params?: string[];
rules?: string[];
actor?: string;
triggers?: string[];
tags?: string[];
notes?: string;
position?: { x: number; y: number };
}
export interface FlowEdge {
id: string;
source: string;
target: string;
label?: string;
detail?: string;
kind?: EdgeKind;
}
export interface FlowMeta {
version: string;
updatedAt: string;
releaseDate?: string;
description: string;
nodeCount?: number;
edgeCount?: number;
}
export interface FlowManifest {
meta: FlowMeta;
nodes: FlowNode[];
edges: FlowEdge[];
}
// ─── 병합 ──────────────────────────────────────────────────
const allNodes: FlowNode[] = [
...(ingest as FlowNode[]),
...(pipeline as FlowNode[]),
...(algorithms as FlowNode[]),
...(fleet as FlowNode[]),
...(output as FlowNode[]),
...(storage as FlowNode[]),
...(backend as FlowNode[]),
...(frontend as FlowNode[]),
...(decision as FlowNode[]),
...(external as FlowNode[]),
];
const allEdges: FlowEdge[] = edgesJson as FlowEdge[];
export const manifest: FlowManifest = {
meta: {
...(metaJson as FlowMeta),
nodeCount: allNodes.length,
edgeCount: allEdges.length,
},
nodes: allNodes,
edges: allEdges,
};
// ─── stage 색상 매핑 (다크 테마) ──────────────────────────
export const STAGE_COLORS: Record<string, string> = {
: '#38bdf8', // sky-400
: '#a78bfa', // violet-400
: '#60a5fa', // blue-400
: '#c084fc', // purple-400
: '#f472b6', // pink-400
: '#fb923c', // orange-400
: '#facc15', // yellow-400
API: '#22c55e', // green-500
UI: '#14b8a6', // teal-500
: '#f59e0b', // amber-500
: '#94a3b8', // slate-400
};
export const KIND_LABELS: Record<NodeKind, string> = {
source: '원천',
cache: '캐시',
pipeline: '파이프라인',
algorithm: '알고리즘',
output: '출력 모듈',
storage: '저장소',
api: 'API',
ui: '화면',
decision: '의사결정',
external: '외부',
};
export const TRIGGER_LABELS: Record<NodeTrigger, string> = {
scheduled: '주기 자동',
event: '이벤트',
user_action: '사용자 액션',
on_demand: '조회 시',
};
export const STATUS_LABELS: Record<NodeStatus, string> = {
implemented: '구현됨',
partial: '부분 구현',
planned: '계획',
deprecated: '폐기 예정',
};
export const ALL_STAGES = Array.from(new Set(allNodes.map((n) => n.stage)));
export const ALL_MENUS = Array.from(
new Set(allNodes.map((n) => n.menu).filter(Boolean) as string[]),
);

파일 보기

@ -0,0 +1,6 @@
{
"version": "1.0.0",
"updatedAt": "2026-04-07T00:00:00Z",
"releaseDate": "2026-04-07",
"description": "KCG AI Monitoring 시스템 워크플로우 — snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 전체 노드/엣지"
}

파일 보기

@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/index.css';
import './flow/SystemFlowViewer.css';
import { SystemFlowViewer } from './flow/SystemFlowViewer';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SystemFlowViewer />
</StrictMode>,
);

12
frontend/system-flow.html Normal file
파일 보기

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KCG AI Monitoring — System Flow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/systemFlowMain.tsx"></script>
</body>
</html>

파일 보기

@ -28,4 +28,12 @@ export default defineConfig({
}, },
}, },
}, },
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
systemFlow: path.resolve(__dirname, 'system-flow.html'),
},
},
},
}) })