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
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
This commit is contained in:
커밋
1a6604d3b9
@ -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/"
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@ -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
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 실재 확인)
|
||||||
|
- 운영자 의사결정 시뮬레이션
|
||||||
194
frontend/package-lock.json
generated
194
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
453
frontend/src/flow/SystemFlowViewer.css
Normal file
453
frontend/src/flow/SystemFlowViewer.css
Normal file
@ -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);
|
||||||
|
}
|
||||||
316
frontend/src/flow/SystemFlowViewer.tsx
Normal file
316
frontend/src/flow/SystemFlowViewer.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/flow/components/FilterBar.tsx
Normal file
192
frontend/src/flow/components/FilterBar.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
frontend/src/flow/components/NodeDetailPanel.tsx
Normal file
251
frontend/src/flow/components/NodeDetailPanel.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/flow/components/NodeListSidebar.tsx
Normal file
59
frontend/src/flow/components/NodeListSidebar.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/flow/components/nodeShapes.ts
Normal file
69
frontend/src/flow/components/nodeShapes.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
frontend/src/flow/manifest/01-ingest.json
Normal file
80
frontend/src/flow/manifest/01-ingest.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
88
frontend/src/flow/manifest/02-pipeline.json
Normal file
88
frontend/src/flow/manifest/02-pipeline.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
142
frontend/src/flow/manifest/03-algorithms.json
Normal file
142
frontend/src/flow/manifest/03-algorithms.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
108
frontend/src/flow/manifest/04-fleet.json
Normal file
108
frontend/src/flow/manifest/04-fleet.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
98
frontend/src/flow/manifest/05-output.json
Normal file
98
frontend/src/flow/manifest/05-output.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
183
frontend/src/flow/manifest/06-storage.json
Normal file
183
frontend/src/flow/manifest/06-storage.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
172
frontend/src/flow/manifest/07-backend.json
Normal file
172
frontend/src/flow/manifest/07-backend.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
189
frontend/src/flow/manifest/08-frontend.json
Normal file
189
frontend/src/flow/manifest/08-frontend.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
90
frontend/src/flow/manifest/09-decision.json
Normal file
90
frontend/src/flow/manifest/09-decision.json
Normal file
@ -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": ["단속 결과 등록"]
|
||||||
|
}
|
||||||
|
]
|
||||||
22
frontend/src/flow/manifest/10-external.json
Normal file
22
frontend/src/flow/manifest/10-external.json
Normal file
@ -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 채팅용 분석 컨텍스트 캐시"
|
||||||
|
}
|
||||||
|
]
|
||||||
149
frontend/src/flow/manifest/edges.json
Normal file
149
frontend/src/flow/manifest/edges.json
Normal file
@ -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" }
|
||||||
|
]
|
||||||
165
frontend/src/flow/manifest/index.ts
Normal file
165
frontend/src/flow/manifest/index.ts
Normal file
@ -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[]),
|
||||||
|
);
|
||||||
6
frontend/src/flow/manifest/meta.json
Normal file
6
frontend/src/flow/manifest/meta.json
Normal file
@ -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 분석, 이벤트 생성, 운영자 의사결정까지 전체 노드/엣지"
|
||||||
|
}
|
||||||
11
frontend/src/systemFlowMain.tsx
Normal file
11
frontend/src/systemFlowMain.tsx
Normal file
@ -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
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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user