diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b40c850..b37d2a0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -40,3 +40,32 @@ jobs: cp -r frontend/dist/* /deploy/kcg-ai-monitoring/ echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')" 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/" diff --git a/CLAUDE.md b/CLAUDE.md index b200dad..5d3d644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,3 +100,29 @@ make format # 프론트 prettier - 팀 규칙: `.claude/rules/` - 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증 - 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 명명**: `.` (예: `output.event_generator`, `ui.parent_review`) +- **딥링크**: `/system-flow.html#node=` — 산출문서에서 노드 직접 참조 +- **가이드**: `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'` diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index da3bf27..23c68c5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 추가 +- System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화 + - 102 노드 + 133 엣지, 10개 카테고리 매니페스트 + - stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원 + - 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용 + - `/version` 스킬 사후 처리로 manifest version 자동 동기화 + - CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존 + ## [2026-04-07] ### 추가 diff --git a/docs/system-flow-guide.md b/docs/system-flow-guide.md new file mode 100644 index 0000000..6f02079 --- /dev/null +++ b/docs/system-flow-guide.md @@ -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 | 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 실재 확인) +- 운영자 의사결정 시뮬레이션 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4b99f34..fed012b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@deck.gl/mapbox": "^9.2.11", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "deck.gl": "^9.2.11", "echarts": "^6.0.0", @@ -2126,6 +2127,24 @@ "integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==", "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": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz", @@ -2135,12 +2154,37 @@ "@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": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz", "integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==", "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": { "version": "4.3.1", "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": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", @@ -2712,6 +2816,12 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2823,6 +2933,37 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -2866,6 +3007,15 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -2890,6 +3040,50 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 78fc41c..a9efafd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@deck.gl/mapbox": "^9.2.11", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "deck.gl": "^9.2.11", "echarts": "^6.0.0", diff --git a/frontend/src/flow/SystemFlowViewer.css b/frontend/src/flow/SystemFlowViewer.css new file mode 100644 index 0000000..ab13810 --- /dev/null +++ b/frontend/src/flow/SystemFlowViewer.css @@ -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); +} diff --git a/frontend/src/flow/SystemFlowViewer.tsx b/frontend/src/flow/SystemFlowViewer.tsx new file mode 100644 index 0000000..9f6eb0d --- /dev/null +++ b/frontend/src/flow/SystemFlowViewer.tsx @@ -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 = {}; + 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: ( +
+
{node.stage} · {node.kind}
+
{node.label}
+
{node.id}
+
+ ), + node, + } as Record; + }); + }); + + // 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({ + search: '', + stage: '전체', + menu: '전체', + kinds: new Set(), + triggers: new Set(), + statuses: new Set(), + }); + const [groupBy, setGroupBy] = useState<'stage' | 'menu'>('stage'); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(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 ( +
+ + +
+ + + + { + 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)" + /> + +
+ +
+ ); +} diff --git a/frontend/src/flow/components/FilterBar.tsx b/frontend/src/flow/components/FilterBar.tsx new file mode 100644 index 0000000..73a478e --- /dev/null +++ b/frontend/src/flow/components/FilterBar.tsx @@ -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; + triggers: Set; + statuses: Set; +} + +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 ( +
+

KCG AI Monitoring · System Flow

+
+ v{meta.version} · {meta.releaseDate} · 노드 {meta.nodeCount} · 엣지 {meta.edgeCount} +
+
+ onChange({ ...filter, search: e.target.value })} + /> + + +
+ + +
+
+ + 상세 필터 + +
+
종류
+
+ {ALL_KINDS.map((k) => ( + + ))} +
+
+ 트리거 +
+
+ {ALL_TRIGGERS.map((t) => ( + + ))} +
+
+ 상태 +
+
+ {ALL_STATUSES.map((s) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/flow/components/NodeDetailPanel.tsx b/frontend/src/flow/components/NodeDetailPanel.tsx new file mode 100644 index 0000000..abed6a8 --- /dev/null +++ b/frontend/src/flow/components/NodeDetailPanel.tsx @@ -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 ( +
+
엣지
+

{selectedEdge.label || selectedEdge.id}

+
{selectedEdge.id}
+
+ + {selectedEdge.kind ?? 'data'} + +
+ {selectedEdge.detail && ( +
+
설명
+
{selectedEdge.detail}
+
+ )} +
+
Source → Target
+ {source && ( + + )} + {target && ( + + )} +
+
+ ); + } + + if (!selectedNode) { + return ( +
+
+ 노드 또는 엣지를 클릭하면 +
+ 상세 정보가 표시됩니다. +
+
+ ); + } + + const accent = stageColor(selectedNode.stage); + const incoming = allEdges.filter((e) => e.target === selectedNode.id); + const outgoing = allEdges.filter((e) => e.source === selectedNode.id); + + return ( +
+
{selectedNode.stage}
+

{selectedNode.label}

+
{selectedNode.id}
+ +
+ + {KIND_LABELS[selectedNode.kind]} + + {TRIGGER_LABELS[selectedNode.trigger]} + + {STATUS_LABELS[selectedNode.status]} + + {selectedNode.menu && ( + 메뉴: {selectedNode.menu} + )} +
+ + {selectedNode.shortDescription && ( +
+
개요
+
{selectedNode.shortDescription}
+
+ )} + + {selectedNode.notes && ( +
+
설명
+
{selectedNode.notes}
+
+ )} + + {selectedNode.file && ( +
+
코드
+ {selectedNode.file} + {selectedNode.symbol && ( + + {selectedNode.symbol} + + )} +
+ )} + + {selectedNode.actor && ( +
+
담당자
+
{selectedNode.actor}
+
+ )} + + {selectedNode.triggers && selectedNode.triggers.length > 0 && ( +
+
발화 조건
+
    + {selectedNode.triggers.map((t, i) => ( +
  • {t}
  • + ))} +
+
+ )} + + {selectedNode.inputs && selectedNode.inputs.length > 0 && ( +
+
입력 (Inputs)
+
    + {selectedNode.inputs.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + + {selectedNode.outputs && selectedNode.outputs.length > 0 && ( +
+
출력 (Outputs)
+
    + {selectedNode.outputs.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + + {selectedNode.params && selectedNode.params.length > 0 && ( +
+
파라미터
+
    + {selectedNode.params.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + + {selectedNode.rules && selectedNode.rules.length > 0 && ( +
+
규칙
+
    + {selectedNode.rules.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + + {incoming.length > 0 && ( +
+
들어오는 흐름 ({incoming.length})
+ {incoming.map((e) => { + const src = allNodes.find((n) => n.id === e.source); + return ( + + ); + })} +
+ )} + + {outgoing.length > 0 && ( +
+
나가는 흐름 ({outgoing.length})
+ {outgoing.map((e) => { + const tgt = allNodes.find((n) => n.id === e.target); + return ( + + ); + })} +
+ )} + + {selectedNode.tags && selectedNode.tags.length > 0 && ( +
+
태그
+
+ {selectedNode.tags.map((t) => ( + + {t} + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/flow/components/NodeListSidebar.tsx b/frontend/src/flow/components/NodeListSidebar.tsx new file mode 100644 index 0000000..97f773d --- /dev/null +++ b/frontend/src/flow/components/NodeListSidebar.tsx @@ -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(); + 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 ( + + ); +} diff --git a/frontend/src/flow/components/nodeShapes.ts b/frontend/src/flow/components/nodeShapes.ts new file mode 100644 index 0000000..2dd6eea --- /dev/null +++ b/frontend/src/flow/components/nodeShapes.ts @@ -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; + } +} diff --git a/frontend/src/flow/manifest/01-ingest.json b/frontend/src/flow/manifest/01-ingest.json new file mode 100644 index 0000000..daba14e --- /dev/null +++ b/frontend/src/flow/manifest/01-ingest.json @@ -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"] + } +] diff --git a/frontend/src/flow/manifest/02-pipeline.json b/frontend/src/flow/manifest/02-pipeline.json new file mode 100644 index 0000000..f02448a --- /dev/null +++ b/frontend/src/flow/manifest/02-pipeline.json @@ -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" + } +] diff --git a/frontend/src/flow/manifest/03-algorithms.json b/frontend/src/flow/manifest/03-algorithms.json new file mode 100644 index 0000000..c93dd87 --- /dev/null +++ b/frontend/src/flow/manifest/03-algorithms.json @@ -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" + } +] diff --git a/frontend/src/flow/manifest/04-fleet.json b/frontend/src/flow/manifest/04-fleet.json new file mode 100644 index 0000000..d0e7392 --- /dev/null +++ b/frontend/src/flow/manifest/04-fleet.json @@ -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"] + } +] diff --git a/frontend/src/flow/manifest/05-output.json b/frontend/src/flow/manifest/05-output.json new file mode 100644 index 0000000..07cecc8 --- /dev/null +++ b/frontend/src/flow/manifest/05-output.json @@ -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"] + } +] diff --git a/frontend/src/flow/manifest/06-storage.json b/frontend/src/flow/manifest/06-storage.json new file mode 100644 index 0000000..f630700 --- /dev/null +++ b/frontend/src/flow/manifest/06-storage.json @@ -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" + } +] diff --git a/frontend/src/flow/manifest/07-backend.json b/frontend/src/flow/manifest/07-backend.json new file mode 100644 index 0000000..ee0cac2 --- /dev/null +++ b/frontend/src/flow/manifest/07-backend.json @@ -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" + } +] diff --git a/frontend/src/flow/manifest/08-frontend.json b/frontend/src/flow/manifest/08-frontend.json new file mode 100644 index 0000000..3b6d315 --- /dev/null +++ b/frontend/src/flow/manifest/08-frontend.json @@ -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" + } +] diff --git a/frontend/src/flow/manifest/09-decision.json b/frontend/src/flow/manifest/09-decision.json new file mode 100644 index 0000000..9df6ecc --- /dev/null +++ b/frontend/src/flow/manifest/09-decision.json @@ -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": ["단속 결과 등록"] + } +] diff --git a/frontend/src/flow/manifest/10-external.json b/frontend/src/flow/manifest/10-external.json new file mode 100644 index 0000000..b9afa75 --- /dev/null +++ b/frontend/src/flow/manifest/10-external.json @@ -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 채팅용 분석 컨텍스트 캐시" + } +] diff --git a/frontend/src/flow/manifest/edges.json b/frontend/src/flow/manifest/edges.json new file mode 100644 index 0000000..329f594 --- /dev/null +++ b/frontend/src/flow/manifest/edges.json @@ -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" } +] diff --git a/frontend/src/flow/manifest/index.ts b/frontend/src/flow/manifest/index.ts new file mode 100644 index 0000000..f69a547 --- /dev/null +++ b/frontend/src/flow/manifest/index.ts @@ -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 = { + 수집: '#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 = { + source: '원천', + cache: '캐시', + pipeline: '파이프라인', + algorithm: '알고리즘', + output: '출력 모듈', + storage: '저장소', + api: 'API', + ui: '화면', + decision: '의사결정', + external: '외부', +}; + +export const TRIGGER_LABELS: Record = { + scheduled: '주기 자동', + event: '이벤트', + user_action: '사용자 액션', + on_demand: '조회 시', +}; + +export const STATUS_LABELS: Record = { + 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[]), +); diff --git a/frontend/src/flow/manifest/meta.json b/frontend/src/flow/manifest/meta.json new file mode 100644 index 0000000..a33607d --- /dev/null +++ b/frontend/src/flow/manifest/meta.json @@ -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 분석, 이벤트 생성, 운영자 의사결정까지 전체 노드/엣지" +} diff --git a/frontend/src/systemFlowMain.tsx b/frontend/src/systemFlowMain.tsx new file mode 100644 index 0000000..e00713b --- /dev/null +++ b/frontend/src/systemFlowMain.tsx @@ -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( + + + , +); diff --git a/frontend/system-flow.html b/frontend/system-flow.html new file mode 100644 index 0000000..c0fd579 --- /dev/null +++ b/frontend/system-flow.html @@ -0,0 +1,12 @@ + + + + + + KCG AI Monitoring — System Flow + + +
+ + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 953ff3a..dde1356 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -28,4 +28,12 @@ export default defineConfig({ }, }, }, + build: { + rollupOptions: { + input: { + main: path.resolve(__dirname, 'index.html'), + systemFlow: path.resolve(__dirname, 'system-flow.html'), + }, + }, + }, })