Compare commits

...

140 커밋

작성자 SHA1 메시지 날짜
50d816e2ff Merge pull request 'release: 2026-04-20 (37건 커밋)' (#89) from release/2026-04-20 into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 21s
2026-04-20 06:56:08 +09:00
be315f59aa docs: 릴리즈 노트 정리 (2026-04-20) 2026-04-20 06:52:25 +09:00
30abe6a951 Merge pull request 'feat(db): Detection Model Registry 스키마 (V034, Phase 1-1)' (#87) from feature/phase1-1-detection-models-schema into develop 2026-04-20 06:49:02 +09:00
82ffe7cb06 Merge branch 'develop' into feature/phase1-1-detection-models-schema 2026-04-20 06:48:43 +09:00
afde36480d Merge pull request 'fix(ui): 모니터링/디자인시스템 런타임 에러 해소' (#88) from fix/runtime-errors-monitoring-designsystem into develop 2026-04-20 06:36:32 +09:00
d971624090 docs: 릴리즈 노트 업데이트 2026-04-20 06:31:33 +09:00
eee9e79818 fix(ui): 모니터링/디자인시스템 런타임 에러 해소
### SystemStatusPanel TypeError
- 증상: /monitoring 에서 Uncaught TypeError: Cannot read properties of undefined (reading 'toLocaleString')
- 원인: stats 객체는 존재하나 total 필드가 undefined 인 경우 (백엔드 응답이 기대 shape 와 다를 때) 크래시
- 수정: stats?.total != null ? ... / stats.critical ?? 0 식 null-safe 전환 (total/clusterCount/gearGroups/critical/high/medium/low 전부)

### CatalogBadges 렌더링 오류
- 증상: /design-system.html 에서
  (1) Each child in a list should have a unique "key" prop
  (2) Objects are not valid as a React child (found: object with keys {ko, en})
- 원인: PERFORMANCE_STATUS_META 의 meta 는 {intent, hex, label: {ko, en}} 형식. code 필드 없고 label 이 객체.
  - Object.values() + <Trk key={meta.code}> 로 undefined key 중복
  - getKoLabel 이 meta.label (객체) 그대로 반환해 Badge children 에 객체 주입
  다른 카탈로그는 fallback: {ko, en} 패턴이라 문제 없음 (performanceStatus 만 label 객체)
- 수정:
  - Object.entries() 로 순회해 Record key 를 안정적 식별자로 사용
  - AnyMeta.label 타입을 string | {ko,en} 확장
  - getKoLabel/getEnLabel 우선순위: fallback.ko → label.ko → label(문자열) → code → key
  - PERFORMANCE_STATUS_META 자체는 변경 안 함 (admin 페이지들이 label.ko/label.en 직접 참조 중)

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc+ESLint 통과
2026-04-20 06:31:11 +09:00
410d0da9cf docs: 릴리즈 노트 업데이트 2026-04-20 06:22:58 +09:00
dae7aea861 feat(db): Detection Model Registry 스키마 (V034, Phase 1-1)
prediction 의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서
파라미터·버전을 관리할 수 있도록 하는 기반 인프라의 DB 계층.

기존 V014 correlation_param_models 패턴을 일반화:
- JSONB params + is_active(V014) → model_id × version × role×status (V034)
- 한 모델을 여러 파라미터 셋으로 동시 실행 지원 (PRIMARY/SHADOW/CHALLENGER)
- Compare API 기반 제공 (PRIMARY vs SHADOW diff 집계)

### 스키마 (테이블 4 + 뷰 1)

1. detection_models — 모델 카탈로그 (model_id PK, tier 1~5, category, entry_module/callable, is_enabled)
2. detection_model_dependencies — 모델 간 DAG 엣지 (self-loop 금지, input_key 포함)
3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
   - status: DRAFT / TESTING / ACTIVE / ARCHIVED
   - role: PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
   - UNIQUE partial index: model_id 당 PRIMARY×ACTIVE 최대 1건
   - SHADOW/CHALLENGER×ACTIVE 는 N건 허용 (병렬 비교)
   - parent_version_id 로 fork 계보 추적
4. detection_model_run_outputs — 버전별 실행 결과 원시 snapshot
   - PARTITION BY RANGE (cycle_started_at)
   - 초기 2개월(2026-04, 2026-05) 파티션 + 이후 월별 자동생성 TODO (Phase 1-2)
   - input_ref JSONB GIN index — 같은 입력 기준 PRIMARY×SHADOW JOIN 용
5. detection_model_metrics — 사이클 단위 집계 메트릭 (cycle_duration_ms 등)
6. v_detection_model_comparison — PRIMARY×SHADOW 같은 입력으로 JOIN 한 VIEW

### 권한
- auth_perm_tree: 'ai-operations:detection-models' (parent=admin, nav_sort=250)
- ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ

### 후속 (Phase 1-2)
- partition_manager 에 detection_model_run_outputs 월별 자동 생성/만료 로직 추가
- 기본 retention 7일 (SHADOW 대량 누적 대비)

### 검증
- Flyway 자동 적용 (백엔드 재빌드+배포)
- 이후 Python Model Registry 가 이 스키마 위에서 동작
2026-04-20 06:22:25 +09:00
2395ef1613 Merge pull request 'feat(detection): 환적 의심 전용 탐지 페이지 신설 (Phase 0-3)' (#86) from feature/phase0-3-transshipment-detection into develop 2026-04-20 05:52:05 +09:00
a8ce9a4ea9 docs: 릴리즈 노트 업데이트 2026-04-20 05:51:25 +09:00
cbfed23823 feat(detection): 환적 의심 전용 탐지 페이지 신설 (Phase 0-3)
docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
prediction algorithms/transshipment.py 5단계 필터 파이프라인 결과를 전체 목록·
집계·상세 수준으로 조회하는 READ 전용 대시보드.

### 배경
기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준(특정 MMSI 의 환적
이력)이고, 환적 의심 선박 전체 목록을 보려면 ChinaFishing 의 탭 중 하나를 거쳐야
했다. /api/analysis/transship 엔드포인트는 이미 존재하나 전용 페이지가 없었음.

### 변경
- frontend/src/features/detection/TransshipmentDetection.tsx 신설 (405 라인)
  - PageContainer + PageHeader(ArrowLeftRight) + KPI 5장
    (Total / Transship tier CRITICAL/HIGH/MEDIUM / Risk CRITICAL)
  - DataTable 8컬럼 (analyzedAt / mmsi / pairMmsi / duration / tier / risk / zone)
  - features.transship_tier 읽어 Badge 로 심각도 표시
  - 필터: hours(1/6/12/24/48) / riskLevel / mmsi 검색
  - 상세 패널: 분석 피처 JSON 원본 + 좌표 + transship_score
  - 기존 analysisApi.getTransshipSuspects 재사용 — backend 변경 없음
- index.ts + componentRegistry.ts 등록
- detection.json (ko/en) transshipment.* 네임스페이스 추가 (각 44키)
- common.json (ko/en) nav.transshipment 추가
- V033__menu_transshipment_detection.sql
  - auth_perm_tree(detection:transshipment, nav_sort=910)
  - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ

### 권한 주의
/api/analysis/transship 의 @RequirePermission 은 현재 detection:dark-vessel.
이 메뉴 READ 만으로는 API 호출 불가. 현행 운영자 역할(OPERATOR/ANALYST/FIELD)
은 dark-vessel READ 도 보유하므로 실용 동작.
향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
detection:transshipment 로 교체하는 권한 일관화는 별도 MR (후속).

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc + ESLint 통과 예정
- Flyway V033 자동 적용 (백엔드 재배포 필요)
2026-04-20 05:51:06 +09:00
f2d145c9a2 Merge pull request 'feat(detection): 불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)' (#85) from feature/phase0-2-illegal-fishing-pattern into develop 2026-04-20 05:47:17 +09:00
214f063f1e docs: 릴리즈 노트 업데이트 2026-04-20 05:46:34 +09:00
e49ab0f4e8 feat(detection): 불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)
docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소. event_generator
가 생산하는 카테고리 중 불법 조업 관련 3종을 READ 전용 대시보드로 통합.

대상 카테고리:
- GEAR_ILLEGAL   — G-01 수역·어구 / G-05 고정어구 drift / G-06 쌍끌이
- EEZ_INTRUSION  — 영해 침범 / 접속수역 + 고위험
- ZONE_DEPARTURE — 특정수역 진입 (risk ≥ 40)

### 변경
- frontend/src/services/illegalFishingPatternApi.ts 신설
  - 기존 /api/events 를 category 다중 병렬 조회 후 머지 (backend 변경 없음)
  - category '' 이면 3 카테고리 통합, 지정 시 단일 카테고리만
  - size 기본 200 × 3 categories = 최대 600건, occurredAt desc 정렬
  - byCategory / byLevel 집계 포함
- frontend/src/features/detection/IllegalFishingPattern.tsx 신설 (391 라인)
  - PageContainer + PageHeader(Ban 아이콘) + Section + KPI 5장 + 카테고리별 3장
  - DataTable (occurredAt/level/category/title/mmsi/zone/status 7컬럼)
  - 필터: category / level / mmsi (최근 DEFAULT_SIZE 건 범위)
  - 상세 패널: JSON features 포함, EventList 로 네비게이션 링크
  - design-system SSOT 준수: Badge intent, Select aria-label, text-* 시맨틱 토큰
- index.ts + componentRegistry.ts export/lazy 등록
- detection.json (ko/en) illegalPattern.* 네임스페이스 추가 (각 60키)
- common.json (ko/en) nav.illegalFishing 추가
- V032__menu_illegal_fishing_pattern.sql
  - auth_perm_tree 엔트리 (rsrc_cd=detection:illegal-fishing, nav_sort=920)
  - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ
  - READ 전용 페이지 (처리 액션은 EventList 경유)

### 검증
- npx tsc --noEmit 통과 (0 에러)
- 백엔드 변경 없음 (기존 /api/events category 필터 재사용)
- Flyway V032 자동 적용 (백엔드 재배포 필요)
2026-04-20 05:46:13 +09:00
2e674ccc5b Merge pull request 'fix(db): candidate_source 컬럼 VARCHAR(30)→(100) 확장 (V031)' (#84) from fix/candidate-source-length into develop 2026-04-17 11:53:08 +09:00
2b25dc1c92 docs: 릴리즈 노트 업데이트 2026-04-17 11:52:08 +09:00
0f4a9cb7d6 fix(db): candidate_source 컬럼 VARCHAR(30)→(100) 확장 (V031)
gear_group_parent_candidate_snapshots.candidate_source 의 VARCHAR(30) 제약
때문에 prediction gear_correlation 스테이지가 매 사이클 실패하던 문제 해소.

원인:
- prediction/algorithms/gear_parent_inference.py:875 의
  candidate_source = ','.join(sorted(meta['sources']))
  가 복수 source 라벨 (CORRELATION/EPISODE/LABEL/LINEAGE/MATCH) 을 쉼표 join
  하며 최대 약 39자. VARCHAR(30) 초과 시 psycopg2.errors.StringDataRightTruncation
  을 유발해 _insert_candidate_snapshots 전체 ROLLBACK.

발견 경위:
- Phase 0-1 (PR #83) 의 stage_runner + logger.exception 전환 후 journal 에
  찍힌 풀 스택트레이스로 드러남. 기존에는 logger.warning 한 줄 ("gear
  correlation failed: ...") 만 남아 원인 특정 불가.

영향 범위:
- 백엔드 JPA 엔티티 미참조 → 재빌드·재배포 불필요
- Flyway 자동 적용 (백엔드 기동 시)
- prediction 재기동만 필요 (기존 코드 그대로, 이제 INSERT 성공 기대)

검증:
- 재배포 후 journalctl 에서 'gear correlation failed' 로그 사라짐 확인
- kcg.gear_group_parent_candidate_snapshots 에 최근 15분 건수 증가 확인
2026-04-17 11:51:35 +09:00
3e29bc9995 Merge pull request 'refactor(prediction): 사이클 스테이지 에러 경계 도입 (Phase 0-1)' (#83) from feature/phase0-cycle-error-boundary into develop 2026-04-17 11:30:17 +09:00
a32d09f75a docs: 릴리즈 노트 업데이트 2026-04-17 11:28:58 +09:00
197da13826 refactor(prediction): 사이클 스테이지 에러 경계 도입 (Phase 0-1)
docs/prediction-analysis.md P1 권고 반영. 5분 사이클의 각 스테이지를
한 try/except 로 뭉친 기존 구조를 스테이지 단위로 분리해 실패 지점을
명시적으로 특정하고 부분 실패 시에도 후속 스테이지가 계속 돌아가도록 개선.

- prediction/pipeline/stage_runner.py 신설
  - run_stage(name, fn, *args, required=False, **kwargs) 유틸
  - required=True 면 예외 re-raise (상위 사이클 try/except 가 잡도록)
  - required=False 면 logger.exception 으로 stacktrace 보존 + None 반환
  - 지속시간 로깅 포함

- prediction/scheduler.py run_analysis_cycle() 수정
  - 출력 단계 6모듈을 각각 run_stage() 로 분리:
    violation_classifier / event_generator / kpi_writer /
    stats_aggregate_hourly / stats_aggregate_daily / alert_dispatcher
  - upsert_results / cleanup_old 도 run_stage 로 래핑 (upsert 는 required=True)
  - 내부 try/except 의 logger.warning → logger.exception 으로 업그레이드
    (fetch_dark_history, gear collision event promotion, group polygon,
     gear correlation, pair detection, chat cache)
  - 스테이지 실패 시 journalctl -u kcg-ai-prediction 에서 stacktrace 로
    원인 바로 특정 가능 (기존은 "failed: X" 한 줄만 남아 디버깅 불가)

검증:
- python3 -c "import ast; ast.parse(...)" scheduler.py / stage_runner.py 통과
- run_stage smoke test (정상/실패 흡수/required 재raise 3가지) 통과

범위 밖 (후속):
- Phase 0-2 ILLEGAL_FISHING_PATTERN 전용 페이지 (다음 MR)
- Phase 0-3 Transshipment 전용 페이지 (다음 MR)
2026-04-17 11:28:30 +09:00
bae2dbde08 Merge pull request 'docs: prediction-analysis 신규 + 루트/SFR 문서 drift 해소' (#82) from feature/docs-refresh-2026-04-17 into develop 2026-04-17 11:22:41 +09:00
451f38036a docs: 릴리즈 노트 업데이트 2026-04-17 11:21:56 +09:00
b37e18d952 docs: prediction-analysis 신규 + 루트/SFR 문서 drift 해소
- docs/prediction-analysis.md 신설 — opus 4.7 독립 리뷰 기반 prediction 구조/방향 심층 분석
  (9개 섹션: 아키텍처·5분 사이클·17 알고리즘·4대 도메인 커버리지·6축 구조 평가·개선 제안 P1~P4·임계값 전수표)
- AGENTS.md / README.md — V001~V016→V030, Python 3.9→3.11+, 14→17 알고리즘 모듈
- docs/architecture.md — /gear-collision 라우트 추가 (26→27 보호 경로)
- docs/sfr-traceability.md — V029→V030, 48→51 테이블, SFR-10 에 GEAR_IDENTITY_COLLISION 추가
- docs/sfr-user-guide.md — 어구 정체성 충돌 페이지 섹션 신설
- docs/system-flow-guide.md — 노드 수 102→115, V030 manifest 미반영 경고
- backend/README.md — "Phase 2 예정" 상태 → 실제 운영 구성 + PR #79 hotfix 요구사항 전면 재작성
2026-04-17 11:20:53 +09:00
594741906b Merge pull request 'release: 2026-04-17.4 (14건 커밋)' (#81) from develop into main 2026-04-17 07:43:28 +09:00
ddcb493160 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17.4)' (#80) from release/2026-04-17.4 into develop 2026-04-17 07:42:57 +09:00
b0d9630dde docs: 릴리즈 노트 정리 (2026-04-17.4) 2026-04-17 07:42:44 +09:00
b1bd6e507a Merge pull request 'fix(backend): Spring 6.1 RestClient bean 모호성 기동 실패 해소' (#79) from hotfix/backend-parameters-flag into develop 2026-04-17 07:42:10 +09:00
f07d68b43f fix(backend): Spring 6.1 RestClient bean 모호성 기동 실패 해소
증상: rocky-211 의 kcg-ai-backend 가 `No qualifying bean of type RestClient,
but 2 were found: predictionRestClient, signalBatchRestClient` 로 기동 실패 반복.
PR #A 의 RestClientConfig 도입 이후 잠복해 있던 문제로, PredictionProxyController /
VesselAnalysisProxyController 의 필드 @Qualifier 가 Lombok `@RequiredArgsConstructor`
가 만든 constructor parameter 로 복사되지 않아 Spring 6.1 의 bean 이름 fallback 이
실패한 것.

- backend/pom.xml — default-compile / default-testCompile 의 configuration 에
  `<parameters>true</parameters>` 추가. spring-boot-starter-parent 기본값을 executions
  override 과정에서 덮어쓰지 않도록 명시.
- backend/src/main/java/lombok.config — `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier`.
  Lombok 이 필드의 @Qualifier 를 생성된 constructor parameter 로 복사해야 Spring 이
  파라미터 레벨 annotation 으로 해당 bean 을 식별할 수 있음.

검증: javap 로 PredictionProxyController 생성자의 RuntimeVisibleParameterAnnotations 에
@Qualifier("predictionRestClient") 가 실제 복사되었는지 확인, 재빌드/재배포 후 rocky-211
기동 성공("Started KcgAiApplication in 7.333 seconds") + Tomcat 18080 정상 리스닝.
2026-04-17 07:40:57 +09:00
5be83d2d9a Merge pull request 'release: 2026-04-17.3 (19건 커밋)' (#77) from develop into main 2026-04-17 07:36:56 +09:00
28be92047b Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17.3)' (#78) from release/2026-04-17.3 into develop 2026-04-17 07:35:23 +09:00
f92810b1b4 docs: 릴리즈 노트 정리 (2026-04-17.3) 2026-04-17 07:34:27 +09:00
b106113e47 Merge pull request 'docs: 절대 지침 섹션 추가 (develop 동기화 + design-system 준수)' (#76) from docs/claude-md-absolute-rules into develop 2026-04-17 07:29:28 +09:00
9a9388c37a docs: 릴리즈 노트 업데이트 2026-04-17 07:28:14 +09:00
48794e6962 docs: 절대 지침 섹션 추가 (develop 동기화 + design-system 준수) 2026-04-17 07:23:58 +09:00
fafed8ccdf Merge pull request 'release: 2026-04-17.2 (5건 커밋)' (#75) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-17 07:19:51 +09:00
485743c0e1 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17.2)' (#74) from release/2026-04-17.2 into develop 2026-04-17 07:18:29 +09:00
ed48735310 docs: 릴리즈 노트 정리 (2026-04-17.2) 2026-04-17 07:18:19 +09:00
e0af0e089d Merge pull request 'feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가 + docs 정비' (#73) from feature/gear-identity-collision into develop 2026-04-17 07:14:08 +09:00
a4e29629fc feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는
공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로
fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소.

Prediction
- algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity
- fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전
- output/event_generator.py: run_gear_identity_collision_events 추가
- scheduler.py: track_gear_identity 직후 이벤트 승격 호출

Backend (domain/analysis)
- GearIdentityCollision 엔티티 + Repository(Specification+stats)
- GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve)
- GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve)
- GearCollisionResponse / StatsResponse / ResolveRequest (record)

DB
- V030__gear_identity_collision.sql: gear_identity_collisions 테이블
  + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한

Frontend
- shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록
- services/gearCollisionApi.ts (list/stats/get/resolve)
- features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable
  + 분류 액션 폼, design system SSOT 준수)
- componentRegistry + feature index + i18n detection.json / common.json(ko/en)
2026-04-17 06:53:12 +09:00
831045ace9 docs: 프로젝트 산출문서 2026-04-17 기준 정비
- docs/architecture.md: shared/components/ui 9개·i18n 네임스페이스 갱신
- docs/sfr-traceability.md: v3.0 전면 재작성 (운영 상태 기반 531 라인)
- docs/sfr-user-guide.md: 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신
- docs/data-sharing-analysis.md / next-refactoring.md / page-workflow.md: stale 3건 제거
2026-04-17 06:52:51 +09:00
62d14fc519 Merge pull request 'release: 2026-04-17 (11건 커밋)' (#72) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-17 05:39:15 +09:00
760bceed32 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17)' (#71) from release/2026-04-17 into develop 2026-04-17 05:38:19 +09:00
fe43f6b022 docs: 릴리즈 노트 정리 (2026-04-17) 2026-04-17 05:37:39 +09:00
38c97686fc Merge pull request 'refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw HTML → 공통 컴포넌트 치환' (#70) from refactor/design-system-ssot into develop 2026-04-16 17:10:10 +09:00
5731fa30a1 docs: 릴리즈 노트 업데이트 (PR #C 디자인시스템 정비) 2026-04-16 17:09:32 +09:00
c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00
2c23049c8e Merge pull request 'refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거' (#69) from refactor/i18n-alert-aria-confirm into develop 2026-04-16 16:33:22 +09:00
03f2ea08db docs: 릴리즈 노트 업데이트 (PR #B i18n 정비) 2026-04-16 16:32:53 +09:00
8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)

alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl

aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)

MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
2026-04-16 16:32:37 +09:00
5a57959bd5 Merge pull request 'refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 + 카탈로그' (#68) from refactor/cleanup-iran-backend-catalog into develop 2026-04-16 16:20:05 +09:00
bb40958858 docs: 릴리즈 노트 업데이트 (PR #A 구조 정비) 2026-04-16 16:19:02 +09:00
9251d7593c refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 분리 + 카탈로그 등록
iran 백엔드 프록시 잔재 제거:
- IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거
- Frontend UI 라벨/주석/system-flow manifest deprecated 마킹
- CLAUDE.md 시스템 구성 다이어그램 최신화

백엔드 계층 분리:
- AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거
- AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true)
- Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합

감사 로그 보강:
- EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가
- VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록

카탈로그 정합성:
- performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출)
- alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder
- LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출
- GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
2026-04-16 16:18:18 +09:00
c8673246f3 Merge pull request 'release: 2026-04-16.7 (4건 커밋)' (#67) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 15:26:34 +09:00
312dde7b86 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.7)' (#66) from release/2026-04-16.7 into develop 2026-04-16 15:25:50 +09:00
9063095a9b docs: 릴리즈 노트 정리 (2026-04-16.7) 2026-04-16 15:25:36 +09:00
65b98c53be Merge pull request 'feat: 경량 분석 riskScore 해상도 개선 + vessel_type 매핑 + 중국 그리드 정합성' (#65) from feature/risk-scoring-vessel-type into develop 2026-04-16 15:23:37 +09:00
3372d06545 docs: 릴리즈 노트 업데이트 2026-04-16 15:22:51 +09:00
524df19f20 feat(frontend): 선박 유형 한글 카탈로그 + 중국 선박 분석 그리드 정합성
vessel_type 카탈로그
- shared/constants/vesselTypes.ts 신규 — TRAWL/PURSE/GILLNET/LONGLINE/
  TRAP/CARGO/UNKNOWN 7종 + getVesselTypeLabel / getVesselTypeIntent
  헬퍼. 기존 alertLevels 카탈로그 패턴 답습
- catalogRegistry 에 VESSEL_TYPES 등록 — design-system 쇼케이스에 자동
  노출

RealVesselAnalysis 필터 props 확장
- Props 에 mmsiPrefix / minRiskScore / size 추가 (all·spoofing mode)
- 선박 유형 컬럼을 한글 라벨로 렌더
- RealAllVessels 편의 export 를 mmsiPrefix='412' 로 고정 + 제목을
  '중국 선박 전체 분석 결과 (실시간)' 로 변경

효과
- Tab 1 상단 그리드가 중국 선박만 표시해 페이지 성격과 일치
- 선박 유형 '저인망/선망/유자망/연승/통발/운반선/미분류' 한글 표시
- 55점 HIGH 같은 중국 선박이 상단/하단 양쪽에 일관되게 노출
2026-04-16 15:20:08 +09:00
6fb0b04992 feat(prediction): 경량 분석 riskScore 해상도 개선 + vessel_type 매핑
경량 경로 선박 60.8%가 45점 고정으로 수렴하고 98.6%가 vessel_type
UNKNOWN 으로만 기록되던 문제를 해결한다.

riskScore (compute_lightweight_risk_score)
- dark_suspicion_score(0~100) 직접 반영: min(30, score*0.3)
- EEZ_OR_BEYOND 기선 근접도 가산 (12NM 내 +15, 24NM 내 +8)
- dark_history_24h 가산 (dark_suspicion_score 미반영 케이스만)
- 허가 이력 +20 → +8/+15 차등 (dark_suspicion_score 있을 때 이중계산 방지)
- gap_duration_min 4구간 차등 (fallback: 720m/180m/60m/30m)

vessel_type (신규 vessel_type_mapping.py)
- fleet_vessels fishery_code → VesselType 매핑:
  PT/PT-S/OT → TRAWL, GN → GILLNET, PS → PURSE, FC → CARGO
- GILLNET / CARGO 2개 값 신규 추가 (기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN)
- scheduler.py 경량 경로에서 등록선은 매핑, 미등록선은 UNKNOWN 유지

배포 후 검증 (redis-211 15:15 사이클)
- risk_score 분포: 45점 60.8% → 0% (11~40 범위 고르게 분산)
- vessel_type: UNKNOWN 98.6% → 89.1% (886척이 구체 유형으로 전환,
  TRAWL 518 / LONGLINE 171 / TRAP 78 / PURSE 73 / GILLNET 38 / CARGO 8)
- 412354335 샘플: 45 MEDIUM 고정 → 20 LOW (dss=40 × 0.3 + 축소 허가)
2026-04-16 15:19:55 +09:00
2f94c2a0a4 Merge pull request 'release: 2026-04-16.6 (5건 커밋)' (#64) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 14:40:48 +09:00
1def64dd1d Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.6)' (#63) from release/2026-04-16.6 into develop 2026-04-16 14:39:52 +09:00
49c11e7b4a docs: 릴리즈 노트 정리 (2026-04-16.6) 2026-04-16 14:38:59 +09:00
9607f798dd Merge pull request 'feat: 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널' (#62) from feature/china-fishing-backend-api into develop 2026-04-16 14:36:31 +09:00
a9f81c6c7e docs: 릴리즈 노트 업데이트 2026-04-16 14:35:43 +09:00
d82eaf7e79 feat(frontend): 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널
Tab 1 AI 감시 대시보드 / Tab 2 환적탐지 / Tab 3 어구판별 3개 탭을
deprecated iran proxy 에서 자체 /api/analysis/* 로 전환하고, 특이운항
선박의 24h 항적과 판별 구간 상세를 지도와 패널로 제공한다.

서비스 계층
- analysisApi.ts 확장: getAnalysisStats / getAnalysisVessels(필터 3종) /
  getGearDetections 추가. VesselAnalysis 에 violationCategories /
  bd09OffsetM / ucafScore / ucftScore / clusterId 필드 노출
- analysisAdapter.ts: flat VesselAnalysis → nested VesselAnalysisItem
  변환으로 기존 컴포넌트 재사용
- vesselAnalysisApi.ts fetchVesselAnalysis @deprecated 마킹

Tab 1 (ChinaFishing)
- 서버 집계(stats) 기준 카운터 재구성. 중국어선 / Dark / 환적 / 고위험
  모두 mmsiPrefix=412 로 서버 필터
- 선박 리스트 vessel_type UNKNOWN 인 경우 "중국어선" + "미분류" 로 표시
- 특이운항 row 클릭 → 아래 행에 미니맵 + 판별 패널 배치
- 관심영역 / VIIRS / 기상 / VTS 카드에 "데모 데이터" 뱃지. 비허가 /
  제재 / 관심 탭 disabled + "준비중" 뱃지

Tab 2 (RealVesselAnalysis)
- /analysis/dark / /analysis/transship / /analysis/vessels mode별 분기
- 상단 통계 카드를 items 클라이언트 집계로 전환해 하단 테이블과 정합

Tab 3 (GearIdentification)
- 최하단 "최근 자동탐지 결과" 섹션 추가. row 클릭 시 상단 입력 폼
  프리필 + 결과 패널에 자동탐지 근거 프리셋

특이운항 판별 시각화 (VesselMiniMap / VesselAnomalyPanel /
vesselAnomaly 유틸)
- 24h getAnalysisHistory 로드 → classifyAnomaly 로 DARK/SPOOFING/
  TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 5개 카테고리 판별. 좌표는
  top-level lat/lon 우선, features.gap_start_* fallback
- groupAnomaliesToSegments: 5분 주기 반복되는 동일 신호를 시작~종료
  구간으로 병합
- 미니맵: 전체 궤적은 연한 파랑, segment 시간범위와 매칭되는 AIS
  궤적 서브구간을 severity 색(CRITICAL 빨강 / WARNING 주황 / INFO
  파랑) 으로 하이라이트. 이벤트 기준 좌표는 작은 흰 점
- 판별 패널: 시작→종료 · 지속 · N회 연속 감지 · 카테고리 뱃지 · 설명
2026-04-16 14:31:26 +09:00
820ed75585 feat(backend): /api/analysis stats + gear-detections 엔드포인트 추가
중국어선 감시 화면의 실데이터 연동을 위해 기존 /api/analysis 에 집계/
필터 기능을 보강한다.

- VesselAnalysisResult 엔티티에 violation_categories TEXT[] 매핑 추가
- VesselAnalysisResponse 에 violationCategories / bd09OffsetM /
  ucafScore / ucftScore / clusterId 5개 필드 노출
- /api/analysis/vessels 에 mmsiPrefix / minRiskScore / minFishingPct
  필터 파라미터 추가
- /api/analysis/stats: MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER
  집계 (total/dark/spoofing/transship/risk별/zone별/fishing/avgRisk)
- /api/analysis/gear-detections: gear_code/judgment NOT NULL 인 row
  MMSI 중복 제거 목록. 어구/어망 판별 탭 '자동탐지 결과' 섹션 연동용
- deprecated 스텁 /api/vessel-analysis 는 프론트 호출 제거 후 다음
  릴리즈에서 삭제 예정 (이번 PR 에서는 유지)
2026-04-16 14:31:02 +09:00
14eb4c7ea3 feat(prediction): vessel_analysis_results 에 분석 시점 lat/lon 저장
AnalysisResult 에 lat/lon 필드 + to_db_tuple 반영 + upsert_results SQL
컬럼 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로
모두 분석 시점의 선박 위치를 함께 기록해 프론트 미니맵에서 특이운항
판별 위치를 실제 항적 위에 표시할 수 있게 한다.

배포 후 첫 사이클 8173/8173 lat/lon non-null 확인.
2026-04-16 14:30:49 +09:00
d0c8a88f21 Merge pull request 'release: 2026-04-16.5 (Phase 1-B admin DS)' (#61) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-16 11:37:36 +09:00
da4557a5df Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.5)' (#60) from release/2026-04-16.5 into develop 2026-04-16 11:37:20 +09:00
3248ec581b docs: 릴리즈 노트 정리 (2026-04-16) 2026-04-16 11:37:15 +09:00
369aaec06f Merge pull request 'refactor(frontend): admin Phase 1-B 디자인 시스템 하드코딩 색상 제거' (#59) from feature/admin-ds-phase1b into develop 2026-04-16 11:36:45 +09:00
f1dc9f7a5a docs: 릴리즈 노트 업데이트 2026-04-16 11:36:22 +09:00
234169d540 refactor(frontend): admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)
129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환:
- text-cyan-400 (45건) → text-label
- text-green-400/500 (51건) → text-label + Badge intent="success"
- text-red-400/500 (31건) → text-heading + Badge intent="critical"
- text-blue-400 (33건) → text-label + Badge intent="info"
- text-purple-400 (20건) → text-heading
- text-yellow/orange/amber (32건) → text-heading + Badge intent="warning"

raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등)
미사용 import 정리 (SaveButton/DataTable/lucide 아이콘)

대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs,
AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement,
PermissionsPanel, SystemConfig

검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
2026-04-16 11:25:51 +09:00
7d101604cc Merge pull request 'release: 2026-04-16.4 (50건 커밋)' (#58) from develop into main 2026-04-16 11:10:45 +09:00
6f997ad796 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.4)' (#57) from release/2026-04-16.4 into develop 2026-04-16 11:09:42 +09:00
15e17759f8 docs: 릴리즈 노트 정리 (2026-04-16) 2026-04-16 11:09:17 +09:00
25de59be12 Merge pull request 'feat(prediction): DAR-03 탐지 튜닝 v2 (pair/매칭/G-02/G-03)' (#56) from feature/dar03-tuning-v2 into develop 2026-04-16 11:07:47 +09:00
20e2b029c5 docs: 릴리즈 노트 업데이트 2026-04-16 11:07:06 +09:00
7314c7f65f fix(prediction): match_ais_to_registry 대상을 전체 중국 선박으로 확장 (A-2 핵심 버그)
버그: all_ais 를 vessel_dfs(classification 통과 500척)만 대상으로 구성 →
허가선 906척 중 실제 AIS 존재 866척(upper bound 95.8%) 임에도 매칭률
9.4% 에 머물렀던 진짜 원인.

수정: vessel_store._tracks 전체에서 중국 MID(412/413/414) 활성 선박을
대상으로 match_ais_to_registry 호출. 매칭률 upper bound 95.8% 까지 회복 기대.

검증: 이번 AIS 실제 샘플 조사로 판명:
- AIS 고유 정규화 이름 411,908 개
- 허가선 정규화 이름 904 개
- 교집합 866 개 (95.8%)
→ 정규화 로직은 정상 작동. 문제는 호출 범위였음.
2026-04-16 10:32:54 +09:00
535704707b fix(prediction): FUZZY 정규화를 공백/대소문자만으로 축소 + name_en 전용 (A-2 후속)
버그 원인: 초기 정규화가 선박번호(suffix)까지 제거 → '浙岭渔20865' → '浙岭渔' 로
축약 → 동명이 수십 개 발생 → len(unassigned)>1 조건에 전부 탈락 → FUZZY=0건.

중국/한국 어선명은 업체명+선박번호가 고유 식별자이므로 숫자 자체는 보존해야 함.
정규화는 공백/구두점/대소문자/'NO.' 마커만 통일:
  'ZHE LING YU 20865' ↔ 'zhelingyu20865' ↔ 'ZHE-LING-YU-20865' 모두 일치

FUZZY 매칭 key 는 name_en 만 등록 (AIS 보고 이름이 영문이 주류).
2026-04-16 10:13:35 +09:00
37ae1bfa48 feat(prediction): 서버 스크립트에 tier/match_method/G-02/G-03 추적 추가
scheduler.py features write 확장:
- pair_tier (STRONG/PROBABLE/SUSPECT)
- pair_type, pair_reject_reason
- similarity, confidence
- registered_fishery_code
→ SUSPECT tier 까지 raw_pair 로 보존하여 통계 집계 가능

diagnostic-snapshot.sh (5분 주기):
- 4-4.1 pair_trawl tier 분포 + avg_sync_min
- 4-4.2 reject 사유 journal 로그 tail
- 4-4.3 G-02 금어기 상세 (observed_at, fishery_code)
- 4-4.4 G-03 미등록 어구 상세 (detected/registered/allowed)
- 7.5-2b match_method 분포 (EXACT vs FUZZY)
- 7.5-2c fishery_code × match_method 교차

hourly-analysis-snapshot.sh (1시간 주기):
- P3.5 match_method 분포 + avg_confidence
- P3.6 fishery_code × match_method 교차
- D3.6 pair_tier 분포 + avg_sync_min + avg_sep_nm
- D3.7 G-02/G-03 건수 + gear_judgment 분포
- D3.8 reject 사유 1시간 journal 집계
2026-04-16 09:50:42 +09:00
68e690d791 refactor(prediction): pair_trawl tier 분류 + join_key time_bucket 전환
두 가지 근본 버그를 동시에 해결:

1. Join key 버그 — raw AIS timestamp(ms 단위) inner join 은 두 선박 간 우연히
   일치하는 확률이 거의 0. vessel_store._tracks 의 time_bucket(5분 리샘플)
   컬럼을 우선 사용. _pair_join_key() 헬퍼로 fallback 지원.

2. AND 게이트 0건 문제 — 스펙 100%(2h 연속 + 500m + SOG 2-4 + sog_delta 0.5 +
   cog 10°)를 전부 요구하면 실제 공조 페어를 놓침. Tier 분류로 재설계:
   - STRONG  : 스펙 100% (24 cycles, 기존 조건)
   - PROBABLE: 800m / SOG 1.5-5 / sog_delta 1.0 / cog 20° / 12 cycles + 0.6 ratio
   - SUSPECT : 동일 완화 조건 / 6 cycles + 0.3 ratio (플래그만)
   G-06 판정은 STRONG/PROBABLE 만. SUSPECT 는 약한 신호로 노출.

거부 사유 카운터(REJECT_COUNTERS) + tier 카운트를 사이클별 로그 출력.
'조건이 엄격한건지 실제 페어가 없는건지' 원인 구분 가능.

피드백 메모리: feedback_detection_tier.md
2026-04-16 09:47:08 +09:00
f5374a5316 refactor(prediction): pair_trawl sog/cog on-demand 계산 + 중국 MID 413/414 추가
- pair_trawl._ensure_sog_cog(): _trajectory_similarity 진입 시 sog/cog 없으면
  vessel_store._compute_sog_cog() 로 haversine 계산 (tracks + timestamp 만 있으면 OK)
- pool 을 vessel_store._tracks 전체(55k)로 원복: 한국 440xxx/러시아 273xxx 페어 탐색 가능
- base 필터 중국 MID 확장: 412 → 412/413/414 (본토/홍콩/마카오)
- df_targets groupby 우회 제거 (불필요한 결합)
2026-04-16 09:29:38 +09:00
6c08d831d0 fix(prediction): pair_trawl pool 에 sog/cog 계산된 궤적 사용 (A-1 후속)
버그: vessel_store._tracks 는 raw_sog 만 보유 → _trajectory_similarity 가
sog/cog 컬럼 부재로 항상 0 반환 → candidates=0.

df_targets(select_analysis_targets 결과, 412* 전체 8k+ 에 sog/cog 계산)
를 mmsi 별로 groupby 하여 pool_tracks dict 생성. base 확장 필터의
sog 컬럼도 동일하게 적용.

첫 사이클 로그: base=512, pool=54825 → candidates=0 (2026-04-16 09:25).
수정 후 재검증 예정.
2026-04-16 09:27:42 +09:00
1a065840bd feat(prediction): G-02 금어기 + G-03 미등록 어구 탐지 추가 (A-4)
V029 fishery_permit_cn 스키마를 입력으로 하여 보류 중이던 G-02/G-03
판정 함수를 신설. classify_gear_violations() 시그니처에 permit_periods,
registered_fishery_code, observation_ts 매개변수 추가.

- G-02 (CLOSED_SEASON_FISHING, score 18): 관측 시각이 fishing_period_1/2
  허가 기간 밖이면 금어기 조업
- G-03 (UNREGISTERED_GEAR, score 12): 감지 어구가 fishery_code 허용 어구
  집합(PT→TRAWL/PT-S, GN→GILLNET, PS→PURSE, OT→TRAWL, FC→금지)에 없음
- fleet_tracker: _parse_period_range() 'YYYY/MM/DD - YYYY/MM/DD' 파서 +
  get_permit_periods() + get_registered_fishery_code()
- violation_classifier: CLOSED_SEASON_FISHING / UNREGISTERED_GEAR judgment →
  ILLEGAL_GEAR 카테고리 매핑

데이터 부재(permit_periods 빈 값, fishery_code 미등록) 시 판정 보류 → False.
검증 목표: 1시간 내 G-02/G-03 각 ≥ 1건
2026-04-16 09:12:07 +09:00
64df7b180c feat(prediction): fleet_tracker NAME_FUZZY 매칭 추가 (A-2)
_normalize_vessel_name()로 선박번호 suffix(호/號/号/NoN/#N) +
공백/구두점 제거 후 upper() 통일. EXACT 실패 시 FUZZY 단계로
매칭 시도. 동명이 후보 2개 이상이거나 이미 다른 MMSI 할당된 vid는
제외하여 중복 방지.

- match_ais_to_registry: NAME_EXACT → NAME_FUZZY (confidence 0.80)
- track_gear_identity: parent_name 매칭에도 FUZZY 적용
- _name_fuzzy_map 캐시로 1회 lookup

검증 목표: fleet_vessels.mmsi 매칭률 8.7% → 30%+
2026-04-16 09:08:35 +09:00
d817e4cbbf perf(prediction): pair_trawl base 확장 + 임계 완화 (A-1)
- SIMILARITY_OBSERVE 0.50 → 0.45 (pool 확대 후 recall 확보)
- min_common_samples 6 → 4 (비분류 선박 샘플 부족 보정)
- base_mmsis에 조업 속력대(1.5~5.0kn) 중국 412* 전체 추가
  (classifications 500척만으로 bbox 기점 부족 → 실제 공조 페어 누락)

검증 목표: 10분 사이클에서 pair_detected ≥ 1
2026-04-16 09:06:50 +09:00
020b3b7643 Merge pull request 'release: 2026-04-16.3 (6건 커밋) — Admin 디자인 시스템 Phase 1-A' (#55) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 08:45:02 +09:00
ac67b9b7af Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.3)' (#54) from release/2026-04-16.3 into develop 2026-04-16 08:44:29 +09:00
9c74459acc docs: 릴리즈 노트 정리 (2026-04-16.3) 2026-04-16 08:44:17 +09:00
03d3d428e5 Merge pull request 'refactor(admin): 3개 신규 페이지 디자인 시스템 준수 + RBAC skeleton (Phase 1-A)' (#53) from feature/admin-ds-rbac-refactor into develop 2026-04-16 08:43:52 +09:00
e6b053dfa2 Merge remote-tracking branch 'origin/develop' into feature/admin-ds-rbac-refactor
# Conflicts:
#	docs/RELEASE-NOTES.md
2026-04-16 08:43:33 +09:00
a68945bd07 docs: 릴리즈 노트 업데이트 2026-04-16 08:40:07 +09:00
69b97d33f6 refactor(admin): 3개 신규 페이지 디자인 시스템 준수 + RBAC skeleton (Phase 1-A)
- performanceStatus.ts 카탈로그 신설 (status→intent/hex/label)
- 자체 탭 네비 3건 → TabBar/TabButton (underline variant)
- raw <button> 3건 → TabButton
- PerformanceMonitoring hex 9건 → getPerformanceStatusHex 카탈로그
- statusIntent/barColor 로컬 함수 → 카탈로그 getPerformanceStatusIntent + utilizationStatus
- 3개 페이지 useAuth().hasPermission() skeleton 배치 (Phase 3 action guard 대비)
- 남은 hex 10건(DAR-10/11 KPI)은 Phase 1-B 전역 admin 시맨틱 색상 통일에서 처리

검증: tsc + ESLint + vite build 모두 통과
2026-04-16 08:39:34 +09:00
21b5048a9c Merge pull request 'release: 2026-04-16.2 — 성능 모니터링 + DAR-10/11 + DAR-03 어구 비교' (#52) from release/2026-04-16.2-main into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 08:21:43 +09:00
74bdfa3f04 Merge branch 'main' into release/2026-04-16.2-main 2026-04-16 08:21:09 +09:00
bf473c12bf Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.2)' (#49) from release/2026-04-16.2 into develop 2026-04-16 08:03:44 +09:00
77b6fc9b14 docs: 릴리즈 노트 정리 (2026-04-16.2) 2026-04-16 08:03:32 +09:00
0f29172a5d Merge pull request 'feat: 시스템관리 > 감사·보안에 성능 모니터링(PER-01~06) 메뉴 추가' (#48) from merge/performance-monitoring-into-develop into develop 2026-04-16 08:01:53 +09:00
d12c81f233 Merge remote-tracking branch 'origin/feature/performance-monitoring-menu' into merge-check/performance-monitoring-menu-into-develop 2026-04-16 07:59:56 +09:00
6c7c0f4ca6 Merge pull request 'release: 2026-04-16 (20건 커밋) — DAR-03 탐지 보강 + 한중어업협정 906척 레지스트리' (#47) from develop into main 2026-04-16 07:49:39 +09:00
0a5d8fe213 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16)' (#46) from release/2026-04-16 into develop 2026-04-16 07:48:41 +09:00
dd0a934203 docs: 릴리즈 노트 정리 (2026-04-16) 2026-04-16 07:48:23 +09:00
47c553d993 Merge pull request 'feat(prediction): DAR-03 탐지 로직 보강 + 한중어업협정 906척 레지스트리 적재' (#45) from feature/dar03-detection-hardening into develop 2026-04-16 07:46:30 +09:00
9d538cffd8 docs: 릴리즈 노트 업데이트 2026-04-16 07:45:14 +09:00
8ff04a8cca feat(prediction): DAR-03 탐지 로직 보강 + 한중어업협정 906척 레지스트리 적재
- V029: kcg.fishery_permit_cn 신규 테이블(연단위, permit_year+permit_no 복합 유니크) + fleet_vessels permit_year/fishery_code 컬럼
- load_fishery_permit_cn.py: xls → DB 적재 스크립트, 906척 + 497 신청인사 upsert
- G-04/G-05/G-06 Dead code 해결: classify_gear_violations 호출 연결, dir() 버그 제거
- find_pair_candidates: bbox 1차 + 궤적 유사도(location/sog_corr/cog_alignment) 2차, role 가점
- spoofing 산식 교체: 1시간 윈도우 + teleport 절대 가점 + extreme 50kn 단독 0.6 확정
- transshipment 선종 완화: shipTy 부분일치 + 412* FISHING 간주
- gear_code DB write 경로 신설 + fleet_tracker API 3개 추가
- cron 스크립트: fishery_permit/pair_type/fleet_role 신규 섹션
2026-04-16 07:43:24 +09:00
Nan Kyung Lee
755f3919ba feat: 시스템관리 > 감사·보안에 성능 모니터링(PER-01~06) 메뉴 추가
- V028 Flyway 마이그레이션: admin:performance-monitoring 권한 트리 + RBAC
- PerformanceMonitoring.tsx: 5 탭 구조(성능 현황·응답성·처리용량·AI 모델·가용성/확장성)
- PER-01~06 전체 커버: 3,000명 규모·상황실 100명 24/7 SLO·S&P 글로벌 AIS 영향 최소화 8대 전략
- 6개 AI 모델 성능 지표(정확도·정밀도·재현율·F1·ROC-AUC) 표시
- 디자인 시스템 준수: PageContainer/PageHeader/Card/Badge intent 기반

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:12:53 +09:00
35cc889d23 Merge pull request 'release: 2026-04-15 (5건 커밋)' (#43) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 18s
2026-04-15 13:43:29 +09:00
68940e73b0 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-15)' (#42) from release/2026-04-15 into develop 2026-04-15 13:42:34 +09:00
1cc4c9dfd7 docs: 릴리즈 노트 정리 (2026-04-15) 2026-04-15 13:42:06 +09:00
ba6908a0d4 Merge pull request 'feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합' (#41) from feature/dar03-fishing-pattern-enhancement into develop 2026-04-15 13:30:01 +09:00
52ac478069 docs: 릴리즈 노트 [Unreleased] DAR-03 + 모선 검토 + 리플레이 항목 추가 2026-04-15 13:29:01 +09:00
2ee8a0e7ff feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합
- prediction: G-01/G-04/G-05/G-06 위반 분류 + 쌍끌이 공조 탐지 추가
- backend: 모선 확정/제외 API + signal-batch 항적 프록시 + ParentResolution 점수 근거 필드 확장
- frontend: 어구 탐지 그리드 다중필터/지도 flyTo, 후보 검토 패널(점수 근거+확정/제외), 24h convex hull 리플레이 + TripsLayer 애니메이션
- gitignore: 루트 .venv/ 추가
2026-04-15 13:26:15 +09:00
908b2cdafa Merge pull request 'release: 단속 계획 순찰 작전 + 감사·보안 DAR-10/DAR-11' (#40) from release/2026-04-14-enforcement-dar into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-14 16:55:56 +09:00
Nan Kyung Lee
256152f7fc feat: 시스템관리 > 감사·보안에 데이터 모델 검증(DAR-11) 메뉴 추가
- 5탭 구성: 검증 현황 / 논리 모델 검증 / 물리 모델 검증 / 중복·정합성 점검 / 검증 결과 이력
- 4단계 검증 절차 (계획 수립→논리 검증→물리 검증→결과 보고)
- 논리 모델 8항목 (완전성·정합성·정규화·표준), 물리 모델 10항목 (구조·타입·인덱스·제약·성능)
- 중복·정합성 점검 6항목 + 8개 주제영역 48테이블 매핑
- V027 마이그레이션: admin:data-model-verification 권한 트리 + ADMIN 역할 권한

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:55:05 +09:00
Nan Kyung Lee
77f39497e5 feat: 시스템관리 > 감사·보안에 데이터 보관·파기 정책(DAR-10) 메뉴 추가
- 5탭 구성: 보관 현황 / 유형별 보관기간 / 파기 절차 / 예외·연장 / 파기 감사 대장
- 6종 데이터 유형별 보관기간 기준표 (법적 근거 포함)
- 4단계 파기 승인 절차 워크플로우 (선별→신청→승인→기록)
- 보존 연장 예외 관리 (수사·소송·감사·재난 4가지 사유)
- 파기 감사 대장 (대상·일시·담당자·방식·용량 기록)
- V026 마이그레이션: admin:data-retention 권한 트리 + ADMIN 역할 권한

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:55:05 +09:00
Nan Kyung Lee
a7aaa7fc13 feat: 단속 계획에 단일 함정 순찰 작전·다함정 순찰 작전 탭 추가
- 단일 함정 순찰 작전: 4가지 작전 유형(정찰 순찰/긴급 출동/감시 초계/근접 차단), 가용 함정 현황, SOP 절차
- 다함정 순찰 작전: 4가지 작전 유형(포위 차단/광역 초계/합동 단속/호위), 역할 분담, 통신 프로토콜, SOP 절차

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:55:05 +09:00
Nan Kyung Lee
0d35807765 feat: AI 모델관리 어구 탐지 탭에 DAR-03 5종 어구 구조 비교 추가
- FAO ISSCFG 기준 5종 어구(저층트롤/쌍끌이/스토우넷/자망/통발) 특성 비교 표
- 어구별 구조 도식 5개 (이미지 + 사양 + G코드 연계)
- AIS 신호 특성 및 이상 판정 기준 비교 표
- 근거: FAO 분류 + Wang et al.(2022) 논문
- 이미지 5장 /public/dar03/ 배포
- 디자인 시스템 준수 (Card/Badge intent/시맨틱 토큰)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:54:20 +09:00
0bc8883bb8 Merge pull request 'release: 2026-04-14 (5건 커밋)' (#38) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-14 08:21:25 +09:00
359eebe200 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-14)' (#37) from release/2026-04-14 into develop 2026-04-14 08:20:48 +09:00
9076797699 docs: 릴리즈 노트 정리 (2026-04-14)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:20:30 +09:00
3d7896b4f2 Merge pull request 'feat(frontend): 탐지 결과 운영 워크플로우 UI 구축' (#36) from feature/detection-workflow-ui into develop 2026-04-14 08:15:52 +09:00
56af7690fb docs: 릴리즈 노트 [Unreleased] 탐지 워크플로우 UI 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:09:34 +09:00
d354c1ebc7 feat(frontend): 탐지 결과 운영 워크플로우 UI 구축
- DarkVesselDetection: 판정 상세 사이드 패널(점수 산출 내역 P1~P11,
  GAP 상세, 7일 이력 차트), 선박 위치 gap_start_lat/lon fallback,
  클릭 시 지도 하이라이트
- TransferDetection: 5단계 필터 기반 환적 운영 화면 재구성
  (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- GearDetection: 모선 추론 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED),
  추정 모선 MMSI, 후보 수 3개 컬럼 추가
- EnforcementPlan: CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험)
  아이콘+라벨로 "탐지 기반 단속 대상" 통합 표시
- darkVesselPatterns: prediction P1~P11 전 패턴 한국어 카탈로그 +
  buildScoreBreakdown() 점수 산출 유틸
- ScoreBreakdown: 가점/감점 분리 점수 내역 시각화 공통 컴포넌트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:56:52 +09:00
4a32cfc72e Merge pull request 'release: 2026-04-13.2 (5건 커밋)' (#35) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 14s
2026-04-13 11:51:21 +09:00
767ec4a84c Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-13.2)' (#34) from release/2026-04-13.2 into develop 2026-04-13 11:50:44 +09:00
6f68dce380 docs: 릴리즈 노트 정리 (2026-04-13.2)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:50:31 +09:00
ce01c2134e Merge pull request 'refactor(frontend): LGCNS 3개 페이지 디자인 시스템 공통 구조 전환' (#33) from feature/lgcns-design-system-align into develop 2026-04-13 11:49:11 +09:00
feb28dbb85 docs: 릴리즈 노트 [Unreleased] LGCNS 디자인 시스템 전환 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:45:42 +09:00
99d72e3622 refactor(frontend): LGCNS 3개 페이지 디자인 시스템 공통 구조 전환
- 커스텀 탭 → TabBar/TabButton 공통 컴포넌트 교체 (3개 파일)
- hex 색상 맵 → Tailwind 클래스 토큰 전환, style={{ }} 인라인 제거
- 인라인 Badge intent 삼항 → 카탈로그 함수 교체 (getAgentPermTypeIntent 등)
- 신규 카탈로그: mlopsJobStatuses (4종), aiSecurityStatuses (위협3+권한5+결과3)
- catalogRegistry에 4건 등록 → design-system.html 쇼케이스 자동 노출
- statusIntent.ts에 '허용', '위험', '관리자', '중지', '실행' 매핑 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:41:49 +09:00
f304f778ca Merge pull request 'release: 2026-04-13 (21건 커밋)' (#32) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 17s
2026-04-13 11:15:46 +09:00
a88e3c5076 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-13)' (#31) from release/2026-04-13 into develop 2026-04-13 11:15:07 +09:00
2eddd01d17 docs: 릴리즈 노트 정리 (2026-04-13)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:14:44 +09:00
90c270ac53 Merge pull request 'feat: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가' (#30) from feature/lgcns-mlops-ai-security-menu into develop 2026-04-13 11:13:02 +09:00
df75e085a7 docs: 릴리즈 노트 [Unreleased] LGCNS MLOps + AI 보안 메뉴 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:12:19 +09:00
7e37b5b680 Merge remote-tracking branch 'origin/develop' into feature/lgcns-mlops-ai-security-menu 2026-04-13 11:10:53 +09:00
45371315ba feat: prediction 알고리즘 재설계 + 프론트 CRUD 권한 가드 보완 (#29) 2026-04-13 11:08:11 +09:00
Nan Kyung Lee
1244f07de6 feat: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
- V025 마이그레이션: admin 그룹 하위 3개 메뉴 등록
  - LGCNS MLOps (AI 플랫폼, nav_sort=350)
  - AI 보안 (감사·보안, nav_sort=1800)
  - AI Agent 보안 (감사·보안, nav_sort=1900)
- 페이지 컴포넌트 3개 신규 생성
- componentRegistry, i18n(ko/en) 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:51:05 +09:00
019598ff55 Merge pull request 'release: 2026-04-09.2 워크플로우 연결 + 메뉴 DB SSOT' (#28) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 14s
2026-04-09 16:05:19 +09:00
190개의 변경된 파일19268개의 추가작업 그리고 3751개의 파일을 삭제

2
.gitignore vendored
파일 보기

@ -5,6 +5,7 @@ backend/target/
backend/build/ backend/build/
# === Python (prediction) === # === Python (prediction) ===
.venv/
prediction/.venv/ prediction/.venv/
prediction/__pycache__/ prediction/__pycache__/
prediction/**/__pycache__/ prediction/**/__pycache__/
@ -54,6 +55,7 @@ frontend/.vite/
# === 대용량/참고 문서 === # === 대용량/참고 문서 ===
*.hwpx *.hwpx
*.docx
# === Claude Code === # === Claude Code ===
!.claude/ !.claude/

248
AGENTS.md Normal file
파일 보기

@ -0,0 +1,248 @@
# KCG AI Monitoring (모노레포)
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
## 모노레포 구조
```
kcg-ai-monitoring/
├── frontend/ # React 19 + TypeScript + Vite (UI)
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
├── prediction/ # Python 3.11+ + FastAPI (AIS 분석 엔진, 5분 주기)
├── database/ # PostgreSQL 마이그레이션 참조용 README (실제 Flyway 파일은 backend/src/main/resources/db/migration/ V001~V030, 51 테이블)
│ └── migration/
├── deploy/ # 배포 가이드 + 서버 설정 문서
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
├── .gitea/ # Gitea Actions CI/CD (프론트 자동배포)
├── .Codex/ # Codex 워크플로우
├── .githooks/ # Git hooks
└── Makefile # 통합 dev/build 명령
```
## 시스템 구성
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어
```bash
make install # 전체 의존성 설치
make dev # 프론트 + 백엔드 동시 실행
make dev-all # 프론트 + 백엔드 + prediction 동시 실행
make dev-frontend # 프론트만
make dev-backend # 백엔드만
make dev-prediction # prediction 분석 엔진만 (FastAPI :8001)
make build # 전체 빌드
make lint # 프론트 lint
make format # 프론트 prettier
```
## 기술 스택
### Frontend (`frontend/`)
- React 19, TypeScript 5.9, Vite 8
- Tailwind CSS 4 + CVA
- MapLibre GL 5 + deck.gl 9 (지도)
- ECharts 6 (차트)
- Zustand 5 (상태관리)
- i18next (ko/en)
- React Router 7
- ESLint 10 + Prettier
### Prediction (`prediction/`) — 분석 엔진
- Python 3.11+, FastAPI, APScheduler
- 17개 알고리즘 모듈 (다크베셀, 스푸핑, 환적, 어구 상관·부모·정체성 충돌, 쌍끌이, 위험도, 어선 분류 등)
- 7단계 분류 파이프라인 (전처리→행동→리샘플→특징→분류→클러스터→계절)
- AIS 원본: SNPDB (5분 증분), 결과: kcgaidb (직접 write)
- prediction과 backend는 DB만 공유 (HTTP 호출 X, 단 실시간 상태 조회용 FastAPI 프록시 `/api/prediction/*` 예외)
### Backend (`backend/`)
- Spring Boot 3.x + Java 21
- Spring Security + JWT
- PostgreSQL + Flyway
- Caffeine (권한 캐싱)
- 트리 기반 RBAC (wing 패턴)
### Database (`kcgaidb`)
- PostgreSQL
- 사용자: `kcg-app`
- 스키마: `kcg`
## 배포 환경
| 서비스 | 서버 (SSH) | 포트 | 관리 |
|---|---|---|---|
| 프론트엔드 | rocky-211 | nginx 443 | Gitea Actions 자동배포 |
| 백엔드 | rocky-211 | 18080 | `systemctl restart kcg-ai-backend` |
| prediction | redis-211 | 18092 | `systemctl restart kcg-ai-prediction` |
- **URL**: https://kcg-ai-monitoring.gc-si.dev
- **배포 상세**: `deploy/README.md` 참조
- **CI/CD**: `.gitea/workflows/deploy.yml` (프론트만 자동, 백엔드/prediction 수동)
## 권한 체계
좌측 탭(메뉴) = 권한 그룹, 내부 패널/액션 = 자식 자원, CRUD 단위 개별 제어.
상세는 `.Codex/plans/vast-tinkering-knuth.md` 참조.
## 팀 컨벤션
- 팀 규칙: `.Codex/rules/`
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
## 디자인 시스템 (필수 준수)
프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다.
모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다.
### 쇼케이스 진입
- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개)
- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/`
- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사
- URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md`
- **단축키 `A`**: 다크/라이트 테마 토글
### 공통 컴포넌트 (반드시 사용)
| 컴포넌트 | 위치 | 용도 |
|---|---|---|
| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** |
| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) |
| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) |
| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant |
| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant |
| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) |
| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) |
| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 |
### 카탈로그 기반 라벨/색상
분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다.
중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다.
```tsx
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>
```
ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용.
숫자 위험도는 `getRiskIntent(0~100)` 사용.
### CSS 작성 규칙
1. **인라인 색상 금지**`style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지
- 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`)
2. **하드코딩 Tailwind 색상 금지**`bg-red-500/20 text-red-400` 같은 직접 작성 금지
- 반드시 Badge intent 또는 카탈로그 API 호출
3. **className override 정책**
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
4. **시맨틱 토큰 우선**`theme.css @layer utilities`의 토큰 사용
- `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright`
- `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background`
5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결
6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동)
### 페이지 작성 표준 템플릿
```tsx
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function MyPage() {
const { t, i18n } = useTranslation('common');
const lang = i18n.language as 'ko' | 'en';
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="페이지 제목"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
추가
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
</Section>
</PageContainer>
);
}
```
### 접근성 (a11y) 필수
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제
- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고
### 변경 사이클
1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증
2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정
3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가
4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
### 금지 패턴 체크리스트
- ❌ `<Badge className="bg-red-500/20 text-red-400">``<Badge intent="critical">`
- ❌ `<button className="bg-blue-600 ...">``<Button variant="primary">`
- ❌ `<input className="bg-surface ...">``<Input>`
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
- ❌ `!important``cn()` 활용
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
## System Flow 뷰어 (개발 단계용)
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
- **소스**: `frontend/system-flow.html` + `frontend/src/systemFlowMain.tsx` + `frontend/src/flow/`
- **매니페스트**: `frontend/src/flow/manifest/` (10개 카테고리 JSON + meta.json + edges.json)
- **노드 ID 명명**: `<category>.<snake_case>` (예: `output.event_generator`, `ui.parent_review`)
- **딥링크**: `/system-flow.html#node=<node_id>` — 산출문서에서 노드 직접 참조
- **가이드**: `docs/system-flow-guide.md` 참조
### `/version` 스킬 사후 처리 (필수)
`/version` 스킬을 실행하여 새 SemVer 버전이 결정되면, Codex는 이어서 다음 작업을 **자동으로** 수행한다 (`/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'`

파일 보기

@ -2,6 +2,45 @@
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼 해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
## 🚨 절대 지침 (Absolute Rules)
아래 두 지침은 모든 작업에 우선 적용된다. 사용자가 명시적으로 해제하지 않는 한 우회 금지.
### 1. 신규 기능 설계·구현 착수 시: 원격 develop 동기화 필수
신규 기능/버그 수정/리팩터 등 **어떤 작업이든 브랜치를 새로 만들기 전**에는 아래 절차를 반드시 수행한다.
```bash
git fetch origin --prune
# origin/develop이 로컬 develop보다 앞서 있는지 확인
git log --oneline develop..origin/develop | head
```
- **로컬 develop이 뒤처진 경우** → 사용자에게 다음을 권유하고 동의를 받은 후 진행:
> "`origin/develop`이 로컬보다 N개 커밋 앞서 있습니다. 최신화 후 신규 브랜치를 생성하는 것을 권장합니다. 진행할까요?"
승인 시: `git checkout develop && git pull --ff-only origin develop` → 그 위에서 `git checkout -b <new-branch>`
- **로컬 develop이 최신인 경우** → 그대로 develop에서 신규 브랜치 분기
- **로컬 develop이 없는 경우**`git checkout -b develop origin/develop`로 tracking branch 먼저 생성
- **로컬에 unstaged/uncommitted 변경이 있을 때** → 사용자에게 먼저 알리고 stash/commit 여부 확인 후 진행. 임의로 폐기 금지.
**이유**: 오래된 develop 위에서 작업하면 머지 충돌·리베이스 비용이 커지고, 이미 해결된 이슈를 중복 해결할 위험이 있다. 브랜치 분기 시점의 기반을 항상 최신으로 맞춘다.
**적용 범위**: `/push`, `/mr`, `/create-mr`, `/release`, `/fix-issue` 스킬 실행 시, 그리고 Claude가 자발적으로 새 브랜치를 만들 때 모두.
### 2. 프론트엔드 개발 시: `design-system.html` 쇼케이스 규칙 전면 준수
`frontend/` 하위의 모든 페이지·컴포넌트·스타일 작성은 `design-system.html`(쇼케이스)에 정의된 컴포넌트·토큰·카탈로그만 사용한다. 이 문서 하단 **"디자인 시스템 (필수 준수)"** 섹션의 규칙을 **예외 없이** 따른다.
핵심 요약 (상세는 하단 섹션 참조):
- 공통 컴포넌트 우선 사용: `Badge`, `Button`, `Input`, `Select`, `TabBar`, `Card`, `PageContainer`, `PageHeader`, `Section`
- 라벨/색상은 `shared/constants/` 카탈로그 API(`getAlertLevelIntent` 등) 경유, ad-hoc 문자열 매핑 금지
- **인라인 색상·하드코딩 Tailwind 색상·`!important` 전면 금지**
- 접근성: `<button type="button">`, 아이콘 전용은 `aria-label`, 폼 요소는 `aria-label`/`<label>` 필수
위반 시 리뷰 단계에서 반려 대상. 신규 페이지는 하단의 **"페이지 작성 표준 템플릿"** 을 시작점으로 사용한다.
---
## 모노레포 구조 ## 모노레포 구조
``` ```
@ -24,14 +63,14 @@ kcg-ai-monitoring/
``` ```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb] [Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write ↑ write
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장) [Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택) [SNPDB PostgreSQL] (AIS 원본)
``` ```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) - **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations) - **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사 - **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어 ## 명령어

파일 보기

@ -51,6 +51,7 @@ src/
| [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 | | [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 |
| [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 | | [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 |
| [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) | | [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) |
| [docs/prediction-analysis.md](docs/prediction-analysis.md) | Prediction 모듈 구조/방향 심층 분석 (2026-04-17, opus 4.7 독립 리뷰) |
## SFR 요구사항 대응 현황 ## SFR 요구사항 대응 현황
@ -67,7 +68,7 @@ src/
| SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 | | SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 |
| SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 | | SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 |
| SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 | | SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 |
| SFR-10 | 어구 탐지 | `/gear-detection` | UI 완료 | | SFR-10 | 어구 탐지 | `/gear-detection`, `/gear-collision`(V030) | UI 완료 |
| SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 | | SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 |
| SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 | | SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 |
| SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 | | SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 |

파일 보기

@ -1,18 +1,88 @@
# Backend (Spring Boot) # Backend (Spring Boot)
Phase 2에서 초기화 예정. 운영 배포 중 — rocky-211 `:18080` (`kcg-ai-backend` systemd).
## 계획된 구성 ## 구성
- Spring Boot 3.x + Java 21
- PostgreSQL + Flyway - **Runtime**: Spring Boot 3.5.7 + Java 21
- Spring Security + JWT - **DB**: PostgreSQL (kcgaidb) + Flyway V001~V030 (51 테이블)
- Caffeine 캐시 - **Auth**: Spring Security + JWT 쿠키 + BCrypt
- 트리 기반 RBAC 권한 체계 (wing 패턴) - **Cache**: Caffeine (권한 트리 10분 TTL)
- **Permission**: 트리 기반 RBAC (47 리소스 × 5 operation)
- **HTTP client**: `RestClient` + 명시적 `@Bean` 주입 (`predictionRestClient`, `signalBatchRestClient`)
## 책임 ## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- iran 백엔드 분석 데이터 프록시
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md` - 자체 인증/권한/감사 로그 + 관리자 API
- 운영자 의사결정 (모선 확정·제외·학습, 어구 정체성 충돌 분류)
- prediction 분석 결과 조회 (`/api/analysis/*`) + 이벤트 허브 (`/api/events`, `/api/alerts`)
- prediction 실시간 상태 프록시 (`/api/prediction/*`)
## 빌드 · 배포
```bash
# 로컬 실행
./mvnw spring-boot:run
# 프로덕션 빌드
./mvnw clean package -DskipTests
# → target/kcg-ai-backend-*.jar
# 운영 배포 (수동)
scp target/kcg-ai-backend-*.jar rocky-211:/opt/kcg-ai-backend/
ssh rocky-211 "sudo systemctl restart kcg-ai-backend"
```
## 필수 컴파일 설정 (PR #79 hotfix)
Spring 6.1 의 parameter-level `@Qualifier` 주입이 동작하려면 두 가지가 **필수**:
1. **`pom.xml`** — `maven-compiler-plugin``default-compile``default-testCompile` 양쪽 execution 에
`<parameters>true</parameters>` 설정. 파라미터 이름을 바이트코드에 보존해야 `@Qualifier` resolve 가 가능.
2. **`src/main/java/lombok.config`** — `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier`
설정. Lombok `@RequiredArgsConstructor` 가 필드의 `@Qualifier` 를 생성자 파라미터에 복사해야 Spring 이 인식.
둘 중 하나라도 누락되면 `PredictionProxyController` 같은 다중 `RestClient` bean 주입 컨트롤러가 기동 시점에
`NoUniqueBeanDefinitionException` 으로 크래시 루프에 빠진다. 로컬에서 `./mvnw spring-boot:run` 실패는
운영 restart 시 동일하게 재현되므로 **MR 범위 밖이어도 우선 해결**.
## 주요 패키지 구조
```
src/main/java/gc/mda/kcg/
├── config/ # RestClientConfig, SecurityConfig, OpenApi, CorsConfig, CaffeineConfig 등
├── auth/ # LoginController, JWT, 비밀번호 정책
├── permission/ # 트리 RBAC, @RequirePermission AOP, 캐시
├── audit/ # @Auditable AOP, AuditLogService, AccessLogFilter
├── domain/
│ ├── analysis/ # VesselAnalysisController, GearCollisionController (V030), PredictionProxyController 등
│ ├── fleet/ # ParentInferenceWorkflowController, FleetService
│ ├── event/ # EventController, AlertController
│ ├── enforcement/ # EnforcementController, EnforcementPlanController
│ ├── master/ # MasterDataController (CodeMaster, GearTypeMaster, VesselPermit 등)
│ ├── admin/ # AdminLogController, AdminStatsController, UserManagementController
│ └── stats/ # StatsController (KPI 집계)
└── Application.java
```
모든 컨트롤러는 `controller → service → repository` 계층을 준수하며, 쓰기 액션은
`@RequirePermission` + `@Auditable` 로 권한·감사 일관 적용.
## Flyway 마이그레이션
- 경로: [src/main/resources/db/migration/V001__*.sql ~ V030__gear_identity_collision.sql](src/main/resources/db/migration/)
- 최신: **V030** (2026-04-17) — `gear_identity_collisions` 테이블 + `detection:gear-collision` 권한 트리
- Flyway 자동 적용: Spring Boot 기동 시점
## 디렉토리 밖 의존성
- **prediction → kcgaidb**: prediction 이 직접 write. backend 는 HTTP 호출 없이 DB 조회로만 연동
- **signal-batch**: `http://192.168.1.18:18090/signal-batch` (정적정보 보강, `signalBatchRestClient` 주입)
- **prediction (FastAPI)**: `http://redis-211:18092` (실시간 상태/채팅 프록시 전용)
## 운영 체크리스트
1. 빌드 성공 → local `./mvnw spring-boot:run` 기동 확인 → curl `/api/auth/me` 200 확인
2. scp 배포 → `systemctl restart kcg-ai-backend``journalctl -u kcg-ai-backend -n 100`
3. 응답 확인: `curl -k https://kcg-ai-monitoring.gc-si.dev/api/analysis/vessels?hours=1`
4. Flyway 에러 시: `backend.application` 로그에서 `Migration ... failed` 확인

파일 보기

@ -142,6 +142,7 @@
<goal>compile</goal> <goal>compile</goal>
</goals> </goals>
<configuration> <configuration>
<parameters>true</parameters>
<annotationProcessorPaths> <annotationProcessorPaths>
<path> <path>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@ -161,6 +162,7 @@
<goal>testCompile</goal> <goal>testCompile</goal>
</goals> </goals>
<configuration> <configuration>
<parameters>true</parameters>
<annotationProcessorPaths> <annotationProcessorPaths>
<path> <path>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

파일 보기

@ -1,17 +1,11 @@
package gc.mda.kcg.admin; package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission; import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -28,127 +22,23 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminStatsController { public class AdminStatsController {
private final AuditLogRepository auditLogRepository; private final AdminStatsService adminStatsService;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit") @GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ") @RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() { public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>(); return adminStatsService.auditStats();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
} }
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access") @GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ") @RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() { public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>(); return adminStatsService.accessStats();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
} }
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login") @GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ") @RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() { public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>(); return adminStatsService.loginStats();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
} }
} }

파일 보기

@ -0,0 +1,124 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 서비스.
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminStatsService {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
*/
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
*/
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
*/
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
public class AppProperties { public class AppProperties {
private Prediction prediction = new Prediction(); private Prediction prediction = new Prediction();
private IranBackend iranBackend = new IranBackend(); private SignalBatch signalBatch = new SignalBatch();
private Cors cors = new Cors(); private Cors cors = new Cors();
private Jwt jwt = new Jwt(); private Jwt jwt = new Jwt();
@ -22,7 +22,7 @@ public class AppProperties {
} }
@Getter @Setter @Getter @Setter
public static class IranBackend { public static class SignalBatch {
private String baseUrl; private String baseUrl;
} }

파일 보기

@ -0,0 +1,49 @@
package gc.mda.kcg.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* 외부 서비스용 RestClient Bean 정의.
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 일원화한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestClientConfig {
private final AppProperties appProperties;
/**
* prediction FastAPI 서비스 호출용.
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
*/
@Bean
public RestClient predictionRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getPrediction().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
log.info("predictionRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
/**
* signal-batch 선박 항적 서비스 호출용.
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
*/
@Bean
public RestClient signalBatchRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
}

파일 보기

@ -0,0 +1,27 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* vessel_analysis_results 집계 응답.
* MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다.
*/
public record AnalysisStatsResponse(
long total,
long darkCount,
long spoofingCount,
long transshipCount,
long criticalCount,
long highCount,
long mediumCount,
long lowCount,
long territorialCount,
long contiguousCount,
long eezCount,
long fishingCount,
BigDecimal avgRiskScore,
OffsetDateTime windowStart,
OffsetDateTime windowEnd
) {
}

파일 보기

@ -0,0 +1,64 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
*
* 경로: /api/analysis/gear-collisions
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
* - GET /stats status/severity 집계
* - GET /{id} 단건 상세
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
*/
@RestController
@RequestMapping("/api/analysis/gear-collisions")
@RequiredArgsConstructor
public class GearCollisionController {
private static final String RESOURCE = "detection:gear-collision";
private final GearIdentityCollisionService service;
@GetMapping
@RequirePermission(resource = RESOURCE, operation = "READ")
public Page<GearCollisionResponse> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String name,
@RequestParam(defaultValue = "48") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.list(status, severity, name, hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
.map(GearCollisionResponse::from);
}
@GetMapping("/stats")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
return service.stats(hours);
}
@GetMapping("/{id}")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionResponse get(@PathVariable Long id) {
return GearCollisionResponse.from(service.get(id));
}
@PostMapping("/{id}/resolve")
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
public GearCollisionResponse resolve(
@PathVariable Long id,
@Valid @RequestBody GearCollisionResolveRequest req
) {
return GearCollisionResponse.from(service.resolve(id, req));
}
}

파일 보기

@ -0,0 +1,18 @@
package gc.mda.kcg.domain.analysis;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* gear_identity_collisions 분류(해결) 액션 요청.
*
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
* note : 선택 (운영자 메모)
*/
public record GearCollisionResolveRequest(
@NotBlank
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
String action,
String note
) {
}

파일 보기

@ -0,0 +1,57 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* gear_identity_collisions 조회 응답 DTO.
*/
public record GearCollisionResponse(
Long id,
String name,
String mmsiLo,
String mmsiHi,
String parentName,
Long parentVesselId,
OffsetDateTime firstSeenAt,
OffsetDateTime lastSeenAt,
Integer coexistenceCount,
Integer swapCount,
BigDecimal maxDistanceKm,
BigDecimal lastLatLo,
BigDecimal lastLonLo,
BigDecimal lastLatHi,
BigDecimal lastLonHi,
String severity,
String status,
String resolutionNote,
List<Map<String, Object>> evidence,
OffsetDateTime updatedAt
) {
public static GearCollisionResponse from(GearIdentityCollision e) {
return new GearCollisionResponse(
e.getId(),
e.getName(),
e.getMmsiLo(),
e.getMmsiHi(),
e.getParentName(),
e.getParentVesselId(),
e.getFirstSeenAt(),
e.getLastSeenAt(),
e.getCoexistenceCount(),
e.getSwapCount(),
e.getMaxDistanceKm(),
e.getLastLatLo(),
e.getLastLonLo(),
e.getLastLatHi(),
e.getLastLonHi(),
e.getSeverity(),
e.getStatus(),
e.getResolutionNote(),
e.getEvidence(),
e.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,14 @@
package gc.mda.kcg.domain.analysis;
import java.util.Map;
/**
* gear_identity_collisions status/severity 집계 응답.
*/
public record GearCollisionStatsResponse(
long total,
Map<String, Long> byStatus,
Map<String, Long> bySeverity,
int hours
) {
}

파일 보기

@ -0,0 +1,38 @@
package gc.mda.kcg.domain.analysis;
import java.time.OffsetDateTime;
import java.util.List;
/**
* prediction 자동 어구 탐지 결과 응답 DTO.
* gear_code / gear_judgment NOT NULL row의 핵심 필드만 노출.
*/
public record GearDetectionResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
String vesselType,
String gearCode,
String gearJudgment,
String permitStatus,
String riskLevel,
Integer riskScore,
String zoneCode,
List<String> violationCategories
) {
public static GearDetectionResponse from(VesselAnalysisResult e) {
return new GearDetectionResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
e.getVesselType(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getRiskLevel(),
e.getRiskScore(),
e.getZoneCode(),
e.getViolationCategories()
);
}
}

파일 보기

@ -0,0 +1,99 @@
package gc.mda.kcg.domain.analysis;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
*
* 동일 어구 이름이 서로 다른 MMSI 같은 cycle 동시 송출되는 공존 이력.
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 운영자 분류(status) 갱신.
*/
@Entity
@Table(name = "gear_identity_collisions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class GearIdentityCollision {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "mmsi_lo", nullable = false, length = 20)
private String mmsiLo;
@Column(name = "mmsi_hi", nullable = false, length = 20)
private String mmsiHi;
@Column(name = "parent_name", length = 100)
private String parentName;
@Column(name = "parent_vessel_id")
private Long parentVesselId;
@Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt;
@Column(name = "last_seen_at", nullable = false)
private OffsetDateTime lastSeenAt;
@Column(name = "coexistence_count", nullable = false)
private Integer coexistenceCount;
@Column(name = "swap_count", nullable = false)
private Integer swapCount;
@Column(name = "max_distance_km", precision = 8, scale = 2)
private BigDecimal maxDistanceKm;
@Column(name = "last_lat_lo", precision = 9, scale = 6)
private BigDecimal lastLatLo;
@Column(name = "last_lon_lo", precision = 10, scale = 6)
private BigDecimal lastLonLo;
@Column(name = "last_lat_hi", precision = 9, scale = 6)
private BigDecimal lastLatHi;
@Column(name = "last_lon_hi", precision = 10, scale = 6)
private BigDecimal lastLonHi;
@Column(name = "severity", nullable = false, length = 20)
private String severity;
@Column(name = "status", nullable = false, length = 30)
private String status;
@Column(name = "resolved_by")
private UUID resolvedBy;
@Column(name = "resolved_at")
private OffsetDateTime resolvedAt;
@Column(name = "resolution_note", columnDefinition = "text")
private String resolutionNote;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "evidence", columnDefinition = "jsonb")
private List<Map<String, Object>> evidence;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
}

파일 보기

@ -0,0 +1,41 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import java.time.OffsetDateTime;
import java.util.List;
public interface GearIdentityCollisionRepository
extends JpaRepository<GearIdentityCollision, Long>,
JpaSpecificationExecutor<GearIdentityCollision> {
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
OffsetDateTime after, Pageable pageable);
/**
* status 카운트 집계 (hours 윈도우).
* 반환: [{status, count}, ...] Object[] {String status, Long count}
*/
@Query("""
SELECT g.status AS status, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.status
""")
List<Object[]> countByStatus(OffsetDateTime after);
/**
* severity 카운트 집계 (hours 윈도우).
*/
@Query("""
SELECT g.severity AS severity, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.severity
""")
List<Object[]> countBySeverity(OffsetDateTime after);
}

파일 보기

@ -0,0 +1,133 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
*
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable}
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GearIdentityCollisionService {
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
private final GearIdentityCollisionRepository repository;
@Transactional(readOnly = true)
public Page<GearIdentityCollision> list(
String status,
String severity,
String name,
int hours,
Pageable pageable
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
List<Predicate> preds = new ArrayList<>();
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
if (status != null && !status.isBlank()) {
preds.add(cb.equal(root.get("status"), status));
}
if (severity != null && !severity.isBlank()) {
preds.add(cb.equal(root.get("severity"), severity));
}
if (name != null && !name.isBlank()) {
preds.add(cb.like(root.get("name"), "%" + name + "%"));
}
return cb.and(preds.toArray(new Predicate[0]));
};
return repository.findAll(spec, pageable);
}
@Transactional(readOnly = true)
public GearIdentityCollision get(Long id) {
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
}
@Transactional(readOnly = true)
public GearCollisionStatsResponse stats(int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Map<String, Long> byStatus = new HashMap<>();
long total = 0;
for (Object[] row : repository.countByStatus(after)) {
String s = (String) row[0];
long c = ((Number) row[1]).longValue();
byStatus.put(s, c);
total += c;
}
Map<String, Long> bySeverity = new HashMap<>();
for (Object[] row : repository.countBySeverity(after)) {
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
}
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
}
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
@Transactional
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
GearIdentityCollision row = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
AuthPrincipal principal = currentPrincipal();
OffsetDateTime now = OffsetDateTime.now();
switch (req.action().toUpperCase()) {
case "REVIEWED" -> {
row.setStatus("REVIEWED");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "CONFIRMED_ILLEGAL" -> {
row.setStatus("CONFIRMED_ILLEGAL");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "FALSE_POSITIVE" -> {
row.setStatus("FALSE_POSITIVE");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "REOPEN" -> {
row.setStatus("OPEN");
row.setResolvedBy(null);
row.setResolvedAt(null);
row.setResolutionNote(req.note());
}
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
}
row.setUpdatedAt(now);
return repository.save(row);
}
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
return p;
}
return null;
}
}

파일 보기

@ -1,70 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map;
/**
* iran 백엔드 REST 클라이언트.
*
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
* 호출 실패 graceful degradation: null 반환 프론트에 응답.
*
* 향후 prediction 이관 IranBackendClient를 PredictionDirectClient로 교체하면 .
*/
@Slf4j
@Component
public class IranBackendClient {
private final RestClient restClient;
private final boolean enabled;
public IranBackendClient(AppProperties appProperties) {
String baseUrl = appProperties.getIranBackend().getBaseUrl();
this.enabled = baseUrl != null && !baseUrl.isBlank();
this.restClient = enabled
? RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.build()
: RestClient.create();
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
}
public boolean isEnabled() {
return enabled;
}
/**
* GET 호출 (Map 반환). 실패 null 반환.
*/
public Map<String, Object> getJson(String path) {
if (!enabled) return null;
try {
@SuppressWarnings("unchecked")
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
return body;
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
/**
* 임의 타입 GET 호출.
*/
public <T> T getAs(String path, Class<T> responseType) {
if (!enabled) return null;
try {
return restClient.get().uri(path).retrieve().body(responseType);
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
}

파일 보기

@ -2,66 +2,129 @@ package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission; import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.client.RestClient;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.client.RestClientException;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
/** /**
* Prediction (Python FastAPI) 서비스 프록시. * Prediction FastAPI 서비스 프록시.
* 현재는 stub - Phase 5에서 연결. * 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
*
* 엔드포인트:
* GET /api/prediction/health FastAPI /health
* GET /api/prediction/status FastAPI /api/v1/analysis/status
* POST /api/prediction/trigger FastAPI /api/v1/analysis/trigger
* POST /api/prediction/chat stub (Phase 9)
* GET /api/prediction/groups/{key}/history FastAPI /api/v1/groups/{key}/history?hours=
* GET /api/prediction/correlation/{key}/tracks FastAPI /api/v1/correlation/{key}/tracks?hours=&min_score=
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/api/prediction") @RequestMapping("/api/prediction")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PredictionProxyController { public class PredictionProxyController {
private final IranBackendClient iranClient; @Qualifier("predictionRestClient")
private final RestClient predictionClient;
@GetMapping("/health") @GetMapping("/health")
public ResponseEntity<?> health() { public ResponseEntity<?> health() {
Map<String, Object> data = iranClient.getJson("/api/prediction/health"); return proxyGet("/health", Map.of(
if (data == null) {
return ResponseEntity.ok(Map.of(
"status", "DISCONNECTED", "status", "DISCONNECTED",
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)" "message", "Prediction 서비스 미연결"
)); ));
} }
return ResponseEntity.ok(data);
}
@GetMapping("/status") @GetMapping("/status")
@RequirePermission(resource = "monitoring", operation = "READ") @RequirePermission(resource = "monitoring", operation = "READ")
public ResponseEntity<?> status() { public ResponseEntity<?> status() {
Map<String, Object> data = iranClient.getJson("/api/prediction/status"); return proxyGet("/api/v1/analysis/status", Map.of("status", "DISCONNECTED"));
if (data == null) {
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
}
return ResponseEntity.ok(data);
} }
@PostMapping("/trigger") @PostMapping("/trigger")
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE") @RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
public ResponseEntity<?> trigger() { public ResponseEntity<?> trigger() {
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결")); return proxyPost("/api/v1/analysis/trigger", null,
Map.of("ok", false, "message", "Prediction 서비스 미연결"));
} }
/** /**
* AI 채팅 프록시 (POST). * AI 채팅 프록시 (POST) Phase 9에서 연결.
* 향후 prediction 인증 통과 SSE 스트리밍으로 전환.
*/ */
@PostMapping("/chat") @PostMapping("/chat")
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ") @RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) { public ResponseEntity<?> chat(@RequestBody Map<String, Object> body) {
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"ok", false, "ok", false,
"serviceAvailable", false, "serviceAvailable", false,
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "") "message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: "
+ body.getOrDefault("message", "")
)); ));
} }
/**
* 그룹 스냅샷 이력 (FastAPI 위임).
*/
@GetMapping("/groups/{groupKey}/history")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> groupHistory(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours
) {
return proxyGet("/api/v1/groups/" + groupKey + "/history?hours=" + hours,
Map.of("serviceAvailable", false, "groupKey", groupKey));
}
/**
* 상관관계 궤적 (FastAPI 위임).
*/
@GetMapping("/correlation/{groupKey}/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> correlationTracks(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours,
@RequestParam(name = "min_score", required = false) Double minScore
) {
String path = "/api/v1/correlation/" + groupKey + "/tracks?hours=" + hours;
if (minScore != null) path += "&min_score=" + minScore;
return proxyGet(path, Map.of("serviceAvailable", false, "groupKey", groupKey));
}
// 내부 헬퍼
@SuppressWarnings("unchecked")
private ResponseEntity<?> proxyGet(String path, Map<String, Object> fallback) {
try {
Map<String, Object> body = predictionClient.get()
.uri(path)
.retrieve()
.body(Map.class);
return ResponseEntity.ok(body != null ? body : fallback);
} catch (RestClientException e) {
log.debug("Prediction 호출 실패 GET {}: {}", path, e.getMessage());
return ResponseEntity.ok(fallback);
}
}
@SuppressWarnings("unchecked")
private ResponseEntity<?> proxyPost(String path, Object requestBody, Map<String, Object> fallback) {
try {
var spec = predictionClient.post().uri(path);
Map<String, Object> body;
if (requestBody != null) {
body = spec.body(requestBody).retrieve().body(Map.class);
} else {
body = spec.retrieve().body(Map.class);
}
return ResponseEntity.ok(body != null ? body : fallback);
} catch (RestClientException e) {
log.debug("Prediction 호출 실패 POST {}: {}", path, e.getMessage());
return ResponseEntity.ok(fallback);
}
}
} }

파일 보기

@ -7,13 +7,13 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
/** /**
* vessel_analysis_results 직접 조회 API. * vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공. * prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
*/ */
@RestController @RestController
@RequestMapping("/api/analysis") @RequestMapping("/api/analysis")
@ -33,17 +33,52 @@ public class VesselAnalysisController {
@RequestParam(required = false) String zoneCode, @RequestParam(required = false) String zoneCode,
@RequestParam(required = false) String riskLevel, @RequestParam(required = false) String riskLevel,
@RequestParam(required = false) Boolean isDark, @RequestParam(required = false) Boolean isDark,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(required = false) Integer minRiskScore,
@RequestParam(required = false) BigDecimal minFishingPct,
@RequestParam(defaultValue = "1") int hours, @RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size @RequestParam(defaultValue = "50") int size
) { ) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours); OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return service.getAnalysisResults( return service.getAnalysisResults(
mmsi, zoneCode, riskLevel, isDark, after, mmsi, zoneCode, riskLevel, isDark,
mmsiPrefix, minRiskScore, minFishingPct, after,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt")) PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(VesselAnalysisResponse::from); ).map(VesselAnalysisResponse::from);
} }
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* - hours: 윈도우 (기본 1시간)
* - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택)
*/
@GetMapping("/stats")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public AnalysisStatsResponse getStats(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix
) {
return service.getStats(hours, mmsiPrefix);
}
/**
* prediction 자동 어구 탐지 결과 목록.
* gear_code/gear_judgment NOT NULL row만 MMSI 중복 제거 반환.
*/
@GetMapping("/gear-detections")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<GearDetectionResponse> listGearDetections(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getGearDetections(hours, mmsiPrefix,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(GearDetectionResponse::from);
}
/** /**
* 특정 선박 최신 분석 결과 (features 포함). * 특정 선박 최신 분석 결과 (features 포함).
*/ */

파일 보기

@ -0,0 +1,373 @@
package gc.mda.kcg.domain.analysis;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 그룹/상관관계 직접 DB 조회 서비스.
* kcg.group_polygon_snapshots, kcg.gear_correlation_scores,
* kcg.correlation_param_models JdbcTemplate으로 직접 쿼리.
* ParentResolution 합성은 JPA를 통해 수행.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselAnalysisGroupService {
private final JdbcTemplate jdbc;
private final ParentResolutionRepository parentResolutionRepo;
private final ObjectMapper objectMapper;
/**
* 그룹 목록 (최신 스냅샷 per group_key+sub_cluster_id) + parentResolution 합성.
* 목록에서는 polygon/members를 제외하여 응답 크기를 최소화.
* polygon·members는 detail API에서만 반환.
*
* @param groupType null이면 전체, "GEAR" GEAR_IN_ZONE+GEAR_OUT_ZONE만
*/
public Map<String, Object> getGroups(String groupType) {
List<Map<String, Object>> rows;
// LATERAL JOIN으로 최신 스냅샷만 빠르게 조회 (DISTINCT ON 대비 60x 개선)
String typeFilter = "GEAR".equals(groupType)
? "AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')"
: "";
String sql = """
SELECT g.*
FROM (
SELECT DISTINCT group_key, sub_cluster_id
FROM kcg.group_polygon_snapshots
WHERE snapshot_time > NOW() - INTERVAL '1 hour'
%s
) keys
JOIN LATERAL (
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members::text, color
FROM kcg.group_polygon_snapshots
WHERE group_key = keys.group_key AND sub_cluster_id = keys.sub_cluster_id
ORDER BY snapshot_time DESC LIMIT 1
) g ON true
""".formatted(typeFilter);
try {
rows = jdbc.queryForList(sql);
} catch (Exception e) {
log.warn("group_polygon_snapshots 조회 실패: {}", e.getMessage());
return Map.of("serviceAvailable", false, "items", List.of(), "count", 0);
}
// parentResolution 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : parentResolutionRepo.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// correlation_scores 실시간 최고 점수 + 후보 일괄 조회
Map<String, Object[]> corrTopByGroup = new HashMap<>();
try {
List<Map<String, Object>> corrRows = jdbc.queryForList("""
SELECT group_key, sub_cluster_id,
MAX(current_score) AS max_score,
COUNT(*) FILTER (WHERE current_score > 0.3) AS candidate_count
FROM kcg.gear_correlation_scores
WHERE model_id = (SELECT id FROM kcg.correlation_param_models WHERE is_default = true LIMIT 1)
AND freeze_state = 'ACTIVE'
GROUP BY group_key, sub_cluster_id
""");
for (Map<String, Object> cr : corrRows) {
String ck = cr.get("group_key") + "::" + cr.get("sub_cluster_id");
corrTopByGroup.put(ck, new Object[]{cr.get("max_score"), cr.get("candidate_count")});
}
} catch (Exception e) {
log.debug("correlation top score 조회 실패: {}", e.getMessage());
}
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = buildGroupItem(row);
String groupKey = String.valueOf(row.get("group_key"));
Object subRaw = row.get("sub_cluster_id");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
String compositeKey = groupKey + "::" + sub;
ParentResolution res = resolutionByKey.get(compositeKey);
// 실시간 최고 점수 (correlation_scores)
Object[] corrTop = corrTopByGroup.get(compositeKey);
if (corrTop != null) {
item.put("liveTopScore", corrTop[0]);
item.put("candidateCount", corrTop[1]);
}
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("selectedParentName", res.getSelectedParentName());
resolution.put("topScore", res.getTopScore());
resolution.put("confidence", res.getConfidence());
resolution.put("secondScore", res.getSecondScore());
resolution.put("scoreMargin", res.getScoreMargin());
resolution.put("decisionSource", res.getDecisionSource());
resolution.put("stableCycles", res.getStableCycles());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
items.add(item);
}
return Map.of("serviceAvailable", true, "count", items.size(), "items", items);
}
/**
* 단일 그룹 상세 (최신 스냅샷 + 24시간 이력).
*/
public Map<String, Object> getGroupDetail(String groupKey) {
String latestSql = """
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members::text, color
FROM kcg.group_polygon_snapshots
WHERE group_key = ?
ORDER BY snapshot_time DESC
LIMIT 1
""";
String historySql = """
SELECT snapshot_time,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
member_count,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
members::text, sub_cluster_id, area_sq_nm
FROM kcg.group_polygon_snapshots
WHERE group_key = ? AND snapshot_time > NOW() - INTERVAL '24 hours'
ORDER BY snapshot_time ASC
""";
try {
List<Map<String, Object>> latestRows = jdbc.queryForList(latestSql, groupKey);
if (latestRows.isEmpty()) {
return Map.of("serviceAvailable", false, "groupKey", groupKey,
"message", "그룹을 찾을 수 없습니다.");
}
Map<String, Object> latest = buildGroupItem(latestRows.get(0));
List<Map<String, Object>> historyRows = jdbc.queryForList(historySql, groupKey);
List<Map<String, Object>> history = new ArrayList<>();
for (Map<String, Object> row : historyRows) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("snapshotTime", row.get("snapshot_time"));
entry.put("centerLat", row.get("center_lat"));
entry.put("centerLon", row.get("center_lon"));
entry.put("memberCount", row.get("member_count"));
entry.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
entry.put("members", parseJsonArray((String) row.get("members")));
entry.put("subClusterId", row.get("sub_cluster_id"));
entry.put("areaSqNm", row.get("area_sq_nm"));
history.add(entry);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("groupKey", groupKey);
result.put("latest", latest);
result.put("history", history);
return result;
} catch (Exception e) {
log.warn("getGroupDetail 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
return Map.of("serviceAvailable", false, "groupKey", groupKey);
}
}
/**
* 그룹별 상관관계 점수 목록 (활성 모델 기준).
*/
public Map<String, Object> getGroupCorrelations(String groupKey, Double minScore) {
String sql = """
SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score AS score, s.streak_count AS streak,
s.observation_count AS observations, s.freeze_state,
0 AS shadow_bonus, s.sub_cluster_id,
s.proximity_ratio, s.visit_score, s.heading_coherence,
m.id AS model_id, m.name AS model_name, m.is_default
FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
WHERE s.group_key = ? AND m.is_active = true
AND (? IS NULL OR s.current_score >= ?)
ORDER BY s.current_score DESC
""";
try {
List<Map<String, Object>> rows = jdbc.queryForList(
sql, groupKey, minScore, minScore);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("targetMmsi", row.get("target_mmsi"));
item.put("targetType", row.get("target_type"));
item.put("targetName", row.get("target_name"));
item.put("score", row.get("score"));
item.put("streak", row.get("streak"));
item.put("observations", row.get("observations"));
item.put("freezeState", row.get("freeze_state"));
item.put("subClusterId", row.get("sub_cluster_id"));
item.put("proximityRatio", row.get("proximity_ratio"));
item.put("visitScore", row.get("visit_score"));
item.put("headingCoherence", row.get("heading_coherence"));
item.put("modelId", row.get("model_id"));
item.put("modelName", row.get("model_name"));
item.put("isDefault", row.get("is_default"));
items.add(item);
}
return Map.of("groupKey", groupKey, "count", items.size(), "items", items);
} catch (Exception e) {
log.warn("getGroupCorrelations 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
return Map.of("serviceAvailable", false, "groupKey", groupKey, "items", List.of(), "count", 0);
}
}
/**
* 후보 상세 raw metrics (최근 N건).
*/
public Map<String, Object> getCandidateMetrics(String groupKey, String targetMmsi) {
String sql = """
SELECT observed_at, proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence, drift_similarity,
shadow_stay, shadow_return, gear_group_active_ratio
FROM kcg.gear_correlation_raw_metrics
WHERE group_key = ? AND target_mmsi = ?
ORDER BY observed_at DESC LIMIT 20
""";
try {
List<Map<String, Object>> rows = jdbc.queryForList(sql, groupKey, targetMmsi);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("observedAt", row.get("observed_at"));
item.put("proximityRatio", row.get("proximity_ratio"));
item.put("visitScore", row.get("visit_score"));
item.put("activitySync", row.get("activity_sync"));
item.put("dtwSimilarity", row.get("dtw_similarity"));
item.put("speedCorrelation", row.get("speed_correlation"));
item.put("headingCoherence", row.get("heading_coherence"));
item.put("driftSimilarity", row.get("drift_similarity"));
item.put("shadowStay", row.get("shadow_stay"));
item.put("shadowReturn", row.get("shadow_return"));
item.put("gearGroupActiveRatio", row.get("gear_group_active_ratio"));
items.add(item);
}
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "count", items.size(), "items", items);
} catch (Exception e) {
log.warn("getCandidateMetrics 실패: {}", e.getMessage());
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "items", List.of(), "count", 0);
}
}
/**
* 모선 확정/제외 처리.
*/
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT id, sub_cluster_id FROM kcg.gear_group_parent_resolution WHERE group_key = ? LIMIT 1",
groupKey);
if (existing.isEmpty()) {
return Map.of("ok", false, "message", "resolution을 찾을 수 없습니다.");
}
Long id = ((Number) existing.get(0).get("id")).longValue();
if ("confirm".equals(action)) {
jdbc.update("""
UPDATE kcg.gear_group_parent_resolution
SET status = 'MANUAL_CONFIRMED', selected_parent_mmsi = ?,
manual_comment = ?, approved_at = NOW(), updated_at = NOW()
WHERE id = ?
""", targetMmsi, comment, id);
} else if ("reject".equals(action)) {
jdbc.update("""
UPDATE kcg.gear_group_parent_resolution
SET rejected_candidate_mmsi = ?, manual_comment = ?,
rejected_at = NOW(), updated_at = NOW()
WHERE id = ?
""", targetMmsi, comment, id);
} else {
return Map.of("ok", false, "message", "알 수 없는 액션: " + action);
}
return Map.of("ok", true, "action", action, "groupKey", groupKey, "targetMmsi", targetMmsi);
} catch (Exception e) {
log.warn("resolveParent 실패: {}", e.getMessage());
return Map.of("ok", false, "message", e.getMessage());
}
}
// 내부 헬퍼
private Map<String, Object> buildGroupItem(Map<String, Object> row) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("groupType", row.get("group_type"));
item.put("groupKey", row.get("group_key"));
item.put("groupLabel", row.get("group_label"));
item.put("subClusterId", row.get("sub_cluster_id"));
item.put("snapshotTime", row.get("snapshot_time"));
item.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
item.put("centerLat", row.get("center_lat"));
item.put("centerLon", row.get("center_lon"));
item.put("areaSqNm", row.get("area_sq_nm"));
item.put("memberCount", row.get("member_count"));
item.put("zoneId", row.get("zone_id"));
item.put("zoneName", row.get("zone_name"));
item.put("members", parseJsonArray((String) row.get("members")));
item.put("color", row.get("color"));
item.put("resolution", null);
item.put("candidateCount", null);
return item;
}
private Map<String, Object> parseGeoJson(String geoJson) {
if (geoJson == null || geoJson.isBlank()) return null;
try {
return objectMapper.readValue(geoJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("GeoJSON 파싱 실패: {}", e.getMessage());
return null;
}
}
private List<Object> parseJsonArray(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<Object>>() {});
} catch (Exception e) {
log.debug("JSON 배열 파싱 실패: {}", e.getMessage());
return List.of();
}
}
}

파일 보기

@ -1,109 +1,70 @@
package gc.mda.kcg.domain.analysis; package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import gc.mda.kcg.permission.annotation.RequirePermission; import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.*; import java.util.List;
import java.util.Map;
/** /**
* iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID). * 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
* *
* 라우팅: * 라우팅:
* GET /api/vessel-analysis 전체 분석결과 + 통계 (단순 프록시)
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성 * GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세 * GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세 + 24h 이력
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수 * GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
* *
* 권한: detection / detection:gear-detection (READ) * 권한: detection:gear-detection (READ)
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/api/vessel-analysis") @RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor @RequiredArgsConstructor
public class VesselAnalysisProxyController { public class VesselAnalysisProxyController {
private final IranBackendClient iranClient; private final VesselAnalysisGroupService groupService;
private final ParentResolutionRepository resolutionRepository;
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
@GetMapping @GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() { public ResponseEntity<?> getVesselAnalysis() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis"); // vessel_analysis_results 직접 조회는 /api/analysis/vessels 사용.
if (data == null) { // 엔드포인트는 하위 호환을 위해 구조 반환.
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"serviceAvailable", false, "serviceAvailable", true,
"message", "iran 백엔드 미연결", "message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
"items", List.of(), "items", List.of(),
"stats", Map.of(), "stats", Map.of(),
"count", 0 "count", 0
)); ));
} }
// 통과 + 메타데이터 추가
Map<String, Object> enriched = new LinkedHashMap<>(data);
enriched.put("serviceAvailable", true);
return ResponseEntity.ok(enriched);
}
/** /**
* 그룹 목록 + 자체 DB의 parentResolution 합성. * 그룹 목록 + 자체 DB의 parentResolution 합성.
* 그룹에 resolution 필드 추가.
*/ */
@GetMapping("/groups") @GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ") @RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups() { public ResponseEntity<?> getGroups(
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups"); @RequestParam(required = false) String groupType
if (data == null) { ) {
return ResponseEntity.ok(Map.of( Map<String, Object> result = groupService.getGroups(groupType);
"serviceAvailable", false,
"items", List.of(),
"count", 0
));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.getOrDefault("items", List.of());
// 자체 DB의 모든 resolution을 group_key로 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : resolutionRepository.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// 그룹에 합성
for (Map<String, Object> item : items) {
String groupKey = String.valueOf(item.get("groupKey"));
Object subRaw = item.get("subClusterId");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
ParentResolution res = resolutionByKey.get(groupKey + "::" + sub);
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
}
Map<String, Object> result = new LinkedHashMap<>(data);
result.put("items", items);
result.put("serviceAvailable", true);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@GetMapping("/groups/{groupKey}/detail") @GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ") @RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) { public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail"); Map<String, Object> result = groupService.getGroupDetail(groupKey);
if (data == null) { return ResponseEntity.ok(result);
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
} }
@GetMapping("/groups/{groupKey}/correlations") @GetMapping("/groups/{groupKey}/correlations")
@ -112,12 +73,57 @@ public class VesselAnalysisProxyController {
@PathVariable String groupKey, @PathVariable String groupKey,
@RequestParam(required = false) Double minScore @RequestParam(required = false) Double minScore
) { ) {
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations"; Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
if (minScore != null) path += "?minScore=" + minScore; return ResponseEntity.ok(result);
Map<String, Object> data = iranClient.getJson(path); }
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey)); /**
* 후보 상세 raw metrics (최근 20건 관측 이력).
*/
@GetMapping("/groups/{groupKey}/candidates/{targetMmsi}/metrics")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getCandidateMetrics(
@PathVariable String groupKey,
@PathVariable String targetMmsi
) {
return ResponseEntity.ok(groupService.getCandidateMetrics(groupKey, targetMmsi));
}
/**
* 모선 확정/제외.
* POST body: { "action": "confirm"|"reject", "targetMmsi": "...", "comment": "..." }
*/
@PostMapping("/groups/{groupKey}/resolve")
@RequirePermission(resource = "detection:gear-detection", operation = "UPDATE")
public ResponseEntity<?> resolveParent(
@PathVariable String groupKey,
@RequestBody Map<String, String> body
) {
String action = body.getOrDefault("action", "");
String targetMmsi = body.getOrDefault("targetMmsi", "");
String comment = body.getOrDefault("comment", "");
return ResponseEntity.ok(groupService.resolveParent(groupKey, action, targetMmsi, comment));
}
/**
* 선박 항적 일괄 조회 (signal-batch 프록시).
* POST /api/vessel-analysis/tracks signal-batch /api/v2/tracks/vessels
*/
@PostMapping("/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> vesselTracks(@RequestBody Map<String, Object> body) {
try {
String json = signalBatchClient.post()
.uri("/api/v2/tracks/vessels")
.body(body)
.retrieve()
.body(String.class);
return ResponseEntity.ok()
.header("Content-Type", "application/json")
.body(json != null ? json : "[]");
} catch (RestClientException e) {
log.warn("signal-batch 항적 조회 실패: {}", e.getMessage());
return ResponseEntity.ok("[]");
} }
return ResponseEntity.ok(data);
} }
} }

파일 보기

@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@ -57,4 +58,61 @@ public interface VesselAnalysisRepository
""") """)
Page<VesselAnalysisResult> findLatestTransshipSuspects( Page<VesselAnalysisResult> findLatestTransshipSuspects(
@Param("after") OffsetDateTime after, Pageable pageable); @Param("after") OffsetDateTime after, Pageable pageable);
/**
* 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거).
* mmsiPrefix null 이면 전체, 아니면 LIKE ':prefix%'.
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.gearCode IS NOT NULL
AND v.gearJudgment IS NOT NULL
AND v.analyzedAt > :after
AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%'))
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.analyzedAt DESC
""")
Page<VesselAnalysisResult> findLatestGearDetections(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix,
Pageable pageable);
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* mmsiPrefix null 이면 전체.
* 반환 Map : total, dark_count, spoofing_count, transship_count,
* critical_count, high_count, medium_count, low_count,
* territorial_count, contiguous_count, eez_count,
* fishing_count, avg_risk_score
*/
@Query(value = """
WITH latest AS (
SELECT DISTINCT ON (mmsi) *
FROM kcg.vessel_analysis_results
WHERE analyzed_at > :after
AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%')
ORDER BY mmsi, analyzed_at DESC
)
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count,
COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count,
COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count,
COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count,
COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count,
COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count,
COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count,
COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count,
COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count,
COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count,
COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count,
COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score
FROM latest
""", nativeQuery = true)
Map<String, Object> aggregateStats(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix);
} }

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -16,6 +17,7 @@ public record VesselAnalysisResponse(
String vesselType, String vesselType,
BigDecimal confidence, BigDecimal confidence,
BigDecimal fishingPct, BigDecimal fishingPct,
Integer clusterId,
String season, String season,
// 위치 // 위치
Double lat, Double lat,
@ -24,11 +26,14 @@ public record VesselAnalysisResponse(
BigDecimal distToBaselineNm, BigDecimal distToBaselineNm,
// 행동 // 행동
String activityState, String activityState,
BigDecimal ucafScore,
BigDecimal ucftScore,
// 위협 // 위협
Boolean isDark, Boolean isDark,
Integer gapDurationMin, Integer gapDurationMin,
String darkPattern, String darkPattern,
BigDecimal spoofingScore, BigDecimal spoofingScore,
BigDecimal bd09OffsetM,
Integer speedJumpCount, Integer speedJumpCount,
// 환적 // 환적
Boolean transshipSuspect, Boolean transshipSuspect,
@ -45,6 +50,7 @@ public record VesselAnalysisResponse(
String gearCode, String gearCode,
String gearJudgment, String gearJudgment,
String permitStatus, String permitStatus,
List<String> violationCategories,
// features // features
Map<String, Object> features Map<String, Object> features
) { ) {
@ -56,16 +62,20 @@ public record VesselAnalysisResponse(
e.getVesselType(), e.getVesselType(),
e.getConfidence(), e.getConfidence(),
e.getFishingPct(), e.getFishingPct(),
e.getClusterId(),
e.getSeason(), e.getSeason(),
e.getLat(), e.getLat(),
e.getLon(), e.getLon(),
e.getZoneCode(), e.getZoneCode(),
e.getDistToBaselineNm(), e.getDistToBaselineNm(),
e.getActivityState(), e.getActivityState(),
e.getUcafScore(),
e.getUcftScore(),
e.getIsDark(), e.getIsDark(),
e.getGapDurationMin(), e.getGapDurationMin(),
e.getDarkPattern(), e.getDarkPattern(),
e.getSpoofingScore(), e.getSpoofingScore(),
e.getBd09OffsetM(),
e.getSpeedJumpCount(), e.getSpeedJumpCount(),
e.getTransshipSuspect(), e.getTransshipSuspect(),
e.getTransshipPairMmsi(), e.getTransshipPairMmsi(),
@ -78,6 +88,7 @@ public record VesselAnalysisResponse(
e.getGearCode(), e.getGearCode(),
e.getGearJudgment(), e.getGearJudgment(),
e.getPermitStatus(), e.getPermitStatus(),
e.getViolationCategories(),
e.getFeatures() e.getFeatures()
); );
} }

파일 보기

@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -125,6 +126,10 @@ public class VesselAnalysisResult {
@Column(name = "permit_status", length = 20) @Column(name = "permit_status", length = 20)
private String permitStatus; private String permitStatus;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "violation_categories", columnDefinition = "text[]")
private List<String> violationCategories;
// features JSONB // features JSONB
@JdbcTypeCode(SqlTypes.JSON) @JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb") @Column(name = "features", columnDefinition = "jsonb")

파일 보기

@ -7,8 +7,10 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* vessel_analysis_results 직접 조회 서비스. * vessel_analysis_results 직접 조회 서비스.
@ -26,9 +28,10 @@ public class VesselAnalysisService {
*/ */
public Page<VesselAnalysisResult> getAnalysisResults( public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark, String mmsi, String zoneCode, String riskLevel, Boolean isDark,
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
OffsetDateTime after, Pageable pageable OffsetDateTime after, Pageable pageable
) { ) {
Specification<VesselAnalysisResult> spec = Specification.where(null); Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
if (after != null) { if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after)); spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
@ -36,6 +39,10 @@ public class VesselAnalysisService {
if (mmsi != null && !mmsi.isBlank()) { if (mmsi != null && !mmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi)); spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
} }
if (mmsiPrefix != null && !mmsiPrefix.isBlank()) {
final String prefix = mmsiPrefix;
spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%"));
}
if (zoneCode != null && !zoneCode.isBlank()) { if (zoneCode != null && !zoneCode.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode)); spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
} }
@ -45,10 +52,66 @@ public class VesselAnalysisService {
if (isDark != null && isDark) { if (isDark != null && isDark) {
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark"))); spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
} }
if (minRiskScore != null) {
spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore));
}
if (minFishingPct != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct));
}
return repository.findAll(spec, pageable); return repository.findAll(spec, pageable);
} }
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리).
*/
public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) {
OffsetDateTime windowEnd = OffsetDateTime.now();
OffsetDateTime windowStart = windowEnd.minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
Map<String, Object> row = repository.aggregateStats(windowStart, prefix);
return new AnalysisStatsResponse(
longOf(row, "total"),
longOf(row, "dark_count"),
longOf(row, "spoofing_count"),
longOf(row, "transship_count"),
longOf(row, "critical_count"),
longOf(row, "high_count"),
longOf(row, "medium_count"),
longOf(row, "low_count"),
longOf(row, "territorial_count"),
longOf(row, "contiguous_count"),
longOf(row, "eez_count"),
longOf(row, "fishing_count"),
bigDecimalOf(row, "avg_risk_score"),
windowStart,
windowEnd
);
}
/**
* prediction 자동 어구 탐지 결과 목록.
*/
public Page<VesselAnalysisResult> getGearDetections(int hours, String mmsiPrefix, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
return repository.findLatestGearDetections(after, prefix, pageable);
}
private static long longOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return 0L;
return ((Number) v).longValue();
}
private static BigDecimal bigDecimalOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal bd) return bd;
return new BigDecimal(v.toString());
}
/** /**
* 특정 선박 최신 분석 결과. * 특정 선박 최신 분석 결과.
*/ */

파일 보기

@ -1,5 +1,6 @@
package gc.mda.kcg.domain.enforcement; package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest; import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest; import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest; import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
@ -48,6 +49,7 @@ public class EnforcementService {
} }
@Transactional @Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) { public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder() EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid()) .enfUid(generateEnfUid())
@ -87,6 +89,7 @@ public class EnforcementService {
} }
@Transactional @Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) { public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id); EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result()); if (req.result() != null) record.setResult(req.result());
@ -107,6 +110,7 @@ public class EnforcementService {
} }
@Transactional @Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) { public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder() EnforcementPlan plan = EnforcementPlan.builder()
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase()) .planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())

파일 보기

@ -2,12 +2,9 @@ package gc.mda.kcg.domain.event;
import gc.mda.kcg.permission.annotation.RequirePermission; import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
/** /**
* 알림 조회 API. * 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공. * 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
@ -17,7 +14,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AlertController { public class AlertController {
private final PredictionAlertRepository alertRepository; private final AlertService alertService;
/** /**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능. * 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -30,10 +27,8 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size @RequestParam(defaultValue = "20") int size
) { ) {
if (eventId != null) { if (eventId != null) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId); return alertService.findByEventId(eventId);
} }
return alertRepository.findAllByOrderBySentAtDesc( return alertService.findAll(PageRequest.of(page, size));
PageRequest.of(page, size)
);
} }
} }

파일 보기

@ -0,0 +1,29 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 예측 알림 조회 서비스.
* 이벤트에 대해 발송된 알림(SMS/푸시 ) 이력을 조회한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AlertService {
private final PredictionAlertRepository alertRepository;
public List<PredictionAlert> findByEventId(Long eventId) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
public Page<PredictionAlert> findAll(Pageable pageable) {
return alertRepository.findAllByOrderBySentAtDesc(pageable);
}
}

파일 보기

@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
/** /**
* 모선 워크플로우 핵심 서비스 (HYBRID). * 모선 워크플로우 핵심 서비스.
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub) * - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* - 운영자 결정: 자체 DB (gear_group_parent_resolution ) * - 운영자 결정: 자체 DB (gear_group_parent_resolution )
* *
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록. * 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/** /**
* 모선 확정 결과 (운영자 의사결정). * 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장. * prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
*/ */
@Entity @Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg", @Table(name = "gear_group_parent_resolution", schema = "kcg",
@ -34,6 +34,27 @@ public class ParentResolution {
@Column(name = "selected_parent_mmsi", length = 20) @Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi; private String selectedParentMmsi;
@Column(name = "selected_parent_name", length = 200)
private String selectedParentName;
@Column(name = "confidence", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal confidence;
@Column(name = "top_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal topScore;
@Column(name = "second_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal secondScore;
@Column(name = "score_margin", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal scoreMargin;
@Column(name = "decision_source", length = 30)
private String decisionSource;
@Column(name = "stable_cycles", columnDefinition = "integer default 0")
private Integer stableCycles;
@Column(name = "rejected_candidate_mmsi", length = 20) @Column(name = "rejected_candidate_mmsi", length = 20)
private String rejectedCandidateMmsi; private String rejectedCandidateMmsi;

파일 보기

@ -4,9 +4,7 @@ import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@ -18,10 +16,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MasterDataController { public class MasterDataController {
private final CodeMasterRepository codeMasterRepository; private final MasterDataService masterDataService;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// ======================================================================== // ========================================================================
// 코드 마스터 (인증만, 권한 불필요) // 코드 마스터 (인증만, 권한 불필요)
@ -29,12 +24,12 @@ public class MasterDataController {
@GetMapping("/api/codes") @GetMapping("/api/codes")
public List<CodeMaster> listCodes(@RequestParam String group) { public List<CodeMaster> listCodes(@RequestParam String group) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group); return masterDataService.listCodes(group);
} }
@GetMapping("/api/codes/{codeId}/children") @GetMapping("/api/codes/{codeId}/children")
public List<CodeMaster> listChildren(@PathVariable String codeId) { public List<CodeMaster> listChildren(@PathVariable String codeId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId); return masterDataService.listChildren(codeId);
} }
// ======================================================================== // ========================================================================
@ -43,35 +38,24 @@ public class MasterDataController {
@GetMapping("/api/gear-types") @GetMapping("/api/gear-types")
public List<GearType> listGearTypes() { public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder(); return masterDataService.listGearTypes();
} }
@GetMapping("/api/gear-types/{gearCode}") @GetMapping("/api/gear-types/{gearCode}")
public GearType getGearType(@PathVariable String gearCode) { public GearType getGearType(@PathVariable String gearCode) {
return gearTypeRepository.findById(gearCode) return masterDataService.getGearType(gearCode);
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
} }
@PostMapping("/api/gear-types") @PostMapping("/api/gear-types")
@RequirePermission(resource = "admin:system-config", operation = "CREATE") @RequirePermission(resource = "admin:system-config", operation = "CREATE")
public GearType createGearType(@RequestBody GearType gearType) { public GearType createGearType(@RequestBody GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) { return masterDataService.createGearType(gearType);
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
} }
@PutMapping("/api/gear-types/{gearCode}") @PutMapping("/api/gear-types/{gearCode}")
@RequirePermission(resource = "admin:system-config", operation = "UPDATE") @RequirePermission(resource = "admin:system-config", operation = "UPDATE")
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) { public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) { return masterDataService.updateGearType(gearCode, gearType);
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
} }
// ======================================================================== // ========================================================================
@ -81,7 +65,7 @@ public class MasterDataController {
@GetMapping("/api/patrol-ships") @GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol:patrol-route", operation = "READ") @RequirePermission(resource = "patrol:patrol-route", operation = "READ")
public List<PatrolShip> listPatrolShips() { public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode(); return masterDataService.listPatrolShips();
} }
@PatchMapping("/api/patrol-ships/{id}/status") @PatchMapping("/api/patrol-ships/{id}/status")
@ -90,47 +74,28 @@ public class MasterDataController {
@PathVariable Long id, @PathVariable Long id,
@RequestBody PatrolShipStatusRequest request @RequestBody PatrolShipStatusRequest request
) { ) {
PatrolShip ship = patrolShipRepository.findById(id) return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
"함정을 찾을 수 없습니다: " + id)); ));
if (request.status() != null) ship.setCurrentStatus(request.status());
if (request.lat() != null) ship.setCurrentLat(request.lat());
if (request.lon() != null) ship.setCurrentLon(request.lon());
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
return patrolShipRepository.save(ship);
} }
// ======================================================================== // ========================================================================
// 선박 허가 (vessel 권한) // 선박 허가 (인증만, 공통 마스터 데이터)
// ======================================================================== // ========================================================================
@GetMapping("/api/vessel-permits") @GetMapping("/api/vessel-permits")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public Page<VesselPermit> listVesselPermits( public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag, @RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus, @RequestParam(required = false) String permitStatus,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size @RequestParam(defaultValue = "20") int size
) { ) {
PageRequest pageable = PageRequest.of(page, size); return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
} }
@GetMapping("/api/vessel-permits/{mmsi}") @GetMapping("/api/vessel-permits/{mmsi}")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public VesselPermit getVesselPermit(@PathVariable String mmsi) { public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi) return masterDataService.getVesselPermit(mmsi);
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
} }
// ======================================================================== // ========================================================================

파일 보기

@ -0,0 +1,115 @@
package gc.mda.kcg.master;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터(코드/어구/함정/선박허가) 조회 관리 서비스.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MasterDataService {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// 코드 마스터
public List<CodeMaster> listCodes(String groupCode) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
}
public List<CodeMaster> listChildren(String parentId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
}
// 어구 유형
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
public GearType getGearType(String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@Transactional
public GearType createGearType(GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@Transactional
public GearType updateGearType(String gearCode, GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// 함정
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@Transactional
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (command.status() != null) ship.setCurrentStatus(command.status());
if (command.lat() != null) ship.setCurrentLat(command.lat());
if (command.lon() != null) ship.setCurrentLon(command.lon());
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
return patrolShipRepository.save(ship);
}
// 선박 허가
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
public VesselPermit getVesselPermit(String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// Command DTO
public record PatrolShipStatusCommand(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -0,0 +1,6 @@
config.stopBubbling = true
# @RequiredArgsConstructor 가 생성하는 constructor parameter 에 필드의 @Qualifier 를 복사한다.
# Spring 6.1+ 의 bean 이름 기반 fallback 은 parameter-level annotation 을 요구하므로,
# 필수 처리하지 않으면 여러 bean 중 모호성이 발생해 기동이 실패한다.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

파일 보기

@ -40,6 +40,10 @@ spring:
server: server:
port: 8080 port: 8080
forward-headers-strategy: framework forward-headers-strategy: framework
compression:
enabled: true
min-response-size: 1024
mime-types: application/json,application/xml,text/html,text/plain
management: management:
endpoints: endpoints:
@ -60,9 +64,8 @@ logging:
app: app:
prediction: prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001} base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
iran-backend: signal-batch:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합) base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174} allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt: jwt:

파일 보기

@ -0,0 +1,76 @@
-- ============================================================
-- V025: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
-- 시스템관리 > AI 플랫폼 / 감사·보안 서브그룹
-- ============================================================
-- ──────────────────────────────────────────────────────────────
-- 1. LGCNS MLOps (시스템관리 > AI 플랫폼, MLOps와 LLM 사이)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:lgcns-mlops', 'admin', 'LGCNS MLOps', 1, 36)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/lgcns-mlops',
label_key = 'nav.lgcnsMlops',
component_key = 'features/ai-operations/LGCNSMLOpsPage',
nav_group = 'admin',
nav_sub_group = 'AI 플랫폼',
nav_sort = 350,
labels = '{"ko":"LGCNS MLOps","en":"LGCNS MLOps"}'
WHERE rsrc_cd = 'admin:lgcns-mlops';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:lgcns-mlops', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────
-- 2. AI 보안 (SER-10) (시스템관리 > 감사·보안, 로그인 이력 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-security', 'admin', 'AI 보안', 1, 55)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-security',
label_key = 'nav.aiSecurity',
component_key = 'features/admin/AISecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1800,
labels = '{"ko":"AI 보안","en":"AI Security"}'
WHERE rsrc_cd = 'admin:ai-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-security', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────
-- 3. AI Agent 보안 (SER-11) (시스템관리 > 감사·보안, AI 보안 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-agent-security', 'admin', 'AI Agent 보안', 1, 56)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-agent-security',
label_key = 'nav.aiAgentSecurity',
component_key = 'features/admin/AIAgentSecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1900,
labels = '{"ko":"AI Agent 보안","en":"AI Agent Security"}'
WHERE rsrc_cd = 'admin:ai-agent-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-agent-security', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,28 @@
-- ============================================================
-- V026: 데이터 보관기간 및 파기 정책 (DAR-10) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-retention', 'admin', '데이터 보관·파기', 1, 57)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/data-retention',
label_key = 'nav.dataRetentionPolicy',
component_key = 'features/admin/DataRetentionPolicy',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2000,
labels = '{"ko":"데이터 보관·파기","en":"Data Retention"}'
WHERE rsrc_cd = 'admin:data-retention';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:data-retention', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,28 @@
-- ============================================================
-- V027: 데이터 모델 검증 (DAR-11) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-model-verification', 'admin', '데이터 모델 검증', 1, 58)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/data-model-verification',
label_key = 'nav.dataModelVerification',
component_key = 'features/admin/DataModelVerification',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2100,
labels = '{"ko":"데이터 모델 검증","en":"Model Verification"}'
WHERE rsrc_cd = 'admin:data-model-verification';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:data-model-verification', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,28 @@
-- ============================================================
-- V028: 성능 모니터링 (PER-01~06) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:performance-monitoring', 'admin', '성능 모니터링', 1, 59)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/performance-monitoring',
label_key = 'nav.performanceMonitoring',
component_key = 'features/admin/PerformanceMonitoring',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2110,
labels = '{"ko":"성능 모니터링","en":"Performance Monitoring"}'
WHERE rsrc_cd = 'admin:performance-monitoring';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:performance-monitoring', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,72 @@
-- V026: 한중어업협정 중국어선 허가현황 원본 테이블 + fleet_vessels 연도 컬럼
-- 출처: docs/중국어선_허가현황_YYYYMMDD.xls (연단위 갱신)
-- ===== 1. fishery_permit_cn : 허가현황 원본 스냅샷 =====
CREATE TABLE IF NOT EXISTS kcg.fishery_permit_cn (
id BIGSERIAL PRIMARY KEY,
permit_year INTEGER NOT NULL,
permit_no VARCHAR(30) NOT NULL,
fishery_type VARCHAR(60), -- 업종 (2척식저인망어업 등)
fishery_code VARCHAR(10) NOT NULL, -- 업종코드 (PT/PT-S/GN/FC/PS/OT)
name_cn VARCHAR(100) NOT NULL,
name_en VARCHAR(200) NOT NULL,
applicant_cn VARCHAR(100),
applicant_en VARCHAR(200),
applicant_addr_cn VARCHAR(300),
applicant_addr_en VARCHAR(300),
registration_no VARCHAR(100),
tonnage NUMERIC(10,2),
port_cn VARCHAR(100),
port_en VARCHAR(200),
callsign VARCHAR(40),
engine_power NUMERIC(10,2),
length_m NUMERIC(6,2),
beam_m NUMERIC(6,2),
depth_m NUMERIC(6,2),
fishing_zones VARCHAR(30), -- Ⅱ,Ⅲ 등
fishing_period_1 VARCHAR(50),
fishing_period_2 VARCHAR(50),
catch_quota_t NUMERIC(10,2),
cumulative_quota_t NUMERIC(10,2),
refrig_hold_count INTEGER,
freezer_hold_count INTEGER,
admin_sanction TEXT,
parent_permit_no VARCHAR(30), -- 부속선(PT-S)이 참조하는 본선 허가번호
volume_enclosed NUMERIC(10,2),
volume_above_deck NUMERIC(10,2),
volume_below_deck NUMERIC(10,2),
volume_excluded NUMERIC(10,2),
raw_data JSONB,
source_file VARCHAR(255),
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (permit_year, permit_no)
);
CREATE INDEX idx_fishery_permit_cn_name_cn ON kcg.fishery_permit_cn(permit_year, name_cn);
CREATE INDEX idx_fishery_permit_cn_name_en ON kcg.fishery_permit_cn(permit_year, LOWER(name_en));
CREATE INDEX idx_fishery_permit_cn_code ON kcg.fishery_permit_cn(permit_year, fishery_code);
CREATE INDEX idx_fishery_permit_cn_parent ON kcg.fishery_permit_cn(permit_year, parent_permit_no);
COMMENT ON TABLE kcg.fishery_permit_cn IS '한중어업협정 중국어선 허가현황 원본 스냅샷 (연단위 갱신)';
COMMENT ON COLUMN kcg.fishery_permit_cn.permit_year IS '허가 연도 (파일명 YYYY에서 추출)';
COMMENT ON COLUMN kcg.fishery_permit_cn.fishery_code IS 'PT(쌍끌이 본선)/PT-S(부속선)/GN(자망)/FC(운반)/PS(선망)/OT(외끌이)';
COMMENT ON COLUMN kcg.fishery_permit_cn.parent_permit_no IS 'PT-S(부속선)가 소속된 본선의 허가번호';
-- ===== 2. fleet_vessels 확장 : 연도 + 업종코드 추적 =====
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS permit_year INTEGER;
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS fishery_code VARCHAR(10);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_permit_year ON kcg.fleet_vessels(permit_year);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_fishery_code ON kcg.fleet_vessels(fishery_code);
COMMENT ON COLUMN kcg.fleet_vessels.permit_year IS '허가 연도. fleet_tracker는 현재 연도만 조회';
COMMENT ON COLUMN kcg.fleet_vessels.fishery_code IS 'fishery_permit_cn.fishery_code 복제 (PT/PT-S/GN/FC/PS/OT)';
-- ===== 3. V014 데모 seed 제거 =====
-- 기존 6행 데모 vessels 제거 (실제 허가현황만 남김).
-- fleet_companies id=1,2는 vessel_permit_master가 FK로 참조하여 삭제 불가 — 잔존 허용
-- (loader 실행 시 실 허가 신청인 회사가 별도 id로 upsert됨)
DELETE FROM kcg.fleet_vessels WHERE permit_no IN (
'ZY-2024-001','ZY-2024-002','ZY-2024-003',
'ZY-2024-010','ZY-2024-011','ZY-2024-012'
);

파일 보기

@ -0,0 +1,90 @@
-- ============================================================================
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 충돌 이력 테이블
-- ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
mmsi_hi VARCHAR(20) NOT NULL,
parent_name VARCHAR(100),
parent_vessel_id BIGINT, -- fleet_vessels.id
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
last_lat_lo NUMERIC(9,6),
last_lon_lo NUMERIC(10,6),
last_lat_hi NUMERIC(9,6),
last_lon_hi NUMERIC(10,6),
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
resolved_by UUID,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
evidence JSONB, -- 최근 관측 요약
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
);
CREATE INDEX IF NOT EXISTS idx_gic_status
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_severity
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_parent
ON kcg.gear_identity_collisions(parent_vessel_id)
WHERE parent_vessel_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gic_name
ON kcg.gear_identity_collisions(name);
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
ON kcg.gear_identity_collisions(last_seen_at DESC);
COMMENT ON TABLE kcg.gear_identity_collisions IS
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
'/gear-collision', 'nav.gearCollision',
'features/detection/GearCollisionDetection', 950,
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 권한 부여
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
-- OPERATOR: READ + UPDATE (분류 액션)
-- VIEWER/ANALYST/FIELD: READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,20 @@
-- V031: gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30) → VARCHAR(100)
--
-- 원인: prediction/algorithms/gear_parent_inference.py 의 _rank_candidates 가
-- candidate_source = ','.join(sorted(meta['sources'])) (line 875)
-- 로 여러 source 라벨(CORRELATION / EPISODE / LABEL / LINEAGE / MATCH 등) 을 쉼표로
-- join 해 저장한다. 모든 source 가 맞춰지면 약 39자(5개 라벨 + 쉼표) 에 달해
-- VARCHAR(30) 를 초과하며 psycopg2.errors.StringDataRightTruncation 을 유발.
--
-- 영향: prediction 사이클의 gear_correlation 스테이지에서 `_insert_candidate_snapshots`
-- 실패 → `gear_group_parent_candidate_snapshots` 테이블 갱신 전체 스킵.
-- Phase 0-1 의 사이클 스테이지 에러 격리 덕분에 후속 스테이지(pair_detection /
-- per-vessel analysis / upsert_results / 출력 모듈들)는 정상 완주.
--
-- 대안 검토:
-- (A) VARCHAR(100) — 여유치. 채택.
-- (B) TEXT — 제약 없음. 컬럼 의미가 "짧은 라벨 집합" 이라 VARCHAR 가 자연스러움.
-- (C) 코드에서 [:30] trim — 의미 손실(source 목록 절단). 부적합.
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ALTER COLUMN candidate_source TYPE VARCHAR(100);

파일 보기

@ -0,0 +1,40 @@
-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed
--
-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종
-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는
-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행.
--
-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째.
-- ──────────────────────────────────────────────────────────────────
-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45,
'/illegal-fishing', 'nav.illegalFishing',
'features/detection/IllegalFishingPattern', 920,
'{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 부여
-- READ 전용 페이지 — 모든 역할에 READ만 부여.
-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동.
-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도).
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,46 @@
-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed
--
-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용
-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고,
-- 이 페이지는 전체 목록·통계 운영 대시보드.
--
-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을
-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가.
-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음.
-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR).
--
-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
-- ──────────────────────────────────────────────────────────────────
-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL)
-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:transshipment', NULL, '환적 의심 탐지', 1, 48,
'/transshipment', 'nav.transshipment',
'features/detection/TransshipmentDetection', 910,
'{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 부여 — READ 전용 대시보드
-- ADMIN: 5 ops 전부 (메뉴 관리 일관성)
-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:transshipment', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:transshipment', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,226 @@
-- V034: Detection Model Registry 기반 스키마 (Phase 1-1)
--
-- prediction 모듈의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고,
-- 운영자가 프론트엔드에서 파라미터·버전을 관리할 수 있도록 하는 기반 인프라.
-- 핵심 개념:
-- * Model — 독립 탐지 단위 (dark_suspicion / gear_violation_g01_g06 등)
-- * Version — 같은 model 의 파라미터 스냅샷. 라이프사이클 DRAFT→ACTIVE→ARCHIVED
-- * Role — PRIMARY(운영 반영, 최대 1개) / SHADOW·CHALLENGER(관측용, N개)
-- * DAG — model_id 간 선행·후행 의존성 (선행 PRIMARY 결과만 후행에 주입)
--
-- 기존 V014 correlation_param_models 패턴의 일반화 — JSONB params + is_active 를
-- model_id × version × role 차원으로 확장. V014 는 Phase 2 에서 이 스키마로
-- 이주 후 2~3 릴리즈 후 deprecate 예정.
--
-- 참고: docs/prediction-analysis.md §7 P1/P2 + .claude/plans/vast-tinkering-knuth.md
-- ══════════════════════════════════════════════════════════════════
-- 1. detection_models — 모델 카탈로그 (고정 메타)
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_models (
model_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(200) NOT NULL,
tier INT NOT NULL, -- 1(Primitive) ~ 5(Meta)
category VARCHAR(40), -- DARK_VESSEL/GEAR/PATTERN/TRANSSHIP/META
description TEXT,
entry_module VARCHAR(200) NOT NULL, -- 'prediction.algorithms.dark_vessel'
entry_callable VARCHAR(100) NOT NULL, -- 'compute_dark_suspicion'
is_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 전역 ON/OFF 킬스위치
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CHECK (tier BETWEEN 1 AND 5)
);
CREATE INDEX IF NOT EXISTS idx_detection_models_tier
ON kcg.detection_models(tier, category);
CREATE INDEX IF NOT EXISTS idx_detection_models_enabled
ON kcg.detection_models(is_enabled) WHERE is_enabled = TRUE;
COMMENT ON TABLE kcg.detection_models IS
'탐지 모델 카탈로그 — prediction Model 인터페이스의 단위 정의';
-- ══════════════════════════════════════════════════════════════════
-- 2. detection_model_dependencies — 모델 간 DAG 엣지
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_dependencies (
model_id VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
depends_on VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE RESTRICT,
input_key VARCHAR(64) NOT NULL, -- 'gap_info' / 'pair_result' 등
PRIMARY KEY (model_id, depends_on, input_key),
CHECK (model_id <> depends_on) -- self-loop 금지
);
CREATE INDEX IF NOT EXISTS idx_detection_model_deps_reverse
ON kcg.detection_model_dependencies(depends_on);
COMMENT ON TABLE kcg.detection_model_dependencies IS
'모델 실행 DAG — 선행 model PRIMARY 결과의 어떤 key 를 후행이 입력으로 쓰는지';
-- ══════════════════════════════════════════════════════════════════
-- 3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_versions (
id BIGSERIAL PRIMARY KEY,
model_id VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
version VARCHAR(32) NOT NULL, -- SemVer 권장 '1.0.0', 자유도 허용
status VARCHAR(20) NOT NULL, -- DRAFT / TESTING / ACTIVE / ARCHIVED
role VARCHAR(20), -- PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
params JSONB NOT NULL, -- 임계값·가중치·상수
notes TEXT,
traffic_weight INT DEFAULT 0, -- CHALLENGER split (0~100, 후속 기능)
parent_version_id BIGINT
REFERENCES kcg.detection_model_versions(id) ON DELETE SET NULL,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
UNIQUE (model_id, version),
CHECK (status IN ('DRAFT','TESTING','ACTIVE','ARCHIVED')),
CHECK (role IS NULL OR role IN ('PRIMARY','SHADOW','CHALLENGER')),
CHECK (status <> 'ACTIVE' OR role IS NOT NULL), -- ACTIVE → role 필수
CHECK (traffic_weight BETWEEN 0 AND 100)
);
-- 한 model_id 에 PRIMARY×ACTIVE 는 최대 1건 (운영 반영 보호)
-- SHADOW/CHALLENGER×ACTIVE 는 N 건 허용
CREATE UNIQUE INDEX IF NOT EXISTS uk_detection_model_primary
ON kcg.detection_model_versions(model_id)
WHERE status = 'ACTIVE' AND role = 'PRIMARY';
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_active
ON kcg.detection_model_versions(model_id, status)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_status
ON kcg.detection_model_versions(status, model_id);
COMMENT ON TABLE kcg.detection_model_versions IS
'모델 버전 + 파라미터 + 라이프사이클. ACTIVE 는 role=PRIMARY 1개 + SHADOW/CHALLENGER N개';
-- ══════════════════════════════════════════════════════════════════
-- 4. detection_model_run_outputs — 버전별 입력·출력 비교 (파티션)
-- 같은 (cycle, model_id, input_ref) 로 PRIMARY vs SHADOW JOIN 비교
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs (
id BIGSERIAL,
cycle_started_at TIMESTAMPTZ NOT NULL, -- 같은 사이클 식별 (모든 버전 공유)
model_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL, -- PRIMARY / SHADOW / CHALLENGER
input_ref JSONB NOT NULL, -- {mmsi, analyzed_at} 등
outputs JSONB NOT NULL, -- 모델 반환값 snapshot
cycle_duration_ms INT,
recorded_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id, cycle_started_at),
CHECK (role IN ('PRIMARY','SHADOW','CHALLENGER'))
) PARTITION BY RANGE (cycle_started_at);
-- 2026-04 파티션 (이후는 partition_manager 가 월별 자동 생성)
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_04
PARTITION OF kcg.detection_model_run_outputs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_05
PARTITION OF kcg.detection_model_run_outputs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE INDEX IF NOT EXISTS idx_run_outputs_compare
ON kcg.detection_model_run_outputs(model_id, cycle_started_at DESC, role);
CREATE INDEX IF NOT EXISTS idx_run_outputs_version
ON kcg.detection_model_run_outputs(version_id, cycle_started_at DESC);
CREATE INDEX IF NOT EXISTS idx_run_outputs_input
ON kcg.detection_model_run_outputs USING GIN (input_ref jsonb_path_ops);
COMMENT ON TABLE kcg.detection_model_run_outputs IS
'버전별 실행 결과 원시 snapshot — PRIMARY vs SHADOW diff 분석용. 월별 파티션, 기본 7일 retention';
-- ══════════════════════════════════════════════════════════════════
-- 5. detection_model_metrics — 사이클 단위 집계 메트릭
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_metrics (
id BIGSERIAL PRIMARY KEY,
model_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL
REFERENCES kcg.detection_model_versions(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
metric_key VARCHAR(64) NOT NULL, -- cycle_duration_ms/detected_count/tier_critical_count
metric_value NUMERIC,
cycle_started_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_lookup
ON kcg.detection_model_metrics(model_id, version_id, cycle_started_at DESC);
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_cycle
ON kcg.detection_model_metrics(cycle_started_at DESC, model_id);
COMMENT ON TABLE kcg.detection_model_metrics IS
'모델 실행 집계 메트릭 — dashboard/compare API 의 관측 소스';
-- ══════════════════════════════════════════════════════════════════
-- 6. Compare VIEW — PRIMARY vs SHADOW 결과를 같은 입력 단위로 JOIN
-- ══════════════════════════════════════════════════════════════════
CREATE OR REPLACE VIEW kcg.v_detection_model_comparison AS
SELECT
p.cycle_started_at,
p.model_id,
p.input_ref,
p.outputs AS primary_outputs,
s.outputs AS shadow_outputs,
p.version_id AS primary_version_id,
s.version_id AS shadow_version_id,
s.role AS shadow_role
FROM kcg.detection_model_run_outputs p
JOIN kcg.detection_model_run_outputs s
ON p.cycle_started_at = s.cycle_started_at
AND p.model_id = s.model_id
AND p.input_ref = s.input_ref
WHERE p.role = 'PRIMARY'
AND s.role IN ('SHADOW', 'CHALLENGER');
COMMENT ON VIEW kcg.v_detection_model_comparison IS
'PRIMARY × SHADOW/CHALLENGER 동일 입력 결과 비교 — backend Compare API 의 집계 소스';
-- ══════════════════════════════════════════════════════════════════
-- 7. 권한 트리 / 메뉴 슬롯
-- ai-operations:detection-models — AI 모델관리 하위 혹은 별도 노드.
-- 기존 ai-operations:ai-model(nav=200) 과 구분하기 위해 별도 nav_sort=250.
-- parent_cd='admin' (AI 운영 그룹 평탄화 패턴 따름)
-- ══════════════════════════════════════════════════════════════════
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('ai-operations:detection-models', 'admin', '탐지 모델 관리', 1, 25,
'/detection-models', 'nav.detectionModels',
'features/ai-operations/DetectionModelManagement', 250,
'{"ko":"탐지 모델 관리","en":"Detection Models"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ══════════════════════════════════════════════════════════════════
-- 8. 권한 부여
-- ADMIN : 5 ops 전부 (모델 카탈로그 관리)
-- OPERATOR : READ + UPDATE (SHADOW activate 정도. promote-primary 는 ADMIN 만)
-- ANALYST/VIEWER: READ (파라미터 조회 + Compare 분석)
-- FIELD : (생략 — 현장 단속 담당, 모델 관리 불필요)
-- ══════════════════════════════════════════════════════════════════
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('ANALYST', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 | | 서비스 | systemd | 포트 | 로그 |
|---|---|---|---| |---|---|---|---|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` | | kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` | | kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` | | kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
## 디렉토리 구조 ## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 | | 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 | | 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 | | 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
| 8001 | kcg-prediction (기존 iran) | redis-211 | | 8001 | kcg-prediction (레거시) | redis-211 |
| 18091 | kcg-prediction-lab | redis-211 | | 18091 | kcg-prediction-lab | redis-211 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 | | 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 6379 | Redis | redis-211 | | 6379 | Redis | redis-211 |
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 | | `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 | | `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 | | `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) | | `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) | | `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |

파일 보기

@ -4,7 +4,178 @@
## [Unreleased] ## [Unreleased]
## [2026-04-09.2] ## [2026-04-20]
### 추가
- **Detection Model Registry DB 스키마 (V034, Phase 1-1)** — prediction 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서 파라미터·버전·가중치를 관리할 수 있는 기반 인프라. 테이블 4종(`detection_models` 카탈로그 / `detection_model_dependencies` DAG / `detection_model_versions` 파라미터 스냅샷·라이프사이클·role / `detection_model_run_outputs` 월별 파티션) + 뷰 1개(`v_detection_model_comparison` PRIMARY×SHADOW JOIN). 한 모델을 서로 다른 파라미터로 **동시 실행**(PRIMARY 1 + SHADOW/CHALLENGER N) 지원, ACTIVE×PRIMARY 는 UNIQUE partial index 로 1건 보호. 권한 트리 `ai-operations:detection-models`(nav_sort=250) + ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ. (후속: Phase 1-2 Model Registry + DAG Executor, Phase 2 PoC 5 모델 마이그레이션)
- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)**`/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2)
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)**`/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
### 변경
- **Prediction 5분 사이클 스테이지 에러 경계 도입 (Phase 0-1)**`prediction/pipeline/stage_runner.py` 신설해 `run_stage(name, fn, required=False)` 유틸 제공. `scheduler.py run_analysis_cycle()` 의 출력 6모듈(violation_classifier / event_generator / kpi_writer / stats_aggregate_hourly / stats_aggregate_daily / alert_dispatcher)을 한 try/except 로 묶던 구조를 스테이지별 독립 실행으로 분리, 한 모듈이 깨져도 다른 모듈이 계속 돌아가도록 개선. `upsert_results` 는 required=True 로 실패 시 사이클 abort. 내부 try/except 의 `logger.warning``logger.exception` 으로 업그레이드(fetch_dark_history / gear collision event promotion / group polygon / gear correlation / pair detection / chat cache)하여 `journalctl -u kcg-ai-prediction` 에서 stacktrace 로 실패 지점 즉시 특정 가능. (docs/prediction-analysis.md P1 권고)
### 수정
- **모니터링/디자인시스템 런타임 에러 해소**`/monitoring``SystemStatusPanel` 에서 `stats.total.toLocaleString()` 호출이 백엔드 응답 shape 이슈로 `stats.total` 이 undefined 일 때 Uncaught TypeError 로 크래시하던 문제 null-safe 로 해소(`stats?.total != null`). `/design-system.html``CatalogBadges``PERFORMANCE_STATUS_META``label: {ko, en}` 객체를 그대로 Badge children 으로 주입해 "Objects are not valid as a React child" 를 던지고 `code` 필드 부재로 key 중복 경고가 함께 뜨던 문제 해소 — `Object.entries` 순회 + `AnyMeta.label``string | {ko,en}` 로 확장 + getKoLabel/getEnLabel 에 label 객체 케이스 추가
- **gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30)→(100) 확장 (V031)** — prediction `gear_parent_inference` 가 여러 source 라벨을 쉼표로 join 한 값(최대 ~39자)이 VARCHAR(30) 제약을 넘어 매 사이클 `StringDataRightTruncation` 으로 gear correlation 스테이지 전체가 실패하던 기존 버그. Phase 0-1 (PR #83) 의 `logger.exception` 전환으로 풀 stacktrace 가 journal 에 찍히면서 원인 특정. backend JPA 엔티티 미참조로 재빌드 불필요, Flyway 자동 적용, prediction 재기동만으로 해소
### 문서
- **Prediction 모듈 심층 분석 리포트 신설**`docs/prediction-analysis.md` (9개 섹션, 250 라인). opus 4.7 독립 리뷰 관점에서 현재 17 알고리즘의 레이어 분리·5분 사이클 시퀀스·4대 도메인 커버리지를 평가하고, 6축(관심사 분리/재사용성/테스트 가능성/에러 격리/동시성/설정 가능성)으로 구조 채점 + P1~P4 개선 제안·임계값 전수표 제공
- **루트·SFR 문서 drift 해소** — V001~V016 → V030 + 51 테이블, Python 3.9 → 3.11+, 14 → 17 알고리즘 모듈 실측 반영. SFR-10 에 GEAR_IDENTITY_COLLISION 패턴 + GearCollisionDetection 페이지 섹션 추가 (sfr-traceability/sfr-user-guide), `/gear-collision` 라우트 architecture.md 포함, system-flow-guide 노드 수 102→115 + V030 manifest 미반영 경고, backend/README "Phase 2 예정" 상태 → 실제 운영 구성 전면 재작성 (PR #79 hotfix 요구사항 명시)
## [2026-04-17]
### 추가
- **어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴** — 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 스푸핑/복제 의심 패턴을 신규 탐지. prediction `fleet_tracker.track_gear_identity()` 가 공존(simultaneous) / 교체(sequential) 경로를 분리해 공존 쌍은 `gear_identity_collisions` 에 UPSERT (누적 공존 횟수, 최대 거리, 양측 좌표, evidence JSONB append). 심각도는 거리/누적/스왑 기반으로 CRITICAL/HIGH/MEDIUM/LOW 자동 재계산, 운영자 확정 상태(CONFIRMED_ILLEGAL/FALSE_POSITIVE)는 보존. CRITICAL/HIGH 승격 시 `prediction_events` 허브에 `GEAR_IDENTITY_COLLISION` 카테고리 등록(dedup 367분). `/api/analysis/gear-collisions` READ + resolve 액션(REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN, `@Auditable GEAR_COLLISION_RESOLVE`). 좌측 메뉴 "어구 정체성 충돌" 자동 노출(nav_sort=950, detection:gear-collision)
- **gearCollisionStatuses 카탈로그**`shared/constants/gearCollisionStatuses.ts` + `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출. OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE 4단계 Badge intent 매핑
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
### 변경
- **디자인 시스템 SSOT 일괄 준수 (30파일)**`frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>``<Button variant>` / raw `<input>``<Input>` / raw `<select>``<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)**`common.json``aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
- **prediction 5분 사이클 안정화**`gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores``target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
### 수정
- **백엔드 RestClient bean 모호성으로 기동 실패 해소** — rocky-211 `kcg-ai-backend` 가 restart 시 `No qualifying bean of type RestClient, but 2 were found: predictionRestClient, signalBatchRestClient` 로 크래시 루프 진입하던 문제. PR #A(2026-04-17) 의 RestClientConfig 도입 이후 잠복해 있던 버그로, `@RequiredArgsConstructor` 가 생성한 constructor parameter 에 필드의 `@Qualifier` 가 복사되지 않아 Spring 6.1 의 parameter-level annotation 기반 주입이 실패한 것. 수정: `backend/pom.xml``maven-compiler-plugin` 실행 설정에 `<parameters>true</parameters>` 명시 + `backend/src/main/java/lombok.config` 신설해 `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier` 등록
### 문서
- **절대 지침 섹션 추가** — CLAUDE.md 최상단에 "절대 지침(Absolute Rules)" 섹션 신설. (1) 신규 브랜치 생성 전 `git fetch``origin/develop` 대비 뒤처지면 사용자 확인 → `git pull --ff-only` → 분기하는 동기화 절차 명시, (2) `frontend/` 작업 시 `design-system.html` 쇼케이스 규칙 전면 준수(공통 컴포넌트 우선 사용, 인라인/하드코딩 Tailwind 색상·`!important` 금지, 접근성 필수 체크리스트) 요약. 하단 기존 "디자인 시스템 (필수 준수)" 상세 섹션과 연결
- **프로젝트 산출문서 2026-04-17 기준 정비**`architecture.md` shared/components/ui 9개·i18n 네임스페이스 갱신, `sfr-traceability.md` v3.0 전면 재작성(운영 상태 기반 531라인), `sfr-user-guide.md` 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신, stale 3건 제거
## [2026-04-16.7]
### 변경
- **경량 분석 riskScore 해상도 개선**`compute_lightweight_risk_score``dark_suspicion_score`(0~100 패턴 기반 의심도) / `dist_from_baseline_nm`(EEZ 외 기선 근접도 12·24NM 차등) / `dark_history_24h`(반복 이력) 반영. 허가·반복 이중계산 방지 축소 로직. 배포 후 실측: 45점 60.8% 고정 수렴 → **0%** (11~40 구간 고르게 분산)
- **vessel_type 매핑** — fleet_vessels 등록선 `fishery_code` (PT/PT-S/OT/GN/PS/FC) 를 `TRAWL/GILLNET/PURSE/CARGO` 로 매핑하는 `vessel_type_mapping.py` 신설. 경량 경로의 `vessel_type='UNKNOWN'` 하드코딩 제거. 실측: UNKNOWN 98.6% → **89.1%** (886척이 구체 유형으로 전환)
- **VesselType 값 확장** — 기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN 에 `GILLNET`(유자망) / `CARGO`(운반선) 2종 추가
- **중국 선박 분석 그리드 정합성** — Tab 1 상단 `RealAllVessels` 편의 export 를 `mmsiPrefix='412'` 로 고정 + 제목 "중국 선박 전체 분석 결과 (실시간)" 로 변경. 상단/하단 모두 중국 선박 기준으로 일관 표시
### 추가
- **선박 유형 한글 카탈로그**`shared/constants/vesselTypes.ts` 신설. 저인망/선망/유자망/연승/통발/운반선/미분류 한글 라벨 + Badge intent. 기존 `alertLevels` 패턴 답습, `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출
## [2026-04-16.6]
### 추가
- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시
- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필
- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등)
- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용
- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가
- **VesselAnalysisResponse 필드 확장**`violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출
- **prediction 분석 시점 좌표 저장**`AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인)
## [2026-04-16.5]
### 변경
- **Admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)** — 129건 Tailwind 색상 → 시맨틱 토큰(text-label/text-heading/text-hint) + Badge intent 치환. raw `<button>``<Button>` 컴포넌트 교체. 미사용 import 정리
## [2026-04-16.4]
### 추가
- **G-02 금어기 조업 탐지**`fishery_permit_cn.fishing_period_1/2` 파싱(YYYY/MM/DD 범위) + `classify_gear_violations()` 에 permit_periods/observation_ts 인자 추가. 허가기간 밖 조업 시 `CLOSED_SEASON_FISHING` judgment
- **G-03 미등록/허가외 어구 탐지** — 감지 어구와 `fishery_code` 허용 목록 대조(PT→trawl, GN→gillnet, FC→금지 등). 불일치 시 `UNREGISTERED_GEAR` judgment
- **NAME_FUZZY 매칭** — 선박명 정규화(공백/대소문자/'NO.' 마커 통일, 선박번호 유지) + name_en 기반 fuzzy lookup. 동명이 중복 방지. 매칭률 9.1% → 53.1%
- **서버 스크립트 tier/G-02/G-03/match_method 추적** — diagnostic(5분) + hourly(1시간) 에 pair_tier 분포, reject 사유 카운터, fishery_code×match_method 교차, G-02/G-03 상세 섹션
### 변경
- **pair_trawl tier 분류** — AND 게이트(스펙 100% 2시간) 대신 STRONG/PROBABLE/SUSPECT 3단계. 완화 임계(800m/SOG 1.5-5/sog_delta 1.0/cog 20°)로 실제 공조 신호 포착. G-06 판정은 STRONG/PROBABLE 만
- **pair_trawl join key** — raw AIS timestamp → `time_bucket`(5분 리샘플). sog/cog on-demand 계산(`_ensure_sog_cog`)으로 vessel_store._tracks 직접 사용
- **pair base 확장** — classification 500척 → 전체 중국 MID(412/413/414) 조업 속력대 선박. candidates 61→1,668, detected 0→57
- **match_ais_to_registry 대상 확장** — vessel_dfs(500척) → vessel_store._tracks 전체 중국 선박(8k+)
### 수정
- **violation_classifier**`CLOSED_SEASON_FISHING`, `UNREGISTERED_GEAR` judgment → `ILLEGAL_GEAR` 카테고리 매핑 추가
## [2026-04-16.3]
### 변경
- **Admin 3개 페이지 디자인 시스템 준수 리팩토링 (Phase 1-A)** — PerformanceMonitoring/DataRetentionPolicy/DataModelVerification 자체 탭 네비 → `TabBar/TabButton` 공통 컴포넌트, 원시 `<button>``TabButton`, PerformanceMonitoring 정적 hex 9건 → `performanceStatus` 카탈로그 경유
- **신규 카탈로그** `shared/constants/performanceStatus.ts` — PerformanceStatus(good/warning/critical/running/passed/failed/active/scheduled/archived) → {intent, hex, label} + utilizationStatus(ratio) 헬퍼
- **RBAC skeleton** — 3개 페이지 최상단 `useAuth().hasPermission('admin:{resource}', 'OP')` 호출 배치 (Phase 3 액션 버튼 추가 시 가드로 연결)
## [2026-04-16.2]
### 추가
- **성능 모니터링(PER-01~06) 메뉴** — 시스템관리 > 감사·보안에 성능 모니터링 페이지 추가 (PerformanceMonitoring 컴포넌트 + V028 메뉴 마이그레이션)
- **데이터 모델 검증(DAR-11) 메뉴** — DataModelVerification 페이지 + V027 메뉴
- **데이터 보관·파기 정책(DAR-10) 메뉴** — DataRetentionPolicy 페이지 + V026 메뉴
- **DAR-03 5종 어구 구조 비교** — AI 모델관리 어구 탐지 탭에 저층 트롤 / 스토우넷 / 자망 / 통발 / 쌍끌이 이미지 및 설명 추가
- **단속 계획 탭 확장** — 단일 함정 순찰 작전 / 다함정 순찰 작전 탭 추가 (EnforcementPlan)
## [2026-04-16]
### 추가
- **한중어업협정 중국어선 허가현황 레지스트리**`kcg.fishery_permit_cn` 신규 테이블(29컬럼, 연단위 스냅샷). V029 마이그레이션 + `fleet_vessels.permit_year/fishery_code` 컬럼 추가. `load_fishery_permit_cn.py`로 연도별 XLS → DB 적재(906척/497 신청인사)
- **페어 탐색 재설계**`find_pair_candidates()` bbox 1차(인접 9 cell) + 궤적 유사도 2차(location/sog_corr/cog_alignment). 동종 어선 페어도 허용, role 가점(PT_REGISTERED/COOP_FISHING/TRANSSHIP_LIKE)
- **fleet_tracker API 3개**`get_pt_registered_mmsis` / `get_gear_episodes` / `get_gear_positions`
### 수정
- **DAR-03 G-04/G-05/G-06 Dead code 해결**`classify_gear_violations()` scheduler 호출 연결. `if 'pair_results' in dir()` 버그 제거. 사이클당 G-05 303건 / G-04 1건 탐지 시작
- **spoofing 산식** — 24h 희석 버그 → 최근 1h 윈도우 + teleport 절대 가점(건당 0.20) + extreme(>50kn) 단독 발견 시 score=max(0.6) 확정
- **gear_code DB write 경로**`AnalysisResult.gear_code` 필드 + `kcgdb.upsert_results()` INSERT/UPDATE + scheduler 두 경로에서 `fleet_tracker.get_vessel_gear_code()` 호출
### 변경
- **transshipment 선종 완화**`_CARRIER_HINTS`(cargo/tanker/supply/carrier/reefer) 부분일치 + 412* 중국어선 FISHING 간주
- **gear drift 임계** — 750m → **500m** (DAR-03 스펙 정합)
- **fleet_tracker 현재 연도 필터**`WHERE permit_year = EXTRACT(YEAR FROM now())::int OR permit_year IS NULL`
### 기타
- cron 스크립트 신규 섹션: hourly P1~P5(허가/매칭/gear_code/fleet_role) + D3.5(pair_type) / diagnostic PART 7.5 + 4-5.5
## [2026-04-15]
### 추가
- **DAR-03 G-code 위반 분류** — prediction에 G-01(수역×어구 위반)/G-04(MMSI 사이클링)/G-05(고정어구 표류)/G-06(쌍끌이 공조) 4개 위반 유형 자동 분류 + 점수 합산
- **쌍끌이 공조 탐지 알고리즘** — pair_trawl.py 신규 (cell-key 파티션 O(n) 스캔, 500m 근접·0.5kn 속도차·10° COG 일치·2h 지속 임계값)
- **모선 검토 워크플로우** — 어구 판정 상세 패널에 후보 검토 UI 추가 (관측 지표 7종 평균 + 보정 지표 + 모선 확정/제외 버튼). 별도 화면 진입 없이 어구 탐지 페이지 내에서 의사결정
- **24시간 궤적 리플레이** — TripsLayer fade trail 애니메이션, 멤버별 개별 타임라인 보간(빈 구간 자연 연속), convex hull 폴리곤 실시간 생성, 후보 선박 항적 동시 재생 (signal-batch /api/v2/tracks/vessels 프록시 연동)
- **어구 탐지 그리드 UX** — 다중 선택 필터 패널(설치 해역/판정/위험도/모선 상태/허가/멤버 수 슬라이더, localStorage 영속화), 행 클릭 시 지도 flyTo, 후보 일치율 칼럼 + 정렬
### 변경
- **그리드 후보 일치율 정확도** — resolution.top_score(평가 시점 고정) 대신 correlation_scores.current_score(실시간 갱신)의 최댓값 사용 → 최신 점수 반영
- **어구 그룹 칼럼 표시** — 모선 후보 MMSI가 그룹명 자리에 표시되던 버그 수정 (groupLabel/groupKey 우선 표시)
- **ParentResolution Entity 확장** — top_score/confidence/score_margin/decision_source/stable_cycles 등 점수 근거 7개 필드 추가
- **백엔드 correlation API 응답 정규화** — snake_case 컬럼을 camelCase로 명시 매핑 (프론트 매핑 누락 방지)
### 수정
- **궤적 리플레이 깜박임** — useMapLayers와 useGearReplayLayers가 동시에 overlay.setProps()를 호출하던 경쟁 조건 제거. 리플레이 활성 시 useMapLayers 비활성화 (단일 렌더링 경로)
- **멤버-중심 연결선 제거** — 어구 그룹 선택 모드에서 불필요하게 그려지던 dashed 연결선 코드 삭제
### 기타
- **루트 .venv/ gitignore 추가**
## [2026-04-14]
### 추가
- **DarkVesselDetection 판정 상세 패널** — 테이블 행 클릭 시 점수 산출 내역(P1~P11), GAP 상세, 7일 이력 차트 사이드 패널 표시
- **ScoreBreakdown 공통 컴포넌트** — 가점/감점 분리 점수 내역 시각화
- **darkVesselPatterns 카탈로그 확장** — prediction 실제 판정 패턴 18종 한국어 라벨+점수+설명 + buildScoreBreakdown() 유틸
- **TransferDetection 환적 운영 화면** — 5단계 파이프라인 기반 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- **GearDetection 모선 추론 연동** — 모선 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 컬럼
### 변경
- **DarkVesselDetection 위치 표시 수정** — lat/lon null 시 features.gap_start_lat/lon fallback, 클릭 시 지도 하이라이트
- **EnforcementPlan 탐지 기반 단속 대상** — CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 통합 표시
- **LGCNS 3개 페이지 디자인 시스템 전환** — LGCNSMLOps/AISecurityPage/AIAgentSecurityPage 공통 구조 적용
## [2026-04-13]
### 추가
- **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성
- **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인
- **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드
- **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드
- **prediction 알고리즘 재설계** — dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계, vessel_store/scheduler 개선
- **프론트엔드 지도 레이어 구조 정리** — BaseMap, useMapLayers, static layers 리팩토링
### 변경
- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁)
- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁)
## [2026-04-09]
### 추가 ### 추가
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현 - **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
@ -27,7 +198,6 @@
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목 - **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동) - **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등) - **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합 - **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort) - auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT - labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
@ -43,23 +213,6 @@
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링 - MainLayout: DB menuConfig에서 사이드바 자동 렌더링
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬 - **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화 - **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-09]
### 추가
- **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류 - **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류
- P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap - P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap
- 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점 - 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점
@ -72,6 +225,10 @@
- pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성) - pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성)
### 변경 ### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
- **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가 - **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가
- **event_generator 룰 전면 재편**: - **event_generator 룰 전면 재편**:
- EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰 - EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰
@ -86,6 +243,8 @@
- `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원 - `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원
### 수정 ### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
- `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움 - `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움
- `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영 - `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영
- `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상 - `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상
@ -94,6 +253,9 @@
- dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화) - dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화)
- transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반 - transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-08] ## [2026-04-08]
### 추가 ### 추가

파일 보기

@ -50,6 +50,7 @@ src/
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth) │ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백) │ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어) │ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
│ │ # 2026-04-17: common.json 에 aria(36)/error(7)/dialog(4)/success(2)/message(5) 네임스페이스 추가
│ └── theme/ # tokens, colors, variants (CVA) │ └── theme/ # tokens, colors, variants (CVA)
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값 │ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries) │ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
@ -89,20 +90,28 @@ src/
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현) │ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
│ └── index.ts # 배럴 export │ └── index.ts # 배럴 export
├── shared/components/ # 공유 UI 컴포넌트 ├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
│ ├── ui/ │ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent │ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
│ │ └── badge.tsx # Badge(CVA intent/size) │ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
│ │ ├── button.tsx # Button (variant 5종 × size 3단계, icon/trailingIcon prop)
│ │ ├── input.tsx # Input (size/state, forwardRef)
│ │ ├── select.tsx # Select (aria-label|aria-labelledby|title TS union 강제)
│ │ ├── textarea.tsx # Textarea
│ │ ├── checkbox.tsx # Checkbox (native input 래퍼)
│ │ ├── radio.tsx # Radio
│ │ └── tabs.tsx # TabBar + TabButton (underline/pill/segmented 3 variant)
│ ├── layout/ # PageContainer / PageHeader / Section (표준 페이지 루트)
│ └── common/ │ └── common/
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력) │ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
│ ├── Pagination.tsx # 페이지네이션 │ ├── Pagination.tsx # 페이지네이션
│ ├── SearchInput.tsx # 검색 입력 │ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
│ ├── ExcelExport.tsx # 엑셀 다운로드 │ ├── ExcelExport.tsx # 엑셀 다운로드
│ ├── FileUpload.tsx # 파일 업로드 │ ├── FileUpload.tsx # 파일 업로드
│ ├── PageToolbar.tsx # 페이지 상단 툴바 │ ├── PageToolbar.tsx # 페이지 상단 툴바
│ ├── PrintButton.tsx # 인쇄 버튼 │ ├── PrintButton.tsx # 인쇄 버튼
│ ├── SaveButton.tsx # 저장 버튼 │ ├── SaveButton.tsx # 저장 버튼
│ └── NotificationBanner.tsx # 알림 배너 │ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
├── features/ # 13 도메인 그룹 (31 페이지) ├── features/ # 13 도메인 그룹 (31 페이지)
│ ├── dashboard/ # 종합 대시보드 (Dashboard) │ ├── dashboard/ # 종합 대시보드 (Dashboard)
@ -309,7 +318,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
--- ---
## 라우팅 구조 (26 보호 경로 + login) ## 라우팅 구조 (27 보호 경로 + login)
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다. `App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
@ -322,6 +331,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
- `/enforcement-plan` — 단속계획 (SFR-06) - `/enforcement-plan` — 단속계획 (SFR-06)
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09) - `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
- `/gear-detection` — 어구 탐지 (SFR-10) - `/gear-detection` — 어구 탐지 (SFR-10)
- `/gear-collision` — 어구 정체성 충돌 (SFR-10, V030 — 동일 어구 이름 × 복수 MMSI 공존 감지)
- `/china-fishing` — 중국어선 탐지 - `/china-fishing` — 중국어선 탐지
- `/patrol-route` — 순찰경로 (SFR-07) - `/patrol-route` — 순찰경로 (SFR-07)
- `/fleet-optimization` — 함대 최적화 (SFR-08) - `/fleet-optimization` — 함대 최적화 (SFR-08)

파일 보기

@ -1,252 +0,0 @@
# Mock 데이터 공유 현황 분석 및 통합 결과
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
> 상태: **통합 완료**
---
## 1. 선박 데이터 교차참조
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
| 선박명 | 등장 파일 수 | 파일 목록 |
|---|---|---|
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
### 문제점
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
---
## 2. 위험도 스케일 불일치
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|---|---|---|---|
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
### 원인 분석
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
- EventList는 레벨 문자열 (`AlertLevel`)
### 통합 방안
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
```
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
```
---
## 3. KPI 수치 중복
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|---|---|---|
| 실시간 탐지 | 47 | 47 |
| EEZ 침범 | 18 | 18 |
| 다크베셀 | 12 | 12 |
| 불법환적 의심 | 8 | 8 |
| 추적 중 | 15 | 15 |
| 나포/검문(금일 단속) | 3 | 3 |
### 문제점
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
- 수치 변경 시 양쪽 모두 수정해야 함
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
---
## 4. 이벤트 타임라인 중복
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|---|---|---|---|---|
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
### 문제점
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
---
## 5. 환적 데이터 100% 중복
`TransferDetection.tsx``ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
```
TransferDetection.tsx:
const transferData = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
ChinaFishing.tsx:
const TRANSFER_DATA = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
```
### 문제점
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
- 한쪽만 수정하면 다른 쪽과 불일치 발생
---
## 6. 함정 상태 불일치
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|---|---|---|---|---|
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
| 1503함 | **미배포** | - | - | **정비중** |
### 문제점
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
---
## 7. 현재 상태: 통합 완료
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
### 7.1 완료된 아키텍처: mock -> store -> page
```
┌─────────────────────────────────────────────────────────────────────────┐
│ src/data/mock/ (7개 공유 모듈) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/features/*/ (페이지 컴포넌트) │
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
└─────────────────────────────────────────────────────────────────────────┘
```
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
| 스토어 | 소비 페이지 |
|---|---|
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
| `useTransferStore` | TransferDetection, ChinaFishing |
| `useGearStore` | GearDetection |
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
### 7.3 페이지 전용 인라인 데이터 (미통합)
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
| 페이지 | 인라인 데이터 | 사유 |
|---|---|---|
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
### 7.4 설계 원칙 (구현 완료)
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
### 7.5 Mock 모듈 상세 (참고용)
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
| # | 모듈 파일 | 스토어 | 내용 |
|---|---|---|---|
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
---
## 8. 작업 완료 요약
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|---|---|---|
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
| `events.ts` | **완료** | 6개 (useEventStore) |
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
| `gear.ts` | **완료** | 1개 (useGearStore) |
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
### 실제 작업 결과
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
---
## 9. 결론
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
달성한 효과:
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.

파일 보기

@ -1,194 +0,0 @@
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
---
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
- `vesselStore` — 선박 목록, 선택, 필터
- `patrolStore` — 순찰 경로/함정
- `eventStore` — 탐지/경보 이벤트
- `kpiStore` — KPI 메트릭, 추세
- `transferStore` — 전재(환적)
- `gearStore` — 어구 탐지
- `enforcementStore` — 단속 이력
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
---
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
### 현재 상태
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
### 남은 작업
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
- [ ] Axios 인터셉터:
- Request: Authorization 헤더 자동 주입
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
---
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
### 현재 상태
- `services/ws.ts``connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
### 남은 작업
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
- [ ] 구독 채널 설계:
- `/topic/ais-positions` — 실시간 AIS 위치
- `/topic/alerts` — 경보/이벤트
- `/topic/detections` — 탐지 결과
- `/user/queue/notifications` — 개인 알림
- [ ] 재연결 로직 (지수 백오프)
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
---
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay``useImperativeHandle`로 외부 노출
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
---
## 5. ✅ 더미 데이터 통합 — COMPLETED
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
```
data/mock/
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
├── transfers.ts # 전재(환적) 데이터
├── patrols.ts # PatrolShip — 순찰 경로/함정
├── gear.ts # 어구 탐지 데이터
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
└── enforcement.ts # 단속 이력 데이터
```
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
- 인터페이스가 API 응답 타입 계약 역할 수행
---
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
### 현재 상태
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
- ko/en 각 10파일 (총 20 JSON)
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
### 남은 작업
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
---
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
---
## 8. 코드 스플리팅 — 미착수
### 현재 상태
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
### 필요한 이유
- 초기 로딩 성능 개선 (FCP, LCP)
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
### 구현 계획
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
```typescript
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
```
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
```typescript
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
'vendor-chart': ['echarts'],
}
```
- [ ] 목표: 초기 번들 < 300KB (gzip), feature chunk < 100KB
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
---
## 9. Light 테마 하드코딩 정리
### 현재 상태
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
### 구현 계획
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
---
## 우선순위 및 의존관계
```
✅ 완료 ─────────────────────────────────────
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
진행 중 / 남은 작업 ──────────────────────────
[6. i18n 내부 텍스트] ──┐
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
[9. Light 테마 정리] ───┘
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
```
### 권장 진행 순서
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)

파일 보기

@ -1,436 +0,0 @@
# 페이지 역할표 및 업무 파이프라인
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
---
## 0. 공통 아키텍처
### 디렉토리 구조
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
```
src/features/
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
auth/ LoginPage
dashboard/ Dashboard
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
enforcement/ EnforcementHistory, EventList
field-ops/ AIAlert, MobileService, ShipAgent
monitoring/ MonitoringDashboard
patrol/ FleetOptimization, PatrolRoute
risk-assessment/ EnforcementPlan, RiskMap
statistics/ ExternalService, ReportManagement, Statistics
surveillance/ LiveMapView, MapControl
vessel/ TransferDetection, VesselDetail
```
### 데이터 흐름
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
```
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
```
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
### 지도 렌더링
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
### 다국어 (i18n)
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
- 지원 언어: 한국어 (ko), 영어 (en)
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
### 테마
- `settingsStore`에서 dark/light 테마 전환 지원
- 기본값: dark (해양 감시 시스템 특성상)
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
---
## 1. 31개 페이지 역할표
### 1.1 인증/관리 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
### 1.2 데이터 수집/연계 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
### 1.3 AI 모델/운영 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
### 1.4 탐지 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
### 1.5 환적 탐지 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
### 1.6 위험도 평가/계획 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
### 1.7 순찰/함대 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
### 1.8 감시/지도 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
### 1.9 대시보드/모니터링 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
### 1.10 이벤트/이력 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
### 1.11 현장 대응 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
### 1.12 통계/외부연계/보고 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
### 1.13 선박 상세 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
### 1.14 시스템 관리 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
---
## 2. 업무 파이프라인 (4개)
### 2.1 탐지 파이프라인
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
```
AIS/레이더/위성 신호
┌─────────┐
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
└────┬────┘
┌──────────────────────────────────────────────┐
│ AI 탐지 엔진 (AIModelManagement 관리) │
│ │
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
│ GearDetection ─────── 불법 어구 탐지 │
│ ChinaFishing ──────── 중국어선 통합 감시 │
│ TransferDetection ─── 환적 행위 탐지 │
│ GearIdentification ── 어구 국적 판별 │
└──────────────┬───────────────────────────────┘
┌──────────┐ ┌───────────────────┐
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
│ └───────────────────┘
┌──────────────────┐
│ EnforcementPlan │ ← 단속 우선지역 예보
└────────┬─────────┘
┌──────────────┐ ┌───────────────────┐
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
└──────┬───────┘ └─────────┬─────────┘
│ │
▼ ▼
┌──────────┐
│ AIAlert │ ← 함정/관제 자동 알림 발송
└────┬─────┘
현장 작전 (MobileService, ShipAgent)
```
### 2.2 대응 파이프라인
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
```
┌──────────┐
│ AIAlert │ ← AI 탐지 알림 자동 발송
└────┬─────┘
┌──────────────────────────────────┐
│ 현장 대응 │
│ │
│ MobileService ── 모바일 경보 수신│
│ ShipAgent ────── 함정 Agent 연동 │
└──────────────┬───────────────────┘
현장 단속 수행
(정선/검문/나포/퇴거)
┌──────────────────────┐
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
└──────────┬───────────┘
┌──────────────────────┐
│ ReportManagement │ ← 증거 패키징, 보고서 생성
└──────────┬───────────┘
검찰/외부기관 (ExternalService 통해 연계)
```
### 2.3 분석 파이프라인
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
```
┌─────────────┐
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
└──────┬──────┘
┌──────────┐
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
└────┬─────┘
┌──────────────┐
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
└──────┬───────┘
┌──────────────┐
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
└──────┬───────┘
전략 수립 (순찰 패턴, 탐지 규칙 조정)
```
### 2.4 관리 파이프라인
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
```
┌────────────────┐
│ AccessControl │ ← RBAC 역할/권한 설정
└───────┬────────┘
┌────────────┐
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
└──────┬─────┘
┌──────────────────────────────────────┐
│ 시스템 설정/관리 │
│ │
│ SystemConfig ──── 공통코드/환경설정 │
│ NoticeManagement ── 공지/배너/팝업 │
│ DataHub ────────── 데이터 수집 관리 │
│ AdminPanel ────── 서버/인프라 모니터 │
└──────────────────────────────────────┘
```
---
## 3. 사용자 역할별 페이지 접근 매트릭스
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
### 3.1 역할 정의
| 역할 | 코드 | 설명 | 인원(시뮬) |
|---|---|---|---|
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
### 3.2 접근 매트릭스
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|---|---|---|---|---|---|
| **인증/관리** | | | | | |
| LoginPage | O | O | O | O | O |
| AccessControl | O | - | - | - | - |
| SystemConfig | O | - | - | - | - |
| NoticeManagement | O | - | - | - | - |
| AdminPanel | O | - | - | - | - |
| **데이터/AI** | | | | | |
| DataHub | O | - | - | - | - |
| AIModelManagement | O | - | O | - | - |
| MLOpsPage | O | - | O | - | - |
| AIAssistant | O | O | O | - | - |
| **탐지** | | | | | |
| DarkVesselDetection | O | - | O | - | - |
| GearDetection | O | - | O | - | - |
| ChinaFishing | O | O | O | - | - |
| TransferDetection | O | - | O | - | - |
| **위험도/계획** | | | | | |
| RiskMap | O | O | O | - | - |
| EnforcementPlan | O | O | - | - | - |
| **순찰** | | | | | |
| PatrolRoute | O | O | - | - | - |
| FleetOptimization | O | O | - | - | - |
| **감시/지도** | | | | | |
| LiveMapView | O | O | O | - | - |
| MapControl | O | O | - | - | - |
| **대시보드** | | | | | |
| Dashboard | O | O | O | O | O |
| MonitoringDashboard | O | O | - | - | - |
| **이벤트/이력** | | | | | |
| EventList | O | O | O | O | - |
| EnforcementHistory | O | - | O | - | - |
| **현장 대응** | | | | | |
| MobileService | O | - | - | O | - |
| ShipAgent | O | - | - | O | - |
| AIAlert | O | O | - | O | - |
| **통계/보고** | | | | | |
| Statistics | O | O | O | - | - |
| ExternalService | O | - | - | - | O |
| ReportManagement | O | O | O | - | - |
| **선박 상세** | | | | | |
| VesselDetail | O | O | O | - | - |
### 3.3 역할별 요약
| 역할 | 접근 가능 페이지 | 페이지 수 |
|---|---|---|
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
---
## 4. 페이지 간 데이터 흐름 요약
```
┌──────────────────┐
│ LoginPage │
│ (인증 게이트) │
└────────┬─────────┘
┌────────────────────┬┴──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
│ │ │ │ │ │
│ AccessControl│ │ DataHub │ │ MobileSvc │
│ SystemConfig │ │ ↓ │ │ ShipAgent │
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
│ AdminPanel │ │ ↓ │ │
└──────────────┘ │ RiskMap │ │
│ ↓ │ ▼
│ EnforcementPlan │ ┌──────────────┐
│ ↓ │ │ 대응 파이프라인│
│ PatrolRoute │ │ │
│ FleetOptim │ │ Enforcement │
│ ↓ │ │ History │
│ LiveMapView │ │ ReportManage │
│ Monitoring │ │ ExternalSvc │
└────────┬────────┘ └──────────────┘
┌─────────────────┐
│ 분석 파이프라인 │
│ │
│ Statistics │
│ VesselDetail │
│ AIAssistant │
└─────────────────┘
```
---
## 5. 미할당 SFR 참고
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
- **VesselDetail**: SFR 번호 미부여, 선박 상세
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트

250
docs/prediction-analysis.md Normal file
파일 보기

@ -0,0 +1,250 @@
# Prediction 모듈 심층 분석 — 구조·방향 리뷰
**대상:** `prediction/` (Python 3.11+, FastAPI, APScheduler, 59 `.py` 파일)
**작성일:** 2026-04-17
**작성 관점:** opus 4.7 독립 리뷰 — 정밀도 튜닝이 아닌 **방향성·코드 구조**
**전제:** 프로토타입·데모 단계. 정밀도 미흡은 인지된 상태.
---
## TL;DR — 3줄 요약
1. **뼈대는 튼튼하다.** 레이어 분리(algorithms/pipeline/output/db/cache), 순수함수 위주 알고리즘, 카테고리별 dedup 윈도우 분리까지 프로토타입치고는 일관된 설계.
2. **약한 고리는 오케스트레이터.** [scheduler.py run_analysis_cycle()](../prediction/scheduler.py) 한 함수가 700+ 라인, 지역 try/except + `logger.warning` 로 흡수된 실패가 많아 "어디서 깨졌는지 조용히 묻힌다". 상태 누적(모듈 전역 `_transship_pair_history`)도 여기 묶여 있음.
3. **커버리지 매트릭스는 4/4 이지만 UI 비대칭.** prediction 이 생산하는 결과 중 `ILLEGAL_FISHING_PATTERN` 이벤트·환적 의심은 DB·백엔드까지 도달하지만 전용 detection UI 가 없다. prediction 품질 개선과 무관하게 운영자가 쓸 수 없는 상태.
**권고 최우선 3가지** — 신규 알고리즘 추가보다 아래가 선행:
- **P1:** 사이클 스테이지 단위 에러 경계(`_stage(...)` 유틸)로 교체해 실패 스테이지 명시 로깅 + 부분 실패 시에도 후속 단계 진행
- **P1:** 하드코딩 임계값(MID prefix, 커버리지 박스, SOG band, 11 pattern 점수) 을 `correlation_param_models` 패턴처럼 DB/config 로 외부화
- **P1:** 환적 전용 + ILLEGAL_FISHING_PATTERN 전용 프론트 페이지 추가 — 이미 DB·API 는 있음
---
## 1. 아키텍처 레이어 — 책임과 결합도
```
prediction/
├── config.py Pydantic Settings + qualified_table() — SSOT 설정
├── scheduler.py APScheduler + run_analysis_cycle() (단일 엔트리)
├── main.py FastAPI app + /health /status /chat 등
├── fleet_tracker.py 상태 보유 (선단 레지스트리, 어구 정체성)
├── time_bucket.py 안전 지연 12분 + backfill 3 버킷
├── algorithms/ 17개 모듈 — 순수함수 중심
├── pipeline/ 8개 모듈 — 7단계 분류 파이프라인
├── output/ 5개 모듈 — event/violation/kpi/stats/alert
├── db/ 4개 모듈 — snpdb / kcgdb / signal_api / partition_manager
├── cache/ vessel_store.py — 24h sliding window 인메모리
├── chat/ Ollama + RAG 스텁
├── models/ result.py — AnalysisResult dataclass
├── data/ monitoring zones JSON 정적 설정
└── tests/ time_bucket / gear_parent_episode / gear_parent_inference 3종
```
| 레이어 | 책임 | 결합도 평가 |
|---|---|---|
| `config.py` | 환경 + SQL identifier 검증 | ✅ SSOT, `qualified_table()` 로 스키마 주입 공격 방지 |
| `algorithms/` | 탐지 로직 (순수) | ✅ 대부분 `df, params -> dict/tuple`. 상호 의존 적음 |
| `pipeline/` | 7단계 sequential | ✅ `orchestrator.ChineseFishingVesselPipeline.run()` 이 DF 를 그대로 파이프 |
| `output/` | 룰 엔진 + DB write | ✅ 룰을 lambda 리스트(event_generator.RULES)로 선언적 관리 |
| `db/` | Connection pool + SQL | ⚠️ `kcgdb.upsert_results(results)` 가 한 트랜잭션에 전부 묶임 (파티션 unique index 활용은 적절) |
| `cache/vessel_store.py` | 전역 싱글턴, 24h 궤적 인메모리 | ⚠️ 모듈 싱글턴 → 테스트 시 mock 어려움 |
| `fleet_tracker.py` | 레지스트리·어구 정체성 상태 | ⚠️ 싱글턴 + 모듈 전역 캐시 |
| `scheduler.py` | 전체 오케스트레이션 | ❌ **700+ 라인 모놀리식** — 아래 §2 상세 |
---
## 2. 5분 사이클 시퀀스 — [scheduler.py:80-804](../prediction/scheduler.py#L80-L804)
사이클 전체가 **한 함수** 안에서 9단계로 진행된다.
| 단계 | 라인 | 역할 | 실패 처리 |
|---|---|---|---|
| 1. 증분 로드 | [97-106](../prediction/scheduler.py#L97-L106) | `snpdb.fetch_incremental()` → vessel_store merge | 전체 try/except 포함 |
| 2. 정적 보강 | [108-112](../prediction/scheduler.py#L108-L112) | signal-batch API 호출 | 전체 try/except |
| 3. 대상 선별 | [114-128](../prediction/scheduler.py#L114-L128) | SOG/COG 계산 + 0건 시 조기 return | ✅ 조기 반환 |
| 4. 파이프라인 | [122-128](../prediction/scheduler.py#L122-L128) | `ChineseFishingVesselPipeline.run()` | 전체 try/except |
| 5. 선단 분석 | [131-177](../prediction/scheduler.py#L131-L177) | fleet_tracker + gear_identity 충돌 감지 | ⚠️ 내부 try/except 로 warning, 전진 |
| 5.5. 어구 그룹·상관·부모 추론 | [181-229](../prediction/scheduler.py#L181-L229) | polygon_builder + gear_correlation | ⚠️ 내부 try/except, 결과 없이 진행 |
| 5.9. 쌍끌이 후보·판정 | [231-303](../prediction/scheduler.py#L231-L303) | pair_trawl STRONG/PROBABLE/SUSPECT | ⚠️ 내부 try/except |
| 6. 개별 선박 분석 | [305-515](../prediction/scheduler.py#L305-L515) | AnalysisResult 생성 (파이프라인 통과자) | 루프 내 continue |
| 6.5. 경량 분석 | [523-682](../prediction/scheduler.py#L523-L682) | 파이프라인 미통과 중국 MID — `compute_lightweight_risk_score` | 루프 내 try/except |
| 7. 환적 의심 | [685-713](../prediction/scheduler.py#L685-L713) | `detect_transshipment` + `_transship_pair_history` 누적 | 전체 try/except |
| 8. DB upsert | [716-717](../prediction/scheduler.py#L716-L717) | `kcgdb.upsert_results()` + `cleanup_old(48h)` | 전체 try/except |
| 9. 출력 모듈 | [720-745](../prediction/scheduler.py#L720-L745) | violation_classifier → event_generator → kpi_writer → aggregate_hourly/daily → alert_dispatcher | ⚠️ **5개 단계를 한 try/except 로 묶음** → 어디서 실패했는지 단일 warning 으로 흡수 |
| 10. 채팅 컨텍스트 캐싱 | [748-791](../prediction/scheduler.py#L748-L791) | Redis | 전체 try/except |
### 구조적 관찰
- **전체 try/except 는 있다** ([97](../prediction/scheduler.py#L97) ~ [803](../prediction/scheduler.py#L803)) — 치명 실패가 다음 사이클을 막지는 않음
- **그러나 내부 스테이지가 너무 무겁다.** 9번째 출력 단계가 5개 모듈을 한 덩어리로 묶어 `logger.warning('output modules failed (non-fatal): %s', e)` 로 흡수. 어느 모듈이 깨졌는지 디버깅하려면 stacktrace 를 `logger.exception` 으로 바꿔야 함
- **Lazy import** 가 스테이지마다 반복 (`from output.event_generator import ...` 등). 시작 시간 단축에는 도움이지만 import 오류가 **첫 사이클 실행 시점에만 드러남** — 배포 후 5분 지연 발견 경험 다수
### 권고 (사이클 구조 재정비)
```python
def _stage(name: str, fn, *args, required=False, **kwargs):
t0 = time.time()
try:
result = fn(*args, **kwargs)
logger.info('stage %s ok in %.2fs', name, time.time() - t0)
return result
except Exception as e:
logger.exception('stage %s failed: %s', name, e)
if required:
raise
return None
```
- 각 스테이지를 `_stage('pair_detection', _run_pair_detection, ...)` 로 감싸면 실패 스테이지 명시 로깅 + stacktrace + 부분 실패 허용 정책을 일관화.
- 9번 단계의 5개 모듈은 각각 별도 `_stage(...)` 호출로 쪼갤 것.
---
## 3. 알고리즘 카탈로그 (17 모듈 × 담당 도메인)
| 파일 | 주 역할 | 입력 | 출력 | 주요 상수 |
|---|---|---|---|---|
| [dark_vessel.py](../prediction/algorithms/dark_vessel.py) | AIS gap + 11 패턴 점수화 | 선박 DF(timestamp, lat, lon, sog, cog) | (score 0~100, patterns[], tier) | GAP_SUSPICIOUS=6000s, VIOLATION=86400s, KR_COVERAGE box |
| [spoofing.py](../prediction/algorithms/spoofing.py) | 텔레포트·극속·BD09 오프셋 | 선박 DF | spoofing_score 0~1 | EXTREME_SPEED=50kn (주석 기준), fishing max=25kn |
| [risk.py](../prediction/algorithms/risk.py) | 종합 risk + 경량 risk 2종 | DF, zone, is_permitted, 외부 점수 | (risk 0~100, level) | tier: 70+=CRITICAL, 50+=HIGH, 30+=MEDIUM |
| [fishing_pattern.py](../prediction/algorithms/fishing_pattern.py) | UCAF/UCFT gear SOG band | DF, gear | (ucaf, ucft) | PT 2.5~4.5, OT 2.0~4.0, GN 0.5~2.5 |
| [transshipment.py](../prediction/algorithms/transshipment.py) | 5단계 필터 파이프라인 | DF targets, pair_history, zone_fn | list of dict | PROXIMITY ~220m, RENDEZVOUS 90min, WATCH 제외 |
| [location.py](../prediction/algorithms/location.py) | zone 분류, haversine_nm, BD09 | (lat, lon) | zone, dist_nm | 12/24 NM 밴드 |
| [gear_correlation.py](../prediction/algorithms/gear_correlation.py) | 멀티모델 EMA + streak | vessel_store, gear_groups, conn | UPDATE gear_correlation_scores | α_base=0.30, polygon=0.70 |
| [gear_identity.py](../prediction/algorithms/gear_identity.py) | 공존 쌍 추출 (V030/PR #73) | gear_signals | collisions[] | CRITICAL_DIST=50km, COEXIST=3회 |
| [gear_parent_inference.py](../prediction/algorithms/gear_parent_inference.py) | 어구 → 모선 assignment | gear_groups + tracks | parent 후보 + confidence | 2-pass: direct-match → candidates |
| [gear_parent_episode.py](../prediction/algorithms/gear_parent_episode.py) | 에피소드 delineation (first_seen~last_seen) | gear_signals 시계열 | episodes[] | gap tolerance |
| [gear_violation.py](../prediction/algorithms/gear_violation.py) | G-01~G-06 통합 판정 (DAR-03) | DF + zone + pair_result + permits | g_codes[], evidence, score | G-06=20pts, G-02=18pts, G-01=15pts |
| [gear_name_rules.py](../prediction/algorithms/gear_name_rules.py) | 어구 이름 정규표현식 | string | parent_code (Optional) | regex set |
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | 쌍끌이 tier 분류 | DF×2, 6h | (pair_detected, tier, pair_type) | PROXIMITY=0.27NM, MIN_SYNC=2h |
| [track_similarity.py](../prediction/algorithms/track_similarity.py) | DTW 궤적 유사도 | DF×2 | 0~1 | - |
| [fleet.py](../prediction/algorithms/fleet.py) | leader/follower/independent | DF, tracker | role | - |
| [polygon_builder.py](../prediction/algorithms/polygon_builder.py) | gear group convex hull | vessel_store, companies | 스냅샷[] | 시간버킷 |
| [vessel_type_mapping.py](../prediction/algorithms/vessel_type_mapping.py) | fishery_code → vessel_type 폴백 | fishery_code | 'TRAWL'/'PT'/... | - |
**관찰:**
- 대부분 **순수 함수** → 재사용·단위테스트 용이. 단 `gear_correlation.run_gear_correlation``conn` 을 받아 DB 를 직접 UPDATE 함 (알고리즘 + I/O 혼재)
- **상수가 모듈 상단에 모여 있는 것은 좋으나 config 외부화는 안 됨**. 현장 운영자가 임계값을 바꾸려면 코드 배포 필요
---
## 4. 4대 도메인 커버리지 매트릭스
| 도메인 | 담당 파일 | 접근 방식 | 구조적 강점 | 구조적 공백 |
|---|---|---|---|---|
| **Dark Vessel** | `dark_vessel.py` + `risk.py` + `compute_dark_suspicion` | gap(100분 임계) + 11 패턴 점수 + 4-tier (CRITICAL≥70) | 패턴 분리(P1~P11) + 한국 커버리지 감점(50pt)으로 자연 gap 구분 | MID/커버리지 박스 하드코딩. 감점 로직은 탐지 누락 위험(coverage box 바깥=자연 gap 가정) — 중국 EEZ 해역 근접 공해에서 의도적 OFF 가 감점 맞을 수 있음 |
| **Gear Tracking** | `gear_correlation` + `gear_identity` + `gear_parent_inference` + `polygon_builder` | 어구 이름 regex → 공존/교체 분기 + 멀티모델 EMA + 모선 추론 + 폴리곤 | 공존(GEAR_IDENTITY_COLLISION)을 1순위 증거로 재정의 (V030). SAVEPOINT 로 PK 충돌 격리 | 어구 신호는 **이름 regex 의존**. 이름이 GEAR_PATTERN 에 맞지 않으면 파이프라인 전체 진입 실패 |
| **Chinese Fishing** | `pipeline/` 7단계 + MID 412/413/414 필터 | preprocess → behavior → resample → feature → classify → cluster → seasonal + rule-based 분류 | 룰 + 군집으로 해석 가능 | MID prefix 하드코딩 2곳 중복. 전체 인구(55k)대비 500척만 파이프라인 통과 — 경량 분석(§6)으로 보완하나 여전히 <10% |
| **Illegal Pattern** | `gear_violation` (G-01~G-06) + `event_generator.RULES` (15+ 룰) + `transshipment` 5단계 | 임계값 기반 독립 룰 × 카테고리별 dedup 윈도우 | 룰이 lambda 리스트로 선언적 | **UI 미노출** — DB `prediction_events` 카테고리 `ILLEGAL_*` 는 생산되나 전용 detection UI 없음. 운영자는 EventList(`/event-list`) 에서만 조회. 환적도 동일 문제 |
---
## 5. 코드 구조 평가 (6축)
| 축 | 평가 | 근거 |
|---|---|---|
| **관심사 분리** | B+ | algorithms / pipeline / output / db 레이어는 깔끔. 단 scheduler 는 오케스트레이터가 아니라 **메가 함수** |
| **재사용성** | A- | 17 알고리즘 모듈 중 ~15개가 순수함수. `run_gear_correlation` 만 conn 혼재 |
| **테스트 가능성** | C+ | unit test 3개만 (time_bucket / gear_parent_episode / gear_parent_inference). `vessel_store` / `fleet_tracker` 싱글턴 → integration test 어려움 |
| **에러 격리** | C | 사이클 전체 try/except + 내부 지역 try/except 혼재. 출력 5모듈이 한 덩어리 → 실패 지점 특정 불가 |
| **동시성** | A- | `ThreadedConnectionPool(1,5)`, `max_instances=1` 스케줄러 — 단일 프로세스 가정 하에서 안전 |
| **설정 가능성** | C- | 임계값 대부분 파일 상수. `correlation_param_models` 패턴만 DB 기반 (예외) |
### 주목할 잘된 점
- **Dedup 윈도우 카테고리별 차등** ([event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39)) — 5분 boundary 집단 만료를 피하기 위해 33/67/89/127/131/181/367분 등 **의도적으로 5의 배수 회피**. 룰 기반 탐지의 대표적인 "튜닝 knob" 이 코드에 명시.
- **gear_identity 공존/교체 분기** ([fleet_tracker.py](../prediction/fleet_tracker.py) 트랜잭션 설계) — SAVEPOINT 로 부분 실패를 사이클 전체 abort 와 분리. 이전 13h 공백 사고의 재발 방지 설계가 구조에 반영됨.
- **Lightweight path** — 파이프라인 통과 못한 중국 MID 를 경량 경로로 계속 커버 ([scheduler.py:523-682](../prediction/scheduler.py#L523-L682)). "정밀 vs 커버리지" 를 두 경로로 나누는 의사결정 자체는 탁월.
### 구조적 채무
- **환경 분기 부재**: `config.py` 에 dev/prod 분기가 없음. `.env` 파일 하나에 의존. 로컬 실행 시 운영 DB 를 건드리는 위험 ([config.py:8-22](../prediction/config.py#L8-L22))
- **상태 있는 모듈 전역 변수**: `_transship_pair_history`, `_last_run`, `_scheduler` ([scheduler.py:16-26](../prediction/scheduler.py#L16-L26)). 테스트 격리 어렵고, 재시작 시 pair 누적 상태 증발
- **DB 쓰기 산재**: `kcgdb.upsert_results` / `save_group_snapshots` / `gear_correlation UPDATE` / `gear_identity UPSERT` / `prediction_events INSERT` 가 서로 다른 트랜잭션. 한 사이클 원자성 X — 의도적일 수 있으나 명시 설계 문서 없음
---
## 6. 방향성 진단 — 프로토타입 → MVP → 운영
### 지금 강점
- **룰 기반 탐지를 탄탄히 다져둔 토대** — 임계값이 드러나 있고 dedup 설계가 명시적. 향후 ML overlay 를 얹을 때 "어디에 얹을지" 가 명확 (dark_suspicion score, transship score, gear_violation score 가 모두 연속값으로 산출)
- **운영자 의사결정 통합 설계** — V030 GEAR_IDENTITY_COLLISION 에서 status(OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE) 가 severity 재계산을 억제하는 패턴 — 사람 loop back 이 설계된 유일한 자리. 다른 도메인에도 이 패턴 확장 가능
### 지금 약점
- **ML 부재** — sklearn/torch 없음. 2026 기준 프로토타입으로는 적절하나, sequence anomaly (dark gap 의 시계열 반복 패턴) 나 behavioral classifier 는 룰만으론 한계
- **하드코딩 지대**: MID prefix(4개소), KR coverage box, SOG band, 11 패턴 점수, 5단계 transship 임계 — 모두 "이 프로젝트에서 튜닝해야 할 핫스팟" 인데 DB/config 분리 안 됨
- **UI 비대칭**: `prediction_events.category='ILLEGAL_FISHING_PATTERN'` 이 생산되지만 전용 UI 없음. 환적도 동일. 결과적으로 **prediction 이 만드는 가치의 일부가 운영자에게 도달하지 못함**
- **테스트 빈곤**: 17 알고리즘 중 3개만 유닛테스트. 사이클 단위 integration test 전혀 없음 — 사이클 회귀가 항상 운영 로그로만 드러남 (13h 공백 사고가 대표 사례)
---
## 7. 구조적 개선 제안 (우선순위별)
### P1 — 지금 해야 할 것 (운영 안정성)
1. **사이클 스테이지 단위 에러 경계**`_stage(name, fn, required=False)` 유틸로 9번 출력 5모듈을 쪼갤 것. `logger.exception` 으로 stacktrace 보존. `required=True``fetch_incremental` 같은 fatal 에만 적용 → 실패 시 조기 반환
2. **임계값 외부화**`correlation_param_models` 패턴을 확장해 `detection_params` 테이블 신설 (algo_name, param_key, value, active_from, active_to). 배포 없이 해상도 튜닝 가능. 운영자 권한으로 접근 시 감사 로그
3. **ILLEGAL_FISHING_PATTERN 전용 페이지** + **환적 전용 페이지** — 백엔드 API·DB 는 이미 존재. 프론트만 GearCollisionDetection 패턴으로 추가 (`PageContainer` + `DataTable` + `Badge intent`)
4. **사이클 부분 원자성 명시** — DB 쓰기 경계 문서화 (어디까지가 한 트랜잭션인지). 최소한 [architecture.md](architecture.md) 또는 신설 `docs/prediction-transactions.md` 에 다이어그램
### P2 — 다음 (품질 확보)
5. **알고리즘 유닛테스트 커버리지** — 17 모듈 중 최소 10 개 (dark_vessel 11 패턴 / transshipment 5단계 / gear_violation 6 G-code / spoofing / risk) 에 fixture 기반 테스트. `tests/fixtures/` 에 AIS DF CSV 샘플
6. **DB fixture integration test** — testcontainers-python 으로 PostgreSQL 띄워 한 사이클 실행 + 결과 테이블 assert. CI 에서 돌릴 수 있도록 데이터 10 척 x 1h 정도 경량
7. **`vessel_store` / `fleet_tracker` 의존성 주입 개편** — 모듈 싱글턴 → `AnalysisContext` dataclass 로 명시 주입. 테스트 mock 가능
8. **MID prefix·커버리지 box 를 `monitoring_zones` JSON 연장** — 이미 `data/monitoring_zones.json` 이 있음. 동일 포맷으로 `mid_prefixes.json` / `kr_coverage.json` 추가
### P3 — 중기 (가치 확장)
9. **ML overlay 타겟 설정** — dark_suspicion score (11 패턴 합산) 은 classifier training target 으로 최적. GCN/Transformer 로 **"gap 시퀀스가 의도적인가"** 를 학습. 룰 유지 + 게이트만 ML 로 대체 (shadow mode 로 비교)
10. **correlation 파라미터 MLOps 연동**`correlation_param_models` 를 MLflow 로 실험 기록 → 성능 좋은 모델 자동 active 전환
11. **AIS 벤치마크 데이터셋** — 한중어업협정 906척 중 과거 단속 이력 있는 선박을 positive label. 현재 매칭률 53%+ 이므로 샘플 확보 가능. tier 별 precision/recall 산출
### P4 — 장기 (스케일)
12. **multi-process / async** — APScheduler + 단일 스레드 한계. 현 55k 선박 / 2.3M points / 110초 사이클에서 8k 중국 증가 + 한국 확장 시 5분 주기 내 완료 불가 예측. asyncio + ray / dask 로 스테이지 병렬
13. **Event bus 분리** — 지금은 `prediction_events` INSERT 가 동기. outbox 패턴으로 비동기 분리 시 백엔드/프론트 실시간 push 기반 (WebSocket) 로 진화 가능
---
## 8. 부록 — 임계값 전수표 (외부화 우선순위)
| 위치 | 상수 | 현재값 | P1 외부화 | 비고 |
|---|---|---|---|---|
| [scheduler.py:28](../prediction/scheduler.py#L28) | `_KR_DOMESTIC_PREFIXES` | `('440','441')` | ✅ | 한국 MID |
| [scheduler.py:140,247](../prediction/scheduler.py#L140) | 중국 MID prefix | `'412' '413' '414'` 하드코딩 2곳 | ✅ | `mid_prefixes.json` |
| [scheduler.py:256](../prediction/scheduler.py#L256) | pair pool SOG band | `1.5 <= mean_sog <= 5.0` | ✅ | 조업 속력대 |
| [dark_vessel.py:6-8](../prediction/algorithms/dark_vessel.py#L6-L8) | GAP 임계 3종 | 6000/10800/86400s | ✅ | 100분/3h/24h |
| [dark_vessel.py:11-12](../prediction/algorithms/dark_vessel.py#L11-L12) | `_KR_COVERAGE_LAT/LON` | 32.0~39.5 / 124.0~132.0 | ✅ | AIS 수신 박스 |
| [dark_vessel.py:257-359](../prediction/algorithms/dark_vessel.py#L257-L359) | 11 패턴 점수 P1~P11 | 10~30 pt 분산 | ⚠️ | 탐지 정책 튜닝 대상 |
| [event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39) | DEDUP_WINDOWS 12 카테고리 | 33~367분 | ⚠️ | 이미 의도적 |
| [config.py:25-37](../prediction/config.py#L25-L37) | 파이프라인 주기 등 | 5/24/60/30/12/3 | ✅ env | `.env` 로 이미 가능 |
| [transshipment.py](../prediction/algorithms/transshipment.py) | PROXIMITY / RENDEZVOUS | 0.002deg / 90min | ✅ | 환적 민감도 |
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | PROXIMITY / SOG_Δ / COG_Δ / MIN_SYNC | 0.27NM / 0.5kn / 10° / 2h | ⚠️ | tier 재분류 기준 |
**P1:** 배포 없이 튜닝 가능해야 할 것
**⚠️:** 튜닝 자체가 탐지 정책 변경 → 릴리즈 노트 필요
---
## 9. 관련 파일 인덱스
- 오케스트레이터: [scheduler.py](../prediction/scheduler.py)
- 설정: [config.py](../prediction/config.py)
- 상태 컴포넌트: [fleet_tracker.py](../prediction/fleet_tracker.py), [cache/vessel_store.py](../prediction/cache/vessel_store.py)
- 알고리즘: [algorithms/](../prediction/algorithms/)
- 파이프라인: [pipeline/orchestrator.py](../prediction/pipeline/orchestrator.py)
- 출력: [output/event_generator.py](../prediction/output/event_generator.py), [output/violation_classifier.py](../prediction/output/violation_classifier.py), [output/kpi_writer.py](../prediction/output/kpi_writer.py), [output/stats_aggregator.py](../prediction/output/stats_aggregator.py), [output/alert_dispatcher.py](../prediction/output/alert_dispatcher.py)
- DB: [db/kcgdb.py](../prediction/db/kcgdb.py), [db/snpdb.py](../prediction/db/snpdb.py), [db/partition_manager.py](../prediction/db/partition_manager.py)
연관 운영 문서:
- [architecture.md](architecture.md) — 프론트엔드 아키텍처
- [sfr-traceability.md](sfr-traceability.md) — SFR 매트릭스
- [system-flow-guide.md](system-flow-guide.md) — system-flow 노드 명세
- `backend/README.md` — 백엔드 구성 (V030 + PR #79 hotfix 요구사항 명시)
---
## 변경 이력
| 일자 | 내용 |
|---|---|
| 2026-04-17 | 초판 — opus 4.7 독립 리뷰. 구조/방향 중심 + 우선순위별 개선 제안 |

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,7 +1,8 @@
# SFR 요구사항별 화면 사용 가이드 # SFR 요구사항별 화면 사용 가이드
> **문서 작성일:** 2026-04-06 > **문서 작성일:** 2026-04-06
> **시스템 버전:** v0.1.0 (프로토타입) > **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼) > **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼) > **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
@ -11,7 +12,12 @@
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다. 이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다. ### 시스템 현황 (2026-04-17 기준)
- **프런트엔드·백엔드·분석엔진(prediction) 운영 배포 완료** — 자체 JWT 인증 + 트리 기반 RBAC + 감사 로그 + 65+ API
- **AI 분석 엔진(prediction)**: 5분 주기로 AIS 원천 데이터(snpdb)를 분석하여 결과를 `kcgaidb` 에 자동 저장 (14 알고리즘 + DAR-03 G-01~G-06)
- **실시간 연동 화면**: Dashboard / MonitoringDashboard / ChinaFishing / DarkVesselDetection / GearDetection / EnforcementHistory / EventList / AIAlert / Statistics / AccessControl / PermissionsPanel / Audit 등 **15+ 화면이 실 API + prediction 결과를 실시간으로 표시**
- **Mock 화면**: DataHub / AIModelManagement / RiskMap / PatrolRoute / FleetOptimization / ExternalService / ShipAgent / MLOpsPage / AIAssistant 는 UI 완성, 백엔드/AI 엔진 연동은 단계적 추가 중
- **자세한 추적 매트릭스**: `docs/sfr-traceability.md` v3.0 참조
--- ---
@ -55,17 +61,18 @@
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER) - 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
- 로그인 후 역할에 따른 메뉴 접근 제어 - 로그인 후 역할에 따른 메뉴 접근 제어
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능 - ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 - ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
**향후 구현 예정:** **향후 구현 예정 (기업 환경 연동):**
- 🔲 SSO(Single Sign-On) 연동 - 🔲 SSO(해양경찰 통합인증) 연동
- 🔲 GPKI(정부 공인인증서) 인증 연동 - 🔲 GPKI(정부 공인인증서) 인증 연동
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화 - 🔲 공무원증 기반 인증 연동
- 🔲 인사 시스템 연동 역할 자동 부여
**보완 필요:**
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
--- ---
@ -83,16 +90,17 @@
- 역할별 접근 가능 메뉴 및 기능 권한 설정 - 역할별 접근 가능 메뉴 및 기능 권한 설정
- 사용자 목록 조회 및 역할 할당 - 사용자 목록 조회 및 역할 할당
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시 - ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
- ✅ 권한 설정 화면 레이아웃 및 인터랙션 - ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 Y → N → 상속 사이클)
- ✅ 부모 READ 거부 시 자식 강제 거부, 상속 표시
- ✅ 역할 CRUD (admin:role-management) + 권한 매트릭스 갱신 (admin:permission-management)
- ✅ 사용자-역할 할당 다이얼로그 (admin:user-management)
- ✅ 모든 권한 변경은 `auth_audit_log` 에 자동 기록 (ROLE_CREATE/UPDATE/DELETE/PERM_UPDATE/USER_ROLE_ASSIGN)
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD - 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
- 🔲 감사 로그(권한 변경 이력) 기록 - 🔲 역할 템플릿 복제 기능
**보완 필요:**
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
--- ---
@ -369,17 +377,18 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 의심 선박 상세 프로필 및 이동 궤적 조회 - 의심 선박 상세 프로필 및 이동 궤적 조회
- 위험도 등급별 분류 표시 - 위험도 등급별 분류 표시
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 의심 선박 7척 목록/지도 시각화 - ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
- ✅ 5가지 행동 패턴 분석 결과 UI - ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 AI Dark Vessel 탐지 엔진 연동 - 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
- 🔲 실시간 AIS 데이터 분석 연동
- 🔲 SAR(위성영상) 기반 탐지 연동 - 🔲 SAR(위성영상) 기반 탐지 연동
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
--- ---
@ -398,16 +407,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 해역별 중국 어선 밀집도 분석 - 해역별 중국 어선 밀집도 분석
- 시계열 활동 패턴 분석 - 시계열 활동 패턴 분석
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 중국 어선 분석 종합 대시보드 UI - ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동**`/api/analysis/*` 경유, MMSI prefix `412` 고정
- ✅ 지도 기반 활동 현황 시각화 - ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합) - 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
- 🔲 실시간 데이터 기반 분석 갱신 - 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
**보완 필요:**
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
--- ---
@ -426,17 +436,48 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도) - 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
- 탐지 이미지 확인 - 탐지 이미지 확인
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 어구 6건 탐지 결과 목록/지도 UI - ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
- ✅ 어구 식별 결정트리 시각화 - ✅ G코드별 탐지: G-01(수역-어구 불일치) / G-02(금어기) / G-03(미등록 어구) / G-04(MMSI cycling) / G-05(고정어구 drift) / G-06(쌍끌이 — STRONG/PROBABLE/SUSPECT tier)
- ✅ 어구 그룹 지도 (ZONE_I~IV 폴리곤 + GeoJsonLayer + IconLayer) + 세부 필터 패널(해역/판정/위험도/모선 상태/허가/멤버 수) + localStorage 영속화
- ✅ GearDetailPanel — 후보 클릭 → 점수 근거(관측 7종 + 보정 3종) + 모선 확정/제외 버튼
- ✅ 24h 궤적 리플레이 (GearReplayController + TripsLayer, SPEED_FACTOR=2880, 24h→30s)
- ✅ 어구/어망 판별 화면 — 허가코드/어구물리특성/발견위치 입력 → 국적 판별(한/중/미확인) + 판별 근거·경고·AI 탐지 Rule·교차 검증 파이프라인
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반) - 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
- 🔲 실시간 CCTV/SAR 영상 분석 연동 - 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
- 🔲 탐지 결과 자동 분류 및 알림
**보완 필요:** ---
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
### 어구 정체성 충돌 (V030, 2026-04-17 추가)
**메뉴 위치:** 탐지/분석 > 어구 정체성 충돌
**URL:** `/gear-collision`
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER (READ) / ADMIN, OPERATOR (UPDATE)
**화면 설명:**
동일한 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 송출되는 공존 케이스를 탐지해
어구 복제/스푸핑 증거로 기록·분류하는 화면입니다. 이전에는 "MMSI 교체"로 오해해 덮어쓰는 바람에
`gear_correlation_scores_pkey` 제약 충돌로 prediction 사이클 전체가 실패하던 이슈(PR #73)를
"공존 패턴"으로 재정의하면서 신설되었습니다.
**주요 기능:**
- 충돌 쌍(name, MMSI 저/고) 목록 + 공존 누적 횟수 + 양 위치 최대 거리 + severity tier
- 운영자 분류 워크플로우: OPEN → REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE (REOPEN 가능)
- severity 자동 산정: CRITICAL(거리≥50km OR 공존≥3회) / HIGH(거리≥10km OR 공존≥2회) / MEDIUM / LOW
- 상세 패널 — 양 MMSI 최근 궤적, 어구 이름 파싱(선박명+어구코드), 매칭된 모선(fleet_vessels)
**구현 완료 (V030 / PR #73, 2026-04-17):**
- ✅ `gear_identity_collisions` 테이블 + UNIQUE(name, mmsi_lo, mmsi_hi) 제약 + 5 인덱스
- ✅ prediction `algorithms/gear_identity.py` 순수함수 + `fleet_tracker.track_gear_identity()` 공존/교체 분기
- ✅ 백엔드 4 엔드포인트 `/api/analysis/gear-collisions` + `GEAR_COLLISION_RESOLVE` @Auditable
- ✅ 프론트 페이지 `GearCollisionDetection.tsx` (DataTable + KPI 5장 + SidePanel + 상태/심각도 필터)
- ✅ 이벤트 허브 연동 — HIGH/CRITICAL 은 `prediction_events` 에 자동 등록 (dedup 367분)
**향후 구현 예정:**
- 🔲 FALSE_POSITIVE 반복 쌍 자동 화이트리스트 (v2 학습 피드백 루프)
- 🔲 KPI 테이블 (`prediction_stats_*`) 에 severity 별 집계 반영
--- ---
@ -455,17 +496,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 이력 상세 정보 조회 및 검색/필터 - 이력 상세 정보 조회 및 검색/필터
- 이력 데이터 엑셀 내보내기 - 이력 데이터 엑셀 내보내기
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 단속 이력 6건 목록/상세 UI - ✅ **실시간 이벤트 조회**`/api/events` 페이징/필터/확인(ACK)/상태 변경
- ✅ AI 매칭 검증 결과 표시 - ✅ **단속 이력 CRUD**`/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 단속 이력 DB 연동 (조회/등록/수정) - 🔲 증거 파일(사진/영상) 업로드 서버 연동
- 🔲 AI 매칭 검증 엔진 연동 - 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
- 🔲 탐지-단속 연계 자동 분석
**보완 필요:**
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
--- ---
@ -487,17 +528,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 함정 배치 현황 요약 - 함정 배치 현황 요약
- 실시간 경보 알림 표시 - 실시간 경보 알림 표시
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI - ✅ **실시간 KPI 카드**`/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치) - ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 실시간 데이터 연동 (WebSocket 등) - 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
- 🔲 KPI 수치 실시간 갱신 - 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
- 🔲 히트맵/타임라인 실시간 업데이트
**보완 필요:**
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
--- ---
@ -516,17 +555,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 경보 처리(확인/대응/종결) 워크플로우 - 경보 처리(확인/대응/종결) 워크플로우
- 경보 발생 이력 조회 - 경보 발생 이력 조회
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 경보 등급별 현황판 UI - ✅ **실시간 경보 수신**`/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
- ✅ 경보 목록/상세 조회 화면 - ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 실시간 경보 수신 연동 - 🔲 경보 자동 에스컬레이션 정책
- 🔲 경보 처리 워크플로우 DB 연동 - 🔲 경보 룰 커스터마이즈 UI
- 🔲 경보 자동 에스컬레이션
**보완 필요:**
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
--- ---
@ -545,17 +582,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 선박/이벤트 클릭 시 상세 정보 팝업 - 선박/이벤트 클릭 시 상세 정보 팝업
- 지도 확대/축소 및 해역 필터링 - 지도 확대/축소 및 해역 필터링
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ LiveMap 기반 실시간 감시 지도 UI - ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
- ✅ 선박/이벤트 마커 및 팝업 인터랙션 - ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 실시간 AIS/VMS 데이터 연동 - 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
- 🔲 WebSocket 기반 실시간 위치 업데이트 - 🔲 SAR 위성영상 오버레이
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
**보완 필요:**
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
--- ---
@ -601,17 +636,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 기간별/해역별/유형별 필터링 - 기간별/해역별/유형별 필터링
- 통계 데이터 엑셀 내보내기 및 인쇄 - 통계 데이터 엑셀 내보내기 및 인쇄
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI - ✅ **실시간 통계 데이터**`/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능 - ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 통계 데이터 DB 연동 - 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
- 🔲 실제 운영 데이터 기반 KPI 자동 산출 - 🔲 맞춤형 지표 대시보드 설정
- 🔲 맞춤형 보고서 생성 기능
**보완 필요:**
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
--- ---
@ -743,17 +776,15 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
- 알림 수신자 설정 및 발송 - 알림 수신자 설정 및 발송
- 알림 전송 결과(성공/실패) 확인 - 알림 전송 결과(성공/실패) 확인
**구현 완료:** **구현 완료 (2026-04-17 기준):**
- ✅ 알림 5건 전송 현황 UI - ✅ **AI 알림 이력 실 API 조회**`/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
- ✅ 알림 유형별 분류 및 상세 조회 - ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
**향후 구현 예정:** **향후 구현 예정:**
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등) - 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
- 🔲 AI 분석 결과 기반 자동 알림 트리거 - 🔲 알림 템플릿 엔진
- 🔲 알림 발송 이력 DB 연동 - 🔲 수신자 그룹 관리
**보완 필요:**
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
--- ---
@ -857,15 +888,27 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
--- ---
## 부록: 현재 시스템 상태 요약 ## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
| 항목 | 상태 | | 항목 | 상태 |
|------|------| |------|------|
| UI 구현 | 모든 SFR 완료 | | UI 구현 | 모든 SFR 완료 |
| 백엔드 연동 | 미구현 (전체) | | **백엔드 연동** | **15+ 화면 실 API 연동 완료** (Auth/RBAC/Audit/Events/Alerts/Enforcement/Stats/Analysis/Master 등 65+ API) |
| 데이터 | 시연용 샘플 데이터 | | **AI 분석 엔진 (prediction)** | **운영 중** — 5분 주기로 snpdb 분석 → kcgaidb 저장, 14 알고리즘 + DAR-03 G-01~G-06 |
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) | | **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
| 실시간 기능 | 미구현 (WebSocket 등 미연동) | | **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
| AI 모델 | 미연동 (탐지/예측/최적화 등) | | **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) | | **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) | | **외부 시스템** | snpdb / gc-signal-batch 연동 완료. 유관기관 OpenAPI(GICOMS/MTIS 등)는 미연동 |
| **디자인 시스템** | `design-system.html` 쇼케이스 SSOT 전영역 준수, 라이트/다크 모드 완전 대응 |
| **다국어** | 한/영 alert/confirm/aria-label 전수 치환 완료 (JSX placeholder 35건은 후속 과제) |
| **모바일 앱** | 웹 시뮬레이션만 제공 (PWA/네이티브 앱 미개발) |
---
## 변경 이력
| 일자 | 내용 |
|------|------|
| 2026-04-06 | 초기 작성 (프론트엔드 프로토타입 v0.1.0 기준) |
| 2026-04-17 | 헤더 + SFR-01/02/09/10/11/12/13/17 주요 섹션 업데이트. 실 API 연동 / prediction 운영 상태 / 2026-04-17 PR #A/#B/#C 반영 |

파일 보기

@ -6,10 +6,15 @@ KCG AI Monitoring 시스템 워크플로우 플로우차트 뷰어 사용법.
`/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다. `/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다.
- 102개 노드 + 133개 엣지 (v1.0.0 기준) - 115개 노드 + 133개 엣지 (manifest 현재 상태, `meta.json` 은 아직 v1.0.0/2026-04-07 로 미갱신)
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱 - 메인 SPA(`/`)와 완전 분리된 별도 React 앱
- 메뉴/링크 노출 없음 — 직접 URL 접근만 - 메뉴/링크 노출 없음 — 직접 URL 접근만
> ⚠️ **V030 미반영 경고**: 2026-04-17 V030 로 추가된 GEAR_IDENTITY_COLLISION 파이프라인 (
> `algo.gear_identity_collision`, `storage.gear_identity_collisions`, `api.gear_collisions_*`,
> `ui.gear_collision`, `decision.gear_collision_resolve`) 노드가 아직 manifest 에 등록되지
> 않았다. 다음 `/version` 릴리즈 시 매니페스트 동기화 필요.
## 접근 URL ## 접근 URL
- **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html - **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  크기: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 54 KiB

파일 보기

@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore'; import { useMenuStore } from '@stores/menuStore';
/* /*

파일 보기

@ -39,6 +39,15 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/detection/ChinaFishing': lazy(() => 'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })), import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
), ),
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
'features/detection/IllegalFishingPattern': lazy(() =>
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
),
'features/detection/TransshipmentDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })),
),
// ── 단속·이벤트 ── // ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() => 'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
@ -80,6 +89,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/ai-operations/MLOpsPage': lazy(() => 'features/ai-operations/MLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })), import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
), ),
'features/ai-operations/LGCNSMLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LGCNSMLOpsPage })),
),
'features/ai-operations/LLMOpsPage': lazy(() => 'features/ai-operations/LLMOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })), import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
), ),
@ -113,6 +125,21 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
default: m.LoginHistoryView, default: m.LoginHistoryView,
})), })),
), ),
'features/admin/AISecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AISecurityPage })),
),
'features/admin/AIAgentSecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
),
'features/admin/DataRetentionPolicy': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataRetentionPolicy })),
),
'features/admin/DataModelVerification': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataModelVerification })),
),
'features/admin/PerformanceMonitoring': lazy(() =>
import('@features/admin').then((m) => ({ default: m.PerformanceMonitoring })),
),
// ── 모선 워크플로우 ── // ── 모선 워크플로우 ──
'features/parent-inference/ParentReview': lazy(() => 'features/parent-inference/ParentReview': lazy(() =>
import('@features/parent-inference/ParentReview').then((m) => ({ import('@features/parent-inference/ParentReview').then((m) => ({

파일 보기

@ -282,8 +282,9 @@ export function MainLayout() {
{/* 언어 토글 */} {/* 언어 토글 */}
<button <button
onClick={toggleLanguage} onClick={toggleLanguage}
aria-label={t('aria.languageToggle')}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap" className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'} title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
> >
{language === 'ko' ? 'EN' : '한국어'} {language === 'ko' ? 'EN' : '한국어'}
</button> </button>
@ -338,7 +339,7 @@ export function MainLayout() {
<div className="relative flex items-center"> <div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input <input
aria-label="페이지 내 검색" aria-label={t('aria.searchInPage')}
value={pageSearch} value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)} onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {

파일 보기

@ -11,19 +11,30 @@ import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRe
*/ */
interface AnyMeta { interface AnyMeta {
code: string; /** 일부 카탈로그는 code 없이 Record key 만 사용 (예: PERFORMANCE_STATUS_META) */
code?: string;
intent?: BadgeIntent; intent?: BadgeIntent;
fallback?: { ko: string; en: string }; fallback?: { ko: string; en: string };
classes?: string | { bg?: string; text?: string; border?: string }; classes?: string | { bg?: string; text?: string; border?: string };
label?: string; /** 문자열 라벨 또는 { ko, en } 객체 라벨 양쪽 지원 */
label?: string | { ko: string; en: string };
} }
function getKoLabel(meta: AnyMeta): string { function getKoLabel(meta: AnyMeta, fallbackKey: string): string {
return meta.fallback?.ko ?? meta.label ?? meta.code; if (meta.fallback?.ko) return meta.fallback.ko;
if (meta.label && typeof meta.label === 'object' && 'ko' in meta.label) {
return meta.label.ko;
}
if (typeof meta.label === 'string') return meta.label;
return meta.code ?? fallbackKey;
} }
function getEnLabel(meta: AnyMeta): string | undefined { function getEnLabel(meta: AnyMeta): string | undefined {
return meta.fallback?.en; if (meta.fallback?.en) return meta.fallback.en;
if (meta.label && typeof meta.label === 'object' && 'en' in meta.label) {
return meta.label.en;
}
return undefined;
} }
function getFallbackClasses(meta: AnyMeta): string | undefined { function getFallbackClasses(meta: AnyMeta): string | undefined {
@ -55,17 +66,19 @@ function renderBadge(meta: AnyMeta, label: string): ReactNode {
} }
function CatalogBadges({ entry }: { entry: CatalogEntry }) { function CatalogBadges({ entry }: { entry: CatalogEntry }) {
const items = Object.values(entry.items) as AnyMeta[]; // Record key 를 안정적 식별자로 사용 (일부 카탈로그는 meta.code 없음)
const items = Object.entries(entry.items) as [string, AnyMeta][];
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
{items.map((meta) => { {items.map(([key, meta]) => {
const koLabel = getKoLabel(meta); const displayCode = meta.code ?? key;
const koLabel = getKoLabel(meta, key);
const enLabel = getEnLabel(meta); const enLabel = getEnLabel(meta);
const trkId = `${entry.showcaseId}-${meta.code}`; const trkId = `${entry.showcaseId}-${displayCode}`;
return ( return (
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm"> <Trk key={key} id={trkId} className="flex items-center gap-3 rounded-sm">
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate"> <code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
{meta.code} {displayCode}
</code> </code>
<div className="flex-1">{renderBadge(meta, koLabel)}</div> <div className="flex-1">{renderBadge(meta, koLabel)}</div>
<div className="flex-1"> <div className="flex-1">

파일 보기

@ -0,0 +1,299 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { getAgentPermTypeIntent, getThreatLevelIntent, getAgentExecResultIntent } from '@shared/constants/aiSecurityStatuses';
import {
Bot, Activity, AlertTriangle,
CheckCircle, FileText,
Users,
Key, Layers, Hand,
} from 'lucide-react';
/*
* SER-11: AI Agent
*
* AI Agent :
* Agent ·
* · MCP Tool
*/
type Tab = 'overview' | 'whitelist' | 'killswitch' | 'identity' | 'mcp' | 'audit';
const TABS: { key: Tab; icon: React.ElementType; label: string }[] = [
{ key: 'overview', icon: Activity, label: 'Agent 현황' },
{ key: 'whitelist', icon: CheckCircle, label: '화이트리스트 도구' },
{ key: 'killswitch', icon: AlertTriangle, label: '자동 중단·승인' },
{ key: 'identity', icon: Users, label: 'Agent 신원·권한' },
{ key: 'mcp', icon: Layers, label: 'MCP Tool 권한' },
{ key: 'audit', icon: FileText, label: '감사 로그' },
];
// ─── Agent 현황 ──────────────────
const AGENTS = [
{ name: '위험도 분석 Agent', type: '조회 전용', tools: 4, status: '활성', calls24h: 1240, lastCall: '04-10 09:28' },
{ name: '법령 Q&A Agent', type: '조회 전용', tools: 3, status: '활성', calls24h: 856, lastCall: '04-10 09:25' },
{ name: '단속 이력 Agent', type: '조회 전용', tools: 5, status: '활성', calls24h: 432, lastCall: '04-10 09:20' },
{ name: '모선 추론 Agent', type: '조회+쓰기', tools: 6, status: '활성', calls24h: 128, lastCall: '04-10 09:15' },
{ name: '데이터 관리 Agent', type: '관리자', tools: 8, status: '대기', calls24h: 0, lastCall: '04-09 16:00' },
];
const AGENT_KPI = [
{ label: '활성 Agent', value: '4', color: 'text-label', bg: 'bg-green-500/10' },
{ label: '등록 Tool', value: '26', color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '24h 호출', value: '2,656', color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '차단 건수', value: '3', color: 'text-heading', bg: 'bg-red-500/10' },
{ label: '승인 대기', value: '0', color: 'text-label', bg: 'bg-yellow-500/10' },
];
// ─── 화이트리스트 도구 ──────────────────
const WHITELIST_TOOLS = [
{ tool: 'db_read_vessel', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '선박 정보 조회' },
{ tool: 'db_read_analysis', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '분석 결과 조회' },
{ tool: 'search_law', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '법령·판례 검색' },
{ tool: 'search_cases', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '유사 사례 검색' },
{ tool: 'read_enforcement', agent: '단속 이력', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '단속 이력 조회' },
{ tool: 'write_parent_result', agent: '모선 추론', permission: 'CREATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 추론 결과 저장' },
{ tool: 'update_parent_status', agent: '모선 추론', permission: 'UPDATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 상태 갱신' },
{ tool: 'db_delete_any', agent: '-', permission: 'DELETE', mcp: '-', status: '차단', desc: 'DB 삭제 (금지 도구)' },
{ tool: 'system_exec', agent: '-', permission: 'ADMIN', mcp: '-', status: '차단', desc: '시스템 명령 실행 (금지)' },
];
// ─── 자동 중단 설정 ──────────────────
const KILL_SWITCH_RULES = [
{ rule: '유해·금지행위 탐지', desc: '유해·금지행위, 잘못된 목표 설정 시 Agent 자동 중단', threshold: '즉시', status: '활성' },
{ rule: '자원 소비 임계값 초과', desc: 'GPU/메모리/API 호출 임계값 초과 시 자동 중단', threshold: 'GPU 90% / 메모리 85%', status: '활성' },
{ rule: '이상 호출 패턴', desc: '비정상적으로 빈번한 Tool 호출 탐지', threshold: '100회/분', status: '활성' },
{ rule: '응답 시간 초과', desc: 'Agent 응답 타임아웃', threshold: '30초', status: '활성' },
];
// ─── 민감명령 승인 ──────────────────
const SENSITIVE_COMMANDS = [
{ command: 'DB 데이터 수정/삭제', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' },
{ command: '모델 배포/롤백', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' },
{ command: '사용자 권한 변경', level: '높음', approval: '관리자 승인 필수', hitl: true, status: '적용' },
{ command: '외부 시스템 연계 호출', level: '중간', approval: '자동 승인 (로그)', hitl: false, status: '적용' },
{ command: '분석 결과 조회', level: '낮음', approval: '자동 승인', hitl: false, status: '적용' },
];
// ─── 에이전트 신원 확인 ──────────────────
const IDENTITY_POLICIES = [
{ policy: '미승인 권한 위임 차단', desc: '명시적으로 승인되지 않은 AI 에이전트로 권한 위임 제한', status: '적용' },
{ policy: 'Agent 간 신원 확인', desc: '협업할 AI 에이전트가 적합한 인증 혹은 신원 보유 중인지 상호 검증', status: '적용' },
{ policy: 'Agent 인증 방식', desc: 'Agent 간 인증은 표준 방식으로 제안, 발주처 간 협의를 통해 최종 선정', status: '적용' },
{ policy: '과도한 호출 방지', desc: 'AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비 방지', status: '적용' },
];
// ─── MCP Tool 권한 ──────────────────
const MCP_PERMISSIONS = [
{ server: 'kcg-db-server', tools: 8, principle: '최소 권한', detail: '조회 Agent는 DB READ만, 쓰기 Agent는 특정 테이블 C/U만', status: '적용' },
{ server: 'kcg-rag-server', tools: 5, principle: '최소 권한', detail: 'RAG 검색만 허용, 인덱스 수정 불가', status: '적용' },
{ server: 'kcg-api-server', tools: 6, principle: '최소 권한', detail: '외부 API 호출은 Rate Limiting + Caching', status: '적용' },
{ server: 'kcg-notify-server', tools: 3, principle: '최소 권한', detail: '알림 발송만 허용, 수신자 목록 수정 불가', status: '적용' },
];
// ─── 감사 로그 ──────────────────
const AUDIT_LOG_SAMPLE = [
{ time: '09:28:15', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '위험도 분석', tool: 'db_read_vessel', result: '성공', latency: '120ms' },
{ time: '09:25:42', chain: 'User → LLM → MCP Client → kcg-rag-server → Milvus', agent: '법령 Q&A', tool: 'search_law', result: '성공', latency: '850ms' },
{ time: '09:20:10', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '단속 이력', tool: 'read_enforcement', result: '성공', latency: '95ms' },
{ time: '09:15:33', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '모선 추론', tool: 'write_parent_result', result: '승인→성공', latency: '230ms' },
{ time: '08:42:00', chain: 'User → LLM → Kill Switch', agent: '데이터 관리', tool: 'db_delete_any', result: '차단', latency: '-' },
];
export function AIAgentSecurityPage() {
const [tab, setTab] = useState<Tab>('overview');
return (
<PageContainer>
<PageHeader
icon={Bot}
iconColor="text-heading"
title="AI Agent 구축·운영 보안"
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
demo
/>
{/* 탭 */}
<TabBar variant="underline">
{TABS.map(tt => (
<TabButton key={tt.key} active={tab === tt.key} icon={<tt.icon className="w-3.5 h-3.5" />} onClick={() => setTab(tt.key)}>
{tt.label}
</TabButton>
))}
</TabBar>
{/* ── ① Agent 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
<div className="flex gap-2">
{AGENT_KPI.map(k => (
<div key={k.label} className={`flex-1 px-4 py-3 rounded-xl border border-border ${k.bg}`}>
<div className={`text-xl font-bold ${k.color}`}>{k.value}</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
))}
</div>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> Agent </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">Agent명</th><th className="text-center py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2">24h </th>
<th className="text-center py-2"> </th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{AGENTS.map(a => (
<tr key={a.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{a.name}</td>
<td className="py-2 text-center"><Badge intent={getAgentPermTypeIntent(a.type)} size="sm">{a.type}</Badge></td>
<td className="py-2 text-center text-heading">{a.tools}</td>
<td className="py-2 text-center text-muted-foreground">{a.calls24h.toLocaleString()}</td>
<td className="py-2 text-center text-muted-foreground">{a.lastCall}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(a.status)} size="sm">{a.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ② 화이트리스트 도구 ── */}
{tab === 'whitelist' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<p className="text-[10px] text-hint mb-3">AI가 · </p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">Tool ID</th><th className="text-left py-2"></th>
<th className="text-center py-2">Agent</th><th className="text-center py-2"></th>
<th className="text-left py-2">MCP Server</th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{WHITELIST_TOOLS.map(t => (
<tr key={t.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-mono text-[9px]">{t.tool}</td>
<td className="py-2 text-hint">{t.desc}</td>
<td className="py-2 text-center text-muted-foreground">{t.agent}</td>
<td className="py-2 text-center"><Badge intent={getAgentPermTypeIntent(t.permission)} size="sm">{t.permission}</Badge></td>
<td className="py-2 text-muted-foreground font-mono text-[9px]">{t.mcp}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(t.status)} size="sm">{t.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
)}
{/* ── ③ 자동 중단·민감명령 승인 ── */}
{tab === 'killswitch' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI (Kill Switch)</div>
<div className="space-y-2">
{KILL_SWITCH_RULES.map(r => (
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<AlertTriangle className="w-4 h-4 text-heading" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
</div>
<span className="text-[9px] text-muted-foreground">: {r.threshold}</span>
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> (Human-in-the-loop)</div>
<p className="text-[10px] text-hint mb-3">· </p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th><th className="text-center py-2"></th>
<th className="text-left py-2"> </th><th className="text-center py-2">HITL</th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{SENSITIVE_COMMANDS.map(c => (
<tr key={c.command} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{c.command}</td>
<td className="py-2 text-center"><Badge intent={getThreatLevelIntent(c.level)} size="sm">{c.level}</Badge></td>
<td className="py-2 text-muted-foreground">{c.approval}</td>
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-label mx-auto" /> : <span className="text-hint">-</span>}</td>
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ④ Agent 신원·권한 ── */}
{tab === 'identity' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{IDENTITY_POLICIES.map(p => (
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Key className="w-4 h-4 text-label" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
<div className="text-[9px] text-hint">{p.desc}</div>
</div>
<Badge intent="success" size="sm">{p.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
)}
{/* ── ⑤ MCP Tool 권한 ── */}
{tab === 'mcp' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">MCP Tool </div>
<p className="text-[10px] text-hint mb-3">MCP를 (Tool) (: 조회 Agent는 DB Update MCP Tool )</p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">MCP Server</th><th className="text-center py-2">Tool </th>
<th className="text-center py-2"></th><th className="text-left py-2"></th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{MCP_PERMISSIONS.map(m => (
<tr key={m.server} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-mono text-[9px]">{m.server}</td>
<td className="py-2.5 text-center text-heading">{m.tools}</td>
<td className="py-2.5 text-center"><Badge intent="info" size="sm">{m.principle}</Badge></td>
<td className="py-2.5 text-hint text-[9px]">{m.detail}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
</tr>
))}</tbody>
</table>
<div className="mt-4 p-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-heading font-medium mb-1">Rate Limiting &amp; Caching</div>
<div className="text-[9px] text-hint">AI , MCP Rate Limiting( ) Caching() </div>
</div>
</CardContent></Card>
)}
{/* ── ⑥ 감사 로그 ── */}
{tab === 'audit' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> (Audit Log)</div>
<p className="text-[10px] text-hint mb-3"> MCP Tool (User Request LLM MCP Client MCP Server Legacy) , A2A </p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"> </th>
<th className="text-center py-2">Agent</th><th className="text-left py-2">Tool</th><th className="text-center py-2"></th><th className="text-right py-2 px-2"></th>
</tr></thead>
<tbody>{AUDIT_LOG_SAMPLE.map(l => (
<tr key={l.time + l.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-muted-foreground font-mono">{l.time}</td>
<td className="py-2 text-hint text-[9px]">{l.chain}</td>
<td className="py-2 text-center text-heading">{l.agent}</td>
<td className="py-2 text-heading font-mono text-[9px]">{l.tool}</td>
<td className="py-2 text-center"><Badge intent={getAgentExecResultIntent(l.result)} size="sm">{l.result}</Badge></td>
<td className="py-2 px-2 text-right text-muted-foreground">{l.latency}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
)}
</PageContainer>
);
}

파일 보기

@ -0,0 +1,357 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import {
Shield, Database, Brain, Lock, Eye, Activity,
AlertTriangle, CheckCircle,
Server, Search, RefreshCw,
} from 'lucide-react';
/*
* SER-10: AI
*
* AI :
* AI
*
*/
type Tab = 'overview' | 'data' | 'training' | 'io' | 'boundary' | 'vulnerability';
const TABS: { key: Tab; icon: React.ComponentType<{ className?: string }>; label: string }[] = [
{ key: 'overview', icon: Activity, label: '보안 현황' },
{ key: 'data', icon: Database, label: '데이터 수집 보안' },
{ key: 'training', icon: Brain, label: 'AI 학습 보안' },
{ key: 'io', icon: Lock, label: '입출력 보안' },
{ key: 'boundary', icon: Server, label: '경계 보안' },
{ key: 'vulnerability', icon: Search, label: '취약점 점검' },
];
// ─── 보안 현황 KPI ──────────────────
const SECURITY_KPI = [
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-label', bg: 'bg-green-500/10' },
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-label', bg: 'bg-yellow-500/10' },
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-label', bg: 'bg-cyan-500/10' },
];
// ─── 데이터 수집 보안 ──────────────────
const DATA_SOURCES = [
{ name: 'AIS 원본 (SNPDB)', provider: '해경', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-10', status: '정상' },
{ name: 'V-PASS 위치정보', provider: '해수부', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-09', status: '정상' },
{ name: '기상 데이터', provider: '기상청', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-08', status: '정상' },
{ name: '위성영상', provider: '해양조사원', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-07', status: '정상' },
{ name: '법령·판례', provider: '법무부', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-05', status: '정상' },
{ name: '단속 이력', provider: '해경', trust: '내부', encryption: 'AES-256', lastAudit: '2026-04-10', status: '정상' },
];
const CONTAMINATION_CHECKS = [
{ check: '오염데이터 탐지', desc: 'AI 학습·재학습 시 오염데이터 검사·관리', status: '활성', lastRun: '04-10 09:15' },
{ check: 'RAG 오염 차단', desc: '신규데이터 참조 시 오염데이터 유입 차단·방지', status: '활성', lastRun: '04-10 09:00' },
{ check: '출처 검증', desc: '공신력 있는 출처·배포 정보 구분 제출', status: '활성', lastRun: '04-10 08:30' },
{ check: '악성코드 사전검사', desc: '신뢰 출처 데이터라도 오염 가능, 사전 검사 필요', status: '활성', lastRun: '04-10 08:00' },
];
// ─── AI 학습 보안 ──────────────────
const TRAINING_POLICIES = [
{ policy: '보안등급별 데이터 분류', desc: 'AI시스템 활용목적 및 등급분류에 맞게 기밀·민감·공개등급 데이터 활용', status: '적용' },
{ policy: '사용자별 접근통제', desc: 'AI시스템이 사용자·부서별 권한에 맞는 학습데이터만 사용하도록 세분화', status: '적용' },
{ policy: '비인가자 접근 차단', desc: 'AI가 비인가자에게 기밀·민감등급 데이터를 제공하지 않도록 통제', status: '적용' },
{ policy: '저장소·DB 접근통제', desc: '보관된 학습데이터에 대한 사용자 접근통제', status: '적용' },
{ policy: '최소 접근권한 설계', desc: '사용자, 그룹, 데이터별로 최소 접근권한만 부여하도록 설계', status: '적용' },
{ policy: '다중 보안 인증', desc: '학습데이터 관리자 권한에 대해서는 다중 보안 인증 등 활용 방안 적용', status: '적용' },
{ policy: '오픈소스 모델 신뢰성', desc: '공신력 있는 출처·배포자가 제공하는 AI 모델·라이브러리 사용', status: '적용' },
];
// ─── 입출력 보안 ──────────────────
const IO_FILTERS = [
{ name: '입력 필터링', desc: '민감정보·적대적 공격 문구 포함여부 확인 및 차단', status: '활성', blocked: 23, total: 15420 },
{ name: '출력 필터링', desc: '응답 내 민감정보 노출 차단', status: '활성', blocked: 8, total: 15420 },
{ name: '입력 길이·형식 제한', desc: '공격용 프롬프트 과도 입력 방지', status: '활성', blocked: 5, total: 15420 },
{ name: '요청 속도 제한', desc: '호출 횟수, 동시 처리 요청수, 출력 용량 등 제한', status: '활성', blocked: 12, total: 15420 },
];
// ─── 경계 보안 ──────────────────
const BOUNDARY_ITEMS = [
{ item: 'DMZ·중계서버', desc: 'AI시스템에 접근하는 사용자·시스템 식별 및 통제', status: '적용' },
{ item: '인가 시스템 제한', desc: 'AI시스템이 인가된 내·외부 시스템·데이터만 활용하도록 제한', status: '적용' },
{ item: '권한 부여 제한', desc: 'AI시스템에 과도한 권한 부여 제한', status: '적용' },
{ item: '민감작업 승인절차', desc: '데이터 수정·시스템 제어 등 민감한 작업 수행 시 담당자 검토·승인', status: '적용' },
];
const EXPLAINABILITY = [
{ item: '추론 시각화', desc: '데이터 수정·시스템 제어 시 추론 과정·결과를 설명하거나 판단 근거 시각화', status: '구현' },
{ item: 'Feature Importance', desc: '모델 결정에 영향을 미친 주요 피처 표시', status: '구현' },
{ item: '판단 근거 제공', desc: '위험도 산출 시 기여 요인 설명', status: '구현' },
];
// ─── 취약점 점검 ──────────────────
const VULN_CHECKS = [
{ target: 'AI 모델 서빙 (PyTorch)', version: '2.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' },
{ target: 'FastAPI 서버', version: '0.115.0', lastScan: '2026-04-10', vulns: 0, status: '안전' },
{ target: 'LangChain (RAG)', version: '0.3.2', lastScan: '2026-04-09', vulns: 1, status: '주의' },
{ target: 'Milvus 벡터DB', version: '2.4.0', lastScan: '2026-04-09', vulns: 0, status: '안전' },
{ target: 'Spring Boot 백엔드', version: '3.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' },
{ target: 'Node.js (Vite)', version: '22.x', lastScan: '2026-04-10', vulns: 0, status: '안전' },
];
const RECOVERY_PLANS = [
{ plan: '모델 백업 저장소', desc: '이상행위 탐지 시 정상 모델·학습데이터 등으로 복원', status: '활성', detail: 'S3 버전별 백업 24개' },
{ plan: '버전정보 관리', desc: '모델·데이터·설정 버전 이력 추적', status: '활성', detail: 'Git + DVC 연동' },
{ plan: '자동 롤백', desc: '성능 저하 감지 시 이전 안정 버전으로 자동 복구', status: '활성', detail: '임계치 기반 트리거' },
];
export function AISecurityPage() {
const [tab, setTab] = useState<Tab>('overview');
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-heading"
title="AI 보안 관리"
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
demo
/>
{/* 탭 */}
<TabBar variant="underline">
{TABS.map(tt => (
<TabButton
key={tt.key}
active={tab === tt.key}
icon={<tt.icon className="w-3.5 h-3.5" />}
onClick={() => setTab(tt.key)}
>
{tt.label}
</TabButton>
))}
</TabBar>
{/* ── ① 보안 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
<div className="flex gap-2">
{SECURITY_KPI.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${k.bg}`}>
<k.icon className={`w-3.5 h-3.5 ${k.color}`} />
</div>
<span className={`text-base font-bold ${k.color}`}>{k.score}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-1.5 text-[10px]">
{[
['데이터 출처 인증', '6/6 소스 인증 완료', '완료'],
['오염데이터 검사', '4/4 검사 활성화', '완료'],
['학습데이터 접근통제', '7/7 정책 적용', '완료'],
['입출력 필터링', '4/4 필터 활성', '완료'],
['경계 보안 설정', '4/4 항목 적용', '완료'],
['취약점 점검', '5/6 안전 (1건 주의)', '주의'],
['복구 계획', '3/3 활성', '완료'],
].map(([k, v, s]) => (
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
{s === '완료' ? <CheckCircle className="w-3 h-3 text-label" /> : <AlertTriangle className="w-3 h-3 text-label" />}
<span className="text-heading flex-1">{k}</span>
<span className="text-hint">{v}</span>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-1.5 text-[10px]">
{[
['04-10 09:15', '오염데이터 검사 완료', '정상', '0건 탐지'],
['04-10 08:42', '입력 필터링 차단', '경고', '공격 패턴 1건 차단'],
['04-09 14:30', '취약점 스캔 완료', '주의', 'LangChain CVE-2026-1234'],
['04-09 10:00', '학습데이터 접근 감사', '정상', '비정상 접근 0건'],
['04-08 16:00', '모델 백업 완료', '정상', 'v2.1.0 → S3'],
['04-08 09:00', 'RAG 오염 차단 검사', '정상', '0건 탐지'],
].map(([time, event, level, detail]) => (
<div key={time + event} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-muted-foreground w-24">{time}</span>
<span className="text-heading flex-1">{event}</span>
<Badge intent={getStatusIntent(level)} size="sm">{level}</Badge>
<span className="text-hint text-[9px]">{detail}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ② 데이터 수집 보안 ── */}
{tab === 'data' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th><th className="text-left py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2"></th>
<th className="text-center py-2"> </th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{DATA_SOURCES.map(d => (
<tr key={d.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{d.name}</td>
<td className="py-2 text-muted-foreground">{d.provider}</td>
<td className="py-2 text-center"><Badge intent="success" size="sm">{d.trust}</Badge></td>
<td className="py-2 text-center text-muted-foreground">{d.encryption}</td>
<td className="py-2 text-center text-muted-foreground">{d.lastAudit}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(d.status)} size="sm">{d.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{CONTAMINATION_CHECKS.map(c => (
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<CheckCircle className="w-4 h-4 text-label" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{c.check}</div>
<div className="text-[9px] text-hint">{c.desc}</div>
</div>
<Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge>
<span className="text-[9px] text-muted-foreground">: {c.lastRun}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ③ AI 학습 보안 ── */}
{tab === 'training' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{TRAINING_POLICIES.map(p => (
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Lock className="w-4 h-4 text-label" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
<div className="text-[9px] text-hint">{p.desc}</div>
</div>
<Badge intent="success" size="sm">{p.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
)}
{/* ── ④ 입출력 보안 ── */}
{tab === 'io' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI · </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
</tr></thead>
<tbody>{IO_FILTERS.map(f => (
<tr key={f.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-medium">{f.name}</td>
<td className="py-2.5 text-hint text-[9px]">{f.desc}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{f.status}</Badge></td>
<td className="py-2.5 text-center text-heading font-bold">{f.blocked}</td>
<td className="py-2.5 text-center text-muted-foreground">{f.total.toLocaleString()}</td>
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
)}
{/* ── ⑤ 경계 보안 ── */}
{tab === 'boundary' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI </div>
<div className="space-y-2">
{BOUNDARY_ITEMS.map(b => (
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Server className="w-4 h-4 text-label" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{b.item}</div>
<div className="text-[9px] text-hint">{b.desc}</div>
</div>
<Badge intent="success" size="sm">{b.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> AI (XAI)</div>
<div className="space-y-2">
{EXPLAINABILITY.map(e => (
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Eye className="w-4 h-4 text-heading" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{e.item}</div>
<div className="text-[9px] text-hint">{e.desc}</div>
</div>
<Badge intent="purple" size="sm">{e.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ⑥ 취약점 점검 ── */}
{tab === 'vulnerability' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI · </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-center py-2"></th>
<th className="text-center py-2"> </th><th className="text-center py-2"></th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{VULN_CHECKS.map(v => (
<tr key={v.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{v.target}</td>
<td className="py-2 text-center text-muted-foreground font-mono">{v.version}</td>
<td className="py-2 text-center text-muted-foreground">{v.lastScan}</td>
<td className="py-2 text-center">
<Badge intent={v.vulns > 0 ? 'warning' : 'success'} size="xs">{v.vulns}</Badge>
</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(v.status)} size="sm">{v.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI </div>
<div className="space-y-2">
{RECOVERY_PLANS.map(r => (
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<RefreshCw className="w-4 h-4 text-label" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
</div>
<span className="text-[9px] text-muted-foreground">{r.detail}</span>
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
</PageContainer>
);
}

파일 보기

@ -94,20 +94,20 @@ export function AccessControl() {
}, [tab, loadUsers, loadAudit]); }, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => { const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return; if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
try { try {
await unlockUser(userId); await unlockUser(userId);
await loadUsers(); await loadUsers();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
// ── 사용자 테이블 컬럼 ────────────── // ── 사용자 테이블 컬럼 ──────────────
// eslint-disable-next-line react-hooks/exhaustive-deps
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [ const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
{ key: 'userAcnt', label: '계정', width: '90px', { key: 'userAcnt', label: '계정', width: '90px',
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> }, render: (v) => <span className="text-label font-mono text-[11px]">{v as string}</span> },
{ key: 'userNm', label: '이름', width: '80px', sortable: true, { key: 'userNm', label: '이름', width: '80px', sortable: true,
render: (v) => <span className="text-heading font-medium">{v as string}</span> }, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'rnkpNm', label: '직급', width: '60px', { key: 'rnkpNm', label: '직급', width: '60px',
@ -133,7 +133,7 @@ export function AccessControl() {
}, },
}, },
{ key: 'failCnt', label: '실패', width: '50px', align: 'center', { key: 'failCnt', label: '실패', width: '50px', align: 'center',
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-red-400' : 'text-hint'}`}>{v as number}</span> }, render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-heading' : 'text-hint'}`}>{v as number}</span> },
{ key: 'authProvider', label: '인증', width: '70px', { key: 'authProvider', label: '인증', width: '70px',
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> }, render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true, { key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
@ -146,15 +146,23 @@ export function AccessControl() {
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false, { key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
render: (_v, row) => ( render: (_v, row) => (
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<button type="button" onClick={() => setAssignTarget(row)} <Button
className="p-1 text-hint hover:text-purple-400" title="역할 배정"> variant="ghost"
<UserCog className="w-3 h-3" /> size="sm"
</button> onClick={() => setAssignTarget(row)}
aria-label="역할 배정"
title="역할 배정"
icon={<UserCog className="w-3 h-3" />}
/>
{row.userSttsCd === 'LOCKED' && ( {row.userSttsCd === 'LOCKED' && (
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)} <Button
className="p-1 text-hint hover:text-green-400" title="잠금 해제"> variant="ghost"
<Key className="w-3 h-3" /> size="sm"
</button> onClick={() => handleUnlock(row.userId, row.userAcnt)}
aria-label="잠금 해제"
title="잠금 해제"
icon={<Key className="w-3 h-3" />}
/>
)} )}
</div> </div>
), ),
@ -166,7 +174,7 @@ export function AccessControl() {
{ key: 'createdAt', label: '일시', width: '160px', sortable: true, { key: 'createdAt', label: '일시', width: '160px', sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> }, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true, { key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> }, render: (v) => <span className="text-label font-mono">{(v as string) || '-'}</span> },
{ key: 'actionCd', label: '액션', width: '180px', sortable: true, { key: 'actionCd', label: '액션', width: '180px', sortable: true,
render: (v) => <span className="text-heading font-medium">{v as string}</span> }, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'resourceType', label: '리소스', width: '110px', { key: 'resourceType', label: '리소스', width: '110px',
@ -180,24 +188,24 @@ export function AccessControl() {
}, },
}, },
{ key: 'failReason', label: '실패 사유', { key: 'failReason', label: '실패 사유',
render: (v) => <span className="text-red-400 text-[10px]">{(v as string) || '-'}</span> }, render: (v) => <span className="text-heading text-[10px]">{(v as string) || '-'}</span> },
], []); ], []);
return ( return (
<PageContainer size="lg"> <PageContainer size="lg">
<PageHeader <PageHeader
icon={Shield} icon={Shield}
iconColor="text-blue-400" iconColor="text-label"
title={t('accessControl.title')} title={t('accessControl.title')}
description={t('accessControl.desc')} description={t('accessControl.desc')}
actions={ actions={
<> <>
{userStats && ( {userStats && (
<div className="flex items-center gap-2 text-[10px] text-hint"> <div className="flex items-center gap-2 text-[10px] text-hint">
<UserCheck className="w-3.5 h-3.5 text-green-500" /> <UserCheck className="w-3.5 h-3.5 text-label" />
<span className="text-green-400 font-bold">{userStats.active}</span> <span className="text-label font-bold">{userStats.active}</span>
<span className="mx-1">|</span> <span className="mx-1">|</span>
<span className="text-red-400 font-bold">{userStats.locked}</span> <span className="text-heading font-bold">{userStats.locked}</span>
<span className="mx-1">|</span> <span className="mx-1">|</span>
<span className="text-heading font-bold">{userStats.total}</span> <span className="text-heading font-bold">{userStats.total}</span>
</div> </div>
@ -237,7 +245,7 @@ export function AccessControl() {
))} ))}
</div> </div>
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-heading">: {error}</div>}
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */} {/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
{tab === 'roles' && <PermissionsPanel />} {tab === 'roles' && <PermissionsPanel />}
@ -249,8 +257,8 @@ export function AccessControl() {
{userStats && ( {userStats && (
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<StatCard label="총 사용자" value={userStats.total} color="text-heading" /> <StatCard label="총 사용자" value={userStats.total} color="text-heading" />
<StatCard label="활성" value={userStats.active} color="text-green-400" /> <StatCard label="활성" value={userStats.active} color="text-label" />
<StatCard label="잠금" value={userStats.locked} color="text-red-400" /> <StatCard label="잠금" value={userStats.locked} color="text-heading" />
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" /> <StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
</div> </div>
)} )}
@ -278,9 +286,9 @@ export function AccessControl() {
{auditStats && ( {auditStats && (
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" /> <StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" /> <StatCard label="24시간" value={auditStats.last24h} color="text-label" />
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" /> <StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-heading" />
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" /> <StatCard label="액션 종류" value={auditStats.byAction.length} color="text-heading" />
</div> </div>
)} )}
@ -292,7 +300,7 @@ export function AccessControl() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{auditStats.byAction.map((a) => ( {auditStats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]"> <Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span> {a.action} <span className="text-label font-bold ml-1">{a.count}</span>
</Badge> </Badge>
))} ))}
</div> </div>

파일 보기

@ -37,7 +37,7 @@ export function AccessLogs() {
<PageContainer size="lg"> <PageContainer size="lg">
<PageHeader <PageHeader
icon={Activity} icon={Activity}
iconColor="text-cyan-400" iconColor="text-label"
title="접근 이력" title="접근 이력"
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록" description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
actions={ actions={
@ -50,10 +50,10 @@ export function AccessLogs() {
{stats && ( {stats && (
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
<MetricCard label="전체 요청" value={stats.total} color="text-heading" /> <MetricCard label="전체 요청" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" /> <MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" /> <MetricCard label="4xx (24h)" value={stats.error4xx} color="text-heading" />
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" /> <MetricCard label="5xx (24h)" value={stats.error5xx} color="text-heading" />
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" /> <MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-heading" />
</div> </div>
)} )}
@ -73,7 +73,7 @@ export function AccessLogs() {
{stats.topPaths.map((p) => ( {stats.topPaths.map((p) => (
<tr key={p.path} className="border-t border-border"> <tr key={p.path} className="border-t border-border">
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td> <td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td> <td className="py-1.5 text-right text-label font-bold">{p.count}</td>
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td> <td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
</tr> </tr>
))} ))}
@ -83,7 +83,7 @@ export function AccessLogs() {
</Card> </Card>
)} )}
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-heading">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -109,8 +109,8 @@ export function AccessLogs() {
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50"> <tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td> <td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td> <td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td> <td className="px-3 py-2 text-heading font-mono">{it.httpMethod}</td>
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td> <td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge> <Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>

파일 보기

@ -67,7 +67,7 @@ export function AdminPanel() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-2"> <CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-blue-400" /></CardTitle> <CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-label" /></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 space-y-2"> <CardContent className="px-4 pb-4 space-y-2">
{[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => ( {[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => (
@ -77,7 +77,7 @@ export function AdminPanel() {
</Card> </Card>
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-2"> <CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-green-400" /> </CardTitle> <CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-label" /> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 space-y-2"> <CardContent className="px-4 pb-4 space-y-2">
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => ( {[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (

파일 보기

@ -36,7 +36,7 @@ export function AuditLogs() {
<PageContainer size="lg"> <PageContainer size="lg">
<PageHeader <PageHeader
icon={FileSearch} icon={FileSearch}
iconColor="text-blue-400" iconColor="text-label"
title="감사 로그" title="감사 로그"
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록" description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
actions={ actions={
@ -50,9 +50,9 @@ export function AuditLogs() {
{stats && ( {stats && (
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<MetricCard label="전체 로그" value={stats.total} color="text-heading" /> <MetricCard label="전체 로그" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" /> <MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" /> <MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-heading" />
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" /> <MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-heading" />
</div> </div>
)} )}
@ -64,7 +64,7 @@ export function AuditLogs() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{stats.byAction.map((a) => ( {stats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1"> <Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span> {a.action} <span className="text-label font-bold ml-1">{a.count}</span>
</Badge> </Badge>
))} ))}
</div> </div>
@ -72,7 +72,7 @@ export function AuditLogs() {
</Card> </Card>
)} )}
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-heading">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -99,7 +99,7 @@ export function AuditLogs() {
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50"> <tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td> <td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td> <td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td> <td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td> <td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@ -107,7 +107,7 @@ export function AuditLogs() {
{it.result || '-'} {it.result || '-'}
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td> <td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate"> <td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
{it.detail ? JSON.stringify(it.detail) : '-'} {it.detail ? JSON.stringify(it.detail) : '-'}

파일 보기

@ -1,29 +1,27 @@
import { useState, useMemo } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { TabBar, TabButton } from '@shared/components/ui/tabs'; import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { SaveButton } from '@shared/components/common/SaveButton';
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses'; import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
import type { BadgeIntent } from '@lib/theme/variants'; import type { BadgeIntent } from '@lib/theme/variants';
import {
Database, RefreshCw, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, AlertTriangle,
CheckCircle, BarChart3, Layers, Plus, Play, Square,
Trash2, Edit2, Eye, FileText, HardDrive, FolderOpen,
Network,
} from 'lucide-react';
/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */
function jobStatusIntent(s: string): BadgeIntent { function jobStatusIntent(s: string): BadgeIntent {
if (s === '수행중') return 'success'; if (s === '수행중') return 'success';
if (s === '대기중') return 'warning'; if (s === '대기중') return 'warning';
if (s === '장애발생') return 'critical'; if (s === '장애발생') return 'critical';
return 'muted'; return 'muted';
} }
import {
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
CheckCircle, XCircle, BarChart3, Layers, Plus, Play, Square,
Trash2, Edit2, Eye, FileText, HardDrive, Upload, FolderOpen,
Network, X, ChevronRight, Info,
} from 'lucide-react';
/* /*
* SFR-03: 통합데이터 · * SFR-03: 통합데이터 ·
@ -115,7 +113,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
render: (v) => <span className="text-hint font-mono">{v as string}</span>, render: (v) => <span className="text-hint font-mono">{v as string}</span>,
}, },
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true, { key: 'system', label: '정보시스템명', width: '100px', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span>, render: (v) => <span className="text-label font-medium">{v as string}</span>,
}, },
{ key: 'linkInfo', label: '연계정보', width: '65px' }, { key: 'linkInfo', label: '연계정보', width: '65px' },
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> }, { key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
@ -126,7 +124,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
render: (v) => { render: (v) => {
const s = v as string; const s = v as string;
return s === '수신대기중' return s === '수신대기중'
? <span className="text-orange-400 text-[9px]">{s}</span> ? <Badge intent="warning" size="xs">{s}</Badge>
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>; : <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
}, },
}, },
@ -157,13 +155,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* 라벨 */} {/* 라벨 */}
<div className="w-16 shrink-0 text-right"> <div className="w-16 shrink-0 text-right">
<div className="text-[11px] font-bold" style={{ <div className="text-[11px] font-bold text-label">{source.name}</div>
color: source.name === 'VTS' ? '#22c55e'
: source.name === 'VTS-AIS' ? '#3b82f6'
: source.name === 'V-PASS' ? '#a855f7'
: source.name === 'E-NAVI' ? '#ef4444'
: '#eab308',
}}>{source.name}</div>
<div className="text-[10px] text-hint">{source.rate}%</div> <div className="text-[10px] text-hint">{source.rate}%</div>
</div> </div>
{/* 타임라인 바 */} {/* 타임라인 바 */}
@ -232,20 +224,21 @@ const collectColumns: DataColumn<CollectJob>[] = [
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true, { key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
render: (v) => { render: (v) => {
const n = v as number; const n = v as number;
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint'; if (n === 0) return <span className="font-bold text-[11px] text-hint">-</span>;
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</span>; const intent: BadgeIntent = n >= 90 ? 'success' : n >= 70 ? 'warning' : 'critical';
return <Badge intent={intent} size="xs">{n}%</Badge>;
}, },
}, },
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false, { key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: (_v, row) => ( render: (_v, row) => (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{row.status === '정지' ? ( {row.status === '정지' ? (
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-heading" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? ( ) : row.status !== '장애발생' ? (
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-heading" title="정지"><Square className="w-3 h-3" /></button>
) : null} ) : null}
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
</div> </div>
), ),
}, },
@ -291,9 +284,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false, { key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: () => ( render: () => (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-heading" title="스토리지"><HardDrive className="w-3 h-3" /></button>
</div> </div>
), ),
}, },
@ -348,6 +341,7 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
export function DataHub() { export function DataHub() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('signal'); const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02'); const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>(''); const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
@ -387,7 +381,7 @@ export function DataHub() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Database} icon={Database}
iconColor="text-cyan-400" iconColor="text-label"
title={t('dataHub.title')} title={t('dataHub.title')}
description={t('dataHub.desc')} description={t('dataHub.desc')}
demo demo
@ -402,11 +396,11 @@ export function DataHub() {
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' }, { label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-blue-400', bg: 'bg-blue-500/10' }, { label: 'ON', value: onCount, icon: Wifi, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' }, { label: 'OFF', value: offCount, icon: WifiOff, color: 'text-heading', bg: 'bg-red-500/10' },
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' }, { label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-label', bg: 'bg-green-500/10' },
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' }, { label: '데이터 소스', value: '5종', icon: Radio, color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' }, { label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-heading', bg: 'bg-orange-500/10' },
].map((kpi) => ( ].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"> <div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}> <div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -449,7 +443,7 @@ export function DataHub() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<input <input
aria-label="수신 현황 기준일" aria-label={tc('aria.receiptDate')}
type="date" type="date"
value={selectedDate} value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)} onChange={(e) => setSelectedDate(e.target.value)}
@ -509,11 +503,11 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card"> <div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{hasPartialOff ? ( {hasPartialOff ? (
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" /> <AlertTriangle className="w-3.5 h-3.5 text-heading" />
) : ( ) : (
<CheckCircle className="w-3.5 h-3.5 text-green-400" /> <CheckCircle className="w-3.5 h-3.5 text-label" />
)} )}
<span className={`text-[11px] font-bold ${hasPartialOff ? 'text-orange-400' : 'text-green-400'}`}> <span className="text-[11px] font-bold text-heading">
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'} {hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
</span> </span>
</div> </div>
@ -653,9 +647,9 @@ export function DataHub() {
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border"> <div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
<span className="text-[9px] text-hint"> {agent.taskCount} · heartbeat {agent.lastHeartbeat.slice(11)}</span> <span className="text-[9px] text-hint"> {agent.taskCount} · heartbeat {agent.lastHeartbeat.slice(11)}</span>
<div className="flex gap-0.5"> <div className="flex gap-0.5">
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-label" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-heading" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button> <button type="button" className="p-1 text-hint hover:text-heading" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div> </div>
</div> </div>
</CardContent> </CardContent>

파일 보기

@ -0,0 +1,385 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { useAuth } from '@/app/auth/AuthContext';
import {
Database, CheckCircle, AlertTriangle, FileText, Users,
Layers, Table2, Search, ChevronRight, GitBranch,
Shield, Eye, ListChecks, ClipboardCheck,
} from 'lucide-react';
/*
* DAR-11: 데이터
*
* ···
* ·.
*
* ·
*/
type Tab = 'overview' | 'logical' | 'physical' | 'duplication' | 'history';
// ─── 검증 현황 KPI ──────────────────
const VERIFICATION_KPI = [
{ label: '전체 테이블', value: '48개', icon: Table2, color: '#3b82f6' },
{ label: '논리 모델 검증', value: '완료', icon: GitBranch, color: '#10b981' },
{ label: '물리 모델 검증', value: '완료', icon: Database, color: '#8b5cf6' },
{ label: '중복 테이블', value: '0건', icon: Search, color: '#06b6d4' },
{ label: '미해결 이슈', value: '1건', icon: AlertTriangle, color: '#f59e0b' },
];
// ─── 검증 참여자 ──────────────────
const PARTICIPANTS = [
{ role: '데이터 설계자', name: '정해진 (주무관)', responsibility: '논리·물리 모델 설계, ERD 작성', badge: 'critical' as const },
{ role: '백엔드 개발자', name: '이상호 (경위)', responsibility: '마이그레이션 구현, 인덱스·성능 최적화', badge: 'info' as const },
{ role: '해양경찰청 담당관', name: '김영수 (사무관)', responsibility: '요구사항 대비 완전성 검토·승인', badge: 'warning' as const },
{ role: 'DB 전문가', name: '외부 자문위원', responsibility: '정규화·반정규화 타당성, 성능 검증', badge: 'success' as const },
];
// ─── 검증 절차 (4단계) ──────────────────
const VERIFICATION_PHASES = [
{ phase: '① 검증 계획 수립', actions: ['검증 범위·기준·일정 확정', '참여자 역할 분담', '체크리스트 작성'], responsible: '데이터 설계자', icon: FileText },
{ phase: '② 논리 모델 검증', actions: ['요구사항 대비 완전성 확인', '엔티티·속성·관계 정합성', '정규화 수준 적정성 검토'], responsible: '설계자 + 담당관', icon: GitBranch },
{ phase: '③ 물리 모델 검증', actions: ['테이블·컬럼·인덱스 구조 확인', '데이터 타입·제약조건 적정성', '성능 관점 반정규화 타당성'], responsible: '개발자 + DB전문가', icon: Database },
{ phase: '④ 결과 보고·조치', actions: ['검증 결과서 작성', '미해결 이슈 추적·조치', '최종 승인 및 이력 등록'], responsible: '전체 참여자', icon: ClipboardCheck },
];
// ─── 논리 모델 검증 항목 ──────────────────
const LOGICAL_CHECKS = [
{ category: '완전성', item: '요구사항 커버리지', desc: '48개 테이블이 SFR/DAR 전체 요구사항을 충족하는지 매핑 확인', result: '100% (48/48)', status: '통과' },
{ category: '완전성', item: '엔티티 누락 여부', desc: '식별된 비즈니스 영역(인증·탐지·단속·통계·관리) 대비 누락 엔티티 점검', result: '누락 0건', status: '통과' },
{ category: '정합성', item: '엔티티 관계 정의', desc: '외래키 관계, 참조 무결성, 카디널리티(1:N, M:N) 적정성', result: '78개 관계 확인', status: '통과' },
{ category: '정합성', item: '속성 정의 명확성', desc: '속성명 명명규칙, 도메인 정의, NULL 허용 정책 준수', result: '전체 준수', status: '통과' },
{ category: '정규화', item: '제3정규형 준수', desc: '함수 종속성 분석, 이행 종속 제거, 제3정규형(3NF) 이상 달성', result: '48/48 테이블', status: '통과' },
{ category: '정규화', item: '반정규화 타당성', desc: '성능 목적 반정규화 항목의 타당성 및 정합성 관리 방안', result: '3건 (타당)', status: '통과' },
{ category: '표준', item: '명명규칙 준수', desc: '테이블·컬럼·인덱스 명명규칙 (snake_case, kcg 스키마 접두어)', result: '전체 준수', status: '통과' },
{ category: '표준', item: '코드성 데이터 표준화', desc: '상태코드·유형코드 등 코드 마스터 테이블 일원화', result: 'code_master 통합', status: '통과' },
];
// ─── 물리 모델 검증 항목 ──────────────────
const PHYSICAL_CHECKS = [
{ category: '구조', item: '테이블 스페이스 배치', desc: 'PostgreSQL kcg 스키마 내 48개 테이블 배치 적정성', result: '적정', status: '통과' },
{ category: '구조', item: '파티셔닝 전략', desc: 'AIS 위치 로그 등 대용량 테이블 파티셔닝 적용 여부', result: 'TimescaleDB hypertable', status: '통과' },
{ category: '데이터 타입', item: '컬럼 타입 적정성', desc: 'VARCHAR 길이, NUMERIC 정밀도, TIMESTAMP 타임존 설정', result: '전체 적정', status: '통과' },
{ category: '데이터 타입', item: 'JSON vs 정규 컬럼', desc: 'JSONB 사용 항목의 타당성 (스키마 유연성 vs 쿼리 성능)', result: '3개 테이블 JSONB (타당)', status: '통과' },
{ category: '인덱스', item: '기본키·외래키 인덱스', desc: 'PK/FK 인덱스 자동 생성 확인, 복합키 순서 적정성', result: '전체 생성 확인', status: '통과' },
{ category: '인덱스', item: '조회 성능 인덱스', desc: '고빈도 조회 패턴 분석 기반 추가 인덱스 설계', result: '12개 추가 인덱스', status: '통과' },
{ category: '제약조건', item: 'NOT NULL·CHECK·UNIQUE', desc: '필수값 제약, 범위 체크, 유일성 제약 적용 현황', result: '전체 적용', status: '통과' },
{ category: '제약조건', item: '참조 무결성 (FK)', desc: '외래키 ON DELETE/UPDATE 정책 (CASCADE/RESTRICT)', result: '정책 수립 완료', status: '통과' },
{ category: '성능', item: '쿼리 실행 계획 검증', desc: '주요 조회 쿼리 EXPLAIN ANALYZE 검증', result: 'P95 < 100ms', status: '통과' },
{ category: '성능', item: '대량 INSERT 성능', desc: 'AIS 5분 주기 배치 INSERT 성능 검증 (bulk insert)', result: '10K rows/sec', status: '주의' },
];
// ─── 중복·정합성 점검 ──────────────────
const DUPLICATION_CHECKS = [
{ target: '테이블 중복', desc: '동일 목적의 테이블이 중복 존재하는지 점검', scope: '48개 테이블', result: '중복 0건', status: '통과' },
{ target: '컬럼 중복', desc: '동일 비즈니스 의미의 컬럼이 다른 이름으로 존재하는지 점검', scope: '전체 컬럼', result: '중복 0건', status: '통과' },
{ target: '반정규화 정합성', desc: '반정규화로 인한 중복 데이터의 동기화 방안 확인', scope: '3개 반정규화 항목', result: '트리거 기반 동기화', status: '통과' },
{ target: '코드값 일관성', desc: '상태코드·유형코드가 code_master 통해 일원 관리되는지 확인', scope: '19개 코드 카탈로그', result: '전체 일원화', status: '통과' },
{ target: '외래키 정합성', desc: '참조 대상 레코드 삭제 시 고아 레코드 발생 여부 점검', scope: '78개 FK 관계', result: '고아 0건', status: '통과' },
{ target: '스키마 간 정합성', desc: 'prediction 분석 결과 → kcgaidb 연계 시 데이터 타입 일치', scope: 'SNPDB ↔ kcgaidb', result: '타입 일치 확인', status: '통과' },
];
// ─── 데이터 주제영역 ──────────────────
const SUBJECT_AREAS = [
{ area: '인증·권한', tables: 'auth_user, auth_role, auth_perm_tree, auth_perm, auth_user_role', count: 5, desc: '사용자·역할·권한 트리 기반 RBAC' },
{ area: 'AIS·선박', tables: 'vessel_master, ais_position, vessel_permit', count: 3, desc: 'AIS 수신 데이터, 선박 기본정보, 허가 현황' },
{ area: '탐지·분석', tables: 'vessel_analysis, dark_vessel, gear_detection, transship_detection, ...', count: 12, desc: '14개 알고리즘 분석 결과 저장' },
{ area: '단속·이벤트', tables: 'prediction_event, enforcement_plan, enforcement_record, ...', count: 8, desc: '이벤트 발생·단속 계획·단속 이력' },
{ area: '모선 워크플로우', tables: 'parent_review, parent_exclusion, label_session, ...', count: 5, desc: '모선 확정·제외·학습 워크플로우' },
{ area: '순찰·함정', tables: 'patrol_ship, patrol_route, fleet_optimization, ...', count: 6, desc: '함정 관리·순찰 경로·최적화' },
{ area: '통계·감사', tables: 'statistics_daily, audit_log, access_log, login_history, ...', count: 5, desc: '통계 집계·감사 로그·접근 이력' },
{ area: '시스템·관리', tables: 'code_master, zone_polygon, gear_type_master, notice, ...', count: 4, desc: '코드 마스터·구역 정보·공지사항' },
];
// ─── 검증 결과 이력 ──────────────────
const VERIFICATION_HISTORY = [
{ id: 'VER-2026-005', date: '2026-04-10', phase: '물리 모델 검증', reviewer: '이상호, 외부 자문', target: 'V015 NUMERIC 정밀도 조정', issues: 0, result: '통과' },
{ id: 'VER-2026-004', date: '2026-04-07', phase: '중복·정합성 점검', reviewer: '정해진', target: '반정규화 3건 정합성', issues: 0, result: '통과' },
{ id: 'VER-2026-003', date: '2026-04-03', phase: '논리 모델 검증', reviewer: '김영수, 정해진', target: 'V014 함정·예측 테이블 추가', issues: 1, result: '조건부 통과' },
{ id: 'VER-2026-002', date: '2026-03-28', phase: '물리 모델 검증', reviewer: '이상호', target: 'V012~V013 이벤트·단속 테이블', issues: 0, result: '통과' },
{ id: 'VER-2026-001', date: '2026-03-20', phase: '논리 모델 검증', reviewer: '전체 참여', target: '초기 V001~V011 전체 구조', issues: 3, result: '조건부 통과' },
{ id: 'VER-2025-012', date: '2025-12-15', phase: '검증 계획 수립', reviewer: '김영수', target: '검증 계획서 v1.0 확정', issues: 0, result: '승인' },
];
export function DataModelVerification() {
const [tab, setTab] = useState<Tab>('overview');
const { hasPermission } = useAuth();
// 향후 Phase 3 에서 검증 승인·이력 등록 버튼 추가 시 가드로 연결
void hasPermission('admin:data-model-verification', 'CREATE');
void hasPermission('admin:data-model-verification', 'UPDATE');
const TABS: Array<{ key: Tab; icon: typeof Eye; label: string }> = [
{ key: 'overview', icon: Eye, label: '검증 현황' },
{ key: 'logical', icon: GitBranch, label: '논리 모델 검증' },
{ key: 'physical', icon: Database, label: '물리 모델 검증' },
{ key: 'duplication', icon: Search, label: '중복·정합성 점검' },
{ key: 'history', icon: FileText, label: '검증 결과 이력' },
];
return (
<PageContainer>
<PageHeader
icon={ListChecks}
iconColor="text-green-600 dark:text-green-400"
title="데이터 모델 검증"
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
demo
/>
<TabBar variant="underline">
{TABS.map(t => (
<TabButton key={t.key} variant="underline" active={tab === t.key}
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
{t.label}
</TabButton>
))}
</TabBar>
{/* ── ① 검증 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
<div className="flex gap-2">
{VERIFICATION_KPI.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
<k.icon className="w-5 h-5" style={{ color: k.color }} />
<div>
<div className="text-lg font-bold" style={{ color: k.color }}>{k.value}</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
</div>
))}
</div>
{/* 검증 절차 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ClipboardCheck className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> (4)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{VERIFICATION_PHASES.map((s, i) => (
<div key={s.phase} className="relative">
{i < VERIFICATION_PHASES.length - 1 && (
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
)}
<div className="bg-surface-overlay rounded-lg p-3 h-full">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[11px] font-bold text-green-600 dark:text-green-400">{s.phase}</span>
</div>
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
<ul className="space-y-1.5">
{s.actions.map(a => (
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
</CardContent></Card>
{/* 참여자 + 주제영역 */}
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<div className="space-y-2">
{PARTICIPANTS.map(p => (
<div key={p.role} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Badge intent={p.badge} size="sm">{p.role}</Badge>
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.name}</div>
<div className="text-[9px] text-hint">{p.responsibility}</div>
</div>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)} )</span>
</div>
<div className="space-y-1.5">
{SUBJECT_AREAS.map(a => (
<div key={a.area} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded text-[10px]">
<Badge intent="muted" size="xs">{a.count}</Badge>
<span className="text-heading font-medium w-24 shrink-0">{a.area}</span>
<span className="text-hint text-[9px] truncate flex-1">{a.desc}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ② 논리 모델 검증 ── */}
{tab === 'logical' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="success" size="xs">{LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} </Badge>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th>
<th className="text-left py-2"> </th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{LOGICAL_CHECKS.map(c => (
<tr key={c.item} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
<td className="py-2.5 text-heading font-medium">{c.item}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="grid grid-cols-4 gap-3">
{SUBJECT_AREAS.map(a => (
<div key={a.area} className="bg-surface-overlay rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading">{a.area}</span>
<Badge intent="info" size="xs">{a.count}</Badge>
</div>
<div className="text-[8px] text-hint mb-1.5">{a.desc}</div>
<div className="text-[8px] text-muted-foreground font-mono leading-relaxed">{a.tables}</div>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ③ 물리 모델 검증 ── */}
{tab === 'physical' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="success" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} </Badge>
{PHYSICAL_CHECKS.some(c => c.status === '주의') && (
<Badge intent="warning" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '주의').length} </Badge>
)}
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th>
<th className="text-left py-2"> </th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{PHYSICAL_CHECKS.map(c => (
<tr key={c.item} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
<td className="py-2.5 text-heading font-medium">{c.item}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
)}
{/* ── ④ 중복·정합성 점검 ── */}
{tab === 'duplication' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> · </span>
<Badge intent="success" size="xs">{DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} </Badge>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{DUPLICATION_CHECKS.map(c => (
<tr key={c.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-medium">{c.target}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{c.scope}</Badge></td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
)}
{/* ── ⑤ 검증 결과 이력 ── */}
{tab === 'history' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{VERIFICATION_HISTORY.length}</Badge>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> ID</th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
<th className="text-left py-2"></th>
<th className="text-left py-2"></th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{VERIFICATION_HISTORY.map(h => (
<tr key={h.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{h.id}</td>
<td className="py-2.5 text-center text-muted-foreground">{h.date}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{h.phase}</Badge></td>
<td className="py-2.5 text-muted-foreground">{h.reviewer}</td>
<td className="py-2.5 text-heading font-medium text-[9px]">{h.target}</td>
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-600 dark:text-yellow-400 font-bold">{h.issues}</span> : <span className="text-green-600 dark:text-green-400">0</span>}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(h.result)} size="sm">{h.result}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
)}
</PageContainer>
);
}

파일 보기

@ -0,0 +1,439 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { useAuth } from '@/app/auth/AuthContext';
import {
Database, Clock, Trash2, ShieldCheck, FileText, AlertTriangle,
CheckCircle, Archive, CalendarClock, UserCheck, Search,
ChevronRight, Lock, Eye, Settings,
} from 'lucide-react';
/*
* DAR-10: 데이터
*
*
* .
*
* ·
*/
type Tab = 'overview' | 'retention' | 'disposal' | 'exception' | 'audit';
// ─── 보관 현황 KPI ──────────────────
const RETENTION_KPI = [
{ label: '관리 데이터 유형', value: '6종', icon: Database, color: '#3b82f6' },
{ label: '보관기간 초과', value: '0건', icon: Clock, color: '#10b981' },
{ label: '파기 대기', value: '3건', icon: Trash2, color: '#f59e0b' },
{ label: '보존 연장 중', value: '1건', icon: ShieldCheck, color: '#8b5cf6' },
{ label: '금월 파기 완료', value: '12건', icon: CheckCircle, color: '#06b6d4' },
];
// ─── 유형별 보관기간 기준표 ──────────────────
const RETENTION_TABLE = [
{ type: '선박 위치 로그 (AIS)', category: '운항 데이터', basis: '해사안전법 시행규칙 제42조', period: '5년', format: 'PostgreSQL + TimescaleDB', volume: '약 2.1TB/년', status: '정상' },
{ type: '단속 자료', category: '법집행 기록', basis: '해양경비법 제18조, 공공기록물법', period: '10년 (영구 가능)', format: 'PostgreSQL + 파일 스토리지', volume: '약 150GB/년', status: '정상' },
{ type: '수사 관련 자료', category: '수사 기록', basis: '형사소송법 제198조, 수사기록 보존규칙', period: '영구 (종결 후 30년)', format: '암호화 스토리지', volume: '약 50GB/년', status: '정상' },
{ type: 'AI 학습용 임시 데이터', category: 'AI 학습 데이터', basis: '개인정보보호법 제21조', period: '학습 완료 후 90일', format: 'S3 + DVC', volume: '약 500GB/주기', status: '정상' },
{ type: 'CCTV·영상 증거', category: '영상 데이터', basis: '개인정보보호법 제25조', period: '30일 (증거 채택 시 영구)', format: 'NAS + HLS', volume: '약 3TB/월', status: '주의' },
{ type: '시스템 접근 로그', category: '감사 로그', basis: '정보통신망법 제45조, 전자금융감독규정', period: '5년', format: 'Elasticsearch', volume: '약 80GB/년', status: '정상' },
];
// ─── 파기 방식 정의 ──────────────────
const DISPOSAL_METHODS = [
{ method: '완전 삭제 (Secure Erase)', desc: 'DoD 5220.22-M 기준 3회 덮어쓰기 후 삭제', target: 'DB 레코드, 파일', encryption: '해당 없음', recovery: '복구 불가능', status: '적용' },
{ method: '암호화 키 폐기', desc: '암호화된 데이터의 복호화 키를 영구 폐기하여 접근 차단', target: '암호화 스토리지', encryption: 'AES-256 키 폐기', recovery: '복구 불가능', status: '적용' },
{ method: '논리적 삭제 + 만료', desc: 'soft-delete 마킹 후 보관기간 만료 시 물리 삭제 전환', target: '운영 DB', encryption: '-', recovery: '만료 전 복구 가능', status: '적용' },
{ method: '물리적 파기', desc: '디가우저(Degausser) 또는 물리적 파쇄로 매체 파기', target: '이동식 매체, 하드디스크', encryption: '해당 없음', recovery: '복구 불가능', status: '적용' },
];
// ─── 파기 승인 절차 ──────────────────
const DISPOSAL_WORKFLOW = [
{ phase: '① 파기 대상 선별', actions: ['보관기간 만료 데이터 자동 탐색', '유형별 파기 대상 목록 생성', '백업 데이터 포함 여부 확인'], responsible: '시스템 자동', icon: Search },
{ phase: '② 파기 신청', actions: ['파기 대상 목록 검토 및 승인 요청', '수사·소송 보존 연장 대상 제외 확인', '파기 방식 지정 (완전삭제/키폐기/물리파기)'], responsible: '데이터 관리자', icon: FileText },
{ phase: '③ 승인 및 집행', actions: ['보안담당관 파기 승인', '파기 실행 (이중 확인 절차)', '백업 데이터 동시 파기'], responsible: '보안담당관', icon: UserCheck },
{ phase: '④ 결과 기록', actions: ['파기 결과 로그 자동 기록', '파기 대장 등록 (대상·일시·담당자·방식)', '감사 보고서 생성 및 보관'], responsible: '시스템 자동', icon: Archive },
];
// ─── 보존 연장 예외 현황 ──────────────────
const EXCEPTIONS = [
{ id: 'EXC-2026-001', dataType: '단속 자료 #2024-1892', reason: '수사 진행 중 (인천해경)', originalExpiry: '2026-03-15', extendedTo: '수사 종결 시까지', approver: '수사과장', status: '연장 중' },
{ id: 'EXC-2026-002', dataType: 'AIS 로그 (2021-Q2)', reason: '재판 증거 제출 (서울중앙지법)', originalExpiry: '2026-06-30', extendedTo: '판결 확정 시까지', approver: '법무담당관', status: '연장 중' },
{ id: 'EXC-2025-015', dataType: 'CCTV 영상 #V-2025-0342', reason: '감사원 감사 대상', originalExpiry: '2025-12-01', extendedTo: '2026-06-30', approver: '감사담당관', status: '해제 예정' },
];
const EXCEPTION_RULES = [
{ rule: '수사·소송 보존', desc: '수사 개시 또는 소송 진행 중인 데이터는 종결 시까지 파기 유예', authority: '수사과장 / 법무담당관' },
{ rule: '감사 보존', desc: '내부·외부 감사 대상 데이터는 감사 완료 후 6개월까지 보존 연장', authority: '감사담당관' },
{ rule: '재난·사고 보존', desc: '해양 사고 관련 데이터는 사고 조사 종결 시까지 보존', authority: '안전관리관' },
{ rule: '정보공개 청구', desc: '정보공개 청구 접수된 데이터는 처리 완료 시까지 보존', authority: '정보공개담당관' },
];
// ─── 파기 감사 대장 ──────────────────
const DISPOSAL_AUDIT_LOG = [
{ id: 'DSP-2026-012', date: '2026-04-10', target: 'AI 학습 임시 데이터 (배치 #B-0392)', type: 'AI 학습 데이터', method: '완전 삭제', volume: '48.2GB', operator: '정해진', approver: '김영수', result: '완료' },
{ id: 'DSP-2026-011', date: '2026-04-08', target: 'CCTV 영상 2026-03월분 (미채택)', type: '영상 데이터', method: '완전 삭제', volume: '2.8TB', operator: '시스템', approver: '김영수', result: '완료' },
{ id: 'DSP-2026-010', date: '2026-04-05', target: '시스템 접근 로그 (2021-Q1)', type: '감사 로그', method: '완전 삭제', volume: '12.5GB', operator: '시스템', approver: '김영수', result: '완료' },
{ id: 'DSP-2026-009', date: '2026-04-03', target: 'AI 학습 임시 데이터 (배치 #B-0391)', type: 'AI 학습 데이터', method: '암호화 키 폐기', volume: '51.7GB', operator: '정해진', approver: '김영수', result: '완료' },
{ id: 'DSP-2026-008', date: '2026-04-01', target: 'AIS 위치 로그 (2021-03)', type: '운항 데이터', method: '완전 삭제', volume: '180GB', operator: '시스템', approver: '이상호', result: '완료' },
{ id: 'DSP-2026-007', date: '2026-03-28', target: '이동식 매체 (USB-0021~0025)', type: '물리 매체', method: '물리적 파기', volume: '5개', operator: '박민수', approver: '김영수', result: '완료' },
];
// ─── 보관 구조 요약 ──────────────────
const STORAGE_ARCHITECTURE = [
{ tier: '운영 스토리지', desc: '실시간 조회·분석 대상 (최근 1년)', tech: 'PostgreSQL + TimescaleDB', encryption: 'TDE (AES-256)', backup: '일일 증분 + 주간 전체', icon: Database },
{ tier: '아카이브 스토리지', desc: '장기 보관 대상 (1~10년)', tech: 'S3 Compatible (Glacier 등급)', encryption: 'SSE-KMS', backup: '월간 무결성 검증', icon: Archive },
{ tier: '백업 스토리지', desc: '재해 복구용 (이중화)', tech: '원격지 NAS + 테이프', encryption: 'AES-256', backup: '분기별 복구 테스트', icon: Lock },
{ tier: '파기 대기 영역', desc: 'soft-delete 후 파기 승인 대기', tech: '격리 스토리지 (접근 제한)', encryption: 'AES-256', backup: '미백업 (파기 예정)', icon: Trash2 },
];
export function DataRetentionPolicy() {
const [tab, setTab] = useState<Tab>('overview');
const { hasPermission } = useAuth();
// 향후 Phase 3 에서 파기 승인/예외 등록 시 disabled 가드로 활용
void hasPermission('admin:data-retention', 'UPDATE');
void hasPermission('admin:data-retention', 'DELETE');
const TABS: Array<{ key: Tab; icon: typeof Eye; label: string }> = [
{ key: 'overview', icon: Eye, label: '보관 현황' },
{ key: 'retention', icon: CalendarClock, label: '유형별 보관기간' },
{ key: 'disposal', icon: Trash2, label: '파기 절차' },
{ key: 'exception', icon: ShieldCheck, label: '예외·연장' },
{ key: 'audit', icon: FileText, label: '파기 감사 대장' },
];
return (
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-blue-600 dark:text-blue-400"
title="데이터 보관기간 및 파기 정책"
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
demo
/>
<TabBar variant="underline">
{TABS.map(t => (
<TabButton key={t.key} variant="underline" active={tab === t.key}
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
{t.label}
</TabButton>
))}
</TabBar>
{/* ── ① 보관 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
<div className="flex gap-2">
{RETENTION_KPI.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
<k.icon className="w-5 h-5" style={{ color: k.color }} />
<div>
<div className="text-lg font-bold" style={{ color: k.color }}>{k.value}</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
</div>
))}
</div>
{/* 보관 구조 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Settings className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> (4-Tier)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{STORAGE_ARCHITECTURE.map(s => (
<div key={s.tier} className="bg-surface-overlay rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[11px] font-bold text-heading">{s.tier}</span>
</div>
<p className="text-[9px] text-hint mb-2">{s.desc}</p>
<div className="space-y-1">
{[
['기술', s.tech],
['암호화', s.encryption],
['백업', s.backup],
].map(([k, v]) => (
<div key={k} className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
<span className="text-muted-foreground">{k}</span>
<span className="text-label">{v}</span>
</div>
))}
</div>
</div>
))}
</div>
</CardContent></Card>
{/* 정책 준수 현황 */}
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-1.5 text-[10px]">
{[
['유형별 보관기간 기준표', '6/6종 수립 완료', '완료'],
['파기 방식 정의', '4/4 방식 적용', '완료'],
['파기 승인 절차', '4단계 절차 운영 중', '완료'],
['보존 연장 예외 관리', '3건 관리 중 (1건 해제 예정)', '정상'],
['백업 데이터 동시 파기', '파기 시 백업 포함 확인', '완료'],
['파기 감사 대장', '12건 기록 (금월)', '완료'],
['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'],
].map(([k, v, s]) => (
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-500" />}
<span className="text-heading flex-1">{k}</span>
<span className="text-hint">{v}</span>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-1.5 text-[10px]">
{DISPOSAL_AUDIT_LOG.slice(0, 6).map(d => (
<div key={d.id} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-muted-foreground w-20">{d.date}</span>
<span className="text-heading flex-1 truncate">{d.target}</span>
<Badge intent={getStatusIntent(d.result)} size="sm">{d.method}</Badge>
<span className="text-hint text-[9px]">{d.volume}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ② 유형별 보관기간 ── */}
{tab === 'retention' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<CalendarClock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{RETENTION_TABLE.length} </Badge>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th>
<th className="text-left py-2"></th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"></th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{RETENTION_TABLE.map(r => (
<tr key={r.type} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-medium">{r.type}</td>
<td className="py-2.5"><Badge intent="muted" size="xs">{r.category}</Badge></td>
<td className="py-2.5 text-muted-foreground text-[9px]">{r.basis}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-bold">{r.period}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{r.format}</td>
<td className="py-2.5 text-center text-muted-foreground">{r.volume}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="grid grid-cols-3 gap-3">
{[
{ law: '해사안전법 시행규칙', article: '제42조', content: 'AIS 장치 기록 보존 의무 (5년)' },
{ law: '해양경비법', article: '제18조', content: '단속 기록 작성·보존 의무' },
{ law: '공공기록물 관리에 관한 법률', article: '제19조', content: '기록물 보존기간 준수 의무' },
{ law: '개인정보보호법', article: '제21조', content: '목적 달성 후 지체 없이 파기' },
{ law: '정보통신망법', article: '제45조', content: '접속기록 5년 보관 의무' },
{ law: '형사소송법', article: '제198조', content: '수사기록 보존 의무' },
].map(l => (
<div key={l.law} className="bg-surface-overlay rounded-lg p-3">
<div className="text-[11px] font-bold text-heading mb-1">{l.law}</div>
<Badge intent="muted" size="xs">{l.article}</Badge>
<div className="text-[9px] text-hint mt-1.5">{l.content}</div>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ③ 파기 절차 ── */}
{tab === 'disposal' && (
<div className="space-y-3">
{/* 파기 승인 워크플로우 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-[12px] font-bold text-heading"> (4)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{DISPOSAL_WORKFLOW.map((s, i) => (
<div key={s.phase} className="relative">
{i < DISPOSAL_WORKFLOW.length - 1 && (
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
)}
<div className="bg-surface-overlay rounded-lg p-3 h-full">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[11px] font-bold text-blue-600 dark:text-blue-400">{s.phase}</span>
</div>
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
<ul className="space-y-1.5">
{s.actions.map(a => (
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
</CardContent></Card>
{/* 파기 방식 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th>
<th className="text-left py-2"></th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"></th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{DISPOSAL_METHODS.map(m => (
<tr key={m.method} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-medium">{m.method}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{m.desc}</td>
<td className="py-2.5 text-muted-foreground">{m.target}</td>
<td className="py-2.5 text-center"><Badge intent={m.encryption.includes('AES') ? 'success' : 'muted'} size="xs">{m.encryption}</Badge></td>
<td className="py-2.5 text-center text-red-600 dark:text-red-400 text-[9px] font-medium">{m.recovery}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ④ 예외·연장 ── */}
{tab === 'exception' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs">{EXCEPTIONS.filter(e => e.status === '연장 중').length} </Badge>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> ID</th>
<th className="text-left py-2"> </th>
<th className="text-left py-2"></th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{EXCEPTIONS.map(e => (
<tr key={e.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{e.id}</td>
<td className="py-2.5 text-heading font-medium">{e.dataType}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{e.reason}</td>
<td className="py-2.5 text-center text-muted-foreground">{e.originalExpiry}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
<td className="py-2.5 text-center text-muted-foreground">{e.approver}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<div className="space-y-2">
{EXCEPTION_RULES.map(r => (
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
</div>
<Badge intent="muted" size="sm">{r.authority}</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ⑤ 파기 감사 대장 ── */}
{tab === 'audit' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{DISPOSAL_AUDIT_LOG.length}</Badge>
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> ID</th>
<th className="text-center py-2"></th>
<th className="text-left py-2"> </th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
<th className="text-center py-2"></th>
</tr>
</thead>
<tbody>
{DISPOSAL_AUDIT_LOG.map(d => (
<tr key={d.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{d.id}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.date}</td>
<td className="py-2.5 text-heading font-medium text-[9px]">{d.target}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{d.type}</Badge></td>
<td className="py-2.5 text-center text-muted-foreground">{d.method}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-mono">{d.volume}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.operator}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.approver}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{d.result}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
)}
</PageContainer>
);
}

파일 보기

@ -41,7 +41,7 @@ export function LoginHistoryView() {
<PageContainer size="lg"> <PageContainer size="lg">
<PageHeader <PageHeader
icon={LogIn} icon={LogIn}
iconColor="text-green-400" iconColor="text-label"
title="로그인 이력" title="로그인 이력"
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)" description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
actions={ actions={
@ -55,10 +55,10 @@ export function LoginHistoryView() {
{stats && ( {stats && (
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
<MetricCard label="전체 시도" value={stats.total} color="text-heading" /> <MetricCard label="전체 시도" value={stats.total} color="text-heading" />
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" /> <MetricCard label="성공 (24h)" value={stats.success24h} color="text-label" />
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" /> <MetricCard label="실패 (24h)" value={stats.failed24h} color="text-label" />
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" /> <MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-heading" />
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" /> <MetricCard label="성공률 (24h)" value={stats.successRate} color="text-label" suffix="%" />
</div> </div>
)} )}
@ -71,7 +71,7 @@ export function LoginHistoryView() {
<CardContent className="px-4 pb-4 space-y-1"> <CardContent className="px-4 pb-4 space-y-1">
{stats.byUser.map((u) => ( {stats.byUser.map((u) => (
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]"> <div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
<span className="text-cyan-400 font-mono">{u.user_acnt}</span> <span className="text-label font-mono">{u.user_acnt}</span>
<span className="text-heading font-bold">{u.count}</span> <span className="text-heading font-bold">{u.count}</span>
</div> </div>
))} ))}
@ -86,9 +86,9 @@ export function LoginHistoryView() {
<div key={d.day} className="flex items-center justify-between text-[11px]"> <div key={d.day} className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span> <span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
<div className="flex gap-3"> <div className="flex gap-3">
<span className="text-green-400"> {d.success}</span> <span className="text-label"> {d.success}</span>
<span className="text-orange-400"> {d.failed}</span> <span className="text-label"> {d.failed}</span>
<span className="text-red-400"> {d.locked}</span> <span className="text-heading"> {d.locked}</span>
</div> </div>
</div> </div>
))} ))}
@ -98,7 +98,7 @@ export function LoginHistoryView() {
</div> </div>
)} )}
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-heading">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -123,11 +123,11 @@ export function LoginHistoryView() {
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50"> <tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td> <td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td> <td className="px-3 py-2 text-label">{it.userAcnt}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge> <Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
</td> </td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td> <td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td> <td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
</tr> </tr>

파일 보기

@ -1,16 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import type { BadgeIntent } from '@lib/theme/variants'; import type { BadgeIntent } from '@lib/theme/variants';
import { import {
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar, Bell, Plus, Edit2, Trash2, Eye,
Users, Megaphone, AlertTriangle, Info, Search, Filter, Megaphone, AlertTriangle, Info,
CheckCircle, Clock, Pin, Monitor, MessageSquare, X, Clock, Pin, Monitor, MessageSquare, X,
} from 'lucide-react'; } from 'lucide-react';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { toDateParam } from '@shared/utils/dateFormat'; import { toDateParam } from '@shared/utils/dateFormat';
import { SaveButton } from '@shared/components/common/SaveButton'; import { SaveButton } from '@shared/components/common/SaveButton';
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner'; import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
@ -58,10 +58,10 @@ const INITIAL_NOTICES: SystemNotice[] = [
]; ];
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [ const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' }, { key: 'info', label: '정보', icon: Info, color: 'text-label' },
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' }, { key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-label' },
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' }, { key: 'urgent', label: '긴급', icon: Bell, color: 'text-label' },
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' }, { key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-label' },
]; ];
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [ const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
@ -74,6 +74,11 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() { export function NoticeManagement() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
const canDelete = hasPermission('admin:notices', 'DELETE');
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES); const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -141,12 +146,12 @@ export function NoticeManagement() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Bell} icon={Bell}
iconColor="text-yellow-400" iconColor="text-label"
title={t('notices.title')} title={t('notices.title')}
description={t('notices.desc')} description={t('notices.desc')}
demo demo
actions={ actions={
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}> <Button variant="primary" size="md" onClick={openNew} disabled={!canCreate} title={!canCreate ? '등록 권한이 필요합니다' : undefined} icon={<Plus className="w-3.5 h-3.5" />}>
</Button> </Button>
} }
@ -156,9 +161,9 @@ export function NoticeManagement() {
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' }, { label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-green-400', bg: 'bg-green-500/10' }, { label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-label', bg: 'bg-green-500/10' },
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' }, { label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' }, { label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-label', bg: 'bg-red-500/10' },
].map((kpi) => ( ].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"> <div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}> <div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -233,14 +238,14 @@ export function NoticeManagement() {
)} )}
</td> </td>
<td className="px-1 py-1.5 text-center"> <td className="px-1 py-1.5 text-center">
{n.pinned && <Pin className="w-3 h-3 text-yellow-400 inline" />} {n.pinned && <Pin className="w-3 h-3 text-label inline" />}
</td> </td>
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5"> <div className="flex items-center justify-center gap-0.5">
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정"> <button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제"> <button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
@ -261,7 +266,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading"> <span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'} {editingId ? '알림 수정' : '새 알림 등록'}
</span> </span>
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading"> <button type="button" aria-label={tc('aria.close')} onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
@ -271,7 +276,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input <input
aria-label="알림 제목" aria-label={tc('aria.noticeTitle')}
value={form.title} value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
@ -283,7 +288,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea <textarea
aria-label="알림 내용" aria-label={tc('aria.noticeContent')}
value={form.message} value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })} onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3} rows={3}
@ -322,7 +327,7 @@ export function NoticeManagement() {
onClick={() => setForm({ ...form, display: opt.key })} onClick={() => setForm({ ...form, display: opt.key })}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${ className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.display === opt.key form.display === opt.key
? 'bg-blue-600/20 text-blue-400 font-bold' ? 'bg-blue-600/20 text-label font-bold'
: 'text-hint hover:bg-surface-overlay' : 'text-hint hover:bg-surface-overlay'
}`} }`}
> >
@ -339,7 +344,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input <input
aria-label="시작일" aria-label={tc('aria.dateFrom')}
type="date" type="date"
value={form.startDate} value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })} onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -349,7 +354,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input <input
aria-label="종료일" aria-label={tc('aria.dateTo')}
type="date" type="date"
value={form.endDate} value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })} onChange={(e) => setForm({ ...form, endDate: e.target.value })}
@ -370,7 +375,7 @@ export function NoticeManagement() {
onClick={() => toggleRole(role)} onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${ className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.targetRoles.includes(role) form.targetRoles.includes(role)
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 font-bold' ? 'bg-surface-overlay text-heading border border-border font-bold'
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay' : 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
}`} }`}
> >
@ -414,7 +419,7 @@ export function NoticeManagement() {
<SaveButton <SaveButton
onClick={handleSave} onClick={handleSave}
label={editingId ? '수정' : '등록'} label={editingId ? '수정' : '등록'}
disabled={!form.title.trim() || !form.message.trim()} disabled={!form.title.trim() || !form.message.trim() || (editingId ? !canUpdate : !canCreate)}
/> />
</div> </div>
</div> </div>

파일 보기

@ -0,0 +1,651 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import {
getPerformanceStatusHex,
getPerformanceStatusIntent,
utilizationStatus,
type PerformanceStatus,
} from '@shared/constants/performanceStatus';
import { useAuth } from '@/app/auth/AuthContext';
import {
Activity, Gauge, Users, Database, Brain, Server,
CheckCircle, AlertTriangle, TrendingUp, Clock,
Zap, Shield, BarChart3, Cpu, HardDrive, Wifi,
} from 'lucide-react';
/*
* (PER-01 ~ PER-06)
*
* 3,000 ( 200· 100 24/7 + ··)
* (V-PASS·VTS·E-nav) + S&P Global AIS A/B
*
* (PER-01) (PER-02·03)
* AI (PER-04) ·(PER-05·06)
*/
type Tab = 'overview' | 'response' | 'capacity' | 'aiModel' | 'availability';
// ─── 성능 KPI ──────────────────
const PERF_KPI: Array<{ label: string; value: string; unit: string; icon: typeof Users; status: PerformanceStatus }> = [
{ label: '현재 동시접속', value: '342', unit: '명', icon: Users, status: 'normal' },
{ label: '대시보드 p95', value: '1.8', unit: '초', icon: Gauge, status: 'good' },
{ label: '시스템 가동률', value: '99.87', unit: '%', icon: Shield, status: 'good' },
{ label: 'AI 추론 p95', value: '1.4', unit: '초', icon: Brain, status: 'good' },
{ label: '배치 SLA 준수', value: '100', unit: '%', icon: CheckCircle, status: 'good' },
{ label: '이벤트 경보', value: '0', unit: '건', icon: AlertTriangle, status: 'normal' },
];
// ─── SLO 적용 그룹 ──────────────────
const USER_GROUPS = [
{ group: '본청 상황실', users: 100, concurrent: '100 (100%)', sla: '≤ 2초 대시보드 / ≤ 1초 지도', priority: 'critical' as const, note: '24/7 상시 접속 · 최상위 SLO' },
{ group: '본청 기타', users: 100, concurrent: '50 (50%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '주간 업무 시간' },
{ group: '지방청(5개)', users: 400, concurrent: '120 (30%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '관할해역 상황실' },
{ group: '관할서', users: 1500, concurrent: '300 (20%)', sla: '≤ 1.5초 조회', priority: 'info' as const, note: '주간 피크' },
{ group: '함정·파출소', users: 800, concurrent: '120 (15%)', sla: '≤ 1초 API', priority: 'info' as const, note: '모바일 Agent · 저대역폭' },
];
// ─── PER-01 응답성 SLO vs 실측 ──────────────────
const RESPONSE_SLO = [
{ target: '메인 대시보드 초기 로드', slo: '2.0초', p50: '0.9초', p95: '1.8초', p99: '2.4초', status: 'good' as const },
{ target: '위험도 지도 격자 조회', slo: '2.0초', p50: '0.7초', p95: '1.6초', p99: '2.1초', status: 'good' as const },
{ target: '의심 선박·어구 단순 조회', slo: '1.5초', p50: '0.4초', p95: '1.1초', p99: '1.7초', status: 'good' as const },
{ target: '복합 분석·시각화', slo: '5.0초', p50: '2.1초', p95: '4.2초', p99: '5.8초', status: 'warn' as const },
{ target: 'AI 추론 API (단건)', slo: '2.0초', p50: '0.6초', p95: '1.4초', p99: '1.9초', status: 'good' as const },
{ target: '연계 API (read)', slo: '500ms', p50: '120ms', p95: '380ms', p99: '510ms', status: 'warn' as const },
{ target: '함정 모바일 Agent API', slo: '1.0초', p50: '0.3초', p95: '0.8초', p99: '1.2초', status: 'good' as const },
{ target: 'AI 탐지 알림 End-to-End', slo: '3.0초', p50: '1.1초', p95: '2.3초', p99: '2.8초', status: 'good' as const },
];
// ─── PER-02 동시접속·TPS ──────────────────
const CAPACITY_METRICS = [
{ metric: '현재 동시접속', current: 342, target: '600 (정상 피크)', max: '900 (작전 피크)', utilization: 57, intent: 'info' as const },
{ metric: '현재 TPS', current: 185, target: '400 TPS', max: '600 TPS', utilization: 46, intent: 'info' as const },
{ metric: '활성 세션', current: 287, target: '500', max: '750', utilization: 57, intent: 'info' as const },
{ metric: 'WebSocket 연결', current: 142, target: '300', max: '500', utilization: 47, intent: 'info' as const },
];
// ─── PER-03 배치 처리 현황 ──────────────────
const BATCH_JOBS = [
{ name: 'AIS 국내 정제·적재', schedule: '매 5분', volume: '~5 GB/일', sla: '5분', avg: '2분 18초', lastRun: '성공', status: 'success' as const },
{ name: 'S&P 글로벌 AIS 집계·격자', schedule: '00:00 야간', volume: '~500 GB/일 (압축 후)', sla: '3시간', avg: '2시간 12분', lastRun: '성공', status: 'success' as const },
{ name: '위성영상 타일링·인덱싱', schedule: '수신 직후', volume: '건당 2~10 GB', sla: '2시간/건', avg: '1시간 24분', lastRun: '성공', status: 'success' as const },
{ name: '피처 스토어 갱신', schedule: '매시 정각', volume: '~200 MB', sla: '1시간', avg: '8분 42초', lastRun: '성공', status: 'success' as const },
{ name: 'AI 모델 재학습 (주간)', schedule: '일요일 02:00', volume: '학습셋 전체', sla: '8시간', avg: '6시간 18분', lastRun: '성공', status: 'success' as const },
{ name: '통계·리포트 집계', schedule: '매시 정각', volume: '~50 MB', sla: '30분', avg: '6분 12초', lastRun: '성공', status: 'success' as const },
{ name: '해양기상·환경 수집', schedule: '매시 정각', volume: '~500 MB', sla: '10분', avg: '3분 48초', lastRun: '지연', status: 'warn' as const },
];
// ─── PER-04 AI 모델 성능 ──────────────────
const AI_MODELS = [
{ model: '불법조업 위험도 예측', accuracy: 92.4, precision: 89.1, recall: 87.6, f1: 88.3, rocAuc: 0.948, target: '≥ 85% 정확도', status: 'good' as const },
{ model: '순찰 경로 추천 (단일)', accuracy: 94.1, precision: 91.2, recall: 90.5, f1: 90.8, rocAuc: 0.961, target: '≥ 90% 정확도', status: 'good' as const },
{ model: '다함정 협력 경로 최적화', accuracy: 91.8, precision: 88.4, recall: 87.9, f1: 88.1, rocAuc: 0.936, target: '≥ 85% 정확도', status: 'good' as const },
{ model: '불법 어선 (Dark Vessel) 탐지', accuracy: 96.2, precision: 94.8, recall: 92.3, f1: 93.5, rocAuc: 0.978, target: '≥ 92% 정확도', status: 'good' as const },
{ model: '불법 어망·어구 탐지', accuracy: 88.7, precision: 85.2, recall: 83.6, f1: 84.4, rocAuc: 0.912, target: '≥ 85% 정확도', status: 'warn' as const },
{ model: 'AIS 조작 패턴 감지', accuracy: 93.5, precision: 91.7, recall: 89.4, f1: 90.5, rocAuc: 0.954, target: '≥ 90% 정확도', status: 'good' as const },
];
// ─── PER-05 가용성·장애복구 ──────────────────
const AVAILABILITY_METRICS = [
{ component: '애플리케이션 서버 (K8s)', uptime: '99.98%', rto: '≤ 30초', rpo: '0 (stateless)', lastIncident: '없음', status: 'good' as const },
{ component: 'DB (PostgreSQL HA)', uptime: '99.95%', rto: '≤ 60초', rpo: '≤ 5초', lastIncident: '2026-03-28', status: 'good' as const },
{ component: 'TimescaleDB (Hot)', uptime: '99.92%', rto: '≤ 120초', rpo: '≤ 15초', lastIncident: '2026-04-02', status: 'good' as const },
{ component: '벡터 DB (RAG)', uptime: '99.87%', rto: '≤ 180초', rpo: '≤ 30초', lastIncident: '2026-04-08', status: 'warn' as const },
{ component: 'NAS 스토리지', uptime: '99.99%', rto: '≤ 60초', rpo: '0 (이중화)', lastIncident: '없음', status: 'good' as const },
{ component: '통합게이트웨이', uptime: '99.89%', rto: '≤ 60초', rpo: '≤ 10초', lastIncident: '2026-04-05', status: 'good' as const },
{ component: 'S&P Global AIS API', uptime: '99.41%', rto: 'Fallback 즉시', rpo: '국내 신호 대체', lastIncident: '2026-04-12', status: 'warn' as const },
{ component: 'LLM Q&A 서버 (H200)', uptime: '99.76%', rto: '≤ 120초', rpo: '0 (stateless)', lastIncident: '2026-04-09', status: 'good' as const },
];
// ─── PER-06 확장성·자원 사용률 ──────────────────
const RESOURCE_USAGE = [
{ resource: '워커 노드 CPU', current: 48, threshold: 70, max: 80, scalePolicy: 'HPA 자동 확장', unit: '%' },
{ resource: '워커 노드 메모리', current: 52, threshold: 75, max: 85, scalePolicy: 'HPA 자동 확장', unit: '%' },
{ resource: 'AI 서버 GPU (RTX pro 6000)', current: 61, threshold: 80, max: 90, scalePolicy: '추론 큐잉', unit: '%' },
{ resource: 'LLM 서버 GPU (H200)', current: 44, threshold: 75, max: 85, scalePolicy: '요청 병합·배치', unit: '%' },
{ resource: 'DB 연결 풀', current: 128, threshold: 300, max: 400, scalePolicy: 'PgBouncer 풀 확대', unit: '개' },
{ resource: 'NAS 사용량', current: 28, threshold: 75, max: 90, scalePolicy: '콜드 티어 이관', unit: '% (100TB)' },
{ resource: 'Kafka 컨슈머 Lag', current: 142, threshold: 5000, max: 10000, scalePolicy: '파티션 증설', unit: 'msg' },
{ resource: 'Redis 캐시 메모리', current: 38, threshold: 70, max: 85, scalePolicy: 'Eviction + 클러스터 확장', unit: '%' },
];
// ─── 성능 영향 최소화 전략 (S&P 글로벌 대응) ──────────────────
const IMPACT_REDUCTION = [
{ strategy: '이중 수집 파이프라인 물리 분리', target: '국내 vs 글로벌 격리', effect: '글로벌 장애 → 국내 무영향', per: 'PER-01·05' },
{ strategy: '경계 조기 필터링', target: '지리·선박 클래스 필터', effect: '원본 50~80% 감축', per: 'PER-03' },
{ strategy: '스트림·백프레셔 (Kafka)', target: 'Lag 임계 초과 시 다운샘플링', effect: '온라인 무영향', per: 'PER-01·03' },
{ strategy: '티어드 스토리지 (Hot/Warm/Cold)', target: '1~7일 / 30일 / 이후', effect: '쿼리 비용 최소화', per: 'PER-03·06' },
{ strategy: '공간 사전 집계 (H3 격자)', target: 'Materialized View', effect: '대시보드 Redis만 조회', per: 'PER-01' },
{ strategy: 'Circuit Breaker (S&P)', target: '실패율 50% 차단', effect: '국내 신호 Fallback', per: 'PER-05' },
{ strategy: 'K8s PriorityClass 격리', target: '온라인 vs 배치', effect: '상황실 SLO 절대 보장', per: 'PER-01·03' },
{ strategy: 'HPA 자동 확장', target: 'CPU/메모리 70% 임계', effect: '피크 자동 대응', per: 'PER-02·06' },
];
// 로컬 status 문자열을 카탈로그 PerformanceStatus로 매핑
const toStatus = (s: 'good' | 'warn' | 'critical' | 'success'): PerformanceStatus => {
if (s === 'good' || s === 'success') return 'good';
if (s === 'warn') return 'warning';
return 'critical';
};
const statusIntent = (s: 'good' | 'warn' | 'critical' | 'success') =>
getPerformanceStatusIntent(toStatus(s));
const barColor = (ratio: number): string =>
getPerformanceStatusHex(utilizationStatus(ratio));
export function PerformanceMonitoring() {
const [tab, setTab] = useState<Tab>('overview');
const { hasPermission } = useAuth();
// 향후 Phase 3 에서 EXPORT 버튼 추가 시 disabled={!canExport} 로 연결
void hasPermission('admin:performance-monitoring', 'EXPORT');
const TABS: Array<{ key: Tab; icon: typeof BarChart3; label: string }> = [
{ key: 'overview', icon: BarChart3, label: '성능 현황' },
{ key: 'response', icon: Gauge, label: '응답성 (PER-01)' },
{ key: 'capacity', icon: Users, label: '처리용량 (PER-02·03)' },
{ key: 'aiModel', icon: Brain, label: 'AI 모델 (PER-04)' },
{ key: 'availability', icon: Shield, label: '가용성·확장성 (PER-05·06)' },
];
return (
<PageContainer>
<PageHeader
icon={Activity}
iconColor="text-cyan-600 dark:text-cyan-400"
title="성능 모니터링"
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
demo
/>
<TabBar variant="underline">
{TABS.map(t => (
<TabButton key={t.key} variant="underline" active={tab === t.key}
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
{t.label}
</TabButton>
))}
</TabBar>
{/* ── ① 성능 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
{/* KPI */}
<div className="grid grid-cols-6 gap-2">
{PERF_KPI.map(k => {
const hex = getPerformanceStatusHex(k.status);
return (
<div key={k.label} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: hex, borderLeftWidth: 3 }}>
<k.icon className="w-5 h-5" style={{ color: hex }} />
<div>
<div className="text-lg font-bold" style={{ color: hex }}>
{k.value}<span className="text-[10px] ml-1 text-hint">{k.unit}</span>
</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
</div>
);
})}
</div>
{/* 사용자 그룹별 SLO */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> SLO ( 2,900 + )</span>
<Badge intent="info" size="xs"> 200 · 100 </Badge>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-left py-2 px-2 font-medium">SLA</th>
<th className="text-center py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{USER_GROUPS.map(g => (
<tr key={g.group} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{g.group}</td>
<td className="py-2 px-2 text-right text-label">{g.users.toLocaleString()}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{g.concurrent}</td>
<td className="py-2 px-2 text-label">{g.sla}</td>
<td className="py-2 px-2 text-center"><Badge intent={g.priority} size="xs">{g.priority === 'critical' ? '최상' : g.priority === 'high' ? '높음' : '일반'}</Badge></td>
<td className="py-2 px-2 text-hint">{g.note}</td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
{/* 성능 영향 최소화 전략 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[12px] font-bold text-heading"> ( AIS )</span>
</div>
<div className="grid grid-cols-2 gap-2">
{IMPACT_REDUCTION.map((s, i) => (
<div key={s.strategy} className="flex items-start gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-600 dark:text-amber-400">{i + 1}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[11px] text-heading font-medium">{s.strategy}</span>
<Badge intent="info" size="xs">{s.per}</Badge>
</div>
<div className="text-[9px] text-hint mb-0.5">: {s.target}</div>
<div className="text-[9px] text-green-600 dark:text-green-400">: {s.effect}</div>
</div>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ② 응답성 (PER-01) ── */}
{tab === 'response' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading">PER-01 SLO vs (p50/p95/p99)</span>
</div>
<Badge intent="success" size="sm">TER-03 </Badge>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">SLO </th>
<th className="text-right py-2 px-2 font-medium">p50</th>
<th className="text-right py-2 px-2 font-medium">p95</th>
<th className="text-right py-2 px-2 font-medium">p99</th>
<th className="text-center py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{RESPONSE_SLO.map(r => (
<tr key={r.target} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{r.target}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400 font-medium">{r.slo}</td>
<td className="py-2 px-2 text-right text-hint">{r.p50}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{r.p95}</td>
<td className="py-2 px-2 text-right text-label">{r.p99}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(r.status)} size="xs">{r.status === 'good' ? '정상' : '주의'}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> SLO (24/7 100)</span>
</div>
<div className="space-y-2">
{[
{ item: '메인 대시보드 초기 로드', target: '≤ 2초', current: '1.8초', met: true },
{ item: '위험도 지도 실시간 갱신', target: '≤ 1초', current: '0.7초', met: true },
{ item: 'AI 탐지 알림 수신 → 표출', target: '≤ 3초 E2E', current: '2.3초', met: true },
{ item: '단속 계획·경로 조회', target: '≤ 1.5초', current: '1.1초', met: true },
{ item: '장애 시 세션 유지', target: '< 30초 복구', current: '18초', met: true },
].map(s => (
<div key={s.item} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
<div>
<div className="text-[11px] text-heading font-medium">{s.item}</div>
<div className="text-[9px] text-hint">: {s.target}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-green-600 dark:text-green-400 font-bold">{s.current}</span>
{s.met ? <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500" />}
</div>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<ul className="space-y-2 text-[11px]">
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">1 p50/p95/p99 </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">OpenTelemetry + Prometheus + Grafana</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">APM:</strong> <span className="text-label"> + Trace ID </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">API :</strong> <span className="text-label">3 · Exponential Backoff · 3</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">SLO 5 PagerDuty</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label">RED/USE + ·· </span></div>
</li>
</ul>
</CardContent></Card>
</div>
</div>
)}
{/* ── ③ 처리용량 (PER-02·03) ── */}
{tab === 'capacity' && (
<div className="space-y-3">
{/* 동시접속·TPS */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading">PER-02 · ( 600 / 900)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{CAPACITY_METRICS.map(c => (
<div key={c.metric} className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1">{c.metric}</div>
<div className="text-xl font-bold text-heading mb-1">{c.current.toLocaleString()}</div>
<div className="text-[9px] text-label mb-2"> {c.target} / {c.max}</div>
<div className="h-1.5 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${c.utilization}%`, backgroundColor: barColor(c.utilization / 100) }} />
</div>
<div className="text-[9px] text-hint mt-1">{c.utilization}% </div>
</div>
))}
</div>
</CardContent></Card>
{/* 배치 작업 현황 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-03 · </span>
<Badge intent="success" size="xs">SLA 6/7</Badge>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"> </th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-right py-2 px-2 font-medium">SLA</th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-center py-2 px-2 font-medium"> </th>
</tr>
</thead>
<tbody>
{BATCH_JOBS.map(j => (
<tr key={j.name} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{j.name}</td>
<td className="py-2 px-2 text-hint">{j.schedule}</td>
<td className="py-2 px-2 text-right text-label">{j.volume}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{j.sla}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{j.avg}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(j.status)} size="xs">{j.lastRun}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
{/* 처리 볼륨 산정 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> ( + S&P )</span>
</div>
<div className="grid grid-cols-3 gap-3 text-[11px]">
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1"> ()</div>
<div className="text-xl font-bold text-heading">1.6 ~ 3.2 TB</div>
<div className="text-[9px] text-label mt-1">AIS() + S&P A/B + + </div>
</div>
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1"> (· )</div>
<div className="text-xl font-bold text-heading">330 ~ 900 GB</div>
<div className="text-[9px] text-green-600 dark:text-green-400 mt-1"> 50~80% </div>
</div>
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1">3 ()</div>
<div className="text-xl font-bold text-heading">~360 TB ~ 1 PB</div>
<div className="text-[9px] text-amber-600 dark:text-amber-400 mt-1">NAS 100TB </div>
</div>
</div>
</CardContent></Card>
</div>
)}
{/* ── ④ AI 모델 성능 (PER-04) ── */}
{tab === 'aiModel' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-04 AI </span>
</div>
<div className="flex items-center gap-2">
<Badge intent="success" size="xs">6 </Badge>
<Badge intent="warning" size="xs">· </Badge>
</div>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">F1</th>
<th className="text-right py-2 px-2 font-medium">ROC-AUC</th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-center py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{AI_MODELS.map(m => (
<tr key={m.model} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{m.model}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{m.accuracy}%</td>
<td className="py-2 px-2 text-right text-label">{m.precision}%</td>
<td className="py-2 px-2 text-right text-label">{m.recall}%</td>
<td className="py-2 px-2 text-right text-label">{m.f1}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{m.rocAuc}</td>
<td className="py-2 px-2 text-hint text-[10px]">{m.target}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(m.status)} size="xs">{m.status === 'good' ? '통과' : '주의'}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<ul className="space-y-2 text-[11px]">
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">// :</strong> <span className="text-label">70/15/15 , K-Fold 5</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label"> KL divergence </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label">F1 3%p </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">Feature Importance + SHAP </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">A/B :</strong> <span className="text-label">Shadow Canary 5% 50% 100% </span></div>
</li>
</ul>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[12px] font-bold text-heading"> (GPU )</span>
</div>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-label">AI (RTX pro 6000 Blackwell ×2)</span>
<span className="text-[11px] text-heading font-bold">61%</span>
</div>
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full bg-amber-500 rounded-full" style={{ width: '61%' }} />
</div>
<div className="text-[9px] text-hint mt-1"> 1.4 · &lt;200ms</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-label">LLM (H200 ×2)</span>
<span className="text-[11px] text-heading font-bold">44%</span>
</div>
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: '44%' }} />
</div>
<div className="text-[9px] text-hint mt-1">Q&A 380ms</div>
</div>
<div className="pt-2 border-t border-border/40">
<div className="text-[10px] text-hint mb-1"> </div>
<div className="flex items-center gap-2">
<Badge intent="info" size="xs"> 100 /</Badge>
<Badge intent="success" size="xs"> &lt;1%</Badge>
</div>
</div>
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ⑤ 가용성·확장성 (PER-05·06) ── */}
{tab === 'availability' && (
<div className="space-y-3">
{/* 가용성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading">PER-05 ( 99.9%)</span>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">RTO</th>
<th className="text-right py-2 px-2 font-medium">RPO</th>
<th className="text-left py-2 px-2 font-medium"> </th>
<th className="text-center py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{AVAILABILITY_METRICS.map(a => (
<tr key={a.component} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{a.component}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{a.uptime}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{a.rto}</td>
<td className="py-2 px-2 text-right text-label">{a.rpo}</td>
<td className="py-2 px-2 text-hint">{a.lastIncident}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(a.status)} size="xs">{a.status === 'good' ? '정상' : '주의'}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
{/* 확장성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Server className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-06 </span>
<Badge intent="info" size="xs">2(6,000) </Badge>
</div>
<div className="grid grid-cols-2 gap-3">
{RESOURCE_USAGE.map(r => {
const ratio = r.current / r.max;
return (
<div key={r.resource} className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-medium">{r.resource}</span>
<span className="text-[11px] text-heading font-bold">{r.current}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ' ' + r.unit}</span>
</div>
<div className="h-2 bg-surface-raised rounded-full overflow-hidden mb-2">
<div className="h-full rounded-full transition-all" style={{ width: `${(ratio) * 100}%`, backgroundColor: barColor(ratio) }} />
</div>
<div className="flex items-center justify-between text-[9px]">
<span className="text-hint"> {r.threshold}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''} · {r.max}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''}</span>
<Badge intent="muted" size="xs">{r.scalePolicy}</Badge>
</div>
</div>
);
})}
</div>
</CardContent></Card>
{/* 요약 지표 */}
<div className="grid grid-cols-4 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Wifi className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<div className="text-2xl font-bold text-cyan-600 dark:text-cyan-400">99.9%</div>
<div className="text-[9px] text-hint mt-1"> 43</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[11px] font-bold text-heading">RTO </span>
</div>
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400"> 60</div>
<div className="text-[9px] text-hint mt-1"> · Self-healing</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Database className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[11px] font-bold text-heading">RPO </span>
</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400"> 10</div>
<div className="text-[9px] text-hint mt-1"> + </div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[11px] font-bold text-heading">Scale-out </span>
</div>
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">×2</div>
<div className="text-[9px] text-hint mt-1">6,000 </div>
</CardContent></Card>
</div>
</div>
)}
</PageContainer>
);
}

파일 보기

@ -6,6 +6,7 @@ import {
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions, fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry, type RoleWithPermissions, type PermTreeNode, type PermEntry,
@ -19,6 +20,7 @@ import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker'; import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi'; import { updateRole as apiUpdateRole } from '@/services/adminApi';
import { useTranslation } from 'react-i18next';
/** /**
* (wing ). * (wing ).
@ -45,6 +47,7 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; } function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() { export function PermissionsPanel() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE'); const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE'); const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
@ -230,7 +233,7 @@ export function PermissionsPanel() {
await updateRolePermissions(selectedRole.roleSn, changes); await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화 await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`); alert(`${tc('success.permissionUpdated')} (${changes.length})`);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown'); setError(e instanceof Error ? e.message : 'unknown');
} finally { } finally {
@ -247,7 +250,7 @@ export function PermissionsPanel() {
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]); setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.createFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
@ -257,23 +260,23 @@ export function PermissionsPanel() {
await load(); await load();
setEditingColor(null); setEditingColor(null);
} catch (e: unknown) { } catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
const handleDeleteRole = async () => { const handleDeleteRole = async () => {
if (!selectedRole) return; if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') { if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.'); alert(tc('message.builtinRoleCannotDelete'));
return; return;
} }
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return; if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
try { try {
await deleteRole(selectedRole.roleSn); await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null); setSelectedRoleSn(null);
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.deleteFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
@ -358,14 +361,17 @@ export function PermissionsPanel() {
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button type="button" onClick={load} <Button
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침"> variant="ghost"
<RefreshCw className="w-3.5 h-3.5" /> size="sm"
</button> onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div> </div>
</div> </div>
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-heading">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -378,28 +384,44 @@ export function PermissionsPanel() {
<div className="text-xs text-label font-bold"></div> <div className="text-xs text-label font-bold"></div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{canCreateRole && ( {canCreateRole && (
<button type="button" onClick={() => setShowCreate(!showCreate)} <Button
className="p-1 text-hint hover:text-green-400" title="신규 역할"> variant="ghost"
<Plus className="w-3.5 h-3.5" /> size="sm"
</button> onClick={() => setShowCreate(!showCreate)}
aria-label="신규 역할"
title="신규 역할"
icon={<Plus className="w-3.5 h-3.5" />}
/>
)} )}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && ( {canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<button type="button" onClick={handleDeleteRole} <Button
className="p-1 text-hint hover:text-red-400" title="역할 삭제"> variant="ghost"
<Trash2 className="w-3.5 h-3.5" /> size="sm"
</button> onClick={handleDeleteRole}
aria-label="역할 삭제"
title="역할 삭제"
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)} )}
</div> </div>
</div> </div>
{showCreate && ( {showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5"> <div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())} <Input
aria-label={tc('aria.roleCode')}
size="sm"
value={newRoleCd}
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)" placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> />
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)} <Input
placeholder="역할 이름" aria-label={tc('aria.roleName')}
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> size="sm"
value={newRoleNm}
onChange={(e) => setNewRoleNm(e.target.value)}
placeholder={tc('aria.roleName')}
/>
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} /> <ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1"> <div className="flex gap-1 pt-1">
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1"> <Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
@ -442,7 +464,7 @@ export function PermissionsPanel() {
<button <button
type="button" type="button"
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))} onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
className="text-[8px] text-hint hover:text-blue-400" className="text-[8px] text-hint hover:text-label"
title="색상 변경" title="색상 변경"
> >
@ -479,9 +501,9 @@ export function PermissionsPanel() {
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'} {selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
</div> </div>
<div className="text-[10px] text-hint mt-0.5"> <div className="text-[10px] text-hint mt-0.5">
: <span className="text-blue-400"> </span> / : <span className="text-label"> </span> /
<span className="text-blue-300/80 ml-1"> </span> / <span className="text-blue-300/80 ml-1"> </span> /
<span className="text-red-400 ml-1"> </span> / <span className="text-heading ml-1"> </span> /
<span className="text-gray-500 ml-1">× </span> / <span className="text-gray-500 ml-1">× </span> /
<span className="text-hint ml-1">· </span> <span className="text-hint ml-1">· </span>
</div> </div>

파일 보기

@ -6,9 +6,9 @@ import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import type { BadgeIntent } from '@lib/theme/variants'; import type { BadgeIntent } from '@lib/theme/variants';
import { import {
Settings, Database, Search, ChevronDown, ChevronRight, Settings, Database, Search,
Map, Fish, Anchor, Ship, Globe, BarChart3, Download, Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
Filter, RefreshCw, BookOpen, Layers, Hash, Info, Filter, RefreshCw, Hash, Info,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES, AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
@ -77,6 +77,7 @@ const SYSTEM_SETTINGS = {
export function SystemConfig() { export function SystemConfig() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<CodeTab>('areas'); const [tab, setTab] = useState<CodeTab>('areas');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [majorFilter, setMajorFilter] = useState(''); const [majorFilter, setMajorFilter] = useState('');
@ -149,7 +150,7 @@ export function SystemConfig() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Database} icon={Database}
iconColor="text-cyan-400" iconColor="text-label"
title={t('systemConfig.title')} title={t('systemConfig.title')}
description={t('systemConfig.desc')} description={t('systemConfig.desc')}
demo demo
@ -168,11 +169,11 @@ export function SystemConfig() {
{/* KPI 카드 */} {/* KPI 카드 */}
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
{[ {[
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' }, { icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-label', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' }, { icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-label', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-purple-400', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' }, { icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-heading', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' }, { icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-label', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' }, { icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-label', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
].map((kpi) => ( ].map((kpi) => (
<Card key={kpi.label} className="bg-surface-raised border-border"> <Card key={kpi.label} className="bg-surface-raised border-border">
<CardContent className="p-3"> <CardContent className="p-3">
@ -218,7 +219,7 @@ export function SystemConfig() {
<div className="relative flex-1 max-w-md"> <div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input <input
aria-label="코드 검색" aria-label={tc('aria.searchCode')}
value={query} value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }} onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={ placeholder={
@ -233,7 +234,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" /> <Filter className="w-3.5 h-3.5 text-hint" />
<select <select
aria-label="대분류 필터" aria-label={tc('aria.categoryFilter')}
value={majorFilter} value={majorFilter}
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }} onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50" className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
@ -270,7 +271,7 @@ export function SystemConfig() {
<tbody> <tbody>
{(pagedData as AreaCode[]).map((a) => ( {(pagedData as AreaCode[]).map((a) => (
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay"> <tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td> <td className="px-4 py-2 text-label font-mono font-medium">{a.code}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{(() => { {(() => {
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan'; const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
@ -313,7 +314,7 @@ export function SystemConfig() {
className="border-b border-border hover:bg-surface-overlay cursor-pointer" className="border-b border-border hover:bg-surface-overlay cursor-pointer"
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)} onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
> >
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td> <td className="px-4 py-2 text-label font-mono font-medium">{s.code}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<Badge intent="muted" size="sm">{s.major}</Badge> <Badge intent="muted" size="sm">{s.major}</Badge>
</td> </td>
@ -323,12 +324,12 @@ export function SystemConfig() {
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td> <td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{s.active {s.active
? <span className="text-green-400 text-[9px]">Y</span> ? <span className="text-label text-[9px]">Y</span>
: <span className="text-hint text-[9px]">N</span> : <span className="text-hint text-[9px]">N</span>
} }
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{s.fishing && <span className="text-yellow-400"></span>} {s.fishing && <span className="text-label"></span>}
</td> </td>
</tr> </tr>
))} ))}
@ -357,7 +358,7 @@ export function SystemConfig() {
<tbody> <tbody>
{(pagedData as FisheryCode[]).map((f) => ( {(pagedData as FisheryCode[]).map((f) => (
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay"> <tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td> <td className="px-4 py-2 text-label font-mono font-medium">{f.code}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{(() => { {(() => {
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted'; const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
@ -399,7 +400,7 @@ export function SystemConfig() {
<tbody> <tbody>
{(pagedData as VesselTypeCode[]).map((v) => ( {(pagedData as VesselTypeCode[]).map((v) => (
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay"> <tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td> <td className="px-3 py-2 text-label font-mono font-medium">{v.code}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{(() => { {(() => {
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted'; const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
@ -409,13 +410,7 @@ export function SystemConfig() {
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td> <td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
<td className="px-3 py-2 text-heading font-medium">{v.name}</td> <td className="px-3 py-2 text-heading font-medium">{v.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={`text-[9px] font-mono ${ <span className="text-[9px] font-mono text-label">{v.source}</span>
v.source === 'AIS' ? 'text-cyan-400'
: v.source === 'GIC' ? 'text-green-400'
: v.source === 'RRA' ? 'text-blue-400'
: v.source === 'PMS' ? 'text-orange-400'
: 'text-muted-foreground'
}`}>{v.source}</span>
</td> </td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td> <td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
@ -431,23 +426,25 @@ export function SystemConfig() {
{/* 페이지네이션 (코드 탭에서만) */} {/* 페이지네이션 (코드 탭에서만) */}
{tab !== 'settings' && totalPages > 1 && ( {tab !== 'settings' && totalPages > 1 && (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button type="button" <Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(0, page - 1))} onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
</button> </Button>
<span className="text-[11px] text-hint"> <span className="text-[11px] text-hint">
{page + 1} / {totalPages} ({totalItems.toLocaleString()}) {page + 1} / {totalPages} ({totalItems.toLocaleString()})
</span> </span>
<button type="button" <Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages - 1, page + 1))} onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
</button> </Button>
</div> </div>
)} )}
@ -466,7 +463,7 @@ export function SystemConfig() {
<Card key={section} className="bg-surface-raised border-border"> <Card key={section} className="bg-surface-raised border-border">
<CardHeader className="px-4 pt-3 pb-2"> <CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label flex items-center gap-1.5"> <CardTitle className="text-xs text-label flex items-center gap-1.5">
<meta.icon className="w-3.5 h-3.5 text-cyan-400" /> <meta.icon className="w-3.5 h-3.5 text-label" />
{meta.title} {meta.title}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -474,9 +471,7 @@ export function SystemConfig() {
{items.map((item) => ( {items.map((item) => (
<div key={item.key} className="flex justify-between items-center text-[11px]"> <div key={item.key} className="flex justify-between items-center text-[11px]">
<span className="text-hint">{item.label}</span> <span className="text-hint">{item.label}</span>
<span className={`font-medium ${ <span className="font-medium text-label">{item.value}</span>
item.value === '활성' ? 'text-green-400' : 'text-label'
}`}>{item.value}</span>
</div> </div>
))} ))}
</CardContent> </CardContent>

파일 보기

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { X, Check, Loader2 } from 'lucide-react'; import { X, Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi'; import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
import { getRoleBadgeStyle } from '@shared/constants/userRoles'; import { getRoleBadgeStyle } from '@shared/constants/userRoles';
@ -11,6 +13,7 @@ interface Props {
} }
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) { export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
const { t: tc } = useTranslation('common');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]); const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -44,7 +47,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
onSaved(); onSaved();
onClose(); onClose();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -60,7 +63,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
{user.userAcnt} ({user.userNm}) - (OR ) {user.userAcnt} ({user.userNm}) - (OR )
</div> </div>
</div> </div>
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading"> <button type="button" aria-label={tc('aria.closeDialog')} onClick={onClose} className="text-hint hover:text-heading">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
@ -99,15 +102,18 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
</div> </div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border"> <div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
<button type="button" onClick={onClose} <Button variant="secondary" size="sm" onClick={onClose}>
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
</button> </Button>
<button type="button" onClick={handleSave} disabled={saving} <Button
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1"> variant="primary"
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />} size="sm"
onClick={handleSave}
disabled={saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
>
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

파일 보기

@ -3,3 +3,8 @@ export { SystemConfig } from './SystemConfig';
export { NoticeManagement } from './NoticeManagement'; export { NoticeManagement } from './NoticeManagement';
export { AdminPanel } from './AdminPanel'; export { AdminPanel } from './AdminPanel';
export { DataHub } from './DataHub'; export { DataHub } from './DataHub';
export { AISecurityPage } from './AISecurityPage';
export { AIAgentSecurityPage } from './AIAgentSecurityPage';
export { DataRetentionPolicy } from './DataRetentionPolicy';
export { DataModelVerification } from './DataModelVerification';
export { PerformanceMonitoring } from './PerformanceMonitoring';

파일 보기

@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
@ -44,6 +46,7 @@ const INITIAL_MESSAGES: Message[] = [
export function AIAssistant() { export function AIAssistant() {
const { t } = useTranslation('ai'); const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES); const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1'); const [selectedConv, setSelectedConv] = useState('1');
@ -79,7 +82,7 @@ export function AIAssistant() {
<PageContainer className="h-full flex flex-col"> <PageContainer className="h-full flex flex-col">
<PageHeader <PageHeader
icon={MessageSquare} icon={MessageSquare}
iconColor="text-green-400" iconColor="text-green-600 dark:text-green-400"
title={t('assistant.title')} title={t('assistant.title')}
description={t('assistant.desc')} description={t('assistant.desc')}
/> />
@ -91,7 +94,7 @@ export function AIAssistant() {
<div className="space-y-1"> <div className="space-y-1">
{SAMPLE_CONVERSATIONS.map(c => ( {SAMPLE_CONVERSATIONS.map(c => (
<div key={c.id} onClick={() => setSelectedConv(c.id)} <div key={c.id} onClick={() => setSelectedConv(c.id)}
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}> className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-600 dark:text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
<div className="truncate">{c.title}</div> <div className="truncate">{c.title}</div>
<div className="text-[8px] text-hint mt-0.5">{c.time}</div> <div className="text-[8px] text-hint mt-0.5">{c.time}</div>
</div> </div>
@ -111,7 +114,7 @@ export function AIAssistant() {
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}> <div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && ( {msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0"> <div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-green-400" /> <Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
</div> </div>
)} )}
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${ <div className={`max-w-[70%] rounded-xl px-4 py-3 ${
@ -123,7 +126,7 @@ export function AIAssistant() {
{msg.refs && msg.refs.length > 0 && ( {msg.refs && msg.refs.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1"> <div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
{msg.refs.map(r => ( {msg.refs.map(r => (
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5"> <Badge key={r} className="bg-green-500/10 text-green-600 dark:text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<FileText className="w-2.5 h-2.5" />{r} <FileText className="w-2.5 h-2.5" />{r}
</Badge> </Badge>
))} ))}
@ -132,7 +135,7 @@ export function AIAssistant() {
</div> </div>
{msg.role === 'user' && ( {msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0"> <div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-blue-400" /> <User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div> </div>
)} )}
</div> </div>
@ -140,17 +143,22 @@ export function AIAssistant() {
</div> </div>
{/* 입력창 */} {/* 입력창 */}
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<input <Input
aria-label="AI 어시스턴트 질의" aria-label="AI 어시스턴트 질의"
size="md"
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()} onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)" placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50" className="flex-1"
/>
<Button
variant="primary"
size="md"
onClick={handleSend}
aria-label={tc('aria.send')}
icon={<Send className="w-4 h-4" />}
/> />
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
</div> </div>

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { import {
@ -57,7 +58,7 @@ const MODELS: ModelVersion[] = [
]; ];
const modelColumns: DataColumn<ModelVersion>[] = [ const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> }, { key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-bold">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: (v) => { render: (v) => {
const s = v as string; const s = v as string;
@ -68,7 +69,7 @@ const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> }, { key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> }, { key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true, { key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; }, render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-600 dark:text-green-400' : n < 15 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}>{n}%</span>; },
}, },
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' }, { key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> }, { key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
@ -175,7 +176,7 @@ const GEAR_CODES: GearCode[] = [
]; ];
const gearColumns: DataColumn<GearCode>[] = [ const gearColumns: DataColumn<GearCode>[] = [
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> }, { key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> }, { key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => { render: (v) => {
@ -197,6 +198,149 @@ const GEAR_PROFILES = [
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] }, features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
]; ];
// ─── DAR-03 5종 어구 구조 비교 (FAO ISSCFG) ──────────
interface DAR03GearSummary {
no: string;
name: string;
faoCode: string;
mesh: string;
iuuRisk: '매우 높음' | '높음' | '중간' | '낮음~중간';
aisType: string;
gCodes: string;
}
const DAR03_GEAR_SUMMARY: DAR03GearSummary[] = [
{ no: '①', name: '저층 트롤', faoCode: 'OTB/TBB', mesh: '≥60mm', iuuRisk: '높음', aisType: '어선 AIS', gCodes: 'G-01, G-03' },
{ no: '②', name: '쌍끌이 트롤', faoCode: 'PTM', mesh: '≥56mm', iuuRisk: '매우 높음', aisType: '어선 AIS 2척', gCodes: 'G-02, G-06' },
{ no: '③', name: '스토우넷', faoCode: 'FYK', mesh: '≥55mm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04, G-05' },
{ no: '④', name: '자망', faoCode: 'GNS/GND', mesh: '55~144mm', iuuRisk: '낮음~중간', aisType: '어구 AIS 부표', gCodes: 'G-03, G-05' },
{ no: '⑤', name: '통발·함정', faoCode: 'FPO', mesh: '탈출구 Ø≥8cm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04' },
];
const DAR03_IUU_INTENT: Record<DAR03GearSummary['iuuRisk'], BadgeIntent> = {
'매우 높음': 'critical',
'높음': 'high',
'중간': 'warning',
'낮음~중간': 'info',
};
interface DAR03GearDetail {
no: string;
name: string;
nameEn: string;
image: string;
specs: { k: string; v: string }[];
gCodes: { code: string; desc: string }[];
}
const DAR03_GEAR_DETAILS: DAR03GearDetail[] = [
{
no: '①', name: '저층 트롤', nameEn: 'Bottom Trawl (OTB/TBB)', image: '/dar03/bottom-trawl.png',
specs: [
{ k: 'FAO 코드', v: 'OTB / TBB' },
{ k: '최소 망목', v: '≥ 60mm (마름모형)' },
{ k: '주요 어종', v: '참조기 · 갈치' },
{ k: '조업 속력', v: '2.5~4.5 knot' },
{ k: '항적 패턴', v: 'U형 회전 · 직선 왕복' },
{ k: 'AIS', v: '어선 AIS (어구 AIS 없음)' },
],
gCodes: [
{ code: 'G-01', desc: '허가 해역 외 트롤 → GIS 교차' },
{ code: 'G-03', desc: '미등록 어구 → label=1' },
],
},
{
no: '②', name: '쌍끌이 중층 트롤', nameEn: 'Pair Midwater Trawl (PTM)', image: '/dar03/pair-trawl.png',
specs: [
{ k: 'FAO 코드', v: 'PTM' },
{ k: '최소 망목', v: '≥ 56mm' },
{ k: '주요 어종', v: '전갱이 · 고등어 · 참조기' },
{ k: '선박 간격', v: '300~500m 유지' },
{ k: '조업 속력', v: '2~4 knot (2척 동기화)' },
{ k: 'AIS', v: '2척 어선 AIS 동기화' },
],
gCodes: [
{ code: 'G-02', desc: '금어기 내 공조 조업 탐지' },
{ code: 'G-06', desc: '2척 동기화 2시간+ → 공조' },
],
},
{
no: '③', name: '스토우넷 (안강망)', nameEn: 'Stow Net (FYK)', image: '/dar03/stow-net.png',
specs: [
{ k: 'FAO 코드', v: 'FYK' },
{ k: '최소 망목', v: '≥ 55mm (캔버스형)' },
{ k: '주요 어종', v: '참조기 · 갈치 · 실치' },
{ k: '설치 방식', v: '말뚝·닻으로 고정' },
{ k: 'AIS', v: '어구 AIS 부표 부착 의무' },
{ k: '탐지 지표', v: '위치 이탈·출현·소실 주기' },
],
gCodes: [
{ code: 'G-01', desc: '위치 편차 200m+ → 구역 외' },
{ code: 'G-04', desc: '신호 30분 내 반복 → MMSI 조작' },
{ code: 'G-05', desc: '이동 500m+ → 인위적 이동' },
],
},
{
no: '④', name: '자망', nameEn: 'Gillnet (GNS/GND)', image: '/dar03/gillnet.png',
specs: [
{ k: 'FAO 코드', v: 'GNS / GND' },
{ k: '최소 망목', v: '55~144mm (어종별 상이)' },
{ k: '참조기 기준', v: '55mm (황해)' },
{ k: '은돔 기준', v: '100mm' },
{ k: 'AIS', v: '어구 AIS 부표 부착' },
{ k: '탐지 지표', v: '미등록 여부·기간 이탈' },
],
gCodes: [
{ code: 'G-02', desc: '금어기 내 신호 출현 → label=1' },
{ code: 'G-03', desc: '등록DB 미매칭 → 불법 자망' },
{ code: 'G-05', desc: '조류 보정 후 500m+ → 이동' },
],
},
{
no: '⑤', name: '통발 · 함정', nameEn: 'Pot / Trap (FPO)', image: '/dar03/pot-trap.png',
specs: [
{ k: 'FAO 코드', v: 'FPO' },
{ k: '탈출구 (꽃게)', v: 'Ø ≥ 8cm 또는 높이 33mm' },
{ k: '탈출구 (참게)', v: '측면 30mm + 말단 7cm' },
{ k: '주요 어종', v: '꽃게 · 참게 · 장어' },
{ k: '미성어 방류율', v: '95% 이상 (탈출구 적용 시)' },
{ k: 'AIS', v: '어구 AIS 부표 부착' },
],
gCodes: [
{ code: 'G-01', desc: '허가 구역 외 설치 → GIS 교차' },
{ code: 'G-04', desc: '어선-어구 출현·소실 60분+ 불일치' },
],
},
];
interface DAR03AisSignal {
no: string;
name: string;
aisType: string;
normal: string[];
threshold: string[];
gCodes: string;
}
const DAR03_AIS_SIGNALS: DAR03AisSignal[] = [
{ no: '①', name: '저층 트롤', aisType: '어선 AIS (Class-A)',
normal: ['2.5~4.5 knot', 'U형 항적 반복'],
threshold: ['5 knot 이상 급가속', '금지 해역 진입'], gCodes: 'G-01, G-03' },
{ no: '②', name: '쌍끌이 트롤', aisType: '어선 AIS 2척',
normal: ['2~4 knot 동기화', '500m 간격 유지'],
threshold: ['동기화 2시간 이상', '동시 AIS 차단 30분+'], gCodes: 'G-02, G-06' },
{ no: '③', name: '스토우넷', aisType: '어구 AIS (Class-B)',
normal: ['위치 완전 고정', '신호 지속 출현'],
threshold: ['위치 편차 200m+', '출현·소실 30분 이내 반복'], gCodes: 'G-01, G-04, G-05' },
{ no: '④', name: '자망', aisType: '어구 AIS (Class-B)',
normal: ['위치 반고정', '조류에 따라 완만이동'],
threshold: ['등록 DB 미매칭', '금어기 내 신호 출현'], gCodes: 'G-02, G-03' },
{ no: '⑤', name: '통발', aisType: '어구 AIS (Class-B)',
normal: ['위치 완전 고정', '신호 지속'],
threshold: ['어선 접근·이탈 불일치 60분+', '구역 외 위치'], gCodes: 'G-01, G-04' },
];
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ─── // ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
interface DetectionEngine { interface DetectionEngine {
@ -253,14 +397,14 @@ export function AIModelManagement() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Brain} icon={Brain}
iconColor="text-purple-400" iconColor="text-purple-600 dark:text-purple-400"
title={t('modelManagement.title')} title={t('modelManagement.title')}
description={t('modelManagement.desc')} description={t('modelManagement.desc')}
demo demo
actions={ actions={
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg"> <div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /> <div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] text-green-400 font-bold"> : {currentModel.version}</span> <span className="text-[10px] text-green-600 dark:text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span> <span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
</div> </div>
} }
@ -269,12 +413,12 @@ export function AIModelManagement() {
{/* KPI */} {/* KPI */}
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' }, { label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-500/10' },
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' }, { label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' }, { label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-600 dark:text-cyan-400', bg: 'bg-cyan-500/10' },
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' }, { label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-500/10' },
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' }, { label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-500/10' },
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' }, { label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-500/10' },
].map((kpi) => ( ].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"> <div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}> <div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -311,13 +455,13 @@ export function AIModelManagement() {
{/* 업데이트 알림 */} {/* 업데이트 알림 */}
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between"> <div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-400 shrink-0" /> <Zap className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0" />
<div> <div>
<div className="text-sm text-blue-300 font-bold"> v2.4.0 </div> <div className="text-sm text-blue-300 font-bold"> v2.4.0 </div>
<div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div> <div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div>
</div> </div>
</div> </div>
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button> <Button variant="primary" size="sm" className="shrink-0"> </Button>
</div> </div>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" /> <DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div> </div>
@ -352,7 +496,7 @@ export function AIModelManagement() {
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-[9px] text-hint"></div> <div className="text-[9px] text-hint"></div>
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div> <div className="text-[12px] font-bold text-cyan-600 dark:text-cyan-400">{rule.weight}%</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -362,7 +506,7 @@ export function AIModelManagement() {
{/* 가중치 합계 */} {/* 가중치 합계 */}
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" /> </div> <div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-600 dark:text-yellow-400" /> </div>
<div className="space-y-4"> <div className="space-y-4">
{rules.filter((r) => r.enabled).map((r, i) => ( {rules.filter((r) => r.enabled).map((r, i) => (
<div key={i}> <div key={i}>
@ -421,7 +565,7 @@ export function AIModelManagement() {
{/* 파이프라인 스테이지 */} {/* 파이프라인 스테이지 */}
<div className="flex gap-2"> <div className="flex gap-2">
{PIPELINE_STAGES.map((stage, i) => { {PIPELINE_STAGES.map((stage, i) => {
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint'; const stColor = stage.status === '정상' ? 'text-green-600 dark:text-green-400' : stage.status === '진행중' ? 'text-blue-600 dark:text-blue-400' : 'text-hint';
return ( return (
<div key={stage.stage} className="flex-1 flex items-start gap-2"> <div key={stage.stage} className="flex-1 flex items-start gap-2">
<Card className="flex-1 bg-surface-raised border-border"> <Card className="flex-1 bg-surface-raised border-border">
@ -552,7 +696,7 @@ export function AIModelManagement() {
<div key={kpi.label}> <div key={kpi.label}>
<div className="flex justify-between text-[10px] mb-1"> <div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">{kpi.label}</span> <span className="text-muted-foreground">{kpi.label}</span>
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}> <span className={achieved ? 'text-green-600 dark:text-green-400 font-bold' : 'text-red-600 dark:text-red-400 font-bold'}>
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`} {kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
</span> </span>
</div> </div>
@ -601,6 +745,166 @@ export function AIModelManagement() {
</Card> </Card>
))} ))}
</div> </div>
{/* ── DAR-03 5종 어구 구조 비교 ── */}
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
<Info className="w-5 h-5 text-indigo-400 shrink-0" />
<div className="flex-1">
<div className="text-[12px] font-bold text-indigo-300">DAR-03 · 5 (FAO ISSCFG)</div>
<div className="text-[10px] text-hint mt-0.5">
· FAO · Wang et al.(2022) · G-01~G-06
</div>
</div>
<Badge intent="purple" size="sm"></Badge>
</div>
{/* 5종 어구 특성 비교 요약 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Anchor className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
5
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th>
<th className="text-center py-2">FAO </th>
<th className="text-center py-2"> </th>
<th className="text-center py-2">IUU </th>
<th className="text-center py-2">AIS </th>
<th className="text-left py-2 px-2"> G코드</th>
</tr>
</thead>
<tbody>
{DAR03_GEAR_SUMMARY.map((g) => (
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2">
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-2">{g.no}</span>
<span className="text-heading font-medium">{g.name}</span>
</td>
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
<td className="py-2 text-center text-muted-foreground font-mono">{g.mesh}</td>
<td className="py-2 text-center">
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
</td>
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
<td className="py-2 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{/* 어구별 구조 도식 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</CardTitle>
<p className="text-[9px] text-hint italic">
FAO Wang et al.(2022) . .
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
{DAR03_GEAR_DETAILS.map((g) => (
<Card key={g.no} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
<div className="flex-1">
<div className="text-[12px] font-bold text-heading">{g.name}</div>
<div className="text-[9px] text-hint">{g.nameEn}</div>
</div>
</div>
<div className="bg-surface-overlay rounded-lg p-2 mb-3 flex items-center justify-center">
<img src={g.image} alt={g.nameEn} className="w-full h-auto max-h-48 object-contain" />
</div>
<div className="space-y-1 mb-3">
{g.specs.map((s) => (
<div key={s.k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
<span className="text-muted-foreground">{s.k}</span>
<span className="text-heading font-medium">{s.v}</span>
</div>
))}
</div>
<div className="border-t border-border pt-2">
<div className="text-[9px] text-hint mb-1.5 font-medium">G코드 </div>
<div className="space-y-1">
{g.gCodes.map((gc) => (
<div key={gc.code} className="flex items-start gap-2 text-[9px]">
<Badge intent="cyan" size="xs">{gc.code}</Badge>
<span className="text-muted-foreground flex-1">{gc.desc}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
{/* AIS 신호 특성 및 이상 판정 기준 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="w-4 h-4 text-purple-600 dark:text-purple-400" />
AIS
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-[10px]">
<thead>
<tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th>
<th className="text-left py-2">AIS </th>
<th className="text-left py-2"> </th>
<th className="text-left py-2"> </th>
<th className="text-left py-2 px-2">G코드</th>
</tr>
</thead>
<tbody>
{DAR03_AIS_SIGNALS.map((s) => (
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
<td className="py-2.5 px-2">
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-1">{s.no}</span>
<span className="text-heading font-medium">{s.name}</span>
</td>
<td className="py-2.5 text-label">{s.aisType}</td>
<td className="py-2.5">
<ul className="space-y-0.5">
{s.normal.map((n) => (
<li key={n} className="text-muted-foreground flex items-start gap-1">
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
<span>{n}</span>
</li>
))}
</ul>
</td>
<td className="py-2.5">
<ul className="space-y-0.5">
{s.threshold.map((th) => (
<li key={th} className="text-muted-foreground flex items-start gap-1">
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5" />
<span>{th}</span>
</li>
))}
</ul>
</td>
<td className="py-2.5 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div> </div>
)} )}
@ -618,8 +922,8 @@ export function AIModelManagement() {
</div> </div>
<div className="ml-auto flex gap-3 shrink-0 text-center"> <div className="ml-auto flex gap-3 shrink-0 text-center">
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint"> </div></div> <div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div> <div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint"> </div></div> <div><div className="text-lg font-bold text-green-600 dark:text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
</div> </div>
</div> </div>
@ -671,7 +975,7 @@ export function AIModelManagement() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"> <div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Ship className="w-4 h-4 text-cyan-400" /> (906, 6 ) <Ship className="w-4 h-4 text-cyan-600 dark:text-cyan-400" /> (906, 6 )
</div> </div>
<table className="w-full text-[10px]"> <table className="w-full text-[10px]">
<thead> <thead>
@ -688,7 +992,7 @@ export function AIModelManagement() {
<tbody> <tbody>
{TARGET_VESSELS.map((v) => ( {TARGET_VESSELS.map((v) => (
<tr key={v.code} className="border-b border-border"> <tr key={v.code} className="border-b border-border">
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td> <td className="py-1.5 text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v.code}</td>
<td className="py-1.5 text-label">{v.name}</td> <td className="py-1.5 text-label">{v.name}</td>
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td> <td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td> <td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
@ -711,7 +1015,7 @@ export function AIModelManagement() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"> <div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-yellow-400" /> <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{ALARM_SEVERITY.map((a) => ( {ALARM_SEVERITY.map((a) => (
@ -761,8 +1065,8 @@ export function AIModelManagement() {
</div> </div>
<div className="flex gap-4 shrink-0 text-center"> <div className="flex gap-4 shrink-0 text-center">
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API </div></div> <div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API </div></div>
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div> <div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div> <div><div className="text-lg font-bold text-blue-600 dark:text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
</div> </div>
</div> </div>
@ -811,7 +1115,7 @@ export function AIModelManagement() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"> <div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-400" /> <Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
RESTful API RESTful API
</div> </div>
<table className="w-full text-[10px] table-fixed"> <table className="w-full text-[10px] table-fixed">
@ -852,7 +1156,7 @@ export function AIModelManagement() {
<td className="py-1.5"> <td className="py-1.5">
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge> <Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
</td> </td>
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td> <td className="py-1.5 font-mono text-cyan-600 dark:text-cyan-400">{api.endpoint}</td>
<td className="py-1.5 text-hint">{api.unit}</td> <td className="py-1.5 text-hint">{api.unit}</td>
<td className="py-1.5 text-label">{api.desc}</td> <td className="py-1.5 text-label">{api.desc}</td>
<td className="py-1.5 text-muted-foreground">{api.sfr}</td> <td className="py-1.5 text-muted-foreground">{api.sfr}</td>
@ -872,7 +1176,7 @@ export function AIModelManagement() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"> <div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-400" /> <Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
API API
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -880,7 +1184,7 @@ export function AIModelManagement() {
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span> <span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button> <button type="button" aria-label={tcCommon('aria.copyExampleUrl')} onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
</div> </div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto"> <pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid {`GET /api/v1/predictions/grid
@ -928,7 +1232,7 @@ export function AIModelManagement() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"> <div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<ExternalLink className="w-4 h-4 text-purple-400" /> <ExternalLink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -952,7 +1256,7 @@ export function AIModelManagement() {
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div> <div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{s.apis.map((a) => ( {s.apis.map((a) => (
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span> <span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-600 dark:text-cyan-400">{a}</span>
))} ))}
</div> </div>
</div> </div>
@ -969,13 +1273,13 @@ export function AIModelManagement() {
<div className="flex gap-3"> <div className="flex gap-3">
{[ {[
{ label: '총 호출', value: '142,856', color: 'text-heading' }, { label: '총 호출', value: '142,856', color: 'text-heading' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' }, { label: 'grid 조회', value: '68,420', color: 'text-blue-600 dark:text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' }, { label: 'zone 조회', value: '32,115', color: 'text-green-600 dark:text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' }, { label: 'time 조회', value: '18,903', color: 'text-yellow-600 dark:text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' }, { label: 'vessel 조회', value: '15,210', color: 'text-orange-600 dark:text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-400' }, { label: 'alarms', value: '8,208', color: 'text-red-600 dark:text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' }, { label: '평균 응답', value: '23ms', color: 'text-cyan-600 dark:text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-400' }, { label: '오류율', value: '0.03%', color: 'text-green-600 dark:text-green-400' },
].map((s) => ( ].map((s) => (
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay"> <div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div> <div className={`text-sm font-bold ${s.color}`}>{s.value}</div>

파일 보기

@ -0,0 +1,456 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { getMlopsJobStatusIntent, getMlopsJobStatusLabel } from '@shared/constants/mlopsJobStatuses';
import { getModelStatusIntent, getModelStatusLabel } from '@shared/constants/modelDeploymentStatuses';
import {
Brain, Database, GitBranch, Activity, Server, Shield,
Settings, Layers, BarChart3, Code, Play,
Zap, FlaskConical, CheckCircle,
Terminal, RefreshCw, Box,
} from 'lucide-react';
/*
* LGCNS MLOps
*
* LGCNS DAP(Data AI Platform) MLOps :
* Job
* Repository
*/
type Tab = 'project' | 'environment' | 'model' | 'job' | 'common' | 'monitoring' | 'repository';
const TABS: { key: Tab; icon: React.ElementType; label: string }[] = [
{ key: 'project', icon: Layers, label: '프로젝트 관리' },
{ key: 'environment', icon: Terminal, label: '분석환경 관리' },
{ key: 'model', icon: Brain, label: '모델 관리' },
{ key: 'job', icon: Play, label: 'Job 실행 관리' },
{ key: 'common', icon: Settings, label: '공통서비스' },
{ key: 'monitoring', icon: Activity, label: '모니터링' },
{ key: 'repository', icon: Database, label: 'Repository' },
];
const JOB_PROGRESS_COLOR: Record<string, string> = {
running: 'bg-blue-500',
done: 'bg-green-500',
fail: 'bg-red-500',
pending: 'bg-muted',
};
// ─── 프로젝트 관리 ──────────────────
const PROJECTS = [
{ id: 'PRJ-001', name: '불법조업 위험도 예측', owner: '분석팀', status: '활성', models: 5, experiments: 12, updated: '2026-04-10' },
{ id: 'PRJ-002', name: '경비함정 경로추천', owner: '운항팀', status: '활성', models: 3, experiments: 8, updated: '2026-04-09' },
{ id: 'PRJ-003', name: '다크베셀 탐지', owner: '분석팀', status: '활성', models: 2, experiments: 6, updated: '2026-04-08' },
{ id: 'PRJ-004', name: '환적 네트워크 분석', owner: '수사팀', status: '대기', models: 1, experiments: 3, updated: '2026-04-05' },
];
// ─── 분석환경 ──────────────────
const ENVIRONMENTS = [
{ name: 'Jupyter Notebook', icon: Code, type: 'IDE', gpu: 'Blackwell x1', status: '실행중', user: '김분석', created: '04-10' },
{ name: 'RStudio Server', icon: BarChart3, type: 'IDE', gpu: '-', status: '중지', user: '이연구', created: '04-08' },
{ name: 'VS Code Server', icon: Terminal, type: 'IDE', gpu: 'H200 x1', status: '실행중', user: '박개발', created: '04-09' },
{ name: 'TensorBoard', icon: Activity, type: '모니터링', gpu: '-', status: '실행중', user: '김분석', created: '04-10' },
];
const WORKFLOWS = [
{ id: 'WF-012', name: 'AIS 전처리 → LSTM 학습', steps: 5, status: 'running', progress: 60, duration: '2h 15m' },
{ id: 'WF-011', name: '어구분류 피처엔지니어링', steps: 3, status: 'done', progress: 100, duration: '45m' },
{ id: 'WF-010', name: 'GNN 환적탐지 학습', steps: 4, status: 'done', progress: 100, duration: '3h 20m' },
];
// ─── 모델 관리 ──────────────────
const MODELS = [
{ name: '불법조업 위험도 v2.1', framework: 'PyTorch', status: 'DEPLOYED', accuracy: 93.2, version: 'v2.1.0', kpi: 'F1=92.3%', endpoint: '/v1/infer/risk' },
{ name: '경비함정 경로추천 v1.5', framework: 'TensorFlow', status: 'DEPLOYED', accuracy: 89.7, version: 'v1.5.2', kpi: 'F1=88.4%', endpoint: '/v1/infer/patrol' },
{ name: 'Transformer 궤적 v0.9', framework: 'PyTorch', status: 'APPROVED', accuracy: 91.2, version: 'v0.9.0', kpi: 'F1=90.5%', endpoint: '-' },
{ name: 'GNN 환적탐지 v0.3', framework: 'DGL', status: 'TESTING', accuracy: 82.3, version: 'v0.3.0', kpi: 'F1=80.1%', endpoint: '-' },
];
const PARAMETERS = [
{ name: 'learning_rate', type: 'float', default: '0.001', range: '1e-5 ~ 0.1' },
{ name: 'batch_size', type: 'int', default: '64', range: '16 ~ 256' },
{ name: 'epochs', type: 'int', default: '50', range: '10 ~ 200' },
{ name: 'dropout', type: 'float', default: '0.2', range: '0.0 ~ 0.5' },
{ name: 'hidden_dim', type: 'int', default: '256', range: '64 ~ 1024' },
];
// ─── Job 실행 관리 ──────────────────
const JOBS = [
{ id: 'JOB-088', name: 'LSTM 위험도 학습', type: '학습', resource: 'Blackwell x2', status: 'running', progress: 72, started: '04-10 08:00', elapsed: '3h 28m' },
{ id: 'JOB-087', name: 'AIS 피처 추출', type: '전처리', resource: 'CPU 16core', status: 'done', progress: 100, started: '04-10 06:00', elapsed: '1h 45m' },
{ id: 'JOB-086', name: 'GNN 하이퍼파라미터 탐색', type: 'HPS', resource: 'H200 x2', status: 'running', progress: 45, started: '04-10 07:30', elapsed: '2h 10m' },
{ id: 'JOB-085', name: '위험도 모델 배포', type: '배포', resource: 'Blackwell x1', status: 'done', progress: 100, started: '04-09 14:00', elapsed: '15m' },
{ id: 'JOB-084', name: 'SAR 이미지 전처리', type: '전처리', resource: 'CPU 32core', status: 'fail', progress: 34, started: '04-09 10:00', elapsed: '0h 50m' },
];
const PIPELINE_STAGES = [
{ name: '데이터 수집', status: 'done' },
{ name: '전처리', status: 'done' },
{ name: '피처 엔지니어링', status: 'done' },
{ name: '모델 학습', status: 'running' },
{ name: '모델 평가', status: 'pending' },
{ name: '모델 배포', status: 'pending' },
];
// ─── 공통서비스 ──────────────────
const COMMON_SERVICES = [
{ name: 'Feature Store', icon: Database, desc: '피처 저장소', status: '정상', version: 'v3.2', detail: '20개 피처 · 2.4TB' },
{ name: 'Model Registry', icon: GitBranch, desc: '모델 레지스트리', status: '정상', version: 'v2.1', detail: '12개 모델 등록' },
{ name: 'Data Catalog', icon: Layers, desc: '데이터 카탈로그', status: '정상', version: 'v1.5', detail: '48 테이블 · 1.2M rows' },
{ name: 'Experiment Tracker', icon: FlaskConical, desc: '실험 추적', status: '정상', version: 'v4.0', detail: '42개 실험 기록' },
{ name: 'API Gateway', icon: Zap, desc: 'API 게이트웨이', status: '정상', version: 'v3.0', detail: '221 req/s' },
{ name: 'Security Manager', icon: Shield, desc: '보안 관리', status: '정상', version: 'v2.0', detail: 'RBAC + JWT' },
];
// ─── 모니터링 ──────────────────
const GPU_RESOURCES = [
{ name: 'Blackwell #1', usage: 78, mem: '38/48GB', temp: '62°C', job: 'JOB-088' },
{ name: 'Blackwell #2', usage: 52, mem: '25/48GB', temp: '55°C', job: 'JOB-088' },
{ name: 'H200 #1', usage: 85, mem: '68/80GB', temp: '71°C', job: 'JOB-086' },
{ name: 'H200 #2', usage: 45, mem: '36/80GB', temp: '48°C', job: '-' },
];
const SYSTEM_METRICS = [
{ label: '실행중 Job', value: '3', icon: Play, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '대기중 Job', value: '2', icon: RefreshCw, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: 'GPU 사용률', value: '65%', icon: Activity, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '배포 모델', value: '2', icon: Brain, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: 'API 호출/s', value: '221', icon: Zap, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
];
const PROJECT_STATS = [
{ label: '활성 프로젝트', value: 3, icon: Layers, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '총 모델', value: 11, icon: Brain, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '총 실험', value: 29, icon: FlaskConical, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '참여 인원', value: 8, icon: Server, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
];
export function LGCNSMLOpsPage() {
const { t, i18n } = useTranslation('ai');
const lang = i18n.language as 'ko' | 'en';
const [tab, setTab] = useState<Tab>('project');
return (
<PageContainer>
<PageHeader
icon={Box}
iconColor="text-cyan-400"
title={t('lgcnsMlops.title')}
description={t('lgcnsMlops.desc')}
demo
/>
{/* 탭 */}
<TabBar variant="underline">
{TABS.map(tt => (
<TabButton key={tt.key} active={tab === tt.key} icon={<tt.icon className="w-3.5 h-3.5" />} onClick={() => setTab(tt.key)}>
{tt.label}
</TabButton>
))}
</TabBar>
{/* ── ① 프로젝트 관리 ── */}
{tab === 'project' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2"></th><th className="text-left py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-right py-2 px-2"></th>
</tr></thead>
<tbody>{PROJECTS.map(p => (
<tr key={p.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-muted-foreground">{p.id}</td>
<td className="py-2 text-heading font-medium">{p.name}</td>
<td className="py-2 text-muted-foreground">{p.owner}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(p.status)} size="sm">{p.status}</Badge></td>
<td className="py-2 text-center text-heading">{p.models}</td>
<td className="py-2 text-center text-heading">{p.experiments}</td>
<td className="py-2 px-2 text-right text-muted-foreground">{p.updated}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<div className="grid grid-cols-4 gap-2">
{PROJECT_STATS.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${k.bg}`}>
<k.icon className={`w-3.5 h-3.5 ${k.color}`} />
</div>
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
</div>
)}
{/* ── ② 분석환경 관리 ── */}
{tab === 'environment' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{ENVIRONMENTS.map(e => (
<div key={e.name} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<e.icon className="w-4 h-4 text-cyan-400" />
<span className="text-[11px] text-heading font-medium w-36">{e.name}</span>
<Badge intent="muted" size="sm">{e.type}</Badge>
<span className="text-[10px] text-muted-foreground w-24">GPU: {e.gpu}</span>
<span className="text-[10px] text-muted-foreground flex-1">: {e.user}</span>
<Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"></div>
<div className="space-y-2">
{WORKFLOWS.map(w => (
<div key={w.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-muted-foreground w-16">{w.id}</span>
<span className="text-[11px] text-heading font-medium flex-1">{w.name}</span>
<span className="text-[10px] text-muted-foreground">Steps: {w.steps}</span>
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full rounded-full ${JOB_PROGRESS_COLOR[w.status] ?? 'bg-muted'}`} style={{ width: `${w.progress}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{w.progress}%</span>
<Badge intent={getMlopsJobStatusIntent(w.status)} size="sm">
{getMlopsJobStatusLabel(w.status, t, lang)}
</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ③ 모델 관리 ── */}
{tab === 'model' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"></th><th className="text-center py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2">KPI</th><th className="text-left py-2"></th><th className="text-left py-2 px-2">Endpoint</th>
</tr></thead>
<tbody>{MODELS.map(m => (
<tr key={m.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{m.name}</td>
<td className="py-2 text-muted-foreground">{m.framework}</td>
<td className="py-2 text-center">
<Badge intent={getModelStatusIntent(m.status)} size="sm">
{getModelStatusLabel(m.status, t, lang)}
</Badge>
</td>
<td className="py-2 text-center text-heading font-bold">{m.accuracy}%</td>
<td className="py-2 text-center text-green-400">{m.kpi}</td>
<td className="py-2 text-muted-foreground">{m.version}</td>
<td className="py-2 px-2 text-muted-foreground font-mono text-[9px]">{m.endpoint}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Parameter </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"></th><th className="text-center py-2"></th><th className="text-left py-2 px-2"></th>
</tr></thead>
<tbody>{PARAMETERS.map(p => (
<tr key={p.name} className="border-b border-border/50">
<td className="py-1.5 px-2 text-heading font-mono">{p.name}</td>
<td className="py-1.5 text-muted-foreground">{p.type}</td>
<td className="py-1.5 text-center text-heading">{p.default}</td>
<td className="py-1.5 px-2 text-muted-foreground">{p.range}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ④ Job 실행 관리 ── */}
{tab === 'job' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Job Pipeline</div>
<div className="flex items-center gap-1 mb-4 px-2">
{PIPELINE_STAGES.map((s, i) => (
<div key={s.name} className="flex items-center gap-1 flex-1">
<div className={`flex-1 py-2 px-3 rounded-lg text-center text-[10px] font-medium ${
s.status === 'done' ? 'bg-green-500/15 text-green-400 border border-green-500/30' :
s.status === 'running' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 animate-pulse' :
'bg-surface-overlay text-hint border border-border'
}`}>
{s.status === 'done' && <CheckCircle className="w-3 h-3 inline mr-1" />}
{s.status === 'running' && <RefreshCw className="w-3 h-3 inline mr-1 animate-spin" />}
{s.name}
</div>
{i < PIPELINE_STAGES.length - 1 && <span className="text-hint text-[10px]"></span>}
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Job </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2">Job명</th><th className="text-center py-2"></th>
<th className="text-left py-2"></th><th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-right py-2 px-2"></th>
</tr></thead>
<tbody>{JOBS.map(j => (
<tr key={j.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-muted-foreground">{j.id}</td>
<td className="py-2 text-heading font-medium">{j.name}</td>
<td className="py-2 text-center"><Badge intent="muted" size="sm">{j.type}</Badge></td>
<td className="py-2 text-muted-foreground text-[9px]">{j.resource}</td>
<td className="py-2">
<div className="flex items-center gap-2 justify-center">
<div className="w-16 h-1.5 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full rounded-full ${JOB_PROGRESS_COLOR[j.status] ?? 'bg-muted'}`} style={{ width: `${j.progress}%` }} />
</div>
<span className="text-heading">{j.progress}%</span>
</div>
</td>
<td className="py-2 text-center">
<Badge intent={getMlopsJobStatusIntent(j.status)} size="sm">
{getMlopsJobStatusLabel(j.status, t, lang)}
</Badge>
</td>
<td className="py-2 px-2 text-right text-muted-foreground">{j.elapsed}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ⑤ 공통서비스 ── */}
{tab === 'common' && (
<div className="grid grid-cols-3 gap-3">
{COMMON_SERVICES.map(s => (
<Card key={s.name}><CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-lg bg-cyan-500/10">
<s.icon className="w-5 h-5 text-cyan-400" />
</div>
<div>
<div className="text-[11px] font-bold text-heading">{s.name}</div>
<div className="text-[9px] text-hint">{s.desc}</div>
</div>
<Badge intent={getStatusIntent(s.status)} size="sm" className="ml-auto">{s.status}</Badge>
</div>
<div className="space-y-1 text-[10px]">
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
<span className="text-hint"></span><span className="text-label">{s.version}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
<span className="text-hint"></span><span className="text-label">{s.detail}</span>
</div>
</div>
</CardContent></Card>
))}
</div>
)}
{/* ── ⑥ 모니터링 ── */}
{tab === 'monitoring' && (
<div className="space-y-3">
<div className="flex gap-2">
{SYSTEM_METRICS.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${k.bg}`}>
<k.icon className={`w-3.5 h-3.5 ${k.color}`} />
</div>
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">GPU </div>
<div className="space-y-2">
{GPU_RESOURCES.map(g => (
<div key={g.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-heading font-medium w-24">{g.name}</span>
<div className="flex-1 h-2 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full rounded-full ${g.usage > 80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} />
</div>
<span className="text-[10px] text-heading font-bold w-8">{g.usage}%</span>
<span className="text-[9px] text-hint">{g.mem}</span>
<span className="text-[9px] text-hint">{g.temp}</span>
<span className="text-[9px] text-muted-foreground">{g.job}</span>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{[
{ name: 'DAP API Gateway', status: 'ok', rps: 221 },
{ name: 'Model Serving', status: 'ok', rps: 186 },
{ name: 'Feature Store', status: 'ok', rps: 45 },
{ name: 'Experiment Tracker', status: 'ok', rps: 32 },
{ name: 'Job Scheduler', status: 'ok', rps: 15 },
{ name: 'PostgreSQL', status: 'ok', rps: 890 },
].map(s => (
<div key={s.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<div className={`w-2 h-2 rounded-full ${s.status === 'ok' ? 'bg-green-500 shadow-[0_0_4px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_4px_#eab308]'}`} />
<span className="text-[10px] text-heading font-medium flex-1">{s.name}</span>
<span className="text-[10px] text-muted-foreground">{s.rps} req/s</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ⑦ Repository ── */}
{tab === 'repository' && (
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">/ Repository</div>
<div className="space-y-1.5 text-[10px]">
{[
['kcg-ai-monitoring', 'frontend + backend + prediction 모노레포'],
['kcg-ml-models', '모델 아카이브 (버전별 weight)'],
['kcg-data-pipeline', 'ETL 스크립트 + Airflow DAG'],
['kcg-feature-store', '피처 정의 + 변환 로직'],
].map(([k, v]) => (
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-heading font-mono">{k}</span><span className="text-hint">{v}</span>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">/ Repository</div>
<div className="space-y-1.5 text-[10px]">
{[
['AIS 원본 데이터', 'SNPDB · 5분 주기 증분 · 1.2TB'],
['학습 데이터셋', '1,456,200건 · 04-03 갱신'],
['벡터 DB (Milvus)', '1.2M 문서 · 3.6M 벡터'],
['모델 Artifact', 'S3 · 24개 버전 · 12.8GB'],
].map(([k, v]) => (
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-heading">{k}</span><span className="text-hint">{v}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
</PageContainer>
);
}

파일 보기

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
@ -107,6 +108,7 @@ const WORKERS = [
export function MLOpsPage() { export function MLOpsPage() {
const { t } = useTranslation('ai'); const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('dashboard'); const [tab, setTab] = useState<Tab>('dashboard');
const [llmSub, setLlmSub] = useState<LLMSubTab>('train'); const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
const [selectedTmpl, setSelectedTmpl] = useState(0); const [selectedTmpl, setSelectedTmpl] = useState(0);
@ -116,7 +118,7 @@ export function MLOpsPage() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Cpu} icon={Cpu}
iconColor="text-purple-400" iconColor="text-purple-600 dark:text-purple-400"
title={t('mlops.title')} title={t('mlops.title')}
description={t('mlops.desc')} description={t('mlops.desc')}
demo demo
@ -134,7 +136,7 @@ export function MLOpsPage() {
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' }, { key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => ( ]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)} <button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}> className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label} <t.icon className="w-3.5 h-3.5" />{t.label}
</button> </button>
))} ))}
@ -159,7 +161,7 @@ export function MLOpsPage() {
<Badge intent="success" size="sm">DEPLOYED</Badge> <Badge intent="success" size="sm">DEPLOYED</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span> <span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
<span className="text-[10px] text-hint">{m.ver}</span> <span className="text-[10px] text-hint">{m.ver}</span>
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span> <span className="text-[10px] text-green-600 dark:text-green-400 font-bold">F1 {m.f1}%</span>
</div> </div>
))}</div> ))}</div>
</CardContent></Card> </CardContent></Card>
@ -187,7 +189,7 @@ export function MLOpsPage() {
{TEMPLATES.map((t, i) => ( {TEMPLATES.map((t, i) => (
<div key={t.name} onClick={() => setSelectedTmpl(i)} <div key={t.name} onClick={() => setSelectedTmpl(i)}
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}> className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-400" /> <t.icon className="w-6 h-6 mx-auto mb-2 text-blue-600 dark:text-blue-400" />
<div className="text-[10px] font-bold text-heading">{t.name}</div> <div className="text-[10px] font-bold text-heading">{t.name}</div>
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div> <div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
</div> </div>
@ -197,7 +199,7 @@ export function MLOpsPage() {
<Card><CardContent className="p-4"> <Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="text-[12px] font-bold text-heading"> </div> <div className="text-[12px] font-bold text-heading"> </div>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button> <Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{EXPERIMENTS.map(e => ( {EXPERIMENTS.map(e => (
@ -208,7 +210,7 @@ export function MLOpsPage() {
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div> <div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span> <span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span> <span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
{e.f1 > 0 && <span className="text-[10px] text-cyan-400 font-bold">F1 {e.f1}</span>} {e.f1 > 0 && <span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">F1 {e.f1}</span>}
</div> </div>
))} ))}
</div> </div>
@ -261,7 +263,7 @@ export function MLOpsPage() {
<tbody>{DEPLOYS.map((d, i) => ( <tbody>{DEPLOYS.map((d, i) => (
<tr key={i} className="border-b border-border hover:bg-surface-overlay"> <tr key={i} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-2 text-heading font-medium">{d.model}</td> <td className="px-3 py-2 text-heading font-medium">{d.model}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{d.ver}</td> <td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{d.ver}</td>
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td> <td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td> <td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
<td className="px-3 py-2 text-label">{d.latency}</td> <td className="px-3 py-2 text-label">{d.latency}</td>
@ -288,7 +290,7 @@ export function MLOpsPage() {
{MODELS.filter(m => m.status === 'APPROVED').map(m => ( {MODELS.filter(m => m.status === 'APPROVED').map(m => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg"> <div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span> <span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button> <Button variant="primary" size="sm" icon={<Rocket className="w-3 h-3" />}></Button>
</div> </div>
))} ))}
</div> </div>
@ -313,15 +315,15 @@ export function MLOpsPage() {
"version": "v2.1.0" "version": "v2.1.0"
}`} /> }`} />
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button> <Button variant="primary" size="sm" icon={<Zap className="w-3 h-3" />}></Button>
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button> <button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
</div> </div>
</CardContent></Card> </CardContent></Card>
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col"> <Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div> <div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2"> <div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
<span className="text-muted-foreground"> <span className="text-green-400 font-bold">200 OK</span></span> <span className="text-muted-foreground"> <span className="text-green-600 dark:text-green-400 font-bold">200 OK</span></span>
<span className="text-muted-foreground"> <span className="text-green-400 font-bold">23ms</span></span> <span className="text-muted-foreground"> <span className="text-green-600 dark:text-green-400 font-bold">23ms</span></span>
</div> </div>
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{ <pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
"risk_score": 87.5, "risk_score": 87.5,
@ -353,7 +355,7 @@ export function MLOpsPage() {
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' }, { key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => ( ]).map(t => (
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)} <button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button> className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
))} ))}
</div> </div>
@ -367,7 +369,7 @@ export function MLOpsPage() {
{LLM_MODELS.map((m, i) => ( {LLM_MODELS.map((m, i) => (
<div key={m.name} onClick={() => setSelectedLLM(i)} <div key={m.name} onClick={() => setSelectedLLM(i)}
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}> className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-400" /> <m.icon className="w-5 h-5 mx-auto mb-1 text-purple-600 dark:text-purple-400" />
<div className="text-[10px] font-bold text-heading">{m.name}</div> <div className="text-[10px] font-bold text-heading">{m.name}</div>
<div className="text-[8px] text-hint">{m.sub}</div> <div className="text-[8px] text-hint">{m.sub}</div>
</div> </div>
@ -381,7 +383,7 @@ export function MLOpsPage() {
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div> <div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
))} ))}
</div> </div>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button> <Button variant="primary" size="sm" className="mt-3 w-full" icon={<Play className="w-3 h-3" />}> </Button>
</CardContent></Card> </CardContent></Card>
</div> </div>
<Card><CardContent className="p-4"> <Card><CardContent className="p-4">
@ -417,10 +419,10 @@ export function MLOpsPage() {
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div> <div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
))} ))}
</div> </div>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button> <Button variant="primary" size="sm" className="mt-3 w-full" icon={<Search className="w-3 h-3" />}> </Button>
</CardContent></Card> </CardContent></Card>
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4"> <Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div> <div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-600 dark:text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
<table className="w-full text-[10px]"> <table className="w-full text-[10px]">
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead> <thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
<tbody>{HPS_TRIALS.map(t => ( <tbody>{HPS_TRIALS.map(t => (
@ -505,7 +507,7 @@ export function MLOpsPage() {
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." /> <input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button> <Button variant="primary" size="md" aria-label={tc('aria.send')} icon={<Send className="w-4 h-4" />} />
</div> </div>
</CardContent></Card> </CardContent></Card>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
export { AIModelManagement } from './AIModelManagement'; export { AIModelManagement } from './AIModelManagement';
export { MLOpsPage } from './MLOpsPage'; export { MLOpsPage } from './MLOpsPage';
export { LGCNSMLOpsPage } from './LGCNSMLOpsPage';
export { AIAssistant } from './AIAssistant'; export { AIAssistant } from './AIAssistant';
export { LLMOpsPage } from './LLMOpsPage'; export { LLMOpsPage } from './LLMOpsPage';

파일 보기

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react'; import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
import { Button } from '@shared/components/ui/button';
import { useAuth } from '@/app/auth/AuthContext'; import { useAuth } from '@/app/auth/AuthContext';
import { LoginError } from '@/services/authApi'; import { LoginError } from '@/services/authApi';
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin'; import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
@ -105,7 +106,7 @@ export function LoginPage() {
{/* 로고 영역 */} {/* 로고 영역 */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
<Shield className="w-8 h-8 text-blue-400" /> <Shield className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div> </div>
<h1 className="text-xl font-bold text-heading">{t('title')}</h1> <h1 className="text-xl font-bold text-heading">{t('title')}</h1>
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p> <p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
@ -122,7 +123,7 @@ export function LoginPage() {
disabled={m.disabled} disabled={m.disabled}
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${ className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
authMethod === m.key authMethod === m.key
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400' ? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-hint hover:bg-surface-overlay hover:text-label' : 'text-hint hover:bg-surface-overlay hover:text-label'
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`} } ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
title={m.disabled ? '향후 도입 예정' : ''} title={m.disabled ? '향후 도입 예정' : ''}
@ -188,16 +189,18 @@ export function LoginPage() {
</div> </div>
{error && ( {error && (
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2"> <div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<AlertCircle className="w-3.5 h-3.5 shrink-0" /> <AlertCircle className="w-3.5 h-3.5 shrink-0" />
{error} {error}
</div> </div>
)} )}
<button <Button
type="submit" type="submit"
variant="primary"
size="md"
disabled={loading} disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap" className="w-full font-bold"
> >
{loading ? ( {loading ? (
<> <>
@ -205,7 +208,7 @@ export function LoginPage() {
{t('button.authenticating')} {t('button.authenticating')}
</> </>
) : t('button.login')} ) : t('button.login')}
</button> </Button>
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */} {/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} /> <DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
@ -215,7 +218,7 @@ export function LoginPage() {
{/* GPKI 인증 (Phase 9 도입 예정) */} {/* GPKI 인증 (Phase 9 도입 예정) */}
{authMethod === 'gpki' && ( {authMethod === 'gpki' && (
<div className="space-y-4 text-center py-12"> <div className="space-y-4 text-center py-12">
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" /> <Fingerprint className="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p> <p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p> <p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div> </div>
@ -224,7 +227,7 @@ export function LoginPage() {
{/* SSO 연동 (Phase 9 도입 예정) */} {/* SSO 연동 (Phase 9 도입 예정) */}
{authMethod === 'sso' && ( {authMethod === 'sso' && (
<div className="space-y-4 text-center py-12"> <div className="space-y-4 text-center py-12">
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" /> <KeyRound className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('sso.title')}</p> <p className="text-sm text-heading font-medium">{t('sso.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p> <p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div> </div>

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint, MarkerData } from '@lib/map'; import type { HeatPoint, MarkerData } from '@lib/map';
import { import {
AlertTriangle, Ship, Anchor, Eye, Navigation, AlertTriangle, Ship, Anchor, Eye, Navigation,
@ -55,7 +55,7 @@ function RiskBar({ value, size = 'default' }: { value: number; size?: 'default'
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환. // backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value)); const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500'; const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400'; const textColor = pct > 70 ? 'text-red-600 dark:text-red-400' : pct > 50 ? 'text-orange-600 dark:text-orange-400' : pct > 30 ? 'text-yellow-600 dark:text-yellow-400' : 'text-blue-600 dark:text-blue-400';
const barW = size === 'sm' ? 'w-16' : 'w-24'; const barW = size === 'sm' ? 'w-16' : 'w-24';
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -78,7 +78,7 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}> <div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
<Icon className="w-4 h-4" style={{ color }} /> <Icon className="w-4 h-4" style={{ color }} />
</div> </div>
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}> <div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />} {isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{Math.abs(diff)} {Math.abs(diff)}
</div> </div>
@ -187,7 +187,7 @@ function SeaAreaMap() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }), createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
createMarkerLayer('threat-markers', THREAT_MARKERS), createMarkerLayer('threat-markers', THREAT_MARKERS),
], []); ], []);
@ -207,16 +207,16 @@ function SeaAreaMap() {
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5"> <div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
<div className="text-[8px] text-muted-foreground font-bold mb-1"> </div> <div className="text-[8px] text-muted-foreground font-bold mb-1"> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[7px] text-blue-400"></span> <span className="text-[7px] text-blue-600 dark:text-blue-400"></span>
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} /> <div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
<span className="text-[7px] text-red-400"></span> <span className="text-[7px] text-red-600 dark:text-red-400"></span>
</div> </div>
</div> </div>
{/* LIVE 인디케이터 */} {/* LIVE 인디케이터 */}
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1"> <div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" /> <div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<Radar className="w-3 h-3 text-blue-500" /> <Radar className="w-3 h-3 text-blue-500" />
<span className="text-[9px] text-blue-400 font-medium"> </span> <span className="text-[9px] text-blue-600 dark:text-blue-400 font-medium"> </span>
</div> </div>
</div> </div>
); );
@ -468,8 +468,8 @@ export function Dashboard() {
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{ <span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6' color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
}}>{area.risk}</span> }}>{area.risk}</span>
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />} {area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-600 dark:text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />} {area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-600 dark:text-green-400" />}
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]"></span>} {area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]"></span>}
</div> </div>
))} ))}
@ -544,7 +544,7 @@ export function Dashboard() {
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-0"> <CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5"> <CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-blue-400" /> <Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -557,19 +557,19 @@ export function Dashboard() {
<div className="text-[8px] text-hint"> {WEATHER_DATA.wind.gust}m/s</div> <div className="text-[8px] text-hint"> {WEATHER_DATA.wind.gust}m/s</div>
</div> </div>
<div className="text-center p-2 rounded-lg bg-surface-overlay"> <div className="text-center p-2 rounded-lg bg-surface-overlay">
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" /> <Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div> <div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
<div className="text-[8px] text-hint"></div> <div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.wave.period}s</div> <div className="text-[8px] text-hint"> {WEATHER_DATA.wave.period}s</div>
</div> </div>
<div className="text-center p-2 rounded-lg bg-surface-overlay"> <div className="text-center p-2 rounded-lg bg-surface-overlay">
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" /> <Thermometer className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div> <div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
<div className="text-[8px] text-hint"></div> <div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.temp.water}°C</div> <div className="text-[8px] text-hint"> {WEATHER_DATA.temp.water}°C</div>
</div> </div>
<div className="text-center p-2 rounded-lg bg-surface-overlay"> <div className="text-center p-2 rounded-lg bg-surface-overlay">
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" /> <Eye className="w-3.5 h-3.5 text-green-600 dark:text-green-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div> <div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
<div className="text-[8px] text-hint"></div> <div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint">{WEATHER_DATA.seaState}</div> <div className="text-[8px] text-hint">{WEATHER_DATA.seaState}</div>

파일 보기

@ -1,35 +1,41 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer } from '@shared/components/layout'; import { PageContainer } from '@shared/components/layout';
import { import {
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, Search, Clock, ChevronRight, ChevronLeft, Cloud,
Eye, AlertTriangle, Radio, RotateCcw, Eye, AlertTriangle, Radio, RotateCcw,
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels'; import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses'; import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GearIdentification } from './GearIdentification'; import { GearIdentification } from './GearIdentification';
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
import { VesselMiniMap } from './components/VesselMiniMap';
import { VesselAnomalyPanel } from './components/VesselAnomalyPanel';
import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly';
import { PieChart as EcPieChart } from '@lib/charts'; import { PieChart as EcPieChart } from '@lib/charts';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import { import {
fetchVesselAnalysis, getAnalysisStats,
filterDarkVessels, getAnalysisVessels,
filterTransshipSuspects, getAnalysisHistory,
type VesselAnalysisItem, type AnalysisStats,
type VesselAnalysisStats, type VesselAnalysis,
} from '@/services/vesselAnalysisApi'; } from '@/services/analysisApi';
import { toVesselItem } from '@/services/analysisAdapter';
// ─── 중국 MMSI prefix ───────────── // ─── 중국 MMSI prefix ─────────────
const CHINA_MMSI_PREFIX = '412'; const CHINA_MMSI_PREFIX = '412';
function isChinaVessel(mmsi: string): boolean {
return mmsi.startsWith(CHINA_MMSI_PREFIX);
}
// ─── 특이운항 선박 리스트 타입 ──────────────── // ─── 특이운항 선박 리스트 타입 ────────────────
type VesselStatus = '의심' | '양호' | '경고'; type VesselStatus = '의심' | '양호' | '경고';
interface VesselItem { interface VesselItem {
@ -51,16 +57,29 @@ function deriveVesselStatus(score: number): VesselStatus {
return '양호'; return '양호';
} }
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem { function mapToVesselItem(
item: VesselAnalysisItem,
idx: number,
t: (k: string, opts?: { defaultValue?: string }) => string,
lang: 'ko' | 'en',
): VesselItem {
const score = item.algorithms.riskScore.score; const score = item.algorithms.riskScore.score;
const vt = item.classification.vesselType;
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
// 이름: fleet_vessels 매핑으로 vessel_type 이 채워진 경우 한글 유형 라벨, 아니면 '중국어선'
const name = hasType ? getVesselTypeLabel(vt, t, lang) : '중국어선';
// 타입 뱃지: fishingPct 기반 Fishing / 그 외는 vessel_type 라벨
const type = item.classification.fishingPct > 0.5
? 'Fishing'
: hasType ? getVesselTypeLabel(vt, t, lang) : getVesselTypeLabel('UNKNOWN', t, lang);
return { return {
id: String(idx + 1), id: String(idx + 1),
mmsi: item.mmsi, mmsi: item.mmsi,
callSign: '-', callSign: '-',
channel: '', channel: '',
source: 'AIS', source: 'AIS',
name: item.classification.vesselType || item.mmsi, name,
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo', type,
country: 'China', country: 'China',
status: deriveVesselStatus(score), status: deriveVesselStatus(score),
riskPct: score, riskPct: score,
@ -202,10 +221,14 @@ export function ChinaFishing() {
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard'); const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState('');
// API state // API state
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]); const [topVessels, setTopVessels] = useState<VesselAnalysisItem[]>([]);
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null); const [apiStats, setApiStats] = useState<AnalysisStats | null>(null);
const [serviceAvailable, setServiceAvailable] = useState(true); const [serviceAvailable, setServiceAvailable] = useState(true);
const [apiLoading, setApiLoading] = useState(false); const [apiLoading, setApiLoading] = useState(false);
const [apiError, setApiError] = useState(''); const [apiError, setApiError] = useState('');
@ -214,10 +237,18 @@ export function ChinaFishing() {
setApiLoading(true); setApiLoading(true);
setApiError(''); setApiError('');
try { try {
const res = await fetchVesselAnalysis(); const [stats, topPage] = await Promise.all([
setServiceAvailable(res.serviceAvailable); getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }),
setAllItems(res.items); getAnalysisVessels({
setApiStats(res.stats); hours: 1,
mmsiPrefix: CHINA_MMSI_PREFIX,
minRiskScore: 40,
size: 20,
}),
]);
setApiStats(stats);
setTopVessels(topPage.content.map(toVesselItem));
setServiceAvailable(true);
} catch (e: unknown) { } catch (e: unknown) {
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false); setServiceAvailable(false);
@ -228,55 +259,77 @@ export function ChinaFishing() {
useEffect(() => { loadApi(); }, [loadApi]); useEffect(() => { loadApi(); }, [loadApi]);
// 중국어선 필터 // 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터)
const chinaVessels = useMemo( useEffect(() => {
() => allItems.filter((i) => isChinaVessel(i.mmsi)), if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; }
[allItems], let cancelled = false;
setHistoryLoading(true); setHistoryError('');
getAnalysisHistory(selectedMmsi, 24)
.then((rows) => { if (!cancelled) setHistory(rows); })
.catch((e: unknown) => {
if (cancelled) return;
setHistory([]);
setHistoryError(e instanceof Error ? e.message : '이력 조회 실패');
})
.finally(() => { if (!cancelled) setHistoryLoading(false); });
return () => { cancelled = true; };
}, [selectedMmsi]);
const anomalySegments = useMemo(
() => groupAnomaliesToSegments(extractAnomalies(history)),
[history],
); );
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]); // ─ 파생 계산 (서버 집계 우선) ─
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]); // Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT.
// topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용).
const countersRow1 = useMemo(() => {
if (!apiStats) return [];
return [
{ label: '통합', count: apiStats.total, color: '#6b7280' },
{ label: 'AIS', count: apiStats.total, color: '#3b82f6' },
{ label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' },
{ label: '어업선', count: apiStats.fishingCount, color: '#10b981' },
];
}, [apiStats]);
// 센서 카운터 (API 기반) const countersRow2 = useMemo(() => {
const countersRow1 = useMemo(() => [ if (!apiStats) return [];
{ label: '통합', count: allItems.length, color: '#6b7280' }, return [
{ label: 'AIS', count: allItems.length, color: '#3b82f6' }, { label: '중국어선', count: apiStats.total, color: '#f97316' },
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' }, { label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' },
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' }, { label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' },
], [allItems]); { label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' },
];
}, [apiStats]);
const countersRow2 = useMemo(() => [ // 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
], [chinaVessels, chinaDark, chinaTransship]);
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
const vesselList: VesselItem[] = useMemo( const vesselList: VesselItem[] = useMemo(
() => chinaVessels () => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
.filter((i) => i.algorithms.riskScore.score >= 40) [topVessels, tcCommon, lang],
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
.slice(0, 20)
.map((item, idx) => mapToVesselItem(item, idx)),
[chinaVessels],
); );
// 위험도별 분포 (도넛 차트용) // 위험도별 분포 (도넛 차트용) — apiStats 기반
const riskDistribution = useMemo(() => { const riskDistribution = useMemo(() => {
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length; if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length; return {
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length; critical: apiStats.criticalCount,
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length; high: apiStats.highCount,
return { critical, high, medium, low, total: chinaVessels.length }; medium: apiStats.mediumCount,
}, [chinaVessels]); low: apiStats.lowCount,
total: apiStats.total,
};
}, [apiStats]);
// 안전도 지수 계산 // 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일)
const safetyIndex = useMemo(() => { const safetyIndex = useMemo(() => {
if (chinaVessels.length === 0) return { risk: 0, safety: 100 }; const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0;
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length; if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 };
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) }; return {
}, [chinaVessels]); risk: Number((avgRisk / 10).toFixed(2)),
safety: Number(((100 - avgRisk) / 10).toFixed(2)),
};
}, [apiStats]);
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
@ -290,22 +343,19 @@ export function ChinaFishing() {
return ( return (
<PageContainer size="sm"> <PageContainer size="sm">
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */} {/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit"> <TabBar variant="segmented">
{modeTabs.map((tab) => ( {modeTabs.map((tab) => (
<button type="button" <TabButton
key={tab.key} key={tab.key}
variant="segmented"
active={mode === tab.key}
onClick={() => setMode(tab.key)} onClick={() => setMode(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${ icon={<tab.icon className="w-3.5 h-3.5" />}
mode === tab.key
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
}`}
> >
<tab.icon className="w-3.5 h-3.5" />
{tab.label} {tab.label}
</button> </TabButton>
))} ))}
</div> </TabBar>
{/* 환적 탐지 모드 */} {/* 환적 탐지 모드 */}
{mode === 'transfer' && <TransferView />} {mode === 'transfer' && <TransferView />}
@ -319,11 +369,11 @@ export function ChinaFishing() {
{!serviceAvailable && ( {!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs"> <div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" /> <AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span> <span> API - </span>
</div> </div>
)} )}
{apiError && <div className="text-xs text-red-400">: {apiError}</div>} {apiError && <div className="text-xs text-red-600 dark:text-red-400">{tcCommon('error.errorPrefix', { msg: apiError })}</div>}
{apiLoading && ( {apiLoading && (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
@ -331,7 +381,7 @@ export function ChinaFishing() {
</div> </div>
)} )}
{/* iran 백엔드 실시간 분석 결과 */} {/* 중국 선박 실시간 분석 결과 */}
<RealAllVessels /> <RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */} {/* ── 상단 바: 기준일 + 검색 ── */}
@ -340,16 +390,21 @@ export function ChinaFishing() {
<Clock className="w-3.5 h-3.5 text-muted-foreground" /> <Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label"> : {formatDateTime(new Date())}</span> <span className="text-[11px] text-label"> : {formatDateTime(new Date())}</span>
</div> </div>
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침"> <Button
<RotateCcw className="w-3.5 h-3.5" /> variant="secondary"
</button> size="sm"
onClick={loadApi}
aria-label={tcCommon('aria.refresh')}
icon={<RotateCcw className="w-3.5 h-3.5" />}
/>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5"> <div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-hint mr-2" /> <Search className="w-3.5 h-3.5 text-hint mr-2" />
<input aria-label="해역 또는 해구 번호 검색" <input
aria-label={tcCommon('aria.searchAreaOrZone')}
placeholder="해역 또는 해구 번호 검색" placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none" className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/> />
<Search className="w-4 h-4 text-blue-500 cursor-pointer" /> <Search className="w-4 h-4 text-blue-600 dark:text-blue-500 cursor-pointer" />
</div> </div>
</div> </div>
@ -371,7 +426,7 @@ export function ChinaFishing() {
</div> </div>
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground"> <div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
<span> </span> <span> </span>
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span> <span className="text-lg font-extrabold text-heading">{(apiStats?.total ?? 0).toLocaleString()}</span>
<span className="text-hint">()</span> <span className="text-hint">()</span>
</div> </div>
@ -407,13 +462,13 @@ export function ChinaFishing() {
<div className="flex items-center justify-around mt-4"> <div className="flex items-center justify-around mt-4">
<div> <div>
<div className="text-[10px] text-muted-foreground mb-2 text-center"> <div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-orange-400 font-medium"></span> <span className="text-orange-600 dark:text-orange-400 font-medium"></span>
</div> </div>
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" /> <SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
</div> </div>
<div> <div>
<div className="text-[10px] text-muted-foreground mb-2 text-center"> <div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-blue-400 font-medium"></span> <span className="text-blue-600 dark:text-blue-400 font-medium"></span>
</div> </div>
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" /> <SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
</div> </div>
@ -422,38 +477,51 @@ export function ChinaFishing() {
</Card> </Card>
</div> </div>
{/* 관심영역 안전도 */} {/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
<div className="col-span-4"> <div className="col-span-4">
<Card className="bg-surface-raised border-slate-700/30 h-full"> <Card className="bg-surface-raised border-slate-700/30 h-full">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-heading"> </span> <span className="text-sm font-bold text-heading"> </span>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none"> <Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<Select
size="sm"
aria-label={tcCommon('aria.areaOfInterestSelect')}
>
<option> A</option> <option> A</option>
<option> B</option> <option> B</option>
</select> </Select>
</div> </div>
<p className="text-[9px] text-hint mb-3"> .</p> <p className="text-[9px] text-hint mb-3"> .</p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
<div className="flex items-center gap-2 text-[11px]"> <div className="flex items-center gap-2 text-[11px]">
<Eye className="w-3.5 h-3.5 text-blue-400" /> <Eye className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span> <span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div> </div>
<div className="flex items-center gap-2 text-[11px]"> <div className="flex items-center gap-2 text-[11px]">
<AlertTriangle className="w-3.5 h-3.5 text-red-400" /> <AlertTriangle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span> <span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div> </div>
<div className="flex items-center gap-2 text-[11px]"> <div className="flex items-center gap-2 text-[11px]">
<Radio className="w-3.5 h-3.5 text-purple-400" /> <Radio className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span> <span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div> </div>
</div> </div>
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" /> <CircleGauge
value={
apiStats && apiStats.total > 0
? Number(((1 - apiStats.criticalCount / Math.max(apiStats.total, 1)) * 100).toFixed(1))
: 100
}
label=""
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -467,22 +535,27 @@ export function ChinaFishing() {
<div className="col-span-5"> <div className="col-span-5">
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0"> <CardContent className="p-0">
{/* 탭 헤더 */} {/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
<div className="flex border-b border-slate-700/30"> <TabBar variant="underline" className="border-slate-700/30">
{vesselTabs.map((tab) => ( {vesselTabs.map((tab) => {
<button type="button" const disabled = tab !== '특이운항';
return (
<TabButton
key={tab} key={tab}
onClick={() => setVesselTab(tab)} variant="underline"
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${ active={vesselTab === tab}
vesselTab === tab onClick={() => !disabled && setVesselTab(tab)}
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay' disabled={disabled}
: 'text-hint hover:text-label' className="flex-1 justify-center"
}`}
> >
{tab} {tab}
</button> {disabled && (
))} <Badge intent="warning" size="xs" className="font-normal"></Badge>
</div> )}
</TabButton>
);
})}
</TabBar>
{/* 선박 목록 */} {/* 선박 목록 */}
<div className="max-h-[420px] overflow-y-auto"> <div className="max-h-[420px] overflow-y-auto">
@ -491,10 +564,15 @@ export function ChinaFishing() {
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'} {apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
</div> </div>
)} )}
{vesselList.map((v) => ( {vesselList.map((v) => {
const selected = v.mmsi === selectedMmsi;
return (
<div <div
key={v.id} key={v.id}
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group" onClick={() => setSelectedMmsi(selected ? null : v.mmsi)}
className={`flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 transition-colors cursor-pointer group ${
selected ? 'bg-blue-500/10 hover:bg-blue-500/15' : 'hover:bg-surface-overlay'
}`}
> >
<StatusRing status={v.status} riskPct={v.riskPct} /> <StatusRing status={v.status} riskPct={v.riskPct} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -512,7 +590,8 @@ export function ChinaFishing() {
</div> </div>
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" /> <ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
</div> </div>
))} );
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -524,22 +603,21 @@ export function ChinaFishing() {
{/* 통계 차트 */} {/* 통계 차트 */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0"> <CardContent className="p-0">
{/* 탭 */} {/* 탭 — 월별 집계 API 미연동 */}
<div className="flex border-b border-slate-700/30"> <TabBar variant="underline" className="border-slate-700/30">
{statsTabs.map((tab) => ( {statsTabs.map((tab) => (
<button type="button" <TabButton
key={tab} key={tab}
variant="underline"
active={statsTab === tab}
onClick={() => setStatsTab(tab)} onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${ className="flex-1 justify-center"
statsTab === tab
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
> >
{tab} {tab}
</button> <Badge intent="warning" size="xs" className="font-normal"></Badge>
</TabButton>
))} ))}
</div> </TabBar>
<div className="p-4 flex gap-4"> <div className="p-4 flex gap-4">
{/* 월별 통계 - API 미지원, 준비중 안내 */} {/* 월별 통계 - API 미지원, 준비중 안내 */}
@ -584,9 +662,9 @@ export function ChinaFishing() {
{/* 다운로드 버튼 */} {/* 다운로드 버튼 */}
<div className="px-4 pb-3 flex justify-end"> <div className="px-4 pb-3 flex justify-end">
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors"> <Button variant="secondary" size="sm">
</button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -594,12 +672,15 @@ export function ChinaFishing() {
{/* 하단 카드 3개 */} {/* 하단 카드 3개 */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{/* 최근 위성영상 분석 */} {/* 최근 위성영상 분석 (VIIRS 수집 파이프라인 미구축 → 데모) */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading"> </span> <span className="text-[11px] font-bold text-heading"> </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button> <Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div> </div>
<div className="space-y-1.5 text-[10px]"> <div className="space-y-1.5 text-[10px]">
<div className="flex gap-2"> <div className="flex gap-2">
@ -618,16 +699,19 @@ export function ChinaFishing() {
</CardContent> </CardContent>
</Card> </Card>
{/* 기상 예보 */} {/* 기상 예보 (기상청 API 미연동 → 데모) */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading"> </span> <span className="text-[11px] font-bold text-heading"> </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button> <Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-center"> <div className="text-center">
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" /> <Cloud className="w-8 h-8 text-yellow-600 dark:text-yellow-400 mx-auto" />
</div> </div>
<div> <div>
<div className="text-[9px] text-muted-foreground"></div> <div className="text-[9px] text-muted-foreground"></div>
@ -641,12 +725,15 @@ export function ChinaFishing() {
</CardContent> </CardContent>
</Card> </Card>
{/* VTS연계 현황 */} {/* VTS연계 현황 (VTS 시스템 연계 미구축 → 데모) */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading">VTS연계 </span> <span className="text-[11px] font-bold text-heading">VTS연계 </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button> <Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div> </div>
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => ( {VTS_ITEMS.map((vts) => (
@ -654,22 +741,28 @@ export function ChinaFishing() {
key={vts.name} key={vts.name}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${ className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
vts.active vts.active
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20' ? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border border-orange-500/20'
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30' : 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
}`} }`}
> >
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} /> <span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-500' : 'bg-muted'}`} />
{vts.name} {vts.name}
</div> </div>
))} ))}
</div> </div>
<div className="flex justify-between mt-2"> <div className="flex justify-between mt-2">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors"> <Button
<ChevronLeft className="w-4 h-4" /> variant="ghost"
</button> size="sm"
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors"> aria-label={tcCommon('aria.previous')}
<ChevronRight className="w-4 h-4" /> icon={<ChevronLeft className="w-4 h-4" />}
</button> />
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.next')}
icon={<ChevronRight className="w-4 h-4" />}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -677,6 +770,28 @@ export function ChinaFishing() {
</div> </div>
</div> </div>
{/* ── 선택 시: 궤적 미니맵 + 특이운항 판별 구간 상세 (최근 24h 분석 이력 기반) ── */}
{selectedMmsi && (
<div className="grid grid-cols-12 gap-3">
<div className="col-span-5">
<VesselMiniMap
mmsi={selectedMmsi}
vesselName={vesselList.find((v) => v.mmsi === selectedMmsi)?.name}
segments={anomalySegments}
onClose={() => setSelectedMmsi(null)}
/>
</div>
<div className="col-span-7">
<VesselAnomalyPanel
segments={anomalySegments}
loading={historyLoading}
error={historyError}
totalHistoryCount={history.length}
/>
</div>
</div>
)}
</>} </>}
</PageContainer> </PageContainer>
); );

파일 보기

@ -7,12 +7,14 @@ import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react'; import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi'; import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent'; import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { DarkDetailPanel } from './components/DarkDetailPanel';
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */ /* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
@ -51,6 +53,10 @@ function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
const darkScore = (feat.dark_suspicion_score as number) ?? 0; const darkScore = (feat.dark_suspicion_score as number) ?? 0;
const patterns = (feat.dark_patterns as string[]) ?? []; const patterns = (feat.dark_patterns as string[]) ?? [];
// 위치: lat/lon이 없으면 features.gap_start_lat/lon 사용
const lat = (v.lat && v.lat !== 0) ? v.lat : (feat.gap_start_lat as number) ?? 0;
const lon = (v.lon && v.lon !== 0) ? v.lon : (feat.gap_start_lon as number) ?? 0;
return { return {
id: `DV-${String(idx + 1).padStart(3, '0')}`, id: `DV-${String(idx + 1).padStart(3, '0')}`,
mmsi: v.mmsi, mmsi: v.mmsi,
@ -62,8 +68,8 @@ function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
risk: v.riskScore ?? 0, risk: v.riskScore ?? 0,
gap: v.gapDurationMin ?? 0, gap: v.gapDurationMin ?? 0,
lastAIS: formatDateTime(v.analyzedAt), lastAIS: formatDateTime(v.analyzedAt),
lat: v.lat ?? 0, lat,
lng: v.lon ?? 0, lng: lon,
}; };
} }
@ -74,6 +80,7 @@ export function DarkVesselDetection() {
const navigate = useNavigate(); const navigate = useNavigate();
const [tierFilter, setTierFilter] = useState<string>(''); const [tierFilter, setTierFilter] = useState<string>('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const cols: DataColumn<Suspect>[] = useMemo(() => [ const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', { key: 'id', label: 'ID', width: '70px',
@ -81,20 +88,23 @@ export function DarkVesselDetection() {
{ key: 'darkTier', label: '등급', width: '80px', sortable: true, { key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => { render: (v) => {
const tier = v as string; const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>; return <Badge intent={getRiskIntent(tier === 'WATCH' ? 40 : getAlertLevelTierScore(tier))} size="sm">{tier}</Badge>;
} }, } },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true, { key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => { render: (v) => {
const n = v as number; const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>; const c = n >= 70 ? 'text-red-600 dark:text-red-400'
: n >= 50 ? 'text-orange-600 dark:text-orange-400'
: 'text-yellow-600 dark:text-yellow-400';
return <span className={`font-bold font-mono ${c}`}>{n}</span>;
} }, } },
{ key: 'name', label: '선박 유형', sortable: true, { key: 'name', label: '선박 유형', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> }, render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', { key: 'mmsi', label: 'MMSI', width: '100px',
render: (v) => { render: (v) => {
const mmsi = v as string; const mmsi = v as string;
return ( return (
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]" <button type="button" className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}> onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{mmsi} {mmsi}
</button> </button>
@ -109,7 +119,10 @@ export function DarkVesselDetection() {
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => { render: (v) => {
const n = v as number; const n = v as number;
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; const c = n >= 70 ? 'text-red-600 dark:text-red-400'
: n >= 50 ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return <span className={`font-bold ${c}`}>{n}</span>;
} }, } },
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px', { key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> }, render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
@ -121,6 +134,12 @@ export function DarkVesselDetection() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// 선택된 선박의 원본 VesselAnalysis 조회
const selectedVessel = useMemo(
() => selectedMmsi ? rawData.find(v => v.mmsi === selectedMmsi) ?? null : null,
[rawData, selectedMmsi],
);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@ -169,30 +188,56 @@ export function DarkVesselDetection() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => {
...STATIC_LAYERS, const validData = DATA.filter((d) => d.lat !== 0 && d.lng !== 0);
const layers = [
...createStaticLayers(),
// 전체 선박 마커 (tier별 색상)
createMarkerLayer(
'dv-markers',
validData.map((d) => ({
lat: d.lat, lng: d.lng,
color: TIER_HEX[d.darkTier] || '#6b7280',
radius: d.darkScore >= 70 ? 1000 : 600,
label: `${d.id}`,
} as MarkerData)),
),
// CRITICAL 위험 반경
createRadiusLayer( createRadiusLayer(
'dv-radius', 'dv-radius',
DATA.filter((d) => d.darkScore >= 70).map((d) => ({ validData.filter((d) => d.darkScore >= 70).map((d) => ({
lat: d.lat, lng: d.lng, radius: 10000, lat: d.lat, lng: d.lng, radius: 10000,
color: TIER_HEX[d.darkTier] || '#ef4444', color: TIER_HEX[d.darkTier] || '#ef4444',
})), })),
0.08, 0.08,
), ),
createMarkerLayer( ];
'dv-markers',
DATA.filter((d) => d.lat !== 0).map((d) => ({
lat: d.lat, lng: d.lng,
color: TIER_HEX[d.darkTier] || '#6b7280',
radius: d.darkScore >= 70 ? 1200 : 800,
label: `${d.id} ${d.name}`,
} as MarkerData)),
),
], [DATA]);
useMapLayers(mapRef, buildLayers, [DATA]); // 클릭 선택 선박 하이라이트 (흰색 원 + 큰 마커)
if (selectedMmsi) {
const target = validData.find(d => d.mmsi === selectedMmsi);
if (target) {
layers.push(
createRadiusLayer(
'dv-highlight',
[{ lat: target.lat, lng: target.lng, radius: 15000, color: '#ffffff' }],
0.15,
),
createMarkerLayer(
'dv-highlight-marker',
[{ lat: target.lat, lng: target.lng, color: '#ffffff', radius: 2000, label: `${target.mmsi}` } as MarkerData],
),
);
}
}
return layers;
}, [DATA, selectedMmsi]);
useMapLayers(mapRef, buildLayers, [DATA, selectedMmsi]);
return ( return (
<>
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={EyeOff} icon={EyeOff}
@ -213,7 +258,7 @@ export function DarkVesselDetection() {
} }
/> />
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && ( {loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
@ -224,10 +269,10 @@ export function DarkVesselDetection() {
{/* KPI — tier 기반 */} {/* KPI — tier 기반 */}
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' }, { l: '전체', v: tierCounts.total, c: 'text-red-600 dark:text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' }, { l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-600 dark:text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' }, { l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-600 dark:text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' }, { l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-600 dark:text-yellow-400', filter: 'WATCH' },
].map((k) => ( ].map((k) => (
<div key={k.l} <div key={k.l}
onClick={() => setTierFilter(k.filter)} onClick={() => setTierFilter(k.filter)}
@ -244,7 +289,8 @@ export function DarkVesselDetection() {
<DataTable data={DATA} columns={cols} pageSize={10} <DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchPlaceholder="선박유형, MMSI, 패턴 검색..."
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']} searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
exportFilename="Dark_Vessel_탐지" /> exportFilename="Dark_Vessel_탐지"
onRowClick={(row) => setSelectedMmsi(row.mmsi)} />
{/* 탐지 위치 지도 */} {/* 탐지 위치 지도 */}
<Card> <Card>
@ -264,11 +310,17 @@ export function DarkVesselDetection() {
</div> </div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5"> <div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" /> <div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}</span> <span className="text-[10px] text-red-600 dark:text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span> <span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</PageContainer> </PageContainer>
{/* 판정 상세 사이드 패널 */}
{selectedVessel && (
<DarkDetailPanel vessel={selectedVessel} onClose={() => setSelectedMmsi(null)} />
)}
</>
); );
} }

파일 보기

@ -0,0 +1,427 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertOctagon, RefreshCw } from 'lucide-react';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Textarea } from '@shared/components/ui/textarea';
import { Card, CardContent } from '@shared/components/ui/card';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import {
GEAR_COLLISION_STATUS_ORDER,
getGearCollisionStatusIntent,
getGearCollisionStatusLabel,
} from '@shared/constants/gearCollisionStatuses';
import {
getGearCollisionStats,
listGearCollisions,
resolveGearCollision,
type GearCollision,
type GearCollisionResolveAction,
type GearCollisionStats,
} from '@/services/gearCollisionApi';
import { useSettingsStore } from '@stores/settingsStore';
/**
* (GEAR_IDENTITY_COLLISION) .
*
* MMSI cycle
* REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE .
*/
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const DEFAULT_HOURS = 48;
export function GearCollisionDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [rows, setRows] = useState<GearCollision[]>([]);
const [stats, setStats] = useState<GearCollisionStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [severityFilter, setSeverityFilter] = useState<string>('');
const [nameFilter, setNameFilter] = useState<string>('');
const [selected, setSelected] = useState<GearCollision | null>(null);
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
const [resolveNote, setResolveNote] = useState('');
const [resolving, setResolving] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [page, summary] = await Promise.all([
listGearCollisions({
status: statusFilter || undefined,
severity: severityFilter || undefined,
name: nameFilter || undefined,
hours: DEFAULT_HOURS,
size: 200,
}),
getGearCollisionStats(DEFAULT_HOURS),
]);
setRows(page.content);
setStats(summary);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
} finally {
setLoading(false);
}
}, [statusFilter, severityFilter, nameFilter, t]);
useEffect(() => { loadData(); }, [loadData]);
// 선택된 row 와 현재 목록의 동기화
const syncedSelected = useMemo(
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
[rows, selected],
);
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
{
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
},
{
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
render: (_, row) => (
<span className="font-mono text-[10px] text-label">
{row.mmsiLo} {row.mmsiHi}
</span>
),
},
{
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
{
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
width: '90px', align: 'center', sortable: true,
render: (v) => <span className="font-mono text-label">{v as number}</span>,
},
{
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
width: '110px', align: 'right', sortable: true,
render: (v) => {
const n = typeof v === 'number' ? v : Number(v ?? 0);
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
},
},
{
key: 'severity', label: t('gearCollision.columns.severity'),
width: '90px', align: 'center', sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'status', label: t('gearCollision.columns.status'),
width: '110px', align: 'center', sortable: true,
render: (v) => (
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
{getGearCollisionStatusLabel(v as string, t, lang)}
</Badge>
),
},
{
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
width: '130px', sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
], [t, tc, lang]);
const handleResolve = useCallback(async () => {
if (!syncedSelected) return;
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
if (!ok) return;
setResolving(true);
try {
const updated = await resolveGearCollision(syncedSelected.id, {
action: resolveAction,
note: resolveNote || undefined,
});
setSelected(updated);
setResolveNote('');
await loadData();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
} finally {
setResolving(false);
}
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
return (
<PageContainer>
<PageHeader
icon={AlertOctagon}
iconColor="text-orange-600 dark:text-orange-400"
title={t('gearCollision.title')}
description={t('gearCollision.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('gearCollision.list.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('gearCollision.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
<StatCard
label={t('gearCollision.stats.open')}
value={statusCount('OPEN')}
intent="warning"
/>
<StatCard
label={t('gearCollision.stats.reviewed')}
value={statusCount('REVIEWED')}
intent="info"
/>
<StatCard
label={t('gearCollision.stats.confirmed')}
value={statusCount('CONFIRMED_ILLEGAL')}
intent="critical"
/>
<StatCard
label={t('gearCollision.stats.falsePositive')}
value={statusCount('FALSE_POSITIVE')}
intent="muted"
/>
</div>
</Section>
<Section title={t('gearCollision.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('gearCollision.filters.status')}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">{t('gearCollision.filters.allStatus')}</option>
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
))}
</Select>
<Select
aria-label={t('gearCollision.filters.severity')}
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
>
<option value="">{t('gearCollision.filters.allSeverity')}</option>
{SEVERITY_OPTIONS.map((sv) => (
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
))}
</Select>
<Input
aria-label={t('gearCollision.filters.name')}
placeholder={t('gearCollision.filters.name')}
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
/>
<div className="flex items-center justify-end">
<Badge intent="info" size="sm">
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
</Badge>
</div>
</div>
{rows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
</p>
) : (
<DataTable
data={rows as (GearCollision & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as GearCollision)}
/>
)}
</Section>
{syncedSelected && (
<Section title={t('gearCollision.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 text-xs">
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
<DetailRow
label={t('gearCollision.columns.mmsiPair')}
value={`${syncedSelected.mmsiLo}${syncedSelected.mmsiHi}`}
mono
/>
<DetailRow
label={t('gearCollision.columns.parentName')}
value={syncedSelected.parentName ?? '-'}
/>
<DetailRow
label={t('gearCollision.detail.firstSeenAt')}
value={formatDateTime(syncedSelected.firstSeenAt)}
/>
<DetailRow
label={t('gearCollision.detail.lastSeenAt')}
value={formatDateTime(syncedSelected.lastSeenAt)}
/>
<DetailRow
label={t('gearCollision.columns.coexistenceCount')}
value={String(syncedSelected.coexistenceCount)}
/>
<DetailRow
label={t('gearCollision.detail.swapCount')}
value={String(syncedSelected.swapCount)}
/>
<DetailRow
label={t('gearCollision.columns.maxDistance')}
value={
syncedSelected.maxDistanceKm != null
? Number(syncedSelected.maxDistanceKm).toFixed(2)
: '-'
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-label">
{t('gearCollision.columns.severity')}:
</span>
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
</Badge>
<span className="text-xs text-label ml-3">
{t('gearCollision.columns.status')}:
</span>
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
</Badge>
</div>
{syncedSelected.resolutionNote && (
<p className="text-xs text-hint border-l-2 border-border pl-2">
{syncedSelected.resolutionNote}
</p>
)}
<div className="space-y-1.5">
<label
htmlFor="gc-resolve-action"
className="block text-xs text-label"
>
{t('gearCollision.resolve.title')}
</label>
<Select
id="gc-resolve-action"
aria-label={t('gearCollision.resolve.title')}
value={resolveAction}
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
>
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
<option value="CONFIRMED_ILLEGAL">
{t('gearCollision.resolve.confirmedIllegal')}
</option>
<option value="FALSE_POSITIVE">
{t('gearCollision.resolve.falsePositive')}
</option>
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
</Select>
<Textarea
aria-label={t('gearCollision.resolve.note')}
placeholder={t('gearCollision.resolve.notePlaceholder')}
value={resolveNote}
onChange={(e) => setResolveNote(e.target.value)}
rows={3}
/>
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelected(null); setResolveNote(''); }}
>
{t('gearCollision.resolve.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleResolve}
disabled={resolving}
>
{t('gearCollision.resolve.submit')}
</Button>
</div>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-center gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default GearCollisionDetection;

파일 보기

@ -2,22 +2,65 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Checkbox } from '@shared/components/ui/checkbox';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { MarkerData } from '@lib/map'; import {
BaseMap, createStaticLayers,
createGeoJsonLayer, createGearPolygonLayer,
createShipIconLayer, createGearIconLayer,
type MapHandle,
type ShipIconData, type GearIconData,
} from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat'; import { formatDate } from '@shared/utils/dateFormat';
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses'; import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { getAlertLevelHex } from '@shared/constants/alertLevels'; import { getAlertLevelHex } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes';
import { getGearViolationIntent } from '@shared/constants/gearViolationCodes';
import { getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { GearDetailPanel } from './components/GearDetailPanel';
import { GearReplayController } from './components/GearReplayController';
import { useGearReplayStore } from '@stores/gearReplayStore';
import { useGearReplayLayers } from '@/hooks/useGearReplayLayers';
import fishingZonesGeoJson from '@lib/map/data/fishing-zones-wgs84.json';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */ /* SFR-10: 불법 어망·어구 탐지 및 관리 */
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; }; type Gear = {
id: string;
groupKey: string;
type: string; // 어구 그룹 유형 (구역 내/외)
owner: string; // 모선 MMSI 또는 그룹 라벨
zone: string; // 수역 코드
status: string;
permit: string;
installed: string;
lastSignal: string;
risk: string;
lat: number;
lng: number;
parentStatus: string;
parentMmsi: string;
confidence: string;
memberCount: number;
members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>;
// 폴리곤 원본
polygon: unknown;
// G코드 위반 정보
gCodes: string[];
gearViolationScore: number;
gearViolationEvidence: Record<string, Record<string, unknown>>;
pairTrawlDetected: boolean;
pairTrawlPairMmsi: string;
allowedGears: string[];
topScore: number; // 최대 후보 일치율 (0~1)
};
// 한글 위험도 → AlertLevel hex 매핑
const RISK_HEX: Record<string, string> = { const RISK_HEX: Record<string, string> = {
'고위험': getAlertLevelHex('CRITICAL'), '고위험': getAlertLevelHex('CRITICAL'),
'중위험': getAlertLevelHex('MEDIUM'), '중위험': getAlertLevelHex('MEDIUM'),
@ -37,14 +80,31 @@ function deriveStatus(g: GearGroupItem): string {
return '확인 중'; return '확인 중';
} }
function mapGroupToGear(g: GearGroupItem, idx: number): Gear { /** 그룹 유형에서 수역 코드 추론 (backend가 zoneCode를 미제공하므로 groupType 기반) */
function deriveZone(g: GearGroupItem): string {
if (g.groupType === 'GEAR_OUT_ZONE') return 'EEZ_OR_BEYOND';
// GEAR_IN_ZONE: 위치 기반 추론 — 위도/경도로 대략적 수역 판별
const lat = g.centerLat;
const lon = g.centerLon;
if (lat > 37.0 && lon > 129.0) return 'ZONE_I'; // 동해
if (lat < 34.0 && lon > 127.0) return 'ZONE_II'; // 남해
if (lat < 35.5 && lon < 127.0) return 'ZONE_III'; // 서남해
if (lat >= 35.5 && lon < 126.5) return 'ZONE_IV'; // 서해
return 'ZONE_III'; // 기본값
}
function mapGroupToGear(g: GearGroupItem, idx: number, t: (k: string, opts?: { defaultValue?: string }) => string, lang: 'ko' | 'en'): Gear {
const risk = deriveRisk(g); const risk = deriveRisk(g);
const status = deriveStatus(g); const status = deriveStatus(g);
const zone = deriveZone(g);
// 그룹명: 항상 groupLabel/groupKey 사용 (모선 후보 MMSI는 별도 칼럼)
const owner = g.groupLabel || g.groupKey;
return { return {
id: `G-${String(idx + 1).padStart(3, '0')}`, id: `G-${String(idx + 1).padStart(3, '0')}`,
type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'), groupKey: g.groupKey,
owner: g.members[0]?.name || g.members[0]?.mmsi || '-', type: getGearGroupTypeLabel(g.groupType, t, lang),
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외', owner,
zone,
status, status,
permit: 'NONE', permit: 'NONE',
installed: formatDate(g.snapshotTime), installed: formatDate(g.snapshotTime),
@ -52,9 +112,58 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
risk, risk,
lat: g.centerLat, lat: g.centerLat,
lng: g.centerLon, lng: g.centerLon,
parentStatus: g.resolution?.status ?? '-',
parentMmsi: g.resolution?.selectedParentMmsi ?? '-',
confidence: (g.candidateCount ?? 0) > 0 ? `${g.candidateCount}` : '-',
memberCount: g.memberCount ?? 0,
members: g.members ?? [],
polygon: g.polygon,
gCodes: [],
gearViolationScore: 0,
gearViolationEvidence: {},
pairTrawlDetected: false,
pairTrawlPairMmsi: '',
allowedGears: getZoneAllowedGears(zone),
topScore: Math.max(g.liveTopScore ?? 0, g.resolution?.topScore ?? 0),
}; };
} }
/** 필터 그룹 내 체크박스 목록 */
function FilterCheckGroup({ label, selected, onChange, options }: {
label: string;
selected: Set<string>;
onChange: (v: Set<string>) => void;
options: { value: string; label: string }[];
}) {
const toggle = (v: string) => {
const next = new Set(selected);
if (next.has(v)) next.delete(v); else next.add(v);
onChange(next);
};
return (
<div className="space-y-1">
<div className="text-[10px] text-hint font-medium">{label} {selected.size > 0 && <span className="text-primary">({selected.size})</span>}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{options.map(o => (
<Checkbox
key={o.value}
checked={selected.has(o.value)}
onChange={() => toggle(o.value)}
label={o.label}
className="w-3 h-3"
/>
))}
</div>
</div>
);
}
function ReplayOverlay() {
const groupKey = useGearReplayStore(s => s.groupKey);
if (!groupKey) return null;
return <GearReplayController onClose={() => useGearReplayStore.getState().reset()} />;
}
export function GearDetection() { export function GearDetection() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
@ -62,32 +171,110 @@ export function GearDetection() {
const cols: DataColumn<Gear>[] = useMemo(() => [ const cols: DataColumn<Gear>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, { key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> }, { key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium text-[11px]">{v as string}</span> },
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> }, { key: 'owner', label: '어구 그룹', sortable: true,
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true }, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center', { key: 'memberCount', label: '멤버', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as number}</span> },
{ key: 'zone', label: '설치 해역', width: '130px', sortable: true,
render: (v: unknown) => (
<Badge intent={getZoneCodeIntent(v as string)} size="sm">
{getZoneCodeLabel(v as string, t, lang)}
</Badge>
) },
{ key: 'permit', label: '허가', width: '70px', align: 'center',
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> }, render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> }, render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, { key: 'gCodes', label: 'G코드', width: '100px',
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } }, render: (_: unknown, row: Gear) => row.gCodes.length > 0 ? (
<div className="flex flex-wrap gap-0.5">
{row.gCodes.map(code => (
<Badge key={code} intent={getGearViolationIntent(code)} size="sm">{code}</Badge>
))}
</div>
) : <span className="text-hint text-[10px]">-</span> },
{ key: 'risk', label: '위험도', width: '65px', align: 'center', sortable: true,
render: v => {
const r = v as string;
const c = r === '고위험' ? 'text-red-600 dark:text-red-400'
: r === '중위험' ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return <span className={`text-[10px] font-bold ${c}`}>{r}</span>;
} },
{ key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true,
render: v => {
const s = v as string;
const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : 'muted';
const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s;
return <Badge intent={intent} size="sm">{label}</Badge>;
} },
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true,
render: (v: unknown) => {
const s = v as number;
if (s <= 0) return <span className="text-hint text-[10px]">-</span>;
const pct = Math.round(s * 100);
const c = pct >= 72 ? 'text-green-600 dark:text-green-400' : pct >= 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-hint';
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
} },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> }, { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang]); ], [t, tc, lang]);
const [groups, setGroups] = useState<GearGroupItem[]>([]); const [groups, setGroups] = useState<GearGroupItem[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true); const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
// ── 필터 상태 (다중 선택, localStorage 영속화) ──
const [filterOpen, setFilterOpen] = useState(() => {
try { return JSON.parse(localStorage.getItem('kcg-gear-filter-open') ?? 'false'); } catch { return false; }
});
const [filterZone, setFilterZone] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fz') ?? '[]')); } catch { return new Set(); }
});
const [filterStatus, setFilterStatus] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fs') ?? '[]')); } catch { return new Set(); }
});
const [filterRisk, setFilterRisk] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fr') ?? '[]')); } catch { return new Set(); }
});
const [filterParentStatus, setFilterParentStatus] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fps') ?? '[]')); } catch { return new Set(); }
});
const [filterPermit, setFilterPermit] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fp') ?? '[]')); } catch { return new Set(); }
});
const [filterMemberMin, setFilterMemberMin] = useState(() => {
try { return JSON.parse(localStorage.getItem('kcg-gear-fmn') ?? '2'); } catch { return 2; }
});
const [filterMemberMax, setFilterMemberMax] = useState(() => {
try { const v = localStorage.getItem('kcg-gear-fmx'); return v ? JSON.parse(v) : Infinity; } catch { return Infinity; }
});
const checkFilterCount = filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size;
// localStorage 동기화
useEffect(() => {
localStorage.setItem('kcg-gear-filter-open', JSON.stringify(filterOpen));
localStorage.setItem('kcg-gear-fz', JSON.stringify([...filterZone]));
localStorage.setItem('kcg-gear-fs', JSON.stringify([...filterStatus]));
localStorage.setItem('kcg-gear-fr', JSON.stringify([...filterRisk]));
localStorage.setItem('kcg-gear-fps', JSON.stringify([...filterParentStatus]));
localStorage.setItem('kcg-gear-fp', JSON.stringify([...filterPermit]));
localStorage.setItem('kcg-gear-fmn', JSON.stringify(filterMemberMin));
if (filterMemberMax !== Infinity) localStorage.setItem('kcg-gear-fmx', JSON.stringify(filterMemberMax));
else localStorage.removeItem('kcg-gear-fmx');
}, [filterOpen, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res = await fetchGroups(); const res = await fetchGroups('GEAR');
setServiceAvailable(res.serviceAvailable); setServiceAvailable(res.serviceAvailable);
setGroups(res.items.filter( setGroups(res.items);
(i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE',
));
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false); setServiceAvailable(false);
@ -99,39 +286,175 @@ export function GearDetection() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const DATA: Gear[] = useMemo( const DATA: Gear[] = useMemo(
() => groups.map((g, i) => mapGroupToGear(g, i)), () => groups.map((g, i) => mapGroupToGear(g, i, t, lang)),
[groups], [groups, t, lang],
);
// 필터 옵션 (고유값 추출)
const filterOptions = useMemo(() => ({
zones: [...new Set(DATA.map(d => d.zone))].sort(),
statuses: [...new Set(DATA.map(d => d.status))],
risks: [...new Set(DATA.map(d => d.risk))],
parentStatuses: [...new Set(DATA.map(d => d.parentStatus))],
permits: [...new Set(DATA.map(d => d.permit))],
maxMember: DATA.reduce((max, d) => Math.max(max, d.memberCount), 0),
}), [DATA]);
const hasActiveFilter = checkFilterCount > 0 || (filterMemberMin > 2 || (filterMemberMax !== Infinity && filterMemberMax < filterOptions.maxMember));
// 데이터 로드 후 멤버 슬라이더 최대값 초기화
useEffect(() => {
if (filterOptions.maxMember > 0 && filterMemberMax === Infinity) {
setFilterMemberMax(filterOptions.maxMember);
}
}, [filterOptions.maxMember, filterMemberMax]);
// ── 필터 적용 ──
const filteredData = useMemo(() => {
const effMax = filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax;
return DATA.filter(d =>
(filterZone.size === 0 || filterZone.has(d.zone)) &&
(filterStatus.size === 0 || filterStatus.has(d.status)) &&
(filterRisk.size === 0 || filterRisk.has(d.risk)) &&
(filterParentStatus.size === 0 || filterParentStatus.has(d.parentStatus)) &&
(filterPermit.size === 0 || filterPermit.has(d.permit)) &&
d.memberCount >= filterMemberMin && d.memberCount <= effMax,
);
}, [DATA, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax, filterOptions.maxMember]);
const selectedGear = useMemo(
() => DATA.find(g => g.id === selectedId) ?? null,
[DATA, selectedId],
); );
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ // overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
...STATIC_LAYERS, // 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
createRadiusLayer( const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
'gear-radius', get current() { return mapRef.current?.overlay ?? null; },
DATA.filter(g => g.risk === '고위험').map(g => ({ }), []);
lat: g.lat,
lng: g.lng,
radius: 6000,
color: RISK_HEX[g.risk] || "#64748b",
})),
0.1,
),
createMarkerLayer(
'gear-markers',
DATA.map(g => ({
lat: g.lat,
lng: g.lng,
color: RISK_HEX[g.risk] || "#64748b",
radius: g.risk === '고위험' ? 1200 : 800,
label: `${g.id} ${g.type}`,
} as MarkerData)),
),
], [DATA]);
useMapLayers(mapRef, buildLayers, [DATA]); const replayGroupKey = useGearReplayStore(s => s.groupKey);
const isReplayActive = !!replayGroupKey;
const buildLayers = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [
// 1. 정적 레이어 (EEZ + NLL)
...createStaticLayers(),
// 2. 특정해역 I~IV 폴리곤 (항상 표시)
createGeoJsonLayer('fishing-zones', fishingZonesGeoJson, '#6366f1', {
fillOpacity: 15,
lineWidth: 2,
pickable: false,
}),
];
if (isReplayActive) {
// ── 리플레이 모드: 정적 레이어만 (리플레이 훅이 나머지 직접 관리) ──
// 비선택 어구 그룹 중심 (흐린 마름모) — 위치 참조용
layers.push(createGearIconLayer(
'gear-dim',
DATA.filter(g => g.groupKey !== replayGroupKey).map(g => ({
lat: g.lat, lon: g.lng, color: '#475569', size: 10,
} as GearIconData)),
));
} else if (selectedId) {
// ── 선택 모드 (리플레이 비활성) ──
const sel = DATA.find(g => g.id === selectedId);
// 선택된 어구 그룹 폴리곤 강조
if (sel?.polygon) {
layers.push(createGearPolygonLayer('gear-polygon-selected', [{
polygon: sel.polygon,
color: '#f59e0b',
label: `${sel.id} ${sel.type}`,
risk: sel.risk,
}]));
}
// 멤버 아이콘: 선박(PARENT)=삼각형+COG, 어구(GEAR)=마름모
if (sel && sel.members.length > 0) {
const ships = sel.members.filter(m => m.lat != null && m.lon != null && (m.isParent || m.role === 'PARENT'));
const gears = sel.members.filter(m => m.lat != null && m.lon != null && !m.isParent && m.role !== 'PARENT');
if (ships.length > 0) {
layers.push(createShipIconLayer('sel-ships', ships.map(m => ({
lat: m.lat!, lon: m.lon!,
cog: (m as Record<string, unknown>).cog as number | undefined,
color: '#06b6d4', size: 28,
label: `${m.mmsi} ${m.name ?? ''}`,
isParent: true,
} as ShipIconData))));
}
if (gears.length > 0) {
layers.push(createGearIconLayer('sel-gears', gears.map(m => ({
lat: m.lat!, lon: m.lon!,
color: '#f59e0b', size: 18,
label: `${m.mmsi} ${m.name ?? ''}`,
} as GearIconData))));
}
}
// 비선택 어구 그룹 중심 (흐린 마름모)
layers.push(createGearIconLayer(
'gear-dim',
DATA.filter(g => g.id !== selectedId).map(g => ({
lat: g.lat, lon: g.lng, color: '#475569', size: 10,
} as GearIconData)),
));
} else {
// ── 기본 모드: 모든 어구 폴리곤 + 아이콘 ──
layers.push(createGearPolygonLayer(
'gear-polygons',
DATA.filter(g => g.polygon != null).map(g => ({
polygon: g.polygon,
color: RISK_HEX[g.risk] || '#64748b',
label: `${g.id} ${g.type}`,
risk: g.risk,
})),
));
// 어구 그룹 중심 마름모 아이콘
layers.push(createGearIconLayer(
'gear-center-icons',
DATA.map(g => ({
lat: g.lat, lon: g.lng,
color: RISK_HEX[g.risk] || '#64748b',
size: g.risk === '고위험' ? 20 : 14,
label: `${g.id} ${g.owner}`,
} as GearIconData)),
));
}
return layers;
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
useEffect(() => {
if (isReplayActive) return; // replay hook이 overlay 독점
const raf = requestAnimationFrame(() => {
mapRef.current?.overlay?.setProps({ layers: buildLayers() });
});
return () => cancelAnimationFrame(raf);
}, [buildLayers, isReplayActive]);
useGearReplayLayers(overlayRef, buildLayers);
// 수역별 통계
const zoneStats = useMemo(() => {
const stats: Record<string, number> = {};
filteredData.forEach(d => { stats[d.zone] = (stats[d.zone] || 0) + 1; });
return stats;
}, [filteredData]);
return ( return (
<>
<GearDetailPanel gear={selectedGear} onClose={() => setSelectedId(null)} />
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Anchor} icon={Anchor}
@ -143,13 +466,11 @@ export function GearDetection() {
{!serviceAvailable && ( {!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs"> <div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" /> <AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span> <span>AI - </span>
</div> </div>
)} )}
{error && ( {error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
<div className="text-xs text-red-400">: {error}</div>
)}
{loading && ( {loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
@ -157,45 +478,175 @@ export function GearDetection() {
</div> </div>
)} )}
<div className="flex gap-2"> {/* 요약 배지 */}
<div className="flex gap-2 flex-wrap">
{[ {[
{ l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' }, { l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
{ l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, { l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-600 dark:text-red-400' },
{ l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, { l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-600 dark:text-yellow-400' },
{ l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }, { l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-600 dark:text-green-400' },
].map(k => ( ].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"> <div key={k.l} className="flex-1 min-w-[100px] flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span> <span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div> </div>
))} ))}
</div> </div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" /> {/* 수역별 분포 */}
<div className="flex gap-1.5 flex-wrap">
{Object.entries(zoneStats).sort(([,a],[,b]) => b - a).map(([zone, cnt]) => (
<Badge key={zone} intent={getZoneCodeIntent(zone)} size="sm">
{getZoneCodeLabel(zone, t, lang)} {cnt}
</Badge>
))}
</div>
{/* 필터 토글 버튼 */}
<div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label={tc('aria.filterToggle')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${
hasActiveFilter
? 'bg-primary/10 border-primary/40 text-heading'
: 'bg-surface-raised border-border text-label hover:border-primary/50'
}`}>
<Filter className="w-3.5 h-3.5" />
{hasActiveFilter && (
<span className="bg-primary text-on-vivid rounded-full px-1.5 py-0.5 text-[9px] font-bold leading-none">
{filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size}
</span>
)}
</button>
{hasActiveFilter && (
<>
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span>
<Button
variant="ghost"
size="sm"
aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
icon={<X className="w-3 h-3" />}
>
</Button>
</>
)}
</div>
{/* 필터 패널 (접기/펼치기) */}
{filterOpen && (
<div className="bg-surface-raised border border-border rounded-lg p-3 space-y-3">
<FilterCheckGroup label="설치 해역" selected={filterZone} onChange={setFilterZone}
options={filterOptions.zones.map(z => ({ value: z, label: getZoneCodeLabel(z, t, lang) }))} />
<FilterCheckGroup label="판정" selected={filterStatus} onChange={setFilterStatus}
options={filterOptions.statuses.map(s => ({ value: s, label: s }))} />
<FilterCheckGroup label="위험도" selected={filterRisk} onChange={setFilterRisk}
options={filterOptions.risks.map(r => ({ value: r, label: r }))} />
<FilterCheckGroup label="모선 상태" selected={filterParentStatus} onChange={setFilterParentStatus}
options={filterOptions.parentStatuses.map(s => ({
value: s,
label: s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격'
: s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s,
}))} />
<FilterCheckGroup label="허가" selected={filterPermit} onChange={setFilterPermit}
options={filterOptions.permits.map(p => ({ value: p, label: getPermitStatusLabel(p, tc, lang) }))} />
{/* 멤버 수 범위 슬라이더 */}
{filterOptions.maxMember > 2 && (
<div className="space-y-1">
<div className="text-[10px] text-hint font-medium">
<span className="text-label font-bold">{filterMemberMin}~{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))}
aria-label={tc('aria.memberCountMin')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
onChange={e => setFilterMemberMax(Number(e.target.value))}
aria-label={tc('aria.memberCountMax')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
</div>
)}
{/* 패널 내 초기화 */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length} </span>
<Button
variant="ghost"
size="sm"
aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
icon={<X className="w-3 h-3" />}
>
</Button>
</div>
</div>
)}
<DataTable
data={filteredData}
columns={cols}
pageSize={10}
searchPlaceholder="그룹유형, 모선, 해역 검색..."
searchKeys={['type', 'owner', 'zone', 'groupKey']}
exportFilename="어구탐지"
onRowClick={(row: Gear) => {
const newId = row.id === selectedId ? null : row.id;
setSelectedId(newId);
// 선택 시 지도 중심 이동
if (newId) {
const gear = DATA.find(g => g.id === newId);
if (gear && mapRef.current?.map) {
mapRef.current.map.flyTo({
center: [gear.lng, gear.lat],
zoom: 10,
duration: 1000,
});
}
}
}}
/>
{/* 어구 탐지 위치 지도 */} {/* 어구 탐지 위치 지도 */}
<Card> <Card>
<CardContent className="p-0 relative"> <CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.5, 127.0]} zoom={7} height={450} className="rounded-lg overflow-hidden" /> <BaseMap ref={mapRef} center={[34.5, 126.0]} zoom={7} height={500} className="rounded-lg overflow-hidden" />
{/* 범례 */} {/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2"> <div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div> <div className="text-[9px] text-muted-foreground font-bold mb-1.5"></div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> ( /)</span></div> <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> </span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground"> ( )</span></div> <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground"> ( )</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground"> ()</span></div> <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground"> ()</span></div>
</div> </div>
<div className="mt-1.5 pt-1.5 border-t border-border space-y-1">
<div className="text-[8px] text-muted-foreground font-bold"></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-purple-500/30 border border-purple-500/60" /><span className="text-[8px] text-muted-foreground"> I ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-blue-500/30 border border-blue-500/60" /><span className="text-[8px] text-muted-foreground"> II ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-cyan-500/30 border border-cyan-500/60" /><span className="text-[8px] text-muted-foreground"> III ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-amber-500/30 border border-amber-500/60" /><span className="text-[8px] text-muted-foreground"> IV ()</span></div>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border"> <div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div> <div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div> <div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
</div> </div>
</div> </div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5"> <div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<Anchor className="w-3.5 h-3.5 text-orange-400" /> <Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span> <span className="text-[10px] text-orange-600 dark:text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span> <span className="text-[9px] text-hint"> </span>
</div> </div>
{/* 리플레이 컨트롤러 (활성 시 표시) */}
<ReplayOverlay />
</CardContent> </CardContent>
</Card> </Card>
</PageContainer> </PageContainer>
</>
); );
} }

파일 보기

@ -1,12 +1,17 @@
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Search, Anchor, Ship, Eye, AlertTriangle, CheckCircle, XCircle, Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
ChevronRight, ChevronDown, Info, Shield, Radar, Target, Waves, ChevronRight, Info, Shield, Radar, Target, Waves,
ArrowRight, Flag, Zap, HelpCircle ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
// ─── 판별 기준 데이터 ───────────────── // ─── 판별 기준 데이터 ─────────────────
@ -569,7 +574,7 @@ function GearComparisonTable() {
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-0"> <CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5"> <CardTitle className="text-xs text-label flex items-center gap-1.5">
<Info className="w-3.5 h-3.5 text-blue-400" /> <Info className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
· (GB/T 5147-2003 ) · (GB/T 5147-2003 )
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -589,18 +594,18 @@ function GearComparisonTable() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<div className="text-[9px] text-red-400 font-medium mb-1"> </div> <div className="text-[9px] text-red-600 dark:text-red-400 font-medium mb-1"> </div>
{row.chinaFeatures.map((f, i) => ( {row.chinaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1"> <div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f} <span className="text-red-600 dark:text-red-500 mt-0.5 shrink-0">-</span>{f}
</div> </div>
))} ))}
</div> </div>
<div> <div>
<div className="text-[9px] text-blue-400 font-medium mb-1"> </div> <div className="text-[9px] text-blue-600 dark:text-blue-400 font-medium mb-1"> </div>
{row.koreaFeatures.map((f, i) => ( {row.koreaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1"> <div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f} <span className="text-blue-600 dark:text-blue-500 mt-0.5 shrink-0">-</span>{f}
</div> </div>
))} ))}
</div> </div>
@ -619,11 +624,25 @@ function GearComparisonTable() {
// ─── 메인 페이지 ────────────────────── // ─── 메인 페이지 ──────────────────────
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
C40: 'unknown', FC: 'unknown',
};
const ZONE_CODE_SEA_AREA: Record<string, string> = {
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
};
export function GearIdentification() { export function GearIdentification() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT); const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
const [result, setResult] = useState<IdentificationResult | null>(null); const [result, setResult] = useState<IdentificationResult | null>(null);
const [showReference, setShowReference] = useState(false); const [showReference, setShowReference] = useState(false);
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => { const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
setInput((prev) => ({ ...prev, [key]: value })); setInput((prev) => ({ ...prev, [key]: value }));
@ -636,6 +655,58 @@ export function GearIdentification() {
const resetForm = () => { const resetForm = () => {
setInput(DEFAULT_INPUT); setInput(DEFAULT_INPUT);
setResult(null); setResult(null);
setAutoSelected(null);
};
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
const applyAutoDetection = (v: GearDetection) => {
const code = (v.gearCode || '').toUpperCase();
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
setInput({
...DEFAULT_INPUT,
gearCategory: category,
permitCode: code,
mmsiPrefix: v.mmsi.slice(0, 3),
seaArea,
discoveryDate: v.analyzedAt.slice(0, 10),
});
setAutoSelected(v);
// 자동탐지 근거를 결과 패널에 프리셋
const reasons: string[] = [];
const warnings: string[] = [];
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
if (v.permitStatus) {
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
}
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
setResult({
origin: 'china',
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
gearType: category,
gearSubType: code,
gbCode: '',
koreaName: '',
reasons,
warnings,
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
: '추가 정보 입력 후 판별 실행',
alertLevel,
});
// 입력 폼 영역으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
return ( return (
@ -644,7 +715,7 @@ export function GearIdentification() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"> <h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Search className="w-5 h-5 text-cyan-500" /> <Search className="w-5 h-5 text-cyan-600 dark:text-cyan-500" />
{t('gearId.title')} {t('gearId.title')}
</h2> </h2>
<p className="text-[10px] text-hint mt-0.5"> <p className="text-[10px] text-hint mt-0.5">
@ -652,19 +723,47 @@ export function GearIdentification() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" <Button
variant="secondary"
size="sm"
onClick={() => setShowReference(!showReference)} onClick={() => setShowReference(!showReference)}
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5" icon={<Info className="w-3 h-3" />}
> >
<Info className="w-3 h-3" />
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'} {showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
</button> </Button>
</div> </div>
</div> </div>
{/* 레퍼런스 테이블 (토글) */} {/* 레퍼런스 테이블 (토글) */}
{showReference && <GearComparisonTable />} {showReference && <GearComparisonTable />}
{/* 자동탐지 선택 힌트 */}
{autoSelected && (
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
<div className="flex items-center gap-2 flex-wrap">
<Badge intent="info" size="sm"> </Badge>
<span className="text-hint">MMSI</span>
<span className="font-mono text-cyan-600 dark:text-cyan-400">{autoSelected.mmsi}</span>
<span className="text-hint">·</span>
<span className="text-hint"></span>
<span className="font-mono text-label">{autoSelected.gearCode}</span>
<span className="text-hint">·</span>
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
</Badge>
<span className="text-hint ml-2"> . .</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
className="shrink-0 text-[10px]"
>
</Button>
</div>
)}
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
{/* ── 좌측: 입력 폼 ── */} {/* ── 좌측: 입력 폼 ── */}
<div className="col-span-5 space-y-3"> <div className="col-span-5 space-y-3">
@ -783,19 +882,22 @@ export function GearIdentification() {
{/* 판별 버튼 */} {/* 판별 버튼 */}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" <Button
variant="primary"
size="md"
onClick={runIdentification} onClick={runIdentification}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2" icon={<Zap className="w-4 h-4" />}
className="flex-1 font-bold"
> >
<Zap className="w-4 h-4" />
</button> </Button>
<button type="button" <Button
variant="secondary"
size="md"
onClick={resetForm} onClick={resetForm}
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
> >
</button> </Button>
</div> </div>
</div> </div>
@ -855,7 +957,7 @@ export function GearIdentification() {
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-0"> <CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5"> <CardTitle className="text-xs text-label flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-500" /> <CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500" />
({result.reasons.length}) ({result.reasons.length})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -863,7 +965,7 @@ export function GearIdentification() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{result.reasons.map((reason, i) => ( {result.reasons.map((reason, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md"> <div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" /> <ChevronRight className="w-3 h-3 text-green-600 dark:text-green-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-label">{reason}</span> <span className="text-[11px] text-label">{reason}</span>
</div> </div>
))} ))}
@ -875,7 +977,7 @@ export function GearIdentification() {
{result.warnings.length > 0 && ( {result.warnings.length > 0 && (
<Card className="bg-surface-raised border-orange-500/20"> <Card className="bg-surface-raised border-orange-500/20">
<CardHeader className="px-4 pt-3 pb-0"> <CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5"> <CardTitle className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" /> <AlertTriangle className="w-3.5 h-3.5" />
/ ({result.warnings.length}) / ({result.warnings.length})
</CardTitle> </CardTitle>
@ -884,8 +986,8 @@ export function GearIdentification() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{result.warnings.map((warning, i) => ( {result.warnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md"> <div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" /> <XCircle className="w-3 h-3 text-orange-600 dark:text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-300">{warning}</span> <span className="text-[11px] text-orange-700 dark:text-orange-300">{warning}</span>
</div> </div>
))} ))}
</div> </div>
@ -897,13 +999,13 @@ export function GearIdentification() {
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-0"> <CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5"> <CardTitle className="text-xs text-label flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-purple-500" /> <Shield className="w-3.5 h-3.5 text-purple-600 dark:text-purple-500" />
AI Rule ( ) AI Rule ( )
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 pt-2"> <CardContent className="px-4 pb-4 pt-2">
{result.gearType === 'trawl' && ( {result.gearType === 'trawl' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap"> <pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 트롤 탐지 조건 (Trawl Detection Rule) {`# 트롤 탐지 조건 (Trawl Detection Rule)
if speed in range(2.0, 5.0) # knots if speed in range(2.0, 5.0) # knots
and trajectory == 'parallel_sweep' # and trajectory == 'parallel_sweep' #
@ -918,7 +1020,7 @@ and speed_sync > 0.92 # 2선 속도 동기화`}
</pre> </pre>
)} )}
{result.gearType === 'gillnet' && ( {result.gearType === 'gillnet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap"> <pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 자망 탐지 조건 (Gillnet Detection Rule) {`# 자망 탐지 조건 (Gillnet Detection Rule)
if speed < 2.0 # knots if speed < 2.0 # knots
and stop_duration > 30 # min and stop_duration > 30 # min
@ -933,7 +1035,7 @@ and sar_vessel_detect == True # SAR 위치 확인
</pre> </pre>
)} )}
{result.gearType === 'purseSeine' && ( {result.gearType === 'purseSeine' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap"> <pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 선망 탐지 조건 (Purse Seine Detection Rule) {`# 선망 탐지 조건 (Purse Seine Detection Rule)
if trajectory == 'circular' # if trajectory == 'circular' #
and speed_change > 5.0 # kt ( ) and speed_change > 5.0 # kt ( )
@ -949,7 +1051,7 @@ and vessel_spacing < 1000 # m
</pre> </pre>
)} )}
{result.gearType === 'setNet' && ( {result.gearType === 'setNet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap"> <pre className="bg-background rounded-lg p-3 text-[10px] text-red-600 dark:text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 정치망 — EEZ 내 중국어선 미허가 어구 {`# 정치망 — EEZ 내 중국어선 미허가 어구
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망) # GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
# #
@ -975,7 +1077,7 @@ and vessel_spacing < 1000 # m
<Card> <Card>
<CardHeader className="px-4 pt-3 pb-0"> <CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5"> <CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-cyan-500" /> <Waves className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-500" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -1002,6 +1104,158 @@ and vessel_spacing < 1000 # m
)} )}
</div> </div>
</div> </div>
{/* 최근 자동탐지 결과 (prediction 기반) */}
<AutoGearDetectionSection
onSelect={applyAutoDetection}
selectedId={autoSelected?.id ?? null}
/>
</div> </div>
); );
} }
// ─── 자동탐지 결과 섹션 ─────────────────
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
CLOSED_SEASON_FISHING: '금어기 조업',
UNREGISTERED_GEAR: '미등록 어구',
GEAR_MISMATCH: '어구 불일치',
MULTIPLE_VIOLATION: '복합 위반',
NORMAL: '정상',
};
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
CLOSED_SEASON_FISHING: 'critical',
UNREGISTERED_GEAR: 'warning',
GEAR_MISMATCH: 'warning',
MULTIPLE_VIOLATION: 'critical',
NORMAL: 'success',
};
const PERMIT_STATUS_LABEL: Record<string, string> = {
PERMITTED: '허가',
UNPERMITTED: '미허가',
UNKNOWN: '확인불가',
};
function AutoGearDetectionSection({
onSelect,
selectedId,
}: {
onSelect: (v: GearDetection) => void;
selectedId: number | null;
}) {
const { t, i18n } = useTranslation('common');
const lang = (i18n.language as 'ko' | 'en') || 'ko';
const [items, setItems] = useState<GearDetection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
setItems(page.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '조회 실패');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<Radar className="w-4 h-4 text-cyan-600 dark:text-cyan-500" />
(prediction, 1 )
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL ·
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-2 py-1.5 text-left">MMSI</th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-right"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-left"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint"> .</td></tr>
)}
{items.map((v) => {
const selected = v.id === selectedId;
return (
<tr
key={v.id}
onClick={() => onSelect(v)}
className={`border-t border-border cursor-pointer transition-colors ${
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
}`}
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
>
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
<td className="px-2 py-1.5 text-center">
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
</Badge>
</td>
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
</td>
<td className="px-2 py-1.5 text-center">
{v.riskLevel ? (
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
{(v.violationCategories ?? []).join(', ') || '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -0,0 +1,391 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Ban, RefreshCw, ExternalLink } from 'lucide-react';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Card, CardContent } from '@shared/components/ui/card';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import {
ILLEGAL_FISHING_CATEGORIES,
listIllegalFishingEvents,
type IllegalFishingCategory,
type IllegalFishingPatternPage,
} from '@/services/illegalFishingPatternApi';
import type { PredictionEvent } from '@/services/event';
import { useSettingsStore } from '@stores/settingsStore';
/**
* event_generator "불법 조업" 3
* .
*
* GEAR_ILLEGAL : G-01 - / G-05 drift / G-06
* EEZ_INTRUSION : 영해 /
* ZONE_DEPARTURE : 특정수역 (risk 40)
*
* (// ) /event-list .
* **READ ** .
*/
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
const DEFAULT_SIZE = 200;
export function IllegalFishingPattern() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [page, setPage] = useState<IllegalFishingPatternPage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [categoryFilter, setCategoryFilter] = useState<IllegalFishingCategory | ''>('');
const [levelFilter, setLevelFilter] = useState<string>('');
const [mmsiFilter, setMmsiFilter] = useState('');
const [selected, setSelected] = useState<PredictionEvent | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await listIllegalFishingEvents({
category: categoryFilter || undefined,
level: levelFilter || undefined,
vesselMmsi: mmsiFilter || undefined,
size: DEFAULT_SIZE,
});
setPage(result);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed'));
} finally {
setLoading(false);
}
}, [categoryFilter, levelFilter, mmsiFilter, t]);
useEffect(() => {
loadData();
}, [loadData]);
const rows = page?.content ?? [];
const levelCount = (code: string) => page?.byLevel?.[code] ?? 0;
const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0;
const cols: DataColumn<PredictionEvent & Record<string, unknown>>[] = useMemo(
() => [
{
key: 'occurredAt',
label: t('illegalPattern.columns.occurredAt'),
width: '140px',
sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
{
key: 'level',
label: t('illegalPattern.columns.level'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'category',
label: t('illegalPattern.columns.category'),
width: '130px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent="info" size="sm">
{t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })}
</Badge>
),
},
{
key: 'title',
label: t('illegalPattern.columns.title'),
minWidth: '260px',
render: (v) => <span className="text-label">{v as string}</span>,
},
{
key: 'vesselMmsi',
label: t('illegalPattern.columns.mmsi'),
width: '110px',
render: (v, row) => (
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
{(v as string) || '-'}
{row.vesselName ? <span className="text-hint ml-1">({row.vesselName})</span> : null}
</span>
),
},
{
key: 'zoneCode',
label: t('illegalPattern.columns.zone'),
width: '130px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
{
key: 'status',
label: t('illegalPattern.columns.status'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={(v as string) === 'NEW' ? 'warning' : 'muted'} size="sm">
{t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })}
</Badge>
),
},
],
[t, tc, lang],
);
return (
<PageContainer>
<PageHeader
icon={Ban}
iconColor="text-red-600 dark:text-red-400"
title={t('illegalPattern.title')}
description={t('illegalPattern.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('illegalPattern.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('illegalPattern.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('illegalPattern.stats.total')} value={page?.content.length ?? 0} />
<StatCard
label={getAlertLevelLabel('CRITICAL', tc, lang)}
value={levelCount('CRITICAL')}
intent="critical"
/>
<StatCard
label={getAlertLevelLabel('HIGH', tc, lang)}
value={levelCount('HIGH')}
intent="warning"
/>
<StatCard
label={getAlertLevelLabel('MEDIUM', tc, lang)}
value={levelCount('MEDIUM')}
intent="info"
/>
<StatCard
label={getAlertLevelLabel('LOW', tc, lang)}
value={levelCount('LOW')}
intent="muted"
/>
</div>
</Section>
<Section title={t('illegalPattern.byCategory.title')}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{ILLEGAL_FISHING_CATEGORIES.map((code) => (
<Card key={code} variant="default">
<CardContent className="py-2 px-3 flex items-center justify-between">
<div>
<div className="text-xs text-heading">
{t(`illegalPattern.category.${code}`)}
</div>
<div className="text-[10px] text-hint">
{t(`illegalPattern.categoryDesc.${code}`)}
</div>
</div>
<div className="text-lg font-bold text-heading">{categoryCount(code)}</div>
</CardContent>
</Card>
))}
</div>
</Section>
<Section title={t('illegalPattern.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('illegalPattern.filters.category')}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as IllegalFishingCategory | '')}
>
<option value="">{t('illegalPattern.filters.allCategory')}</option>
{ILLEGAL_FISHING_CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`illegalPattern.category.${c}`)}
</option>
))}
</Select>
<Select
aria-label={t('illegalPattern.filters.level')}
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
>
<option value="">{t('illegalPattern.filters.allLevel')}</option>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{getAlertLevelLabel(l, tc, lang)}
</option>
))}
</Select>
<Input
aria-label={t('illegalPattern.filters.mmsi')}
placeholder={t('illegalPattern.filters.mmsi')}
value={mmsiFilter}
onChange={(e) => setMmsiFilter(e.target.value)}
/>
<div className="flex items-center justify-end gap-1.5">
<Badge intent="info" size="sm">
{t('illegalPattern.filters.limit')} · {DEFAULT_SIZE}
</Badge>
</div>
</div>
{rows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">{t('illegalPattern.list.empty')}</p>
) : (
<DataTable
data={rows as (PredictionEvent & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as PredictionEvent)}
/>
)}
</Section>
{selected && (
<Section title={t('illegalPattern.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
<DetailRow
label={t('illegalPattern.columns.occurredAt')}
value={formatDateTime(selected.occurredAt)}
/>
<DetailRow label={t('illegalPattern.columns.category')} value={selected.category} />
<DetailRow label={t('illegalPattern.columns.level')} value={selected.level} />
<DetailRow label={t('illegalPattern.columns.title')} value={selected.title} />
<DetailRow
label={t('illegalPattern.columns.mmsi')}
value={selected.vesselMmsi ?? '-'}
mono
/>
<DetailRow
label={t('illegalPattern.detail.vesselName')}
value={selected.vesselName ?? '-'}
/>
<DetailRow
label={t('illegalPattern.columns.zone')}
value={selected.zoneCode ?? '-'}
/>
<DetailRow
label={t('illegalPattern.detail.location')}
value={
selected.lat != null && selected.lon != null
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
: '-'
}
/>
<DetailRow label={t('illegalPattern.columns.status')} value={selected.status} />
</div>
<div className="space-y-2">
{selected.detail && (
<p className="text-xs text-label border-l-2 border-border pl-2">
{selected.detail}
</p>
)}
{selected.features && Object.keys(selected.features).length > 0 && (
<div>
<div className="text-[10px] text-hint mb-1">
{t('illegalPattern.detail.features')}
</div>
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
{JSON.stringify(selected.features, null, 2)}
</pre>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
{t('illegalPattern.detail.close')}
</Button>
<Button
variant="primary"
size="sm"
icon={<ExternalLink className="w-3.5 h-3.5" />}
onClick={() => {
window.location.href = `/event-list?category=${selected.category}&mmsi=${selected.vesselMmsi ?? ''}`;
}}
>
{t('illegalPattern.detail.openEventList')}
</Button>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-start gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default IllegalFishingPattern;

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, MapPin } from 'lucide-react'; import { Loader2, RefreshCw, MapPin } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import type { BadgeIntent } from '@lib/theme/variants';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes'; import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses'; import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
@ -9,9 +12,9 @@ import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
/** /**
* iran / . * prediction / .
* - GET /api/vessel-analysis/groups * - GET /api/vessel-analysis/groups
* - DB의 ParentResolution * - DB의 ParentResolution
*/ */
export function RealGearGroups() { export function RealGearGroups() {
@ -54,7 +57,7 @@ export function RealGearGroups() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-sm font-bold text-heading flex items-center gap-2"> <div className="text-sm font-bold text-heading flex items-center gap-2">
<MapPin className="w-4 h-4 text-orange-400" /> / (iran ) <MapPin className="w-4 h-4 text-orange-400" /> /
{!available && <Badge intent="critical" size="sm"></Badge>} {!available && <Badge intent="critical" size="sm"></Badge>}
</div> </div>
<div className="text-[10px] text-hint mt-0.5"> <div className="text-[10px] text-hint mt-0.5">
@ -62,29 +65,37 @@ export function RealGearGroups() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)} <Select
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading"> size="sm"
aria-label={tc('aria.groupTypeFilter')}
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
>
<option value=""></option> <option value=""></option>
<option value="FLEET">FLEET</option> <option value="FLEET">FLEET</option>
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option> <option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option> <option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
</select> </Select>
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침"> <Button
<RefreshCw className="w-3.5 h-3.5" /> variant="ghost"
</button> size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div> </div>
</div> </div>
{/* 통계 */} {/* 통계 */}
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
<StatBox label="총 그룹" value={stats.total} color="text-heading" /> <StatBox label="총 그룹" value={stats.total} intent="muted" />
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" /> <StatBox label="FLEET" value={stats.fleet} intent="info" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" /> <StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" /> <StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" /> <StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
</div> </div>
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
{!loading && ( {!loading && (
@ -142,11 +153,22 @@ export function RealGearGroups() {
); );
} }
function StatBox({ label, value, color }: { label: string; value: number; color: string }) { const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
critical: 'text-red-600 dark:text-red-400',
high: 'text-orange-600 dark:text-orange-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
muted: 'text-heading',
purple: 'text-purple-600 dark:text-purple-400',
cyan: 'text-cyan-600 dark:text-cyan-400',
};
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number; intent?: BadgeIntent }) {
return ( return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay"> <div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div> <div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value}</div> <div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{value}</div>
</div> </div>
); );
} }

파일 보기

@ -1,28 +1,39 @@
import { useEffect, useState, useCallback, useMemo } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react'; import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import type { BadgeIntent } from '@lib/theme/variants';
import { getAlertLevelIntent } from '@shared/constants/alertLevels'; import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import { import {
fetchVesselAnalysis, getAnalysisVessels,
type VesselAnalysisItem, getDarkVessels,
type VesselAnalysisStats, getTransshipSuspects,
} from '@/services/vesselAnalysisApi'; } from '@/services/analysisApi';
import { toVesselItem } from '@/services/analysisAdapter';
/** /**
* iran vessel analysis . * vessel_analysis_results .
* - mode: 'dark' (Dark Vessel만) / 'spoofing' ( ) / 'transship' () / 'all' * - mode: 'dark' (Dark Vessel만) / 'spoofing' ( ) / 'transship' () / 'all'
* - + * - +
*/ */
interface Props { interface Props {
mode: 'dark' | 'spoofing' | 'transship' | 'all'; mode: 'dark' | 'spoofing' | 'transship' | 'all';
title: string; title: string;
icon?: React.ReactNode; icon?: React.ReactNode;
/** 'all' / 'spoofing' mode 에서 MMSI prefix 필터 (예: '412' — 중국 선박 한정) */
mmsiPrefix?: string;
/** 'all' / 'spoofing' mode 에서 서버 측 최소 riskScore 필터 */
minRiskScore?: number;
/** 서버 조회 건수 (dark/transship 기본 200) */
size?: number;
} }
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
const ZONE_LABELS: Record<string, string> = { const ZONE_LABELS: Record<string, string> = {
TERRITORIAL_SEA: '영해', TERRITORIAL_SEA: '영해',
CONTIGUOUS_ZONE: '접속수역', CONTIGUOUS_ZONE: '접속수역',
@ -33,9 +44,17 @@ const ZONE_LABELS: Record<string, string> = {
ZONE_IV: '특정해역 IV', ZONE_IV: '특정해역 IV',
}; };
export function RealVesselAnalysis({ mode, title, icon }: Props) { const ENDPOINT_LABEL: Record<Props['mode'], string> = {
all: 'GET /api/analysis/vessels',
dark: 'GET /api/analysis/dark',
transship: 'GET /api/analysis/transship',
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
};
export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore, size = 200 }: Props) {
const { t, i18n } = useTranslation('common');
const lang = (i18n.language as 'ko' | 'en') || 'ko';
const [items, setItems] = useState<VesselAnalysisItem[]>([]); const [items, setItems] = useState<VesselAnalysisItem[]>([]);
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
const [available, setAvailable] = useState(true); const [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -44,24 +63,27 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const res = await fetchVesselAnalysis(); const page = mode === 'dark'
setItems(res.items); ? await getDarkVessels({ hours: 1, size })
setStats(res.stats); : mode === 'transship'
setAvailable(res.serviceAvailable); ? await getTransshipSuspects({ hours: 1, size })
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
setItems(page.content.map(toVesselItem));
setAvailable(true);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown'); setError(e instanceof Error ? e.message : 'unknown');
setAvailable(false);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [mode, mmsiPrefix, minRiskScore, size]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = items; let result = items;
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark); // spoofing 은 /analysis/vessels 결과를 클라에서 임계값 필터
else if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3); if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
else if (mode === 'transship') result = result.filter((i) => i.algorithms.transship.isSuspect);
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter); if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
return result; return result;
}, [items, mode, zoneFilter]); }, [items, mode, zoneFilter]);
@ -71,6 +93,20 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
[filtered], [filtered],
); );
// 통계 카드: mode 로 필터된 items 기반으로 집계해야 상단 숫자와 하단 리스트가 정합
const stats = useMemo(() => {
const modeFilteredItems = mode === 'spoofing'
? items.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3)
: items;
return {
total: modeFilteredItems.length,
criticalCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length,
highCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'HIGH').length,
mediumCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length,
darkCount: modeFilteredItems.filter((i) => i.algorithms.darkVessel.isDark).length,
};
}, [items, mode]);
return ( return (
<Card> <Card>
<CardContent className="p-4 space-y-3"> <CardContent className="p-4 space-y-3">
@ -81,37 +117,42 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
{!available && <Badge intent="critical" size="sm"></Badge>} {!available && <Badge intent="critical" size="sm"></Badge>}
</div> </div>
<div className="text-[10px] text-hint mt-0.5"> <div className="text-[10px] text-hint mt-0.5">
GET /api/vessel-analysis · iran {ENDPOINT_LABEL[mode]} · prediction 5
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)} <Select
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading"> size="sm"
aria-label={t('aria.regionFilter')}
value={zoneFilter}
onChange={(e) => setZoneFilter(e.target.value)}
>
<option value=""> </option> <option value=""> </option>
<option value="TERRITORIAL_SEA"></option> <option value="TERRITORIAL_SEA"></option>
<option value="CONTIGUOUS_ZONE"></option> <option value="CONTIGUOUS_ZONE"></option>
<option value="EEZ_OR_BEYOND">EEZ </option> <option value="EEZ_OR_BEYOND">EEZ </option>
</select> </Select>
<button type="button" onClick={load} <Button
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침"> variant="ghost"
<RefreshCw className="w-3.5 h-3.5" /> size="sm"
</button> onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div> </div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
{stats && (
<div className="grid grid-cols-6 gap-2"> <div className="grid grid-cols-6 gap-2">
<StatBox label="전체" value={stats.total} color="text-heading" /> <StatBox label="전체" value={stats.total} intent="muted" />
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" /> <StatBox label="CRITICAL" value={stats.criticalCount} intent="critical" />
<StatBox label="HIGH" value={stats.high} color="text-orange-400" /> <StatBox label="HIGH" value={stats.highCount} intent="high" />
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" /> <StatBox label="MEDIUM" value={stats.mediumCount} intent="warning" />
<StatBox label="Dark" value={stats.dark} color="text-purple-400" /> <StatBox label="Dark" value={stats.darkCount} intent="purple" />
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" /> <StatBox label="필터링" value={filtered.length} intent="cyan" />
</div> </div>
)}
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && ( {!loading && (
@ -137,10 +178,12 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
)} )}
{sortedByRisk.slice(0, 100).map((v) => ( {sortedByRisk.slice(0, 100).map((v) => (
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50"> <tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td> <td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium"> <td className="px-2 py-1.5 text-heading font-medium">
{v.classification.vesselType} {getVesselTypeLabel(v.classification.vesselType, t, lang)}
{v.classification.confidence > 0 && (
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span> <span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
)}
</td> </td>
<td className="px-2 py-1.5 text-center"> <td className="px-2 py-1.5 text-center">
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm"> <Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
@ -160,7 +203,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
</td> </td>
<td className="px-2 py-1.5 text-right"> <td className="px-2 py-1.5 text-right">
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? ( {v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span> <span className="text-orange-600 dark:text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
) : <span className="text-hint">-</span>} ) : <span className="text-hint">-</span>}
</td> </td>
<td className="px-2 py-1.5 text-center"> <td className="px-2 py-1.5 text-center">
@ -187,11 +230,22 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
); );
} }
function StatBox({ label, value, color }: { label: string; value: number; color: string }) { const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
critical: 'text-red-600 dark:text-red-400',
high: 'text-orange-600 dark:text-orange-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
muted: 'text-heading',
purple: 'text-purple-600 dark:text-purple-400',
cyan: 'text-cyan-600 dark:text-cyan-400',
};
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number | undefined; intent?: BadgeIntent }) {
return ( return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay"> <div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div> <div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div> <div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{(value ?? 0).toLocaleString()}</div>
</div> </div>
); );
} }
@ -200,4 +254,12 @@ function StatBox({ label, value, color }: { label: string; value: number; color:
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />; export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />; export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />; export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
export const RealAllVessels = () => <RealVesselAnalysis mode="all" title="전체 분석 결과 (실시간)" icon={<Radar className="w-4 h-4 text-blue-400" />} />; // 중국 선박 감시 페이지 전용 — MMSI prefix 412 고정
export const RealAllVessels = () => (
<RealVesselAnalysis
mode="all"
title="중국 선박 전체 분석 결과 (실시간)"
icon={<Radar className="w-4 h-4 text-blue-400" />}
mmsiPrefix="412"
/>
);

파일 보기

@ -0,0 +1,405 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeftRight, RefreshCw } from 'lucide-react';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Card, CardContent } from '@shared/components/ui/card';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
import { useSettingsStore } from '@stores/settingsStore';
/**
* (Transshipment) .
*
* prediction `algorithms/transshipment.py` 5
* (transship_suspect=true) ·· .
*
* features.transship_tier (CRITICAL/HIGH/MEDIUM) transship_score .
* `features/vessel/TransferDetection.tsx` ,
* · .
*/
const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const;
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
const DEFAULT_HOURS = 6;
const DEFAULT_SIZE = 200;
type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string;
interface TransshipFeatures {
transship_tier?: TransshipTier;
transship_score?: number;
dark_tier?: string;
[key: string]: unknown;
}
function readTier(row: VesselAnalysis): string {
const f = (row.features ?? {}) as TransshipFeatures;
return f.transship_tier ?? '-';
}
function readScore(row: VesselAnalysis): number | null {
const f = (row.features ?? {}) as TransshipFeatures;
return typeof f.transship_score === 'number' ? f.transship_score : null;
}
export function TransshipmentDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [rows, setRows] = useState<VesselAnalysis[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
const [levelFilter, setLevelFilter] = useState('');
const [mmsiFilter, setMmsiFilter] = useState('');
const [selected, setSelected] = useState<VesselAnalysis | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE });
setRows(resp.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed'));
} finally {
setLoading(false);
}
}, [hours, t]);
useEffect(() => {
loadData();
}, [loadData]);
const filteredRows = useMemo(() => {
return rows.filter((r) => {
if (levelFilter && r.riskLevel !== levelFilter) return false;
if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false;
return true;
});
}, [rows, levelFilter, mmsiFilter]);
const stats = useMemo(() => {
const byLevel: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
const byTier: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 };
for (const r of rows) {
if (r.riskLevel && byLevel[r.riskLevel] !== undefined) {
byLevel[r.riskLevel]++;
}
const tier = readTier(r);
if (byTier[tier] !== undefined) {
byTier[tier]++;
}
}
return { byLevel, byTier };
}, [rows]);
const cols: DataColumn<VesselAnalysis & Record<string, unknown>>[] = useMemo(
() => [
{
key: 'analyzedAt',
label: t('transshipment.columns.analyzedAt'),
width: '140px',
sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
{
key: 'mmsi',
label: t('transshipment.columns.mmsi'),
width: '120px',
sortable: true,
render: (v) => (
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
{v as string}
</span>
),
},
{
key: 'transshipPairMmsi',
label: t('transshipment.columns.pairMmsi'),
width: '120px',
render: (v) => (
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
),
},
{
key: 'transshipDurationMin',
label: t('transshipment.columns.durationMin'),
width: '90px',
align: 'right',
sortable: true,
render: (v) => {
const n = typeof v === 'number' ? v : Number(v ?? 0);
return <span className="font-mono text-label">{n.toFixed(0)}</span>;
},
},
{
key: 'features',
label: t('transshipment.columns.tier'),
width: '90px',
align: 'center',
render: (_, row) => {
const tier = readTier(row);
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
return (
<Badge
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
size="sm"
>
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
</Badge>
);
},
},
{
key: 'riskScore',
label: t('transshipment.columns.riskScore'),
width: '80px',
align: 'right',
sortable: true,
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
},
{
key: 'riskLevel',
label: t('transshipment.columns.riskLevel'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'zoneCode',
label: t('transshipment.columns.zone'),
width: '130px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
],
[t, tc, lang],
);
return (
<PageContainer>
<PageHeader
icon={ArrowLeftRight}
iconColor="text-purple-600 dark:text-purple-400"
title={t('transshipment.title')}
description={t('transshipment.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('transshipment.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('transshipment.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
</div>
</Section>
<Section title={t('transshipment.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('transshipment.filters.hours')}
value={String(hours)}
onChange={(e) => setHours(Number(e.target.value))}
>
{HOUR_OPTIONS.map((h) => (
<option key={h} value={h}>
{t('transshipment.filters.hoursValue', { h })}
</option>
))}
</Select>
<Select
aria-label={t('transshipment.filters.level')}
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
>
<option value="">{t('transshipment.filters.allLevel')}</option>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{getAlertLevelLabel(l, tc, lang)}
</option>
))}
</Select>
<Input
aria-label={t('transshipment.filters.mmsi')}
placeholder={t('transshipment.filters.mmsi')}
value={mmsiFilter}
onChange={(e) => setMmsiFilter(e.target.value)}
/>
<div className="flex items-center justify-end gap-1.5">
<Badge intent="info" size="sm">
{filteredRows.length} / {rows.length}
</Badge>
</div>
</div>
{filteredRows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">
{t('transshipment.list.empty', { hours })}
</p>
) : (
<DataTable
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as VesselAnalysis)}
/>
)}
</Section>
{selected && (
<Section title={t('transshipment.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
<DetailRow
label={t('transshipment.columns.analyzedAt')}
value={formatDateTime(selected.analyzedAt)}
/>
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
<DetailRow
label={t('transshipment.columns.pairMmsi')}
value={selected.transshipPairMmsi ?? '-'}
mono
/>
<DetailRow
label={t('transshipment.columns.durationMin')}
value={`${selected.transshipDurationMin ?? 0}`}
/>
<DetailRow
label={t('transshipment.columns.riskScore')}
value={String(selected.riskScore ?? 0)}
/>
<DetailRow
label={t('transshipment.columns.zone')}
value={selected.zoneCode ?? '-'}
/>
<DetailRow
label={t('transshipment.detail.location')}
value={
selected.lat != null && selected.lon != null
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
: '-'
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-label">
{t('transshipment.columns.tier')}:
</span>
{(() => {
const tier = readTier(selected);
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
return (
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
</Badge>
);
})()}
<span className="text-xs text-label ml-3">
{t('transshipment.detail.transshipScore')}:
</span>
<span className="text-xs font-mono text-heading">
{readScore(selected)?.toFixed(1) ?? '-'}
</span>
</div>
{selected.features && Object.keys(selected.features).length > 0 && (
<div>
<div className="text-[10px] text-hint mb-1">
{t('transshipment.detail.features')}
</div>
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
{JSON.stringify(selected.features, null, 2)}
</pre>
</div>
)}
<div className="flex justify-end pt-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
{t('transshipment.detail.close')}
</Button>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint text-center">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-start gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default TransshipmentDetection;

파일 보기

@ -0,0 +1,193 @@
/**
* DarkDetailPanel Dark Vessel
*
* .
* , , GAP , .
*/
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
import { buildScoreBreakdown } from '@shared/constants/darkVesselPatterns';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
import { X, Ship, MapPin, Clock, AlertTriangle, TrendingUp, ExternalLink, ShieldAlert } from 'lucide-react';
import { BarChart as EcBarChart } from '@lib/charts';
interface DarkDetailPanelProps {
vessel: VesselAnalysis | null;
onClose: () => void;
}
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
const darkTier = (features.dark_tier as string) ?? 'NONE';
const darkScore = (features.dark_suspicion_score as number) ?? 0;
const darkPatterns = (features.dark_patterns as string[]) ?? [];
const darkHistory7d = (features.dark_history_7d as number) ?? 0;
const darkHistory24h = (features.dark_history_24h as number) ?? 0;
const gapStartLat = features.gap_start_lat as number | undefined;
const gapStartLon = features.gap_start_lon as number | undefined;
const gapStartSog = features.gap_start_sog as number | undefined;
const gapStartState = features.gap_start_state as string | undefined;
// 점수 산출 내역
const breakdown = useMemo(() => buildScoreBreakdown(darkPatterns), [darkPatterns]);
// 7일 이력 조회
const loadHistory = useCallback(async () => {
if (!vessel?.mmsi) return;
try {
const res = await getAnalysisHistory(vessel.mmsi, 168); // 7일
setHistory(res);
} catch { setHistory([]); }
}, [vessel?.mmsi]);
useEffect(() => { loadHistory(); }, [loadHistory]);
// 일별 dark 건수 집계 (차트용)
const dailyDarkData = useMemo(() => {
const dayMap: Record<string, number> = {};
for (const h of history) {
if (!h.isDark) continue;
const day = (h.analyzedAt ?? '').slice(0, 10);
if (day) dayMap[day] = (dayMap[day] || 0) + 1;
}
return Object.entries(dayMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, count]) => ({ name: day.slice(5), value: count }));
}, [history]);
if (!vessel) return null;
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="font-bold text-heading text-sm"> </span>
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" />
</button>
</div>
<div className="p-4 space-y-4">
{/* 선박 기본 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline text-right font-mono"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
</button>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.vesselType || 'UNKNOWN'}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.mmsi?.startsWith('412') ? 'CN (중국)' : vessel.mmsi?.slice(0, 3)}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.zoneCode || '-'}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.activityState || '-'}</span>
<span className="text-hint"></span>
<span className="text-right">
<Badge intent={getRiskIntent(vessel.riskScore ?? 0)} size="sm">
{vessel.riskLevel} ({vessel.riskScore})
</Badge>
</span>
</div>
</div>
{/* 점수 산출 내역 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-label font-medium"> </span>
<span className="text-hint text-[10px]">({breakdown.items.length} )</span>
</div>
<ScoreBreakdown items={breakdown.items} totalScore={darkScore} />
</div>
{/* GAP 상세 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium">GAP </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">GAP </span>
<span className="text-label text-right font-mono">
{vessel.gapDurationMin ? `${vessel.gapDurationMin}분 (${(vessel.gapDurationMin / 60).toFixed(1)}h)` : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono text-[10px]">
{gapStartLat != null ? `${gapStartLat.toFixed(4)}°N ${gapStartLon?.toFixed(4)}°E` : '-'}
</span>
<span className="text-hint"> SOG</span>
<span className="text-label text-right font-mono">
{gapStartSog != null ? `${gapStartSog.toFixed(1)}kn` : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gapStartState || '-'}</span>
<span className="text-hint"></span>
<span className="text-label text-right text-[10px]">{formatDateTime(vessel.analyzedAt)}</span>
</div>
</div>
{/* 과거 이력 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> (7)</span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">7 dark </span>
<span className="text-label text-right font-bold">{darkHistory7d}</span>
<span className="text-hint">24 dark</span>
<span className="text-label text-right">{darkHistory24h}</span>
</div>
{dailyDarkData.length > 0 && (
<div className="h-24 mt-2">
<EcBarChart
data={dailyDarkData}
xKey="name"
series={[{ key: 'value', name: 'Dark 건수', color: '#ef4444' }]}
height={96}
/>
</div>
)}
{dailyDarkData.length === 0 && (
<div className="text-hint text-[10px] text-center py-2"> </div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
<Ship className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="primary" size="sm" className="flex-1"
onClick={() => { /* TODO: 단속 대상 등록 API 연동 */ }}>
<Clock className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More