Compare commits

...

44 커밋

작성자 SHA1 메시지 날짜
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
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
7d101604cc Merge pull request 'release: 2026-04-16.4 (50건 커밋)' (#58) from develop into main 2026-04-16 11:10:45 +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
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
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
103개의 변경된 파일3947개의 추가작업 그리고 2726개의 파일을 삭제

파일 보기

@ -2,6 +2,45 @@
해양경찰청 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]
↑ write
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어

파일 보기

@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- iran 백엔드 분석 데이터 프록시
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`

파일 보기

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

파일 보기

@ -1,17 +1,11 @@
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 lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -28,127 +22,23 @@ import java.util.Map;
@RequiredArgsConstructor
public class AdminStatsController {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
private final AdminStatsService adminStatsService;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
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;
return adminStatsService.auditStats();
}
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
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;
return adminStatsService.accessStats();
}
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
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;
return adminStatsService.loginStats();
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -13,7 +13,6 @@ public class AppProperties {
private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@ -27,11 +26,6 @@ public class AppProperties {
private String baseUrl;
}
@Getter @Setter
public static class IranBackend {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;

파일 보기

@ -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,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,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;
}
}
}

파일 보기

@ -1,10 +1,9 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@ -14,7 +13,7 @@ import java.util.Map;
/**
* Prediction FastAPI 서비스 프록시.
* app.prediction.base-url (기본: http://localhost:8001, 운영: http://192.168.1.19:18092)
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
*
* 엔드포인트:
* GET /api/prediction/health FastAPI /health
@ -30,20 +29,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class PredictionProxyController {
private final AppProperties appProperties;
private final RestClient.Builder restClientBuilder;
private RestClient predictionClient;
@PostConstruct
void init() {
String baseUrl = appProperties.getPrediction().getBaseUrl();
predictionClient = restClientBuilder
.baseUrl(baseUrl != null && !baseUrl.isBlank() ? baseUrl : "http://localhost:8001")
.defaultHeader("Accept", "application/json")
.build();
log.info("PredictionProxyController initialized: baseUrl={}", baseUrl);
}
@Qualifier("predictionRestClient")
private final RestClient predictionClient;
@GetMapping("/health")
public ResponseEntity<?> health() {

파일 보기

@ -13,8 +13,7 @@ import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")

파일 보기

@ -2,6 +2,7 @@ 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;
@ -290,6 +291,7 @@ public class VesselAnalysisGroupService {
/**
* 모선 확정/제외 처리.
*/
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인

파일 보기

@ -1,10 +1,9 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@ -16,10 +15,6 @@ import java.util.Map;
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
*/
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회.
*
* 라우팅:
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
@ -35,19 +30,9 @@ import java.util.Map;
public class VesselAnalysisProxyController {
private final VesselAnalysisGroupService groupService;
private final AppProperties appProperties;
private final RestClient.Builder restClientBuilder;
private RestClient signalBatchClient;
@PostConstruct
void init() {
String sbUrl = appProperties.getSignalBatch().getBaseUrl();
signalBatchClient = restClientBuilder
.baseUrl(sbUrl != null && !sbUrl.isBlank() ? sbUrl : "http://192.168.1.18:18090/signal-batch")
.defaultHeader("Accept", "application/json")
.build();
}
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
@GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
@ -69,7 +54,7 @@ public class VesselAnalysisProxyController {
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups(
@org.springframework.web.bind.annotation.RequestParam(required = false) String groupType
@RequestParam(required = false) String groupType
) {
Map<String, Object> result = groupService.getGroups(groupType);
return ResponseEntity.ok(result);

파일 보기

@ -1,5 +1,6 @@
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.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
@ -48,6 +49,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
@ -87,6 +89,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result());
@ -107,6 +110,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder()
.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 lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
@ -17,7 +14,7 @@ import java.util.List;
@RequiredArgsConstructor
public class AlertController {
private final PredictionAlertRepository alertRepository;
private final AlertService alertService;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -30,10 +27,8 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
return alertService.findByEventId(eventId);
}
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
return alertService.findAll(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;
/**
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
* prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",

파일 보기

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

파일 보기

@ -66,9 +66,6 @@ app:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
signal-batch:
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:

파일 보기

@ -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;

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 |
|---|---|---|---|
| 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` |
## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-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 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 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/venv/` | Python 3.9 가상환경 |
| `/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) |

파일 보기

@ -4,6 +4,52 @@
## [Unreleased]
## [2026-04-17.4]
### 수정
- **백엔드 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` 등록. 재빌드 후 bytecode `RuntimeVisibleParameterAnnotations``@Qualifier` 복사 확인, 운영 기동 `Started KcgAiApplication in 7.333 seconds` 복구
## [2026-04-17.3]
### 문서
- **절대 지침 섹션 추가** — CLAUDE.md 최상단에 "절대 지침(Absolute Rules)" 섹션 신설. (1) 신규 브랜치 생성 전 `git fetch``origin/develop` 대비 뒤처지면 사용자 확인 → `git pull --ff-only` → 분기하는 동기화 절차 명시, (2) `frontend/` 작업 시 `design-system.html` 쇼케이스 규칙 전면 준수(공통 컴포넌트 우선 사용, 인라인/하드코딩 Tailwind 색상·`!important` 금지, 접근성 필수 체크리스트) 요약. 하단 기존 "디자인 시스템 (필수 준수)" 상세 섹션과 연결
## [2026-04-17.2]
### 추가
- **어구 정체성 충돌(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 매핑
### 변경
- **prediction 5분 사이클 안정화**`gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores``target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
### 문서
- **프로젝트 산출문서 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건(`data-sharing-analysis.md` / `next-refactoring.md` / `page-workflow.md`) 제거
## [2026-04-17]
### 변경
- **디자인 시스템 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.*')` 로 일괄 치환. parent-inference / admin / detection / enforcement / vessel / statistics / ai-operations 전 영역. MainLayout 언어 토글은 `title={t('message.switchToEnglish')}` + `aria-label={t('aria.languageToggle')}` 로 정비
- **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` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
- **백엔드 계층 분리** — 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 할당 헬퍼로 치환
### 추가
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
## [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]
### 추가

파일 보기

@ -50,6 +50,7 @@ src/
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
│ │ └── 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)
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
@ -89,20 +90,28 @@ src/
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
│ └── index.ts # 배럴 export
├── shared/components/ # 공유 UI 컴포넌트
│ ├── ui/
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
│ │ └── badge.tsx # Badge(CVA intent/size)
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
│ │ ├── 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/
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
│ ├── Pagination.tsx # 페이지네이션
│ ├── SearchInput.tsx # 검색 입력
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
│ ├── ExcelExport.tsx # 엑셀 다운로드
│ ├── FileUpload.tsx # 파일 업로드
│ ├── PageToolbar.tsx # 페이지 상단 툴바
│ ├── PrintButton.tsx # 인쇄 버튼
│ ├── SaveButton.tsx # 저장 버튼
│ └── NotificationBanner.tsx # 알림 배너
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
├── features/ # 13 도메인 그룹 (31 페이지)
│ ├── dashboard/ # 종합 대시보드 (Dashboard)

파일 보기

@ -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 내 서브 컴포넌트

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

파일 보기

@ -1,7 +1,8 @@
# SFR 요구사항별 화면 사용 가이드
> **문서 작성일:** 2026-04-06
> **시스템 버전:** v0.1.0 (프로토타입)
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
@ -11,7 +12,12 @@
이 문서는 **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)
- 로그인 후 역할에 따른 메뉴 접근 제어
**구현 완료:**
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
**구현 완료 (2026-04-17 기준):**
- ✅ 로그인 화면 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(정부 공인인증서) 인증 연동
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
**보완 필요:**
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
- 🔲 공무원증 기반 인증 연동
- 🔲 인사 시스템 연동 역할 자동 부여
---
@ -83,16 +90,17 @@
- 역할별 접근 가능 메뉴 및 기능 권한 설정
- 사용자 목록 조회 및 역할 할당
**구현 완료:**
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
**구현 완료 (2026-04-17 기준):**
- ✅ 트리 기반 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
- 의심 선박 상세 프로필 및 이동 궤적 조회
- 위험도 등급별 분류 표시
**구현 완료:**
- ✅ 의심 선박 7척 목록/지도 시각화
- ✅ 5가지 행동 패턴 분석 결과 UI
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
- ✅ 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 탐지 엔진 연동
- 🔲 실시간 AIS 데이터 분석 연동
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
- 🔲 SAR(위성영상) 기반 탐지 연동
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
---
@ -398,16 +407,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 해역별 중국 어선 밀집도 분석
- 시계열 활동 패턴 분석
**구현 완료:**
- ✅ 중국 어선 분석 종합 대시보드 UI
- ✅ 지도 기반 활동 현황 시각화
**구현 완료 (2026-04-17 기준):**
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동**`/api/analysis/*` 경유, MMSI prefix `412` 고정
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
**향후 구현 예정:**
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
- 🔲 실시간 데이터 기반 분석 갱신
**보완 필요:**
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
---
@ -426,17 +436,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
- 탐지 이미지 확인
**구현 완료:**
- ✅ 어구 6건 탐지 결과 목록/지도 UI
- ✅ 어구 식별 결정트리 시각화
**구현 완료 (2026-04-17 기준):**
- ✅ **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 영상 분석 연동
- 🔲 탐지 결과 자동 분류 및 알림
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
---
@ -455,17 +465,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 이력 상세 정보 조회 및 검색/필터
- 이력 데이터 엑셀 내보내기
**구현 완료:**
- ✅ 단속 이력 6건 목록/상세 UI
- ✅ AI 매칭 검증 결과 표시
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 이벤트 조회**`/api/events` 페이징/필터/확인(ACK)/상태 변경
- ✅ **단속 이력 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 매칭 검증 엔진 연동
- 🔲 탐지-단속 연계 자동 분석
**보완 필요:**
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
---
@ -487,17 +497,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 함정 배치 현황 요약
- 실시간 경보 알림 표시
**구현 완료:**
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 KPI 카드**`/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
**향후 구현 예정:**
- 🔲 실시간 데이터 연동 (WebSocket 등)
- 🔲 KPI 수치 실시간 갱신
- 🔲 히트맵/타임라인 실시간 업데이트
**보완 필요:**
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
---
@ -516,17 +524,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 경보 처리(확인/대응/종결) 워크플로우
- 경보 발생 이력 조회
**구현 완료:**
- ✅ 경보 등급별 현황판 UI
- ✅ 경보 목록/상세 조회 화면
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 경보 수신**`/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 +551,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 선박/이벤트 클릭 시 상세 정보 팝업
- 지도 확대/축소 및 해역 필터링
**구현 완료:**
- ✅ LiveMap 기반 실시간 감시 지도 UI
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 선박 위치 + 이벤트 마커** — 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 기반 실시간 위치 업데이트
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
**보완 필요:**
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
- 🔲 SAR 위성영상 오버레이
---
@ -601,17 +605,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 기간별/해역별/유형별 필터링
- 통계 데이터 엑셀 내보내기 및 인쇄
**구현 완료:**
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 통계 데이터**`/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
**향후 구현 예정:**
- 🔲 통계 데이터 DB 연동
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
- 🔲 맞춤형 보고서 생성 기능
**보완 필요:**
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
- 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
- 🔲 맞춤형 지표 대시보드 설정
---
@ -743,17 +745,15 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
- 알림 수신자 설정 및 발송
- 알림 전송 결과(성공/실패) 확인
**구현 완료:**
- ✅ 알림 5건 전송 현황 UI
- ✅ 알림 유형별 분류 및 상세 조회
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 알림 이력 실 API 조회**`/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
**향후 구현 예정:**
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
- 🔲 AI 분석 결과 기반 자동 알림 트리거
- 🔲 알림 발송 이력 DB 연동
**보완 필요:**
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
- 🔲 알림 템플릿 엔진
- 🔲 수신자 그룹 관리
---
@ -857,15 +857,27 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
---
## 부록: 현재 시스템 상태 요약
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
| 항목 | 상태 |
|------|------|
| UI 구현 | 모든 SFR 완료 |
| 백엔드 연동 | 미구현 (전체) |
| 데이터 | 시연용 샘플 데이터 |
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |
| **백엔드 연동** | **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 |
| **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
| **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 반영 |

파일 보기

@ -39,6 +39,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
),
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),

파일 보기

@ -282,8 +282,9 @@ export function MainLayout() {
{/* 언어 토글 */}
<button
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"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
@ -338,7 +339,7 @@ export function MainLayout() {
<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" />
<input
aria-label="페이지 내 검색"
aria-label={t('aria.searchInPage')}
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {

파일 보기

@ -94,12 +94,12 @@ export function AccessControl() {
}, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
try {
await unlockUser(userId);
await loadUsers();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
@ -146,15 +146,23 @@ export function AccessControl() {
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center justify-center gap-1">
<button type="button" onClick={() => setAssignTarget(row)}
className="p-1 text-hint hover:text-heading" title="역할 배정">
<UserCog className="w-3 h-3" />
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setAssignTarget(row)}
aria-label="역할 배정"
title="역할 배정"
icon={<UserCog className="w-3 h-3" />}
/>
{row.userSttsCd === 'LOCKED' && (
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
className="p-1 text-hint hover:text-label" title="잠금 해제">
<Key className="w-3 h-3" />
</button>
<Button
variant="ghost"
size="sm"
onClick={() => handleUnlock(row.userId, row.userAcnt)}
aria-label="잠금 해제"
title="잠금 해제"
icon={<Key className="w-3 h-3" />}
/>
)}
</div>
),

파일 보기

@ -341,6 +341,7 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
export function DataHub() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
@ -442,7 +443,7 @@ export function DataHub() {
<div className="flex items-center gap-2">
<div className="relative">
<input
aria-label="수신 현황 기준일"
aria-label={tc('aria.receiptDate')}
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}

파일 보기

@ -124,7 +124,7 @@ export function DataModelVerification() {
<PageContainer>
<PageHeader
icon={ListChecks}
iconColor="text-green-400"
iconColor="text-green-600 dark:text-green-400"
title="데이터 모델 검증"
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
demo
@ -157,7 +157,7 @@ export function DataModelVerification() {
{/* 검증 절차 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ClipboardCheck className="w-4 h-4 text-green-400" />
<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">
@ -168,14 +168,14 @@ export function DataModelVerification() {
)}
<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-400" />
<span className="text-[11px] font-bold text-green-400">{s.phase}</span>
<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-400 mb-2">{s.responsible}</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-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
@ -190,7 +190,7 @@ export function DataModelVerification() {
<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-400" />
<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">
@ -207,7 +207,7 @@ export function DataModelVerification() {
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-purple-400" />
<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">
@ -229,7 +229,7 @@ export function DataModelVerification() {
<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-400" />
<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>
@ -249,7 +249,7 @@ export function DataModelVerification() {
<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-400 font-medium text-[9px]">{c.result}</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>
))}
@ -279,7 +279,7 @@ export function DataModelVerification() {
{tab === 'physical' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-400" />
<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 === '주의') && (
@ -302,7 +302,7 @@ export function DataModelVerification() {
<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-400 font-medium text-[9px]">{c.result}</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>
))}
@ -315,7 +315,7 @@ export function DataModelVerification() {
{tab === 'duplication' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-400" />
<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>
@ -335,7 +335,7 @@ export function DataModelVerification() {
<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-400 font-medium text-[9px]">{c.result}</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>
))}
@ -348,7 +348,7 @@ export function DataModelVerification() {
{tab === 'history' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-blue-400" />
<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>
@ -372,7 +372,7 @@ export function DataModelVerification() {
<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-400 font-bold">{h.issues}</span> : <span className="text-green-400">0</span>}</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>
))}

파일 보기

@ -108,7 +108,7 @@ export function DataRetentionPolicy() {
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-blue-400"
iconColor="text-blue-600 dark:text-blue-400"
title="데이터 보관기간 및 파기 정책"
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
demo
@ -141,14 +141,14 @@ export function DataRetentionPolicy() {
{/* 보관 구조 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Settings className="w-4 h-4 text-blue-400" />
<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-400" />
<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>
@ -184,7 +184,7 @@ export function DataRetentionPolicy() {
['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-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
{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>
@ -213,7 +213,7 @@ export function DataRetentionPolicy() {
<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-400" />
<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>
@ -235,7 +235,7 @@ export function DataRetentionPolicy() {
<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-400 font-bold">{r.period}</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>
@ -273,7 +273,7 @@ export function DataRetentionPolicy() {
{/* 파기 승인 워크플로우 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Trash2 className="w-4 h-4 text-red-400" />
<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">
@ -284,14 +284,14 @@ export function DataRetentionPolicy() {
)}
<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-400" />
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
<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-400 mb-2">{s.responsible}</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-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
@ -305,7 +305,7 @@ export function DataRetentionPolicy() {
{/* 파기 방식 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-red-400" />
<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]">
@ -326,7 +326,7 @@ export function DataRetentionPolicy() {
<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-400 text-[9px] font-medium">{m.recovery}</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>
))}
@ -341,7 +341,7 @@ export function DataRetentionPolicy() {
<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-400" />
<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>
@ -364,7 +364,7 @@ export function DataRetentionPolicy() {
<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-400 font-medium text-[9px]">{e.extendedTo}</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>
@ -375,13 +375,13 @@ export function DataRetentionPolicy() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
<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-400" />
<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>
@ -398,7 +398,7 @@ export function DataRetentionPolicy() {
{tab === 'audit' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-green-400" />
<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>
@ -424,7 +424,7 @@ export function DataRetentionPolicy() {
<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-400 font-mono">{d.volume}</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>

파일 보기

@ -74,6 +74,7 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
@ -265,7 +266,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</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" />
</button>
</div>
@ -275,7 +276,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="알림 제목"
aria-label={tc('aria.noticeTitle')}
value={form.title}
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"
@ -287,7 +288,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
aria-label="알림 내용"
aria-label={tc('aria.noticeContent')}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
@ -343,7 +344,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="시작일"
aria-label={tc('aria.dateFrom')}
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -353,7 +354,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="종료일"
aria-label={tc('aria.dateTo')}
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}

파일 보기

@ -153,7 +153,7 @@ export function PerformanceMonitoring() {
<PageContainer>
<PageHeader
icon={Activity}
iconColor="text-cyan-400"
iconColor="text-cyan-600 dark:text-cyan-400"
title="성능 모니터링"
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
demo
@ -192,7 +192,7 @@ export function PerformanceMonitoring() {
{/* 사용자 그룹별 SLO */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-cyan-400" />
<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>
@ -225,20 +225,20 @@ export function PerformanceMonitoring() {
{/* 성능 영향 최소화 전략 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-amber-400" />
<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-400">{i + 1}</div>
<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-400">: {s.effect}</div>
<div className="text-[9px] text-green-600 dark:text-green-400">: {s.effect}</div>
</div>
</div>
))}
@ -253,7 +253,7 @@ export function PerformanceMonitoring() {
<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-400" />
<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>
@ -273,7 +273,7 @@ export function PerformanceMonitoring() {
{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-400 font-medium">{r.slo}</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>
@ -287,7 +287,7 @@ export function PerformanceMonitoring() {
<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-400" />
<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">
@ -304,8 +304,8 @@ export function PerformanceMonitoring() {
<div className="text-[9px] text-hint">: {s.target}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-green-400 font-bold">{s.current}</span>
{s.met ? <CheckCircle className="w-4 h-4 text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />}
<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>
))}
@ -314,32 +314,32 @@ export function PerformanceMonitoring() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-blue-400" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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>
@ -354,7 +354,7 @@ export function PerformanceMonitoring() {
{/* 동시접속·TPS */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-400" />
<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">
@ -375,7 +375,7 @@ export function PerformanceMonitoring() {
{/* 배치 작업 현황 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-400" />
<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>
@ -396,7 +396,7 @@ export function PerformanceMonitoring() {
<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-400">{j.sla}</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>
@ -408,7 +408,7 @@ export function PerformanceMonitoring() {
{/* 처리 볼륨 산정 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="w-4 h-4 text-cyan-400" />
<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]">
@ -420,12 +420,12 @@ export function PerformanceMonitoring() {
<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-400 mt-1"> 50~80% </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-400 mt-1">NAS 100TB </div>
<div className="text-[9px] text-amber-600 dark:text-amber-400 mt-1">NAS 100TB </div>
</div>
</div>
</CardContent></Card>
@ -438,7 +438,7 @@ export function PerformanceMonitoring() {
<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-400" />
<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">
@ -467,7 +467,7 @@ export function PerformanceMonitoring() {
<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-400">{m.rocAuc}</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>
@ -479,28 +479,28 @@ export function PerformanceMonitoring() {
<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-400" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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-500 shrink-0 mt-0.5" />
<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>
@ -508,7 +508,7 @@ export function PerformanceMonitoring() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-amber-400" />
<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">
@ -551,7 +551,7 @@ export function PerformanceMonitoring() {
{/* 가용성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-400" />
<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]">
@ -570,7 +570,7 @@ export function PerformanceMonitoring() {
<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-400">{a.rto}</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>
@ -583,7 +583,7 @@ export function PerformanceMonitoring() {
{/* 확장성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Server className="w-4 h-4 text-purple-400" />
<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>
@ -613,34 +613,34 @@ export function PerformanceMonitoring() {
<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-400" />
<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-400">99.9%</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-400" />
<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-400"> 60</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-400" />
<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-400"> 10</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-400" />
<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-400">×2</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>

파일 보기

@ -6,6 +6,7 @@ import {
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
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 { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
import { useTranslation } from 'react-i18next';
/**
* (wing ).
@ -45,6 +47,7 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
@ -230,7 +233,7 @@ export function PermissionsPanel() {
await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`);
alert(`${tc('success.permissionUpdated')} (${changes.length})`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
@ -247,7 +250,7 @@ export function PermissionsPanel() {
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} 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();
setEditingColor(null);
} catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.');
alert(tc('message.builtinRoleCannotDelete'));
return;
}
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} 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>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-label hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{error && <div className="text-xs text-heading">: {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>}
@ -378,28 +384,44 @@ export function PermissionsPanel() {
<div className="text-xs text-label font-bold"></div>
<div className="flex items-center gap-1">
{canCreateRole && (
<button type="button" onClick={() => setShowCreate(!showCreate)}
className="p-1 text-hint hover:text-label" title="신규 역할">
<Plus className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreate(!showCreate)}
aria-label="신규 역할"
title="신규 역할"
icon={<Plus className="w-3.5 h-3.5" />}
/>
)}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<button type="button" onClick={handleDeleteRole}
className="p-1 text-hint hover:text-heading" title="역할 삭제">
<Trash2 className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={handleDeleteRole}
aria-label="역할 삭제"
title="역할 삭제"
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
{showCreate && (
<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())}
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)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<Input
aria-label={tc('aria.roleCode')}
size="sm"
value={newRoleCd}
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
/>
<Input
aria-label={tc('aria.roleName')}
size="sm"
value={newRoleNm}
onChange={(e) => setNewRoleNm(e.target.value)}
placeholder={tc('aria.roleName')}
/>
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">

파일 보기

@ -77,6 +77,7 @@ const SYSTEM_SETTINGS = {
export function SystemConfig() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<CodeTab>('areas');
const [query, setQuery] = useState('');
const [majorFilter, setMajorFilter] = useState('');
@ -218,7 +219,7 @@ export function SystemConfig() {
<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" />
<input
aria-label="코드 검색"
aria-label={tc('aria.searchCode')}
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={
@ -233,7 +234,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
aria-label="대분류 필터"
aria-label={tc('aria.categoryFilter')}
value={majorFilter}
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"

파일 보기

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

파일 보기

@ -1,5 +1,7 @@
import { useState } from 'react';
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 { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -44,6 +46,7 @@ const INITIAL_MESSAGES: Message[] = [
export function AIAssistant() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1');
@ -79,7 +82,7 @@ export function AIAssistant() {
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-400"
iconColor="text-green-600 dark:text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
@ -91,7 +94,7 @@ export function AIAssistant() {
<div className="space-y-1">
{SAMPLE_CONVERSATIONS.map(c => (
<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="text-[8px] text-hint mt-0.5">{c.time}</div>
</div>
@ -111,7 +114,7 @@ export function AIAssistant() {
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{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">
<Bot className="w-4 h-4 text-green-400" />
<Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
)}
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
@ -123,7 +126,7 @@ export function AIAssistant() {
{msg.refs && msg.refs.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
{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}
</Badge>
))}
@ -132,7 +135,7 @@ export function AIAssistant() {
</div>
{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">
<User className="w-4 h-4 text-blue-400" />
<User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
)}
</div>
@ -140,17 +143,22 @@ export function AIAssistant() {
</div>
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<input
<Input
aria-label="AI 어시스턴트 질의"
size="md"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
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>

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
@ -57,7 +58,7 @@ const MODELS: 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,
render: (v) => {
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: '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,
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: '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>[] = [
{ 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: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
@ -396,14 +397,14 @@ export function AIModelManagement() {
<PageContainer>
<PageHeader
icon={Brain}
iconColor="text-purple-400"
iconColor="text-purple-600 dark:text-purple-400"
title={t('modelManagement.title')}
description={t('modelManagement.desc')}
demo
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="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>
</div>
}
@ -412,12 +413,12 @@ export function AIModelManagement() {
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: '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: '12min', icon: Clock, color: '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: MODELS.length + '개', icon: GitBranch, color: '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: `${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-600 dark:text-yellow-400', bg: 'bg-yellow-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-600 dark:text-blue-400', bg: 'bg-blue-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-600 dark:text-orange-400', bg: 'bg-orange-500/10' },
].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 className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -454,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="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 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>
</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>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div>
@ -495,7 +496,7 @@ export function AIModelManagement() {
</div>
<div className="text-right">
<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>
</CardContent>
@ -505,7 +506,7 @@ export function AIModelManagement() {
{/* 가중치 합계 */}
<Card>
<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">
{rules.filter((r) => r.enabled).map((r, i) => (
<div key={i}>
@ -564,7 +565,7 @@ export function AIModelManagement() {
{/* 파이프라인 스테이지 */}
<div className="flex gap-2">
{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 (
<div key={stage.stage} className="flex-1 flex items-start gap-2">
<Card className="flex-1 bg-surface-raised border-border">
@ -695,7 +696,7 @@ export function AIModelManagement() {
<div key={kpi.label}>
<div className="flex justify-between text-[10px] mb-1">
<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})`}
</span>
</div>
@ -761,7 +762,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Anchor className="w-4 h-4 text-cyan-400" />
<Anchor className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
5
</CardTitle>
</CardHeader>
@ -781,7 +782,7 @@ export function AIModelManagement() {
{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-400 font-mono mr-2">{g.no}</span>
<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>
@ -790,7 +791,7 @@ export function AIModelManagement() {
<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-400 font-mono text-[9px]">{g.gCodes}</td>
<td className="py-2 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
</tr>
))}
</tbody>
@ -802,7 +803,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="w-4 h-4 text-blue-400" />
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</CardTitle>
<p className="text-[9px] text-hint italic">
@ -815,7 +816,7 @@ export function AIModelManagement() {
<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-400 font-mono font-bold text-sm">{g.no}</span>
<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>
@ -854,7 +855,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="w-4 h-4 text-purple-400" />
<Radio className="w-4 h-4 text-purple-600 dark:text-purple-400" />
AIS
</CardTitle>
</CardHeader>
@ -873,7 +874,7 @@ export function AIModelManagement() {
{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-400 font-mono mr-1">{s.no}</span>
<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>
@ -891,13 +892,13 @@ export function AIModelManagement() {
<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-400 shrink-0 mt-0.5" />
<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-400 font-mono text-[9px]">{s.gCodes}</td>
<td className="py-2.5 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
</tr>
))}
</tbody>
@ -921,8 +922,8 @@ export function AIModelManagement() {
</div>
<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-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-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-600 dark:text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
</div>
</div>
@ -974,7 +975,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<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>
<table className="w-full text-[10px]">
<thead>
@ -991,7 +992,7 @@ export function AIModelManagement() {
<tbody>
{TARGET_VESSELS.map((v) => (
<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-heading font-bold text-right">{v.count}</td>
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
@ -1014,7 +1015,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<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 className="space-y-2">
{ALARM_SEVERITY.map((a) => (
@ -1064,8 +1065,8 @@ export function AIModelManagement() {
</div>
<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-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-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-600 dark:text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
</div>
</div>
@ -1114,7 +1115,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<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
</div>
<table className="w-full text-[10px] table-fixed">
@ -1155,7 +1156,7 @@ export function AIModelManagement() {
<td className="py-1.5">
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
</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-label">{api.desc}</td>
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
@ -1175,7 +1176,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<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
</div>
<div className="space-y-3">
@ -1183,7 +1184,7 @@ export function AIModelManagement() {
<div>
<div className="flex items-center justify-between mb-1">
<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>
<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
@ -1231,7 +1232,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<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 className="space-y-2">
@ -1255,7 +1256,7 @@ export function AIModelManagement() {
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
<div className="flex gap-1 flex-wrap">
{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>
@ -1272,13 +1273,13 @@ export function AIModelManagement() {
<div className="flex gap-3">
{[
{ label: '총 호출', value: '142,856', color: 'text-heading' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-600 dark:text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-600 dark:text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-600 dark:text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-600 dark:text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-600 dark:text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-600 dark:text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-600 dark:text-green-400' },
].map((s) => (
<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>

파일 보기

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -107,6 +108,7 @@ const WORKERS = [
export function MLOpsPage() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('dashboard');
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
const [selectedTmpl, setSelectedTmpl] = useState(0);
@ -116,7 +118,7 @@ export function MLOpsPage() {
<PageContainer>
<PageHeader
icon={Cpu}
iconColor="text-purple-400"
iconColor="text-purple-600 dark:text-purple-400"
title={t('mlops.title')}
description={t('mlops.desc')}
demo
@ -134,7 +136,7 @@ export function MLOpsPage() {
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => (
<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}
</button>
))}
@ -159,7 +161,7 @@ export function MLOpsPage() {
<Badge intent="success" size="sm">DEPLOYED</Badge>
<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-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>
</CardContent></Card>
@ -187,7 +189,7 @@ export function MLOpsPage() {
{TEMPLATES.map((t, 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'}`}>
<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-[8px] text-hint mt-0.5">{t.desc}</div>
</div>
@ -197,7 +199,7 @@ export function MLOpsPage() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<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 className="space-y-2">
{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>
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</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>
@ -261,7 +263,7 @@ export function MLOpsPage() {
<tbody>{DEPLOYS.map((d, i) => (
<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-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"><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>
@ -288,7 +290,7 @@ export function MLOpsPage() {
{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">
<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>
@ -313,15 +315,15 @@ export function MLOpsPage() {
"version": "v2.1.0"
}`} />
<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>
</div>
</CardContent></Card>
<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="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-400 font-bold">23ms</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-600 dark:text-green-400 font-bold">23ms</span></span>
</div>
<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,
@ -353,7 +355,7 @@ export function MLOpsPage() {
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => (
<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>
@ -367,7 +369,7 @@ export function MLOpsPage() {
{LLM_MODELS.map((m, 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'}`}>
<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-[8px] text-hint">{m.sub}</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>
<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>
</div>
<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>
<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>
<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]">
<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 => (
@ -505,7 +507,7 @@ export function MLOpsPage() {
</div>
<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="질의를 입력하세요..." />
<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>
</CardContent></Card>
</div>

파일 보기

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 { LoginError } from '@/services/authApi';
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
@ -105,7 +106,7 @@ export function LoginPage() {
{/* 로고 영역 */}
<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">
<Shield className="w-8 h-8 text-blue-400" />
<Shield className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
@ -122,7 +123,7 @@ export function LoginPage() {
disabled={m.disabled}
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
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'
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
title={m.disabled ? '향후 도입 예정' : ''}
@ -188,16 +189,18 @@ export function LoginPage() {
</div>
{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" />
{error}
</div>
)}
<button
<Button
type="submit"
variant="primary"
size="md"
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 ? (
<>
@ -205,7 +208,7 @@ export function LoginPage() {
{t('button.authenticating')}
</>
) : t('button.login')}
</button>
</Button>
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
@ -215,7 +218,7 @@ export function LoginPage() {
{/* GPKI 인증 (Phase 9 도입 예정) */}
{authMethod === 'gpki' && (
<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-[10px] text-hint mt-1"> (Phase 9)</p>
</div>
@ -224,7 +227,7 @@ export function LoginPage() {
{/* SSO 연동 (Phase 9 도입 예정) */}
{authMethod === 'sso' && (
<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-[10px] text-hint mt-1"> (Phase 9)</p>
</div>

파일 보기

@ -55,7 +55,7 @@ function RiskBar({ value, size = 'default' }: { value: number; size?: 'default'
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
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 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';
return (
<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` }}>
<Icon className="w-4 h-4" style={{ color }} />
</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" />}
{Math.abs(diff)}
</div>
@ -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="text-[8px] text-muted-foreground font-bold mb-1"> </div>
<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)' }} />
<span className="text-[7px] text-red-400"></span>
<span className="text-[7px] text-red-600 dark:text-red-400"></span>
</div>
</div>
{/* 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="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<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>
);
@ -468,8 +468,8 @@ export function Dashboard() {
<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'
}}>{area.risk}</span>
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-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-600 dark:text-green-400" />}
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]"></span>}
</div>
))}
@ -544,7 +544,7 @@ export function Dashboard() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<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>
</CardHeader>
@ -557,19 +557,19 @@ export function Dashboard() {
<div className="text-[8px] text-hint"> {WEATHER_DATA.wind.gust}m/s</div>
</div>
<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-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.wave.period}s</div>
</div>
<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-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.temp.water}°C</div>
</div>
<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-[8px] text-hint"></div>
<div className="text-[8px] text-hint">{WEATHER_DATA.seaState}</div>

파일 보기

@ -1,6 +1,10 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
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 {
Search, Clock, ChevronRight, ChevronLeft, Cloud,
@ -9,6 +13,7 @@ import {
} from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
@ -52,18 +57,29 @@ function deriveVesselStatus(score: number): VesselStatus {
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 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 {
id: String(idx + 1),
mmsi: item.mmsi,
callSign: '-',
channel: '',
source: 'AIS',
name: hasType ? vt : '중국어선',
type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
name,
type,
country: 'China',
status: deriveVesselStatus(score),
riskPct: score,
@ -289,8 +305,8 @@ export function ChinaFishing() {
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
const vesselList: VesselItem[] = useMemo(
() => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
[topVessels],
() => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
[topVessels, tcCommon, lang],
);
// 위험도별 분포 (도넛 차트용) — apiStats 기반
@ -327,22 +343,19 @@ export function ChinaFishing() {
return (
<PageContainer size="sm">
{/* ── 모드 탭 (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) => (
<button type="button"
<TabButton
key={tab.key}
variant="segmented"
active={mode === 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 ${
mode === tab.key
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
}`}
icon={<tab.icon className="w-3.5 h-3.5" />}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</button>
</TabButton>
))}
</div>
</TabBar>
{/* 환적 탐지 모드 */}
{mode === 'transfer' && <TransferView />}
@ -360,7 +373,7 @@ export function ChinaFishing() {
</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 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -368,7 +381,7 @@ export function ChinaFishing() {
</div>
)}
{/* iran 백엔드 실시간 분석 결과 */}
{/* 중국 선박 실시간 분석 결과 */}
<RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */}
@ -377,16 +390,21 @@ export function ChinaFishing() {
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label"> : {formatDateTime(new Date())}</span>
</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="새로고침">
<RotateCcw className="w-3.5 h-3.5" />
</button>
<Button
variant="secondary"
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">
<Search className="w-3.5 h-3.5 text-hint mr-2" />
<input aria-label="해역 또는 해구 번호 검색"
<input
aria-label={tcCommon('aria.searchAreaOrZone')}
placeholder="해역 또는 해구 번호 검색"
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>
@ -444,13 +462,13 @@ export function ChinaFishing() {
<div className="flex items-center justify-around mt-4">
<div>
<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>
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
</div>
<div>
<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>
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
</div>
@ -468,29 +486,32 @@ export function ChinaFishing() {
<span className="text-sm font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<Select
size="sm"
aria-label={tcCommon('aria.areaOfInterestSelect')}
>
<option> A</option>
<option> B</option>
</select>
</Select>
</div>
<p className="text-[9px] text-hint mb-3"> .</p>
<div className="flex items-center gap-4">
<div className="space-y-2 flex-1">
<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-green-400 font-bold ml-auto"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div>
<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-green-400 font-bold ml-auto"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div>
<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-green-400 font-bold ml-auto"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div>
</div>
<CircleGauge
@ -515,30 +536,26 @@ export function ChinaFishing() {
<Card className="bg-surface-raised border-slate-700/30">
<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) => {
const disabled = tab !== '특이운항';
return (
<button type="button"
<TabButton
key={tab}
variant="underline"
active={vesselTab === tab}
onClick={() => !disabled && setVesselTab(tab)}
disabled={disabled}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
vesselTab === tab
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
: disabled
? 'text-hint opacity-50 cursor-not-allowed'
: 'text-hint hover:text-label'
}`}
className="flex-1 justify-center"
>
{tab}
{disabled && (
<Badge intent="warning" size="xs" className="font-normal"></Badge>
)}
</button>
</TabButton>
);
})}
</div>
</TabBar>
{/* 선박 목록 */}
<div className="max-h-[420px] overflow-y-auto">
@ -587,22 +604,20 @@ export function ChinaFishing() {
<Card className="bg-surface-raised border-slate-700/30">
<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) => (
<button type="button"
<TabButton
key={tab}
variant="underline"
active={statsTab === tab}
onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
statsTab === tab
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
className="flex-1 justify-center"
>
{tab}
<Badge intent="warning" size="xs" className="font-normal"></Badge>
</button>
</TabButton>
))}
</div>
</TabBar>
<div className="p-4 flex gap-4">
{/* 월별 통계 - API 미지원, 준비중 안내 */}
@ -647,9 +662,9 @@ export function ChinaFishing() {
{/* 다운로드 버튼 */}
<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>
</CardContent>
</Card>
@ -665,7 +680,7 @@ export function ChinaFishing() {
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex gap-2">
@ -692,11 +707,11 @@ export function ChinaFishing() {
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div>
<div className="flex items-center gap-3">
<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 className="text-[9px] text-muted-foreground"></div>
@ -718,7 +733,7 @@ export function ChinaFishing() {
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div>
<div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => (
@ -726,22 +741,28 @@ export function ChinaFishing() {
key={vts.name}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
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'
}`}
>
<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}
</div>
))}
</div>
<div className="flex justify-between mt-2">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.previous')}
icon={<ChevronLeft className="w-4 h-4" />}
/>
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.next')}
icon={<ChevronRight className="w-4 h-4" />}
/>
</div>
</CardContent>
</Card>

파일 보기

@ -12,6 +12,7 @@ import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { DarkDetailPanel } from './components/DarkDetailPanel';
@ -87,20 +88,23 @@ export function DarkVesselDetection() {
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => {
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,
render: (v) => {
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,
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',
render: (v) => {
const mmsi = v as string;
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}`); }}>
{mmsi}
</button>
@ -115,7 +119,10 @@ export function DarkVesselDetection() {
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
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',
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
@ -251,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 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -262,10 +269,10 @@ export function DarkVesselDetection() {
{/* KPI — tier 기반 */}
<div className="flex gap-2">
{[
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
{ l: '전체', v: tierCounts.total, c: 'text-red-600 dark:text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-600 dark:text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-600 dark:text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-600 dark:text-yellow-400', filter: 'WATCH' },
].map((k) => (
<div key={k.l}
onClick={() => setTierFilter(k.filter)}
@ -303,7 +310,7 @@ export function DarkVesselDetection() {
</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="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>
</div>
</CardContent>

파일 보기

@ -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,6 +2,8 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
@ -143,11 +145,13 @@ function FilterCheckGroup({ label, selected, onChange, options }: {
<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 => (
<label key={o.value} className="flex items-center gap-1.5 text-[10px] text-label cursor-pointer hover:text-heading">
<input type="checkbox" checked={selected.has(o.value)} onChange={() => toggle(o.value)}
className="w-3 h-3 rounded border-border accent-primary" />
{o.label}
</label>
<Checkbox
key={o.value}
checked={selected.has(o.value)}
onChange={() => toggle(o.value)}
label={o.label}
className="w-3 h-3"
/>
))}
</div>
</div>
@ -169,7 +173,7 @@ export function GearDetection() {
{ 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 text-[11px]">{v as string}</span> },
{ key: 'owner', label: '어구 그룹', sortable: true,
render: v => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ 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,
@ -191,7 +195,13 @@ export function GearDetection() {
</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-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
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;
@ -200,13 +210,13 @@ export function GearDetection() {
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-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
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-400' : pct >= 50 ? 'text-yellow-400' : 'text-hint';
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> },
@ -320,7 +330,7 @@ export function GearDetection() {
const mapRef = useRef<MapHandle>(null);
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
// iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
get current() { return mapRef.current?.overlay ?? null; },
}), []);
@ -424,7 +434,7 @@ export function GearDetection() {
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (iran 패턴: 단일 렌더링 경로)
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
useEffect(() => {
if (isReplayActive) return; // replay hook이 overlay 독점
const raf = requestAnimationFrame(() => {
@ -456,11 +466,11 @@ export function GearDetection() {
{!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">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span>
<span>AI - </span>
</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-8 text-muted-foreground">
@ -472,9 +482,9 @@ export function GearDetection() {
<div className="flex gap-2 flex-wrap">
{[
{ l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-400' },
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-600 dark:text-red-400' },
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-600 dark:text-yellow-400' },
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-600 dark:text-green-400' },
].map(k => (
<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>
@ -493,7 +503,7 @@ export function GearDetection() {
{/* 필터 토글 버튼 */}
<div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label="필터 설정"
<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'
@ -510,11 +520,15 @@ export function GearDetection() {
{hasActiveFilter && (
<>
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span>
<button type="button" aria-label="필터 초기화"
<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); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised">
<X className="w-3 h-3" />
</button>
icon={<X className="w-3 h-3" />}
>
</Button>
</>
)}
</div>
@ -547,12 +561,12 @@ export function GearDetection() {
<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="최소 멤버 수"
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="최대 멤버 수"
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>
@ -562,11 +576,15 @@ export function GearDetection() {
{/* 패널 내 초기화 */}
<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 type="button" aria-label="필터 초기화"
<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); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay">
<X className="w-3 h-3" />
</button>
icon={<X className="w-3 h-3" />}
>
</Button>
</div>
</div>
)}
@ -620,8 +638,8 @@ export function GearDetection() {
</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">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span>
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-[10px] text-orange-600 dark:text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span>
</div>
{/* 리플레이 컨트롤러 (활성 시 표시) */}

파일 보기

@ -7,7 +7,8 @@ import {
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
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';
@ -573,7 +574,7 @@ function GearComparisonTable() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<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 )
</CardTitle>
</CardHeader>
@ -593,18 +594,18 @@ function GearComparisonTable() {
</div>
<div className="grid grid-cols-2 gap-3">
<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) => (
<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 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) => (
<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>
@ -687,9 +688,7 @@ export function GearIdentification() {
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
? v.riskLevel
: 'LOW';
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
setResult({
origin: 'china',
@ -716,7 +715,7 @@ export function GearIdentification() {
<div className="flex items-center justify-between">
<div>
<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')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
@ -724,13 +723,14 @@ export function GearIdentification() {
</p>
</div>
<div className="flex items-center gap-2">
<button type="button"
<Button
variant="secondary"
size="sm"
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 ? '레퍼런스 닫기' : '비교 레퍼런스'}
</button>
</Button>
</div>
</div>
@ -743,7 +743,7 @@ export function GearIdentification() {
<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-400">{autoSelected.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>
@ -753,12 +753,14 @@ export function GearIdentification() {
</Badge>
<span className="text-hint ml-2"> . .</span>
</div>
<button type="button"
<Button
variant="ghost"
size="sm"
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
className="text-[10px] text-hint hover:text-heading shrink-0"
className="shrink-0 text-[10px]"
>
</button>
</Button>
</div>
)}
@ -880,19 +882,22 @@ export function GearIdentification() {
{/* 판별 버튼 */}
<div className="flex gap-2">
<button type="button"
<Button
variant="primary"
size="md"
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 type="button"
</Button>
<Button
variant="secondary"
size="md"
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>
@ -952,7 +957,7 @@ export function GearIdentification() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<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})
</CardTitle>
</CardHeader>
@ -960,7 +965,7 @@ export function GearIdentification() {
<div className="space-y-1.5">
{result.reasons.map((reason, i) => (
<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>
</div>
))}
@ -972,7 +977,7 @@ export function GearIdentification() {
{result.warnings.length > 0 && (
<Card className="bg-surface-raised border-orange-500/20">
<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" />
/ ({result.warnings.length})
</CardTitle>
@ -981,8 +986,8 @@ export function GearIdentification() {
<div className="space-y-1.5">
{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">
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-300">{warning}</span>
<XCircle className="w-3 h-3 text-orange-600 dark:text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-700 dark:text-orange-300">{warning}</span>
</div>
))}
</div>
@ -994,13 +999,13 @@ export function GearIdentification() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<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 ( )
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{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)
if speed in range(2.0, 5.0) # knots
and trajectory == 'parallel_sweep' #
@ -1015,7 +1020,7 @@ and speed_sync > 0.92 # 2선 속도 동기화`}
</pre>
)}
{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)
if speed < 2.0 # knots
and stop_duration > 30 # min
@ -1030,7 +1035,7 @@ and sar_vessel_detect == True # SAR 위치 확인
</pre>
)}
{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)
if trajectory == 'circular' #
and speed_change > 5.0 # kt ( )
@ -1046,7 +1051,7 @@ and vessel_spacing < 1000 # m
</pre>
)}
{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 내 중국어선 미허가 어구
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
#
@ -1072,7 +1077,7 @@ and vessel_spacing < 1000 # m
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<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>
</CardHeader>
@ -1166,20 +1171,23 @@ function AutoGearDetectionSection({
<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-500" />
<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 type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<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-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 && (
@ -1214,7 +1222,7 @@ function AutoGearDetectionSection({
}`}
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
>
<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">{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">

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, MapPin } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
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 { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
@ -9,9 +12,9 @@ import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* iran / .
* prediction / .
* - GET /api/vessel-analysis/groups
* - DB의 ParentResolution
* - DB의 ParentResolution
*/
export function RealGearGroups() {
@ -54,7 +57,7 @@ export function RealGearGroups() {
<div className="flex items-center justify-between">
<div>
<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>}
</div>
<div className="text-[10px] text-hint mt-0.5">
@ -62,29 +65,37 @@ export function RealGearGroups() {
</div>
</div>
<div className="flex items-center gap-2">
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<Select
size="sm"
aria-label={tc('aria.groupTypeFilter')}
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
>
<option value=""></option>
<option value="FLEET">FLEET</option>
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
</select>
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</Select>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{/* 통계 */}
<div className="grid grid-cols-5 gap-2">
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
<StatBox label="총 그룹" value={stats.total} intent="muted" />
<StatBox label="FLEET" value={stats.fleet} intent="info" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
<StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
</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 && (
@ -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 (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<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>
);
}

파일 보기

@ -1,8 +1,13 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
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 { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import {
getAnalysisVessels,
@ -21,6 +26,12 @@ interface Props {
mode: 'dark' | 'spoofing' | 'transship' | 'all';
title: string;
icon?: React.ReactNode;
/** 'all' / 'spoofing' mode 에서 MMSI prefix 필터 (예: '412' — 중국 선박 한정) */
mmsiPrefix?: string;
/** 'all' / 'spoofing' mode 에서 서버 측 최소 riskScore 필터 */
minRiskScore?: number;
/** 서버 조회 건수 (dark/transship 기본 200) */
size?: number;
}
const ZONE_LABELS: Record<string, string> = {
@ -40,7 +51,9 @@ const ENDPOINT_LABEL: Record<Props['mode'], string> = {
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
};
export function RealVesselAnalysis({ mode, title, icon }: Props) {
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 [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false);
@ -51,10 +64,10 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
setLoading(true); setError('');
try {
const page = mode === 'dark'
? await getDarkVessels({ hours: 1, size: 200 })
? await getDarkVessels({ hours: 1, size })
: mode === 'transship'
? await getTransshipSuspects({ hours: 1, size: 200 })
: await getAnalysisVessels({ hours: 1, size: 200 });
? await getTransshipSuspects({ hours: 1, size })
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
setItems(page.content.map(toVesselItem));
setAvailable(true);
} catch (e: unknown) {
@ -63,7 +76,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
} finally {
setLoading(false);
}
}, [mode]);
}, [mode, mmsiPrefix, minRiskScore, size]);
useEffect(() => { load(); }, [load]);
@ -108,31 +121,38 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
</div>
</div>
<div className="flex items-center gap-2">
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<Select
size="sm"
aria-label={t('aria.regionFilter')}
value={zoneFilter}
onChange={(e) => setZoneFilter(e.target.value)}
>
<option value=""> </option>
<option value="TERRITORIAL_SEA"></option>
<option value="CONTIGUOUS_ZONE"></option>
<option value="EEZ_OR_BEYOND">EEZ </option>
</select>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</Select>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
<div className="grid grid-cols-6 gap-2">
<StatBox label="전체" value={stats.total} color="text-heading" />
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
<StatBox label="Dark" value={stats.darkCount} color="text-purple-400" />
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
<StatBox label="전체" value={stats.total} intent="muted" />
<StatBox label="CRITICAL" value={stats.criticalCount} intent="critical" />
<StatBox label="HIGH" value={stats.highCount} intent="high" />
<StatBox label="MEDIUM" value={stats.mediumCount} intent="warning" />
<StatBox label="Dark" value={stats.darkCount} intent="purple" />
<StatBox label="필터링" value={filtered.length} intent="cyan" />
</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 && (
@ -158,10 +178,12 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
)}
{sortedByRisk.slice(0, 100).map((v) => (
<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">
{v.classification.vesselType}
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
{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>
)}
</td>
<td className="px-2 py-1.5 text-center">
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
@ -181,7 +203,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
</td>
<td className="px-2 py-1.5 text-right">
{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>}
</td>
<td className="px-2 py-1.5 text-center">
@ -208,11 +230,22 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
);
}
function StatBox({ label, value, color }: { label: string; value: number | undefined; 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 (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{(value ?? 0).toLocaleString()}</div>
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{(value ?? 0).toLocaleString()}</div>
</div>
);
}
@ -221,4 +254,12 @@ function StatBox({ label, value, color }: { label: string; value: number | undef
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 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"
/>
);

파일 보기

@ -6,6 +6,7 @@
*/
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';
@ -24,6 +25,7 @@ interface DarkDetailPanelProps {
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
@ -71,12 +73,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 헤더 */}
<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-400" />
<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="닫기">
<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>
@ -85,12 +87,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 선박 기본 정보 */}
<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-400" />
<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-400 hover:underline text-right font-mono"
<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>
@ -114,7 +116,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 점수 산출 내역 */}
<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-400" />
<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>
@ -124,7 +126,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 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-400" />
<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">
@ -150,7 +152,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 과거 이력 */}
<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-400" />
<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">

파일 보기

@ -68,6 +68,7 @@ interface GearDetailPanelProps {
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
const navigate = useNavigate();
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false);
@ -269,14 +270,14 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 헤더 */}
<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-orange-400" />
<ShieldAlert className="w-4 h-4 text-orange-600 dark:text-orange-400" />
<span className="font-bold text-heading text-sm"> </span>
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
{getZoneCodeLabel(gear.zone, t, lang)}
</Badge>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기">
<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>
@ -286,7 +287,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{gear.gCodes.length > 0 && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium">G코드 </span>
<span className="text-hint text-[10px]"> {gear.gearViolationScore}</span>
</div>
@ -311,7 +312,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 어구 그룹 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<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-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
@ -343,7 +344,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 모선 추론 정보 */}
<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-400" />
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
</div>
@ -351,7 +352,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
<button type="button" className="text-cyan-400 hover:underline"
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
{gear.parentMmsi}
</button>
@ -365,7 +366,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 모선 추론 후보 상세 (Correlation) */}
<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-400" />
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
<span className="text-hint text-[10px]">{correlations.length}</span>
@ -392,7 +393,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
aria-label={`${c.targetMmsi} 리플레이 선택`} />
<button type="button"
className="text-cyan-400 hover:underline font-mono text-[11px]"
className="text-cyan-600 dark:text-cyan-400 hover:underline font-mono text-[11px]"
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
{c.targetMmsi}
</button>
@ -466,9 +467,9 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
{/* 헤더 */}
<div className="flex items-center gap-2 text-xs">
<BarChart3 className="w-3.5 h-3.5 text-purple-400" />
<BarChart3 className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
<span className="text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
</div>
@ -574,7 +575,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{hasPairTrawl && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Users className="w-3.5 h-3.5 text-red-400" />
<Users className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium"> </span>
<Badge intent="critical" size="sm">G-06</Badge>
</div>
@ -582,7 +583,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.pairTrawlPairMmsi ? (
<button type="button" className="text-cyan-400 hover:underline"
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
{gear.pairTrawlPairMmsi}
</button>
@ -614,7 +615,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 위치 + 액션 */}
<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-400" />
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">

파일 보기

@ -5,6 +5,7 @@
* Zustand subscribe DOM React re-render .
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { useGearReplayStore } from '@stores/gearReplayStore';
import { Play, Pause, X } from 'lucide-react';
@ -27,6 +28,7 @@ function formatEpochTime(epochMs: number): string {
}
export function GearReplayController({ onClose }: GearReplayControllerProps) {
const { t: tc } = useTranslation('common');
const play = useGearReplayStore((s) => s.play);
const pause = useGearReplayStore((s) => s.pause);
const seek = useGearReplayStore((s) => s.seek);
@ -133,7 +135,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]"
onClick={handleTrackClick}
role="slider"
aria-label="재생 위치"
aria-label={tc('aria.replayPosition')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(initialPct)}
@ -167,7 +169,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
{/* Close */}
<button
type="button"
aria-label="재생 닫기"
aria-label={tc('aria.replayClose')}
onClick={onClose}
className="shrink-0 p-1 hover:bg-surface-raised rounded"
>

파일 보기

@ -38,14 +38,14 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span>
{segments.length > 0 && (
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
· {segments.length}
{criticalCount > 0 && <span className="text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
{warningCount > 0 && <span className="text-orange-400 ml-0.5">WARNING {warningCount}</span>}
{infoCount > 0 && <span className="text-blue-400 ml-0.5">INFO {infoCount}</span>}
{criticalCount > 0 && <span className="text-red-600 dark:text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
{warningCount > 0 && <span className="text-orange-600 dark:text-orange-400 ml-0.5">WARNING {warningCount}</span>}
{infoCount > 0 && <span className="text-blue-600 dark:text-blue-400 ml-0.5">INFO {infoCount}</span>}
</span>
)}
</div>
@ -55,7 +55,7 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
</div>
{error && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-400">
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-600 dark:text-red-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>{error}</span>
</div>

파일 보기

@ -4,6 +4,7 @@
*/
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PathLayer, ScatterplotLayer } from 'deck.gl';
@ -33,6 +34,7 @@ function fmt(ts: string | number): string {
}
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
const { t: tc } = useTranslation('common');
const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false);
@ -180,7 +182,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Ship className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<Ship className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
{vesselName ?? mmsi}
@ -201,7 +203,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
</div>
</div>
{onClose && (
<button type="button" onClick={onClose} aria-label="미니맵 닫기"
<button type="button" onClick={onClose} aria-label={tc('aria.miniMapClose')}
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
<X className="w-3.5 h-3.5" />
</button>
@ -216,7 +218,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
</div>
)}
{!loading && error && (
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-600 dark:text-red-400 px-3 text-center">
{error}
</div>
)}

파일 보기

@ -12,6 +12,14 @@
* , .
*/
import type { VesselAnalysis } from '@/services/analysisApi';
import type { AlertLevel } from '@shared/constants/alertLevels';
/** riskLevel → 특이운항 패널의 severity/한글 레이블 매핑. */
const HIGH_RISK_LEVEL_META: Partial<Record<AlertLevel, { severity: AnomalyPoint['severity']; label: string }>> = {
CRITICAL: { severity: 'critical', label: '고위험 CRITICAL' },
HIGH: { severity: 'warning', label: '위험 HIGH' },
MEDIUM: { severity: 'info', label: '주의 MEDIUM' },
};
export type AnomalyCategory =
| 'DARK'
@ -113,18 +121,15 @@ export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
severity = bumpSeverity(severity, 'warning');
}
if (v.riskLevel === 'CRITICAL') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`고위험 CRITICAL (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'critical');
} else if (v.riskLevel === 'HIGH') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`위험 HIGH (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'warning');
} else if (v.riskLevel === 'MEDIUM' && (v.riskScore ?? 0) >= 40) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`주의 MEDIUM (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'info');
const levelMeta = HIGH_RISK_LEVEL_META[v.riskLevel as AlertLevel];
if (levelMeta) {
const score = v.riskScore ?? 0;
const include = v.riskLevel === 'MEDIUM' ? score >= 40 : true;
if (include) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`${levelMeta.label} (score ${score})`);
severity = bumpSeverity(severity, levelMeta.severity);
}
}
if (cats.length === 0) return null;

파일 보기

@ -2,3 +2,4 @@ export { DarkVesselDetection } from './DarkVesselDetection';
export { GearDetection } from './GearDetection';
export { ChinaFishing } from './ChinaFishing';
export { GearIdentification } from './GearIdentification';
export { GearCollisionDetection } from './GearCollisionDetection';

파일 보기

@ -131,7 +131,7 @@ export function EventList() {
},
},
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
render: (val) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{val as string}</span>,
},
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
render: (_val, row) => {
@ -140,7 +140,7 @@ export function EventList() {
return (
<button
type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
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}`); }}
>
{mmsi}
@ -171,26 +171,26 @@ export function EventList() {
return (
<div className="flex items-center gap-1">
{isNew && (
<button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label={tc('aria.confirmAction')} title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
<button type="button" aria-label="선박 상세" title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
<button type="button" aria-label={tc('aria.vesselDetail')} title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" />
</button>
{isActionable && (
<>
<button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label={tc('aria.enforcementRegister')} title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-600 dark:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</button>
<button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label={tc('aria.falsePositiveProcess')} title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-600 dark:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />
</button>
@ -317,7 +317,7 @@ export function EventList() {
{/* 에러 표시 */}
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-400">
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-600 dark:text-red-400">
: {error}
</div>
)}
@ -327,7 +327,7 @@ export function EventList() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> </span>
<button type="button" title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
<button type="button" aria-label={tc('aria.close')} title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
@ -343,7 +343,7 @@ export function EventList() {
{/* 로딩 인디케이터 */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-muted-foreground"> ...</span>
</div>
)}

파일 보기

@ -37,7 +37,7 @@ const cols: DataColumn<AlertRow>[] = [
key: 'eventId',
label: '이벤트',
width: '80px',
render: (v) => <span className="text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
},
{
key: 'time',
@ -58,7 +58,7 @@ const cols: DataColumn<AlertRow>[] = [
{
key: 'recipient',
label: '수신 대상',
render: (v) => <span className="text-cyan-400">{v as string}</span>,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400">{v as string}</span>,
},
{
key: 'confidence',
@ -70,7 +70,7 @@ const cols: DataColumn<AlertRow>[] = [
const s = v as string;
if (!s) return <span className="text-hint">-</span>;
const n = parseFloat(s);
const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400';
const color = n > 0.9 ? 'text-red-600 dark:text-red-400' : n > 0.8 ? 'text-orange-600 dark:text-orange-400' : 'text-yellow-600 dark:text-yellow-400';
return <span className={`font-bold ${color}`}>{(n * 100).toFixed(0)}%</span>;
},
},
@ -149,10 +149,10 @@ export function AIAlert() {
if (error) {
return (
<PageContainer>
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
<div className="flex items-center justify-center gap-2 text-red-600 dark:text-red-400 py-8">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-600 dark:text-cyan-400">
</button>
</div>
@ -164,15 +164,15 @@ export function AIAlert() {
<PageContainer>
<PageHeader
icon={Send}
iconColor="text-yellow-400"
iconColor="text-yellow-600 dark:text-yellow-400"
title={t('aiAlert.title')}
description={t('aiAlert.desc')}
/>
<div className="flex gap-2">
{[
{ l: '총 발송', v: totalElements, c: 'text-heading' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-400' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-600 dark:text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-600 dark:text-red-400' },
].map((k) => (
<div
key={k.l}

파일 보기

@ -56,7 +56,7 @@ export function MobileService() {
<PageContainer>
<PageHeader
icon={Smartphone}
iconColor="text-blue-400"
iconColor="text-blue-600 dark:text-blue-400"
title={t('mobileService.title')}
description={t('mobileService.desc')}
/>
@ -70,7 +70,7 @@ export function MobileService() {
<div className="p-3 space-y-2">
{/* 긴급 경보 */}
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
<div className="text-[9px] text-red-400 font-bold">[] EEZ </div>
<div className="text-[9px] text-red-600 dark:text-red-400 font-bold">[] EEZ </div>
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
</div>
{/* 지도 영역 — MapLibre GL */}
@ -125,14 +125,14 @@ export function MobileService() {
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
].map(f => (
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<f.icon className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" /> </div>
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-600 dark:text-yellow-400" /> </div>
<div className="space-y-2">
{PUSH_SETTINGS.map(p => (
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">

파일 보기

@ -88,7 +88,7 @@ export function MonitoringDashboard() {
title={t('monitoring.title')}
description={t('monitoring.desc')}
/>
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
{/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="flex gap-2">

파일 보기

@ -29,7 +29,7 @@ interface AnalysisStatus {
*
* :
* 1. (kcg-ai-backend)
* 2. iran + Prediction ( )
* 2. (prediction) +
* 3. ( )
*/
export function SystemStatusPanel() {
@ -94,10 +94,10 @@ export function SystemStatusPanel() {
]}
/>
{/* iran 백엔드 */}
{/* 분석 엔진 */}
<ServiceCard
icon={<Wifi className="w-4 h-4" />}
title="iran 백엔드 (분석)"
title="AI 분석 엔진"
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusIntent={stats ? 'success' : 'critical'}
details={[

파일 보기

@ -3,6 +3,7 @@ import { Tag, X, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
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 { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -67,7 +68,7 @@ export function LabelSession() {
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -75,13 +76,13 @@ export function LabelSession() {
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm('세션을 취소하시겠습니까?')) return;
if (!confirm(tc('dialog.cancelSession'))) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -98,7 +99,7 @@ export function LabelSession() {
<>
<Select
size="sm"
aria-label="상태 필터"
aria-label={tc('aria.statusFilter')}
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
@ -119,26 +120,29 @@ export function LabelSession() {
<CardContent className="p-4">
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
<Tag className="w-3.5 h-3.5" />
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
{!canCreate && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input aria-label="group_key" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input aria-label="sub_cluster_id" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input aria-label="정답 parent MMSI" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
<Input aria-label={tc('aria.groupKey')} size="sm" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreate} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreate} />
<Input aria-label={tc('aria.correctParentMmsi')} size="sm" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48" disabled={!canCreate} />
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</CardContent>
</Card>
{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-12 text-muted-foreground">
@ -171,7 +175,7 @@ export function LabelSession() {
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2">
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
</td>
@ -180,7 +184,7 @@ export function LabelSession() {
<td className="px-3 py-2 text-center">
{it.status === 'ACTIVE' && (
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-600 dark:text-red-400" title="취소" aria-label="취소">
<X className="w-3.5 h-3.5" />
</button>
)}

파일 보기

@ -3,6 +3,7 @@ import { Ban, RotateCcw, Loader2, Globe, Layers } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
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 { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -14,6 +15,7 @@ import {
type CandidateExclusion,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useTranslation } from 'react-i18next';
/**
* .
@ -26,6 +28,7 @@ import { formatDateTime } from '@shared/utils/dateFormat';
*/
export function ParentExclusion() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE');
const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE');
@ -71,7 +74,7 @@ export function ParentExclusion() {
setGrpKey(''); setGrpMmsi(''); setGrpReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -85,7 +88,7 @@ export function ParentExclusion() {
setGlbMmsi(''); setGlbReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -98,7 +101,7 @@ export function ParentExclusion() {
await releaseExclusion(id, '운영자 해제');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -115,7 +118,7 @@ export function ParentExclusion() {
<>
<Select
size="sm"
aria-label="스코프 필터"
aria-label={tc('aria.scopeFilter')}
title="스코프 필터"
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
@ -136,23 +139,26 @@ export function ParentExclusion() {
<CardContent className="p-4 space-y-2">
<div className="text-xs font-medium text-heading flex items-center gap-2">
<Layers className="w-3.5 h-3.5" /> GROUP ( )
{!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>}
{!canCreateGroup && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input aria-label="group_key" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="sub_cluster_id" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="excluded MMSI" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="제외 사유" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<button type="button" onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-orange-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
<Input aria-label={tc('aria.groupKey')} size="sm" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.exclusionReason')} size="sm" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1" disabled={!canCreateGroup} />
<Button
variant="primary"
size="sm"
onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</CardContent>
</Card>
@ -162,24 +168,27 @@ export function ParentExclusion() {
<CardContent className="p-4 space-y-2">
<div className="text-xs font-medium text-heading flex items-center gap-2">
<Globe className="w-3.5 h-3.5" /> GLOBAL ( , )
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
{!canCreateGlobal && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input aria-label="excluded MMSI (전역)" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<input aria-label="전역 제외 사유" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<button type="button" onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
className="px-3 py-1.5 bg-red-600 hover:bg-red-500 disabled:bg-red-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40" disabled={!canCreateGlobal} />
<Input aria-label={tc('aria.globalExclusionReason')} size="sm" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1" disabled={!canCreateGlobal} />
<Button
variant="destructive"
size="sm"
onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
icon={busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</CardContent>
</Card>
{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-12 text-muted-foreground">
@ -218,13 +227,13 @@ export function ParentExclusion() {
</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey || '-'}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId ?? '-'}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.excludedMmsi}</td>
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-center">
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400" title="해제">
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-600 dark:text-blue-400" title="해제" aria-label="해제">
<RotateCcw className="w-3.5 h-3.5" />
</button>
</td>

파일 보기

@ -3,6 +3,7 @@ import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } from 'lucide-react
import { Card, CardContent } from '@shared/components/ui/card';
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 { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -97,7 +98,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('처리 실패: ' + msg);
alert(tc('error.processFailed', { msg }));
} finally {
setActionLoading(null);
}
@ -117,7 +118,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('등록 실패: ' + msg);
alert(tc('error.registerFailed', { msg }));
} finally {
setActionLoading(null);
}
@ -151,39 +152,40 @@ export function ParentReview() {
<CardContent className="p-4">
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
aria-label="group_key"
type="text"
<Input
aria-label={tc('aria.groupKey')}
size="sm"
value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)}
placeholder="group_key (예: 渔船A)"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
className="flex-1"
/>
<input
aria-label="sub_cluster_id"
<Input
aria-label={tc('aria.subClusterId')}
size="sm"
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
placeholder="sub_cluster_id"
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
className="w-32"
/>
<input
<Input
aria-label="parent MMSI"
type="text"
size="sm"
value={newMmsi}
onChange={(e) => setNewMmsi(e.target.value)}
placeholder="parent MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
className="w-40"
/>
<button
type="button"
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
icon={actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
>
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
</button>
</Button>
</div>
</CardContent>
</Card>
@ -192,7 +194,7 @@ export function ParentReview() {
{!canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-yellow-400">
<div className="text-xs text-yellow-600 dark:text-yellow-400">
(UPDATE ). // .
</div>
</CardContent>
@ -202,7 +204,7 @@ export function ParentReview() {
{error && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-red-400">: {error}</div>
<div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>
</CardContent>
</Card>
)}
@ -247,39 +249,42 @@ export function ParentReview() {
{getParentResolutionLabel(it.status, tc, lang)}
</Badge>
</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">
{formatDateTime(it.updatedAt)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-1">
<button
type="button"
<Button
variant="ghost"
size="sm"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'CONFIRM')}
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
title="확정"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
aria-label="확정"
className="text-green-600 dark:text-green-400 hover:bg-green-500/20"
icon={<CheckCircle className="w-3.5 h-3.5" />}
/>
<Button
variant="ghost"
size="sm"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'REJECT')}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
title="거부"
>
<XCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
aria-label="거부"
className="text-red-600 dark:text-red-400 hover:bg-red-500/20"
icon={<XCircle className="w-3.5 h-3.5" />}
/>
<Button
variant="ghost"
size="sm"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'RESET')}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
title="리셋"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
aria-label="리셋"
className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/20"
icon={<RotateCcw className="w-3.5 h-3.5" />}
/>
</div>
</td>
</tr>

파일 보기

@ -32,6 +32,7 @@ const reports: Report[] = [
export function ReportManagement() {
const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const [selected, setSelected] = useState<Report>(reports[0]);
const [search, setSearch] = useState('');
const [showUpload, setShowUpload] = useState(false);
@ -81,7 +82,7 @@ export function ReportManagement() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span>
<button type="button" aria-label="업로드 패널 닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
<button type="button" aria-label={tc('aria.uploadPanelClose')} onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">
@ -120,8 +121,8 @@ export function ReportManagement() {
</div>
<div className="text-[11px] text-hint mt-1"> {r.evidence}</div>
<div className="flex gap-2 mt-2">
<button type="button" className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button type="button" className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
<Button variant="primary" size="sm">PDF</Button>
<Button variant="secondary" size="sm"></Button>
</div>
</div>
))}
@ -135,9 +136,9 @@ export function ReportManagement() {
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-label"> </div>
<button type="button" className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
<Download className="w-3.5 h-3.5" />
</button>
<Button variant="primary" size="sm" icon={<Download className="w-3.5 h-3.5" />}>
</Button>
</div>
<div className="bg-surface-raised border border-slate-700/40 rounded-xl p-6 space-y-5">

파일 보기

@ -15,7 +15,11 @@ import {
type PredictionEvent,
} from '@/services/event';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
import {
getAlertLevelHex,
getAlertLevelMarkerOpacity,
getAlertLevelMarkerRadius,
} from '@shared/constants/alertLevels';
interface MapEvent {
id: string;
@ -52,7 +56,7 @@ function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' })
<div className={`flex-1 ${h} bg-secondary rounded-full overflow-hidden`}>
<div className={`${h} bg-red-500 rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-medium text-red-400">{value.toFixed(2)}</span>
<span className="text-xs font-medium text-red-600 dark:text-red-400">{value.toFixed(2)}</span>
</div>
);
}
@ -113,7 +117,7 @@ export function LiveMapView() {
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
vesselName: e.vesselName ?? '미상',
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
risk: e.aiConfidence ?? getAlertLevelMarkerOpacity(e.level),
lat: e.lat!,
lng: e.lon!,
level: e.level,
@ -171,7 +175,7 @@ export function LiveMapView() {
lat: v.lat,
lng: v.lng,
color,
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
radius: getAlertLevelMarkerRadius(level),
label: v.item.mmsi,
};
}),
@ -240,7 +244,7 @@ export function LiveMapView() {
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-hint"> ...</span>
</div>
)}
@ -248,8 +252,8 @@ export function LiveMapView() {
{!serviceAvailable && !loading && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
<WifiOff className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-[11px] text-yellow-600 dark:text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1"> .</p>
</div>
@ -274,7 +278,7 @@ export function LiveMapView() {
<IconComp className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold text-heading">{evt.type}</span>
</div>
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-400 transition-colors" />
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-600 dark:hover:text-orange-400 transition-colors" />
</div>
<div className="text-[11px] text-hint mb-2">{evt.mmsi} · {evt.nationality} · {evt.time}</div>
<RiskBar value={evt.risk} size="sm" />
@ -314,7 +318,7 @@ export function LiveMapView() {
{/* 실시간 표시 */}
<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" />
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">LIVE</span>
<span className="text-[9px] text-hint"> {mapEvents.length} · {vesselItems.length}</span>
</div>
</div>
@ -329,7 +333,7 @@ export function LiveMapView() {
<div className="bg-red-950/40 border border-red-900/40 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-red-600/30 rounded-lg flex items-center justify-center">
<Ship className="w-4.5 h-4.5 text-red-400" />
<Ship className="w-4.5 h-4.5 text-red-600 dark:text-red-400" />
</div>
<div>
<div className="text-heading font-bold text-sm">{selectedEvent.vesselName}</div>
@ -344,7 +348,7 @@ export function LiveMapView() {
<CardContent className="p-4">
<div className="text-[10px] text-muted-foreground mb-1"> </div>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-bold text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-3xl font-bold text-red-600 dark:text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-sm text-hint">/100</span>
</div>
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
@ -360,29 +364,29 @@ export function LiveMapView() {
<Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-blue-400" />
<Zap className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-heading font-medium">AI </span>
<Badge intent="critical" size="md">신뢰도: High</Badge>
</div>
<div className="space-y-3">
<div className="border-l-2 border-red-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<AlertTriangle className="w-3 h-3 text-red-400" />
<span className="text-red-400 font-medium">{selectedEvent.type}</span>
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
<span className="text-red-600 dark:text-red-400 font-medium">{selectedEvent.type}</span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.vesselName} ({selectedEvent.mmsi})</div>
</div>
<div className="border-l-2 border-orange-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Activity className="w-3 h-3 text-orange-400" />
<span className="text-orange-400 font-medium"> </span>
<Activity className="w-3 h-3 text-orange-600 dark:text-orange-400" />
<span className="text-orange-600 dark:text-orange-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
</div>
<div className="border-l-2 border-green-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Clock className="w-3 h-3 text-green-400" />
<span className="text-green-400 font-medium"> </span>
<Clock className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-green-600 dark:text-green-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">{selectedEvent.time}</div>
</div>

파일 보기

@ -124,7 +124,7 @@ const NTM_DATA: NtmRecord[] = [
const NTM_CATEGORIES = ['전체', '사격훈련', '군사훈련', '기뢰제거', '해양공사', '항로표지', '항로변경', '해양오염', '수중작업'];
const ntmColumns: DataColumn<NtmRecord>[] = [
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'date', label: '발령일', width: '90px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
render: v => {
@ -146,7 +146,7 @@ const ntmColumns: DataColumn<NtmRecord>[] = [
// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup
const columns: DataColumn<TrainingZone>[] = [
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'sea', label: '해역', width: '60px', sortable: true },
@ -301,7 +301,7 @@ export function MapControl() {
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-600 dark:text-cyan-400 border-cyan-500 dark:border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
@ -310,7 +310,7 @@ export function MapControl() {
<Filter className="w-3.5 h-3.5 text-hint" />
{['', '서해', '남해', '동해', '제주'].map(s => (
<button type="button" key={s} onClick={() => setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
{s || '전체'}
</button>
))}
@ -324,9 +324,9 @@ export function MapControl() {
<div className="flex gap-2">
{[
{ label: '전체 통보', value: NTM_DATA.length, color: 'text-heading' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-400' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-600 dark:text-red-400' },
{ label: '해제', value: NTM_DATA.filter(n => n.status === '해제').length, color: 'text-muted-foreground' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-400' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-600 dark:text-orange-400' },
].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">
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
@ -341,14 +341,14 @@ export function MapControl() {
<span className="text-[10px] text-hint">:</span>
{NTM_CATEGORIES.map(c => (
<button type="button" key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
))}
</div>
{/* 최근 발령 중 통보 하이라이트 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-400" />
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
<div className="space-y-2">
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
@ -411,7 +411,7 @@ export function MapControl() {
</div>
{/* 표시 구역 수 */}
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<span className="text-[10px] text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[9px] text-hint ml-1"> </span>
</div>
</CardContent>

파일 보기

@ -201,11 +201,11 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)}
<input aria-label={tc('aria.queryFrom')} value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
<span className="text-hint text-[10px]">~</span>
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)}
<input aria-label={tc('aria.queryTo')} value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
</div>

파일 보기

@ -161,7 +161,7 @@
{
"id": "api.vessel_analysis",
"label": "GET /api/vessel-analysis",
"shortDescription": "선박 분석 결과 (iran 프록시)",
"shortDescription": "선박 분석 결과 (레거시 경로, 신규는 /api/analysis/*)",
"stage": "API",
"kind": "api",
"trigger": "on_demand",

파일 보기

@ -1,13 +1,13 @@
[
{
"id": "external.iran_backend",
"label": "Iran 백엔드 (레거시)",
"shortDescription": "어구 그룹 read-only 프록시",
"label": "Iran 백엔드 (레거시·미사용)",
"shortDescription": "prediction 직접 연동으로 대체됨",
"stage": "외부",
"kind": "external",
"trigger": "on_demand",
"status": "partial",
"notes": "어구 그룹 read-only proxy (선택적, 향후 자체 prediction으로 대체 예정)"
"status": "deprecated",
"notes": "prediction 이 kcgaidb 에 직접 write 하므로 더 이상 호출하지 않는다. 1~2 릴리즈 후 노드 삭제 예정."
},
{
"id": "external.redis",

파일 보기

@ -30,7 +30,7 @@ export type NodeKind =
| 'api' // 백엔드 API 엔드포인트
| 'ui' // 프론트 화면
| 'decision' // 운영자 의사결정 액션
| 'external'; // 외부 시스템 (iran, GPKI 등)
| 'external'; // 외부 시스템 (GPKI 등)
export type NodeTrigger =
| 'scheduled' // 5분 주기 등 자동

파일 보기

@ -1,7 +1,7 @@
/**
* useGearReplayLayers
*
* iran useReplayLayer.ts :
* ( ):
*
* 1. animationStore rAF set({ currentTime }) (gearReplayStore)
* 2. zustand.subscribe(currentTime) renderFrame()
@ -9,10 +9,10 @@
* - seek/정지: 즉시
* 3. renderFrame() overlay.setProps({ layers })
*
* (iran KCG ):
* - PathLayer: 중심 (gold) iran의 PathLayer에
* - TripsLayer: 멤버 fade trail iran과
* - IconLayer: 멤버 () iran의
* :
* - PathLayer: 중심 (gold)
* - TripsLayer: 멤버 fade trail
* - IconLayer: 멤버 ()
* - PolygonLayer: 현재 ( / )
* - TextLayer: MMSI
*
@ -55,7 +55,7 @@ const SLATE: [number, number, number, number] = [148, 163, 184, 120];
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀
const RENDER_INTERVAL_MS = 100; // ~10fps 쓰로틀
function memberIconColor(m: MemberPosition): [number, number, number, number] {
if (m.stale) return SLATE;
@ -71,7 +71,7 @@ export function useGearReplayLayers(
buildBaseLayers: () => Layer[],
) {
const frameCursorRef = useRef(0);
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
const memberCursorsRef = useRef(new Map<string, number>());
// buildBaseLayers를 최신 참조로 유지
@ -79,7 +79,7 @@ export function useGearReplayLayers(
baseLayersRef.current = buildBaseLayers;
/**
* renderFrame iran의 renderFrame과 :
* renderFrame + + overlay.setProps :
* 1. ()
* 2.
* 3. overlay.setProps({ layers })
@ -106,7 +106,7 @@ export function useGearReplayLayers(
);
frameCursorRef.current = newCursor;
// 멤버 보간 — iran의 getCurrentVesselPositions 패턴:
// 멤버 보간 — getCurrentVesselPositions 패턴:
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
const relativeTime = currentTime - startTime;
const members = interpolateFromTripsData(
@ -121,7 +121,7 @@ export function useGearReplayLayers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replayLayers: any[] = [];
// 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴)
// 1. TripsLayer — 멤버 궤적 fade trail
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
if (memberTripsData.length > 0) {
replayLayers.push(createTripsLayer(
@ -132,7 +132,7 @@ export function useGearReplayLayers(
));
}
// 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응)
// 4. 멤버 현재 위치 IconLayer
const ships = members.filter(m => m.isParent);
const gears = members.filter(m => m.isGear);
const others = members.filter(m => !m.isParent && !m.isGear);
@ -330,13 +330,13 @@ export function useGearReplayLayers(
}
}
// iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
const baseLayers = baseLayersRef.current();
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
}, [overlayRef]);
/**
* currentTime iran useReplayLayer.ts:425~458
* currentTime + seek
*
* 핵심: 재생 pendingRafId로 rAF에
*
@ -354,12 +354,12 @@ export function useGearReplayLayers(
if (!useGearReplayStore.getState().groupKey) return;
const isPlaying = useGearReplayStore.getState().isPlaying;
// seek/정지: 즉시 렌더 (iran:437~439)
// seek/정지: 즉시 렌더
if (!isPlaying) {
renderFrame();
return;
}
// 재생 중: 쓰로틀 + pending rAF (iran:441~451)
// 재생 중: 쓰로틀 + pending rAF
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;

파일 보기

@ -8,6 +8,7 @@
"darkVessel": "Dark Vessel",
"gearDetection": "Gear Detection",
"chinaFishing": "Chinese Vessel",
"gearCollision": "Gear Collision",
"patrolRoute": "Patrol Route",
"fleetOptimization": "Fleet Optimize",
"enforcementHistory": "History",
@ -250,5 +251,84 @@
"statistics": "Statistics",
"aiOps": "AI Ops",
"admin": "Admin"
},
"aria": {
"close": "Close",
"closeDialog": "Close dialog",
"closeNotification": "Close notification",
"edit": "Edit",
"delete": "Delete",
"search": "Search",
"clearSearch": "Clear search",
"searchInPage": "Search in page",
"refresh": "Refresh",
"filter": "Filter",
"filterToggle": "Toggle filter",
"filterReset": "Reset filter",
"statusFilter": "Status filter",
"scopeFilter": "Scope filter",
"groupTypeFilter": "Group type filter",
"categoryFilter": "Category filter",
"regionFilter": "Region filter",
"previous": "Previous",
"next": "Next",
"send": "Send",
"confirmAction": "Confirm",
"dateFrom": "Start date",
"dateTo": "End date",
"queryFrom": "Query start time",
"queryTo": "Query end time",
"roleCode": "Role code",
"roleName": "Role name",
"roleDesc": "Role description",
"groupKey": "Group key",
"subClusterId": "Sub cluster ID",
"excludedMmsi": "Excluded MMSI",
"exclusionReason": "Exclusion reason",
"globalExclusionReason": "Global exclusion reason",
"correctParentMmsi": "Correct parent MMSI",
"uploadPanelClose": "Close upload panel",
"noticeTitle": "Notice title",
"noticeContent": "Notice content",
"languageToggle": "Language toggle",
"searchCode": "Search code",
"searchAreaOrZone": "Search area or zone",
"areaOfInterestSelect": "Select area of interest",
"replayPosition": "Replay position",
"replayClose": "Close replay",
"miniMapClose": "Close mini map",
"memberCountMin": "Min members",
"memberCountMax": "Max members",
"receiptDate": "Receipt reference date",
"copyExampleUrl": "Copy example URL",
"vesselDetail": "Vessel detail",
"enforcementRegister": "Register enforcement",
"falsePositiveProcess": "Mark false positive"
},
"error": {
"operationFailed": "Operation failed: {{msg}}",
"createFailed": "Create failed: {{msg}}",
"updateFailed": "Update failed: {{msg}}",
"deleteFailed": "Delete failed: {{msg}}",
"registerFailed": "Register failed: {{msg}}",
"processFailed": "Process failed: {{msg}}",
"errorPrefix": "Error: {{msg}}"
},
"dialog": {
"cancelSession": "Cancel this session?",
"deleteRole": "Delete this role?",
"genericDelete": "Delete?",
"genericRemove": "Remove?"
},
"success": {
"permissionUpdated": "Permissions updated",
"saved": "Saved"
},
"message": {
"noPermission": "No access permission",
"loading": "Loading...",
"builtinRoleCannotDelete": "Built-in role cannot be deleted",
"switchToEnglish": "Switch to English",
"switchToKorean": "Switch to Korean"
}
}

파일 보기

@ -14,5 +14,77 @@
"gearId": {
"title": "Gear Identification",
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
},
"gearCollision": {
"title": "Gear Identity Collision",
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
"stats": {
"title": "Overview",
"total": "Total",
"open": "Open",
"reviewed": "Reviewed",
"confirmed": "Confirmed Illegal",
"falsePositive": "False Positive"
},
"list": {
"title": "Collision Log",
"empty": "No collisions detected in the last {{hours}} hours.",
"refresh": "Refresh"
},
"columns": {
"name": "Gear Name",
"mmsiPair": "MMSI Pair",
"parentName": "Parent Vessel (est.)",
"coexistenceCount": "Coexistence",
"maxDistance": "Max Distance (km)",
"severity": "Severity",
"status": "Status",
"lastSeen": "Last Seen",
"actions": "Actions"
},
"filters": {
"status": "Status",
"severity": "Severity",
"name": "Search by name",
"hours": "Window (hours)",
"allStatus": "All statuses",
"allSeverity": "All severities"
},
"detail": {
"title": "Collision Detail",
"evidence": "Observations",
"trajectoryCompare": "Trajectory Compare",
"firstSeenAt": "First Seen",
"lastSeenAt": "Last Seen",
"swapCount": "Swap Count"
},
"resolve": {
"title": "Operator Review",
"reviewed": "Mark as Reviewed",
"confirmedIllegal": "Confirm Illegal",
"falsePositive": "Mark False Positive",
"reopen": "Reopen",
"note": "Note",
"notePlaceholder": "Record rationale or supporting evidence",
"submit": "Save",
"cancel": "Cancel",
"confirmPrompt": "Update status to the selected classification. Continue?"
},
"status": {
"open": "Open",
"reviewed": "Reviewed",
"confirmedIllegal": "Confirmed Illegal",
"falsePositive": "False Positive"
},
"severity": {
"CRITICAL": "Critical",
"HIGH": "High",
"MEDIUM": "Medium",
"LOW": "Low"
},
"error": {
"loadFailed": "Failed to load collisions",
"resolveFailed": "Failed to save classification"
}
}
}

파일 보기

@ -8,6 +8,7 @@
"darkVessel": "다크베셀 탐지",
"gearDetection": "어구 탐지",
"chinaFishing": "중국어선 분석",
"gearCollision": "어구 정체성 충돌",
"patrolRoute": "순찰경로 추천",
"fleetOptimization": "다함정 최적화",
"enforcementHistory": "단속 이력",
@ -250,5 +251,84 @@
"statistics": "통계·보고",
"aiOps": "AI 운영",
"admin": "시스템 관리"
},
"aria": {
"close": "닫기",
"closeDialog": "대화상자 닫기",
"closeNotification": "알림 닫기",
"edit": "편집",
"delete": "삭제",
"search": "검색",
"clearSearch": "검색어 지우기",
"searchInPage": "페이지 내 검색",
"refresh": "새로고침",
"filter": "필터",
"filterToggle": "필터 설정",
"filterReset": "필터 초기화",
"statusFilter": "상태 필터",
"scopeFilter": "스코프 필터",
"groupTypeFilter": "그룹 유형 필터",
"categoryFilter": "대분류 필터",
"regionFilter": "해역 필터",
"previous": "이전",
"next": "다음",
"send": "전송",
"confirmAction": "확인",
"dateFrom": "시작일",
"dateTo": "종료일",
"queryFrom": "조회 시작 시각",
"queryTo": "조회 종료 시각",
"roleCode": "역할 코드",
"roleName": "역할 이름",
"roleDesc": "역할 설명",
"groupKey": "그룹 키",
"subClusterId": "서브 클러스터 ID",
"excludedMmsi": "제외 MMSI",
"exclusionReason": "제외 사유",
"globalExclusionReason": "전역 제외 사유",
"correctParentMmsi": "정답 parent MMSI",
"uploadPanelClose": "업로드 패널 닫기",
"noticeTitle": "알림 제목",
"noticeContent": "알림 내용",
"languageToggle": "언어 전환",
"searchCode": "코드 검색",
"searchAreaOrZone": "해역 또는 해구 번호 검색",
"areaOfInterestSelect": "관심영역 선택",
"replayPosition": "재생 위치",
"replayClose": "재생 닫기",
"miniMapClose": "미니맵 닫기",
"memberCountMin": "최소 멤버 수",
"memberCountMax": "최대 멤버 수",
"receiptDate": "수신 현황 기준일",
"copyExampleUrl": "예시 URL 복사",
"vesselDetail": "선박 상세",
"enforcementRegister": "단속 등록",
"falsePositiveProcess": "오탐 처리"
},
"error": {
"operationFailed": "작업 실패: {{msg}}",
"createFailed": "생성 실패: {{msg}}",
"updateFailed": "갱신 실패: {{msg}}",
"deleteFailed": "삭제 실패: {{msg}}",
"registerFailed": "등록 실패: {{msg}}",
"processFailed": "처리 실패: {{msg}}",
"errorPrefix": "에러: {{msg}}"
},
"dialog": {
"cancelSession": "세션을 취소하시겠습니까?",
"deleteRole": "해당 역할을 삭제하시겠습니까?",
"genericDelete": "삭제하시겠습니까?",
"genericRemove": "제거하시겠습니까?"
},
"success": {
"permissionUpdated": "권한 갱신",
"saved": "저장되었습니다"
},
"message": {
"noPermission": "접근 권한이 없습니다",
"loading": "로딩 중...",
"builtinRoleCannotDelete": "내장 역할은 삭제할 수 없습니다",
"switchToEnglish": "Switch to English",
"switchToKorean": "한국어로 전환"
}
}

파일 보기

@ -14,5 +14,77 @@
"gearId": {
"title": "어구 식별 분석",
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
},
"gearCollision": {
"title": "어구 정체성 충돌 탐지",
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
"stats": {
"title": "현황 요약",
"total": "전체",
"open": "미검토",
"reviewed": "검토됨",
"confirmed": "불법 확정",
"falsePositive": "오탐"
},
"list": {
"title": "충돌 이력",
"empty": "최근 {{hours}}시간 내 감지된 충돌이 없습니다.",
"refresh": "새로고침"
},
"columns": {
"name": "어구명",
"mmsiPair": "MMSI 쌍",
"parentName": "추정 모선",
"coexistenceCount": "공존 횟수",
"maxDistance": "최대 거리(km)",
"severity": "심각도",
"status": "상태",
"lastSeen": "마지막 감지",
"actions": "액션"
},
"filters": {
"status": "상태",
"severity": "심각도",
"name": "어구명 검색",
"hours": "조회 기간(시간)",
"allStatus": "전체 상태",
"allSeverity": "전체 심각도"
},
"detail": {
"title": "공존 상세",
"evidence": "관측 이력",
"trajectoryCompare": "궤적 비교",
"firstSeenAt": "최초 감지",
"lastSeenAt": "마지막 감지",
"swapCount": "교체 누적"
},
"resolve": {
"title": "운영자 분류",
"reviewed": "검토 완료",
"confirmedIllegal": "불법으로 확정",
"falsePositive": "오탐으로 분류",
"reopen": "재오픈",
"note": "판정 메모",
"notePlaceholder": "분류 사유·추가 증거 등을 기록하세요",
"submit": "저장",
"cancel": "취소",
"confirmPrompt": "선택한 분류로 상태를 갱신합니다. 계속할까요?"
},
"status": {
"open": "미검토",
"reviewed": "검토됨",
"confirmedIllegal": "불법 확정",
"falsePositive": "오탐"
},
"severity": {
"CRITICAL": "매우 심각",
"HIGH": "심각",
"MEDIUM": "주의",
"LOW": "경미"
},
"error": {
"loadFailed": "충돌 목록을 불러오지 못했습니다",
"resolveFailed": "분류 저장에 실패했습니다"
}
}
}

파일 보기

@ -0,0 +1,114 @@
/**
* gear_identity_collisions + API .
* /api/analysis/gear-collisions .
*/
import type { AnalysisPageResponse } from './analysisApi';
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
export interface GearCollision {
id: number;
name: string;
mmsiLo: string;
mmsiHi: string;
parentName: string | null;
parentVesselId: number | null;
firstSeenAt: string;
lastSeenAt: string;
coexistenceCount: number;
swapCount: number;
maxDistanceKm: number | null;
lastLatLo: number | null;
lastLonLo: number | null;
lastLatHi: number | null;
lastLonHi: number | null;
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
resolutionNote: string | null;
evidence: Array<Record<string, unknown>> | null;
updatedAt: string;
}
export interface GearCollisionStats {
total: number;
byStatus: Record<string, number>;
bySeverity: Record<string, number>;
hours: number;
}
export type GearCollisionResolveAction =
| 'REVIEWED'
| 'CONFIRMED_ILLEGAL'
| 'FALSE_POSITIVE'
| 'REOPEN';
export interface GearCollisionResolveRequest {
action: GearCollisionResolveAction;
note?: string;
}
// ─── 내부 헬퍼 ─────────────
function buildQuery(params: Record<string, unknown>): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
qs.set(k, String(v));
}
const s = qs.toString();
return s ? `?${s}` : '';
}
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── 공개 함수 ─────────────
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
export function listGearCollisions(params?: {
status?: string;
severity?: string;
name?: string;
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse<GearCollision>> {
return apiGet('/analysis/gear-collisions', {
hours: 48, page: 0, size: 50, ...params,
});
}
/** status/severity 집계 */
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
return apiGet('/analysis/gear-collisions/stats', { hours });
}
/** 단건 상세 조회 */
export function getGearCollision(id: number): Promise<GearCollision> {
return apiGet(`/analysis/gear-collisions/${id}`);
}
/** 운영자 분류 액션 */
export function resolveGearCollision(
id: number,
body: GearCollisionResolveRequest,
): Promise<GearCollision> {
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
}

파일 보기

@ -1,7 +1,6 @@
/**
* API .
* - /리뷰: 자체 ( DB의 )
* - 향후: iran (HYBRID)
* - // + DB(gear_group_parent_resolution) .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';

파일 보기

@ -1,6 +1,6 @@
/**
* iran API.
* - () iran + HYBRID .
* prediction API ( proxy ).
* @/services/analysisApi /api/analysis/* .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';

파일 보기

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 알림 /
@ -36,6 +37,7 @@ interface NotificationBannerProps {
}
export function NotificationBanner({ notices, userRole }: NotificationBannerProps) {
const { t } = useTranslation('common');
const [dismissed, setDismissed] = useState<Set<string>>(() => {
const stored = sessionStorage.getItem('dismissed_notices');
return new Set(stored ? JSON.parse(stored) : []);
@ -80,7 +82,7 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
{notice.dismissible && (
<button
type="button"
aria-label="알림 닫기"
aria-label={t('aria.closeNotification')}
onClick={() => dismiss(notice.id)}
className="text-hint hover:text-muted-foreground shrink-0"
>

파일 보기

@ -1,4 +1,5 @@
import { Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 검색
@ -11,22 +12,24 @@ interface SearchInputProps {
className?: string;
}
export function SearchInput({ value, onChange, placeholder = '검색...', className = '' }: SearchInputProps) {
export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
const { t } = useTranslation('common');
const effectivePlaceholder = placeholder ?? `${t('action.search')}...`;
return (
<div className={`relative ${className}`}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="text"
aria-label={placeholder}
aria-label={effectivePlaceholder}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
placeholder={effectivePlaceholder}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-8 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
aria-label={t('aria.clearSearch')}
onClick={() => onChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
>

파일 보기

@ -123,3 +123,54 @@ export function getAlertLevelHex(level: string): string {
export function getAlertLevelIntent(level: string): BadgeIntent {
return getAlertLevelMeta(level)?.intent ?? 'muted';
}
/** 타입 가드 — 외부 문자열이 유효한 AlertLevel 코드인지 확인 */
export function isValidAlertLevel(value: unknown): value is AlertLevel {
return typeof value === 'string' && value in ALERT_LEVELS;
}
/** CRITICAL / HIGH 필터용 (상위 위험만 잡는 KPI·경보 집계에서 사용) */
export function isHighSeverity(level: string): boolean {
return level === 'CRITICAL' || level === 'HIGH';
}
/** 정렬용 우선순위 (CRITICAL 이 가장 앞, UNKNOWN 은 마지막) */
export function getAlertLevelOrder(level: string): number {
return getAlertLevelMeta(level)?.order ?? 999;
}
/** 지도 마커 불투명도 (CRITICAL 이 가장 선명) */
export const ALERT_LEVEL_MARKER_OPACITY: Record<AlertLevel, number> = {
CRITICAL: 0.94,
HIGH: 0.91,
MEDIUM: 0.88,
LOW: 0.85,
};
export function getAlertLevelMarkerOpacity(level: string): number {
return ALERT_LEVEL_MARKER_OPACITY[level as AlertLevel] ?? 0.85;
}
/** 지도 마커 반경 (미터) — 위험도가 높을수록 크게 */
export const ALERT_LEVEL_MARKER_RADIUS: Record<AlertLevel, number> = {
CRITICAL: 900,
HIGH: 750,
MEDIUM: 600,
LOW: 500,
};
export function getAlertLevelMarkerRadius(level: string): number {
return ALERT_LEVEL_MARKER_RADIUS[level as AlertLevel] ?? 500;
}
/** Tier(다크베셀 등) 점수 매핑 — 시각화용 숫자 가중치 */
export const ALERT_LEVEL_TIER_SCORE: Record<AlertLevel, number> = {
CRITICAL: 90,
HIGH: 60,
MEDIUM: 30,
LOW: 10,
};
export function getAlertLevelTierScore(level: string): number {
return ALERT_LEVEL_TIER_SCORE[level as AlertLevel] ?? 0;
}

파일 보기

@ -43,6 +43,9 @@ import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses';
import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses';
import { ZONE_CODES } from './zoneCodes';
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
import { VESSEL_TYPES } from './vesselTypes';
import { PERFORMANCE_STATUS_META } from './performanceStatus';
import { GEAR_COLLISION_STATUSES } from './gearCollisionStatuses';
/**
* UI
@ -91,6 +94,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
source: 'backend ViolationType enum',
items: VIOLATION_TYPES,
},
{
id: 'vessel-type',
showcaseId: 'TRK-CAT-vessel-type',
titleKo: '선박 유형',
titleEn: 'Vessel Type',
description: 'TRAWL / PURSE / GILLNET / LONGLINE / TRAP / CARGO / UNKNOWN — prediction 분류 + fleet_vessels 매핑',
source: 'prediction AnalysisResult.vessel_type',
items: VESSEL_TYPES,
},
{
id: 'event-status',
showcaseId: 'TRK-CAT-event-status',
@ -317,6 +329,24 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
source: 'prediction/algorithms/gear_violation.py',
items: GEAR_VIOLATION_CODES,
},
{
id: 'performance-status',
showcaseId: 'TRK-CAT-performance-status',
titleKo: '성능/시스템 상태',
titleEn: 'Performance / System Status',
description: 'good / normal / warning / critical / running / passed / failed / active / scheduled / archived',
source: 'admin 성능·데이터 보관·모델 검증 공통',
items: PERFORMANCE_STATUS_META,
},
{
id: 'gear-collision-status',
showcaseId: 'TRK-CAT-gear-collision-status',
titleKo: '어구 정체성 충돌 상태',
titleEn: 'Gear Identity Collision Status',
description: 'OPEN / REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE',
source: 'backend gear_identity_collisions.status (V030)',
items: GEAR_COLLISION_STATUSES,
},
];
/** ID로 특정 카탈로그 조회 */

파일 보기

@ -0,0 +1,74 @@
/**
* (GEAR_IDENTITY_COLLISION)
*
* SSOT: backend GearIdentityCollision.status (V030 )
* 사용처: GearCollisionDetection / Badge
*/
import type { BadgeIntent } from '@lib/theme/variants';
export type GearCollisionStatus =
| 'OPEN' // 신규 탐지 (미검토)
| 'REVIEWED' // 검토됨 (확정 보류)
| 'CONFIRMED_ILLEGAL' // 불법 확정
| 'FALSE_POSITIVE'; // 오탐 처리
export interface GearCollisionStatusMeta {
code: GearCollisionStatus;
i18nKey: string;
fallback: { ko: string; en: string };
intent: BadgeIntent;
}
export const GEAR_COLLISION_STATUSES: Record<GearCollisionStatus, GearCollisionStatusMeta> = {
OPEN: {
code: 'OPEN',
i18nKey: 'gearCollision.status.open',
fallback: { ko: '미검토', en: 'Open' },
intent: 'warning',
},
REVIEWED: {
code: 'REVIEWED',
i18nKey: 'gearCollision.status.reviewed',
fallback: { ko: '검토됨', en: 'Reviewed' },
intent: 'info',
},
CONFIRMED_ILLEGAL: {
code: 'CONFIRMED_ILLEGAL',
i18nKey: 'gearCollision.status.confirmedIllegal',
fallback: { ko: '불법 확정', en: 'Confirmed Illegal' },
intent: 'critical',
},
FALSE_POSITIVE: {
code: 'FALSE_POSITIVE',
i18nKey: 'gearCollision.status.falsePositive',
fallback: { ko: '오탐', en: 'False Positive' },
intent: 'muted',
},
};
export function getGearCollisionStatusMeta(s: string): GearCollisionStatusMeta | undefined {
return GEAR_COLLISION_STATUSES[s as GearCollisionStatus];
}
export function getGearCollisionStatusIntent(s: string): BadgeIntent {
return getGearCollisionStatusMeta(s)?.intent ?? 'muted';
}
export function getGearCollisionStatusLabel(
s: string,
t: (key: string, opts?: Record<string, unknown>) => string,
lang: 'ko' | 'en' = 'ko',
): string {
const meta = getGearCollisionStatusMeta(s);
if (!meta) return s;
const translated = t(meta.i18nKey, { defaultValue: '' });
return translated || meta.fallback[lang];
}
export const GEAR_COLLISION_STATUS_ORDER: GearCollisionStatus[] = [
'OPEN',
'REVIEWED',
'CONFIRMED_ILLEGAL',
'FALSE_POSITIVE',
];

파일 보기

@ -0,0 +1,53 @@
/**
*
*
* SSOT: prediction `AnalysisResult.vessel_type` (TRAWL / PURSE / LONGLINE / TRAP / GILLNET / CARGO / UNKNOWN).
* prediction + fleet_vessels fishery_code vessel_type .
*
* 사용처: RealVesselAnalysis '선박 유형' , ChinaFishing ,
* VesselDetail / EventList .
*/
import type { BadgeIntent } from '@lib/theme/variants';
export type VesselTypeCode =
| 'TRAWL'
| 'PURSE'
| 'LONGLINE'
| 'TRAP'
| 'GILLNET'
| 'CARGO'
| 'UNKNOWN';
export interface VesselTypeMeta {
code: VesselTypeCode;
i18nKey: string;
fallback: { ko: string; en: string };
intent: BadgeIntent;
}
export const VESSEL_TYPES: Record<VesselTypeCode, VesselTypeMeta> = {
TRAWL: { code: 'TRAWL', i18nKey: 'vesselType.trawl', fallback: { ko: '저인망', en: 'Trawl' }, intent: 'warning' },
PURSE: { code: 'PURSE', i18nKey: 'vesselType.purse', fallback: { ko: '선망', en: 'Purse Seine' }, intent: 'warning' },
GILLNET: { code: 'GILLNET', i18nKey: 'vesselType.gillnet', fallback: { ko: '유자망', en: 'Gillnet' }, intent: 'warning' },
LONGLINE: { code: 'LONGLINE', i18nKey: 'vesselType.longline', fallback: { ko: '연승', en: 'Longline' }, intent: 'info' },
TRAP: { code: 'TRAP', i18nKey: 'vesselType.trap', fallback: { ko: '통발', en: 'Trap' }, intent: 'info' },
CARGO: { code: 'CARGO', i18nKey: 'vesselType.cargo', fallback: { ko: '운반선', en: 'Cargo' }, intent: 'muted' },
UNKNOWN: { code: 'UNKNOWN', i18nKey: 'vesselType.unknown', fallback: { ko: '미분류', en: 'Unknown' }, intent: 'muted' },
};
export function getVesselTypeLabel(
code: string | null | undefined,
t: (k: string, opts?: { defaultValue?: string }) => string,
lang: 'ko' | 'en' = 'ko',
): string {
if (!code) return t(VESSEL_TYPES.UNKNOWN.i18nKey, { defaultValue: VESSEL_TYPES.UNKNOWN.fallback[lang] });
const meta = VESSEL_TYPES[code as VesselTypeCode];
if (!meta) return code;
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
}
export function getVesselTypeIntent(code: string | null | undefined): BadgeIntent {
if (!code) return VESSEL_TYPES.UNKNOWN.intent;
return VESSEL_TYPES[code as VesselTypeCode]?.intent ?? 'muted';
}

파일 보기

@ -2,7 +2,6 @@
*
*
* API (history frames) deck.gl TripsLayer .
* iran KCG에 .
*/
// ── 타입 ──────────────────────────────────────────────────────────────
@ -317,7 +316,6 @@ export function buildMemberMetadata(
/**
* heading() .
* iran animationStore.ts의 calculateHeading과 .
*/
function calcHeading(p1: [number, number], p2: [number, number]): number {
const dx = p2[0] - p1[0];
@ -328,11 +326,11 @@ function calcHeading(p1: [number, number], p2: [number, number]): number {
}
/**
* iran의 getCurrentVesselPositions .
* .
*
* (frameA/frameB) (memberTripsData)
* 24 .
* (O(1)) + fallback (iran과 ).
* (O(1)) + fallback (seek ).
*
* @param memberTripsData (timestamps는 startTime )
* @param memberMeta (name, role, isParent)
@ -375,7 +373,7 @@ export function interpolateFromTripsData(
continue;
}
// 커서 기반 탐색 (iran positionCursors 패턴)
// 커서 기반 탐색 (positionCursors 패턴)
let cursor = cursors.get(mmsi) ?? 0;
if (
@ -406,7 +404,7 @@ export function interpolateFromTripsData(
const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0;
positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog });
} else {
// 선형 보간 (iran의 interpolatePosition과 동일)
// 선형 보간
const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]);
const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio;
const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio;

파일 보기

@ -0,0 +1,157 @@
"""
어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 알고리즘.
동일 어구 이름이 서로 다른 MMSI 같은 5 사이클 동시 AIS 송출되는 케이스를
스푸핑/복제 의심 패턴으로 탐지한다. fleet_tracker.track_gear_identity() 루프 진입
전에 사이클 단위로 사전 집계하는 사용된다.
"""
from datetime import datetime
from itertools import combinations
from typing import Optional
from algorithms.location import haversine_nm
# ──────────────────────────────────────────────────────────────────
# 공존 판정 · 심각도 임계
# ──────────────────────────────────────────────────────────────────
MIN_COEXISTENCE_GROUP = 2 # 같은 이름에 MMSI 2개 이상
IMPOSSIBLE_SPEED_KTS = 60.0 # 두 위치 이동에 필요한 속도가 이보다 크면 물리 불가능
CRITICAL_DISTANCE_KM = 50.0 # 단발이라도 이 거리 이상이면 즉시 CRITICAL
HIGH_DISTANCE_KM = 10.0 # HIGH 기준 거리
CRITICAL_COEXISTENCE_COUNT = 3 # 누적 공존 N회 이상이면 CRITICAL 승격
HIGH_COEXISTENCE_COUNT = 2 # 누적 공존 N회 이상이면 HIGH
NM_TO_KM = 1.852 # 1 nautical mile = 1.852 km
def detect_gear_name_collisions(
gear_signals: list[dict],
now: datetime,
) -> list[dict]:
"""동일 이름 · 다중 MMSI 공존 세트 추출.
Args:
gear_signals: [{mmsi, name, lat, lon}, ...] track_gear_identity 동일 입력.
now: 사이클 기준 시각(UTC).
Returns:
공존 리스트. 이상 동시 송출 케이스는 모든 2-조합을 생성한다.
원소:
{
'name': str,
'mmsi_lo': str, # 사전순으로 작은 MMSI
'mmsi_hi': str,
'lat_lo', 'lon_lo': float,
'lat_hi', 'lon_hi': float,
'distance_km': float,
'parent_name': Optional[str], # 힌트 (GEAR_PATTERN parent 그룹, 있으면)
'observed_at': datetime,
}
"""
if not gear_signals:
return []
# 이름 기준 그룹핑
by_name: dict[str, list[dict]] = {}
for sig in gear_signals:
name = sig.get('name')
mmsi = sig.get('mmsi')
if not name or not mmsi:
continue
by_name.setdefault(name, []).append(sig)
collisions: list[dict] = []
for name, signals in by_name.items():
if len(signals) < MIN_COEXISTENCE_GROUP:
continue
# 같은 MMSI 중복은 제거 (한 cycle 에 동일 MMSI 가 다수 신호로 들어올 수 있음)
unique_by_mmsi: dict[str, dict] = {}
for sig in signals:
unique_by_mmsi.setdefault(sig['mmsi'], sig)
if len(unique_by_mmsi) < MIN_COEXISTENCE_GROUP:
continue
parent_name = _infer_parent_name(name)
mmsis = sorted(unique_by_mmsi.keys())
for a, b in combinations(mmsis, 2):
sa, sb = unique_by_mmsi[a], unique_by_mmsi[b]
dist_km = _haversine_km(
sa.get('lat'), sa.get('lon'),
sb.get('lat'), sb.get('lon'),
)
collisions.append({
'name': name,
'mmsi_lo': a,
'mmsi_hi': b,
'lat_lo': _to_float(sa.get('lat')),
'lon_lo': _to_float(sa.get('lon')),
'lat_hi': _to_float(sb.get('lat')),
'lon_hi': _to_float(sb.get('lon')),
'distance_km': dist_km,
'parent_name': parent_name,
'observed_at': now,
})
return collisions
def classify_severity(
coexistence_count: int,
max_distance_km: Optional[float],
swap_count: int = 0,
) -> str:
"""충돌 심각도 산정.
- CRITICAL: 거리 불가능 / 누적 공존 N회 이상
- HIGH: 상당 거리 / 2 이상
- MEDIUM: 단발 근거리
- LOW: 근거리 + 거리 정보 없음
"""
distance = max_distance_km or 0.0
if distance >= CRITICAL_DISTANCE_KM:
return 'CRITICAL'
if coexistence_count >= CRITICAL_COEXISTENCE_COUNT:
return 'CRITICAL'
if distance >= HIGH_DISTANCE_KM:
return 'HIGH'
if coexistence_count >= HIGH_COEXISTENCE_COUNT:
return 'HIGH'
if swap_count >= HIGH_COEXISTENCE_COUNT:
return 'HIGH'
if max_distance_km is None or max_distance_km < 0.1:
return 'LOW'
return 'MEDIUM'
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
"""두 좌표 사이 거리를 km 로 반환. 입력 누락 시 0.0."""
try:
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
return 0.0
nm = haversine_nm(float(lat1), float(lon1), float(lat2), float(lon2))
return round(nm * NM_TO_KM, 2)
except (TypeError, ValueError):
return 0.0
def _to_float(val) -> Optional[float]:
if val is None:
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def _infer_parent_name(gear_name: str) -> Optional[str]:
"""어구 이름에서 모선명 부분 추출 (느슨).
fleet_tracker 이미 GEAR_PATTERN 으로 정교하게 파싱하지만, 알고리즘 모듈 독립성을
위해 단순 휴리스틱만 유지한다. 값이 필요한 경우 fleet_tracker 호출부에서 덮어쓴다.
"""
if not gear_name:
return None
# '_숫자' 로 끝나는 서픽스 제거
base = gear_name
parts = base.rsplit('_', 2)
if len(parts) >= 2 and any(ch.isdigit() for ch in parts[-1]):
return parts[0]
return None

파일 보기

@ -14,42 +14,71 @@ def compute_lightweight_risk_score(
is_dark: bool = False,
gap_duration_min: int = 0,
spoofing_score: float = 0.0,
dark_suspicion_score: int = 0,
dist_from_baseline_nm: float = 999.0,
dark_history_24h: int = 0,
) -> Tuple[int, str]:
"""위치·허가·다크/스푸핑 기반 경량 위험도 (파이프라인 미통과 선박용).
pipeline path의 compute_vessel_risk_score와 동일한 임계값(70/50/30) 사용해
분류 결과의 일관성을 유지한다. dark/spoofing 신호를 추가하여 max 100 도달 가능.
compute_dark_suspicion 패턴 기반 0~100 점수를 직접 반영해 해상도를 높인다.
이중계산 방지: dark_suspicion_score 이미 무허가/반복을 포함하므로 dark_suspicion_score > 0
경우 허가/반복 가산을 축소한다.
임계값 70/50/30 pipeline path(compute_vessel_risk_score) 동일.
Returns: (risk_score, risk_level)
"""
score = 0
# 1. 위치 기반 (최대 40점)
# 1. 위치 기반 (최대 40점) — EEZ 외 기선 근접도 추가
zone = zone_info.get('zone', '')
if zone == 'TERRITORIAL_SEA':
score += 40
elif zone == 'CONTIGUOUS_ZONE':
score += 10
score += 15
elif zone.startswith('ZONE_'):
if is_permitted is not None and not is_permitted:
score += 25
elif zone == 'EEZ_OR_BEYOND':
# EEZ 외라도 기선 근접 시 가산 (공해·외해 분산)
if dist_from_baseline_nm < 12:
score += 15
elif dist_from_baseline_nm < 24:
score += 8
# 2. 다크 베셀 (최대 25점)
# 2. 다크 베셀 (최대 30점) — dark_suspicion_score 우선
if is_dark:
if gap_duration_min >= 60:
score += 25
elif gap_duration_min >= 30:
score += 10
if dark_suspicion_score >= 1:
# compute_dark_suspicion 이 산출한 패턴 기반 의심도 반영
score += min(30, round(dark_suspicion_score * 0.3))
else:
# fallback: gap 길이만 기준
if gap_duration_min >= 720:
score += 25
elif gap_duration_min >= 180:
score += 20
elif gap_duration_min >= 60:
score += 15
elif gap_duration_min >= 30:
score += 8
# 3. 스푸핑 (최대 15점)
# 3. 스푸핑 (최대 15점) — 현재 중국 선박은 거의 0 (별도 PR 에서 산출 로직 재설계 예정)
if spoofing_score > 0.7:
score += 15
elif spoofing_score > 0.5:
score += 8
# 4. 허가 이력 (최대 20점)
# 4. 허가 이력 (최대 15점) — 이중계산 방지
if is_permitted is not None and not is_permitted:
score += 20
# dark_suspicion_score 에 이미 무허가 +10 반영됨 → 축소
score += 8 if dark_suspicion_score > 0 else 15
# 5. 반복 이력 (최대 10점) — dark_suspicion_score 미반영 케이스만
if dark_suspicion_score == 0 and dark_history_24h > 0:
if dark_history_24h >= 5:
score += 10
elif dark_history_24h >= 2:
score += 5
score = min(score, 100)

파일 보기

@ -0,0 +1,27 @@
"""한중어업협정 fishery_code → VesselType 매핑.
파이프라인 미통과 선박(경량 분석 경로) AIS 샘플 부족으로 분류기가 UNKNOWN 반환한다.
등록선은 fishery_code 이미 확정이므로 이를 활용해 vessel_type 채운다.
VesselType 확장:
기존: TRAWL / PURSE / LONGLINE / TRAP / UNKNOWN
신규: GILLNET (유자망) / CARGO (운반선)
"""
from typing import Optional
FISHERY_CODE_TO_VESSEL_TYPE = {
'PT': 'TRAWL', # 쌍끌이 저인망
'PT-S': 'TRAWL', # 쌍끌이 부속선
'OT': 'TRAWL', # 단선 저인망
'GN': 'GILLNET', # 유자망
'PS': 'PURSE', # 대형선망/위망
'FC': 'CARGO', # 운반선 (조업 아님)
}
def fishery_code_to_vessel_type(fishery_code: Optional[str]) -> str:
"""등록 어업 코드 → 선박 유형. 매칭 없으면 'UNKNOWN'."""
if not fishery_code:
return 'UNKNOWN'
return FISHERY_CODE_TO_VESSEL_TYPE.get(fishery_code.upper(), 'UNKNOWN')

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