Compare commits
140 커밋
feature/pr
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 50d816e2ff | |||
| be315f59aa | |||
| 30abe6a951 | |||
| 82ffe7cb06 | |||
| afde36480d | |||
| d971624090 | |||
| eee9e79818 | |||
| 410d0da9cf | |||
| dae7aea861 | |||
| 2395ef1613 | |||
| a8ce9a4ea9 | |||
| cbfed23823 | |||
| f2d145c9a2 | |||
| 214f063f1e | |||
| e49ab0f4e8 | |||
| 2e674ccc5b | |||
| 2b25dc1c92 | |||
| 0f4a9cb7d6 | |||
| 3e29bc9995 | |||
| a32d09f75a | |||
| 197da13826 | |||
| bae2dbde08 | |||
| 451f38036a | |||
| b37e18d952 | |||
| 594741906b | |||
| ddcb493160 | |||
| b0d9630dde | |||
| b1bd6e507a | |||
| f07d68b43f | |||
| 5be83d2d9a | |||
| 28be92047b | |||
| f92810b1b4 | |||
| b106113e47 | |||
| 9a9388c37a | |||
| 48794e6962 | |||
| fafed8ccdf | |||
| 485743c0e1 | |||
| ed48735310 | |||
| e0af0e089d | |||
| a4e29629fc | |||
| 831045ace9 | |||
| 62d14fc519 | |||
| 760bceed32 | |||
| fe43f6b022 | |||
| 38c97686fc | |||
| 5731fa30a1 | |||
| c1cc36b134 | |||
| 2c23049c8e | |||
| 03f2ea08db | |||
| 8af693a2df | |||
| 5a57959bd5 | |||
| bb40958858 | |||
| 9251d7593c | |||
| c8673246f3 | |||
| 312dde7b86 | |||
| 9063095a9b | |||
| 65b98c53be | |||
| 3372d06545 | |||
| 524df19f20 | |||
| 6fb0b04992 | |||
| 2f94c2a0a4 | |||
| 1def64dd1d | |||
| 49c11e7b4a | |||
| 9607f798dd | |||
| a9f81c6c7e | |||
| d82eaf7e79 | |||
| 820ed75585 | |||
| 14eb4c7ea3 | |||
| d0c8a88f21 | |||
| da4557a5df | |||
| 3248ec581b | |||
| 369aaec06f | |||
| f1dc9f7a5a | |||
| 234169d540 | |||
| 7d101604cc | |||
| 6f997ad796 | |||
| 15e17759f8 | |||
| 25de59be12 | |||
| 20e2b029c5 | |||
| 7314c7f65f | |||
| 535704707b | |||
| 37ae1bfa48 | |||
| 68e690d791 | |||
| f5374a5316 | |||
| 6c08d831d0 | |||
| 1a065840bd | |||
| 64df7b180c | |||
| d817e4cbbf | |||
| 020b3b7643 | |||
| ac67b9b7af | |||
| 9c74459acc | |||
| 03d3d428e5 | |||
| e6b053dfa2 | |||
| a68945bd07 | |||
| 69b97d33f6 | |||
| 21b5048a9c | |||
| 74bdfa3f04 | |||
| bf473c12bf | |||
| 77b6fc9b14 | |||
| 0f29172a5d | |||
| d12c81f233 | |||
| 6c7c0f4ca6 | |||
| 0a5d8fe213 | |||
| dd0a934203 | |||
| 47c553d993 | |||
| 9d538cffd8 | |||
| 8ff04a8cca | |||
|
|
755f3919ba | ||
| 35cc889d23 | |||
| 68940e73b0 | |||
| 1cc4c9dfd7 | |||
| ba6908a0d4 | |||
| 52ac478069 | |||
| 2ee8a0e7ff | |||
| 908b2cdafa | |||
|
|
256152f7fc | ||
|
|
77f39497e5 | ||
|
|
a7aaa7fc13 | ||
|
|
0d35807765 | ||
| 0bc8883bb8 | |||
| 359eebe200 | |||
| 9076797699 | |||
| 3d7896b4f2 | |||
| 56af7690fb | |||
| d354c1ebc7 | |||
| 4a32cfc72e | |||
| 767ec4a84c | |||
| 6f68dce380 | |||
| ce01c2134e | |||
| feb28dbb85 | |||
| 99d72e3622 | |||
| f304f778ca | |||
| a88e3c5076 | |||
| 2eddd01d17 | |||
| 90c270ac53 | |||
| df75e085a7 | |||
| 7e37b5b680 | |||
| 45371315ba | |||
|
|
1244f07de6 | ||
| 019598ff55 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,6 +5,7 @@ backend/target/
|
|||||||
backend/build/
|
backend/build/
|
||||||
|
|
||||||
# === Python (prediction) ===
|
# === Python (prediction) ===
|
||||||
|
.venv/
|
||||||
prediction/.venv/
|
prediction/.venv/
|
||||||
prediction/__pycache__/
|
prediction/__pycache__/
|
||||||
prediction/**/__pycache__/
|
prediction/**/__pycache__/
|
||||||
@ -54,6 +55,7 @@ frontend/.vite/
|
|||||||
|
|
||||||
# === 대용량/참고 문서 ===
|
# === 대용량/참고 문서 ===
|
||||||
*.hwpx
|
*.hwpx
|
||||||
|
*.docx
|
||||||
|
|
||||||
# === Claude Code ===
|
# === Claude Code ===
|
||||||
!.claude/
|
!.claude/
|
||||||
|
|||||||
248
AGENTS.md
Normal file
248
AGENTS.md
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# KCG AI Monitoring (모노레포)
|
||||||
|
|
||||||
|
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
|
||||||
|
|
||||||
|
## 모노레포 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
kcg-ai-monitoring/
|
||||||
|
├── frontend/ # React 19 + TypeScript + Vite (UI)
|
||||||
|
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
|
||||||
|
├── prediction/ # Python 3.11+ + FastAPI (AIS 분석 엔진, 5분 주기)
|
||||||
|
├── database/ # PostgreSQL 마이그레이션 참조용 README (실제 Flyway 파일은 backend/src/main/resources/db/migration/ V001~V030, 51 테이블)
|
||||||
|
│ └── migration/
|
||||||
|
├── deploy/ # 배포 가이드 + 서버 설정 문서
|
||||||
|
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
|
||||||
|
├── .gitea/ # Gitea Actions CI/CD (프론트 자동배포)
|
||||||
|
├── .Codex/ # Codex 워크플로우
|
||||||
|
├── .githooks/ # Git hooks
|
||||||
|
└── Makefile # 통합 dev/build 명령
|
||||||
|
```
|
||||||
|
|
||||||
|
## 시스템 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
|
||||||
|
↑ write
|
||||||
|
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
|
||||||
|
↑ read
|
||||||
|
[SNPDB PostgreSQL] (AIS 원본)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
|
||||||
|
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
|
||||||
|
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
|
||||||
|
|
||||||
|
## 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # 전체 의존성 설치
|
||||||
|
make dev # 프론트 + 백엔드 동시 실행
|
||||||
|
make dev-all # 프론트 + 백엔드 + prediction 동시 실행
|
||||||
|
make dev-frontend # 프론트만
|
||||||
|
make dev-backend # 백엔드만
|
||||||
|
make dev-prediction # prediction 분석 엔진만 (FastAPI :8001)
|
||||||
|
make build # 전체 빌드
|
||||||
|
make lint # 프론트 lint
|
||||||
|
make format # 프론트 prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
### Frontend (`frontend/`)
|
||||||
|
- React 19, TypeScript 5.9, Vite 8
|
||||||
|
- Tailwind CSS 4 + CVA
|
||||||
|
- MapLibre GL 5 + deck.gl 9 (지도)
|
||||||
|
- ECharts 6 (차트)
|
||||||
|
- Zustand 5 (상태관리)
|
||||||
|
- i18next (ko/en)
|
||||||
|
- React Router 7
|
||||||
|
- ESLint 10 + Prettier
|
||||||
|
|
||||||
|
### Prediction (`prediction/`) — 분석 엔진
|
||||||
|
- Python 3.11+, FastAPI, APScheduler
|
||||||
|
- 17개 알고리즘 모듈 (다크베셀, 스푸핑, 환적, 어구 상관·부모·정체성 충돌, 쌍끌이, 위험도, 어선 분류 등)
|
||||||
|
- 7단계 분류 파이프라인 (전처리→행동→리샘플→특징→분류→클러스터→계절)
|
||||||
|
- AIS 원본: SNPDB (5분 증분), 결과: kcgaidb (직접 write)
|
||||||
|
- prediction과 backend는 DB만 공유 (HTTP 호출 X, 단 실시간 상태 조회용 FastAPI 프록시 `/api/prediction/*` 예외)
|
||||||
|
|
||||||
|
### Backend (`backend/`)
|
||||||
|
- Spring Boot 3.x + Java 21
|
||||||
|
- Spring Security + JWT
|
||||||
|
- PostgreSQL + Flyway
|
||||||
|
- Caffeine (권한 캐싱)
|
||||||
|
- 트리 기반 RBAC (wing 패턴)
|
||||||
|
|
||||||
|
### Database (`kcgaidb`)
|
||||||
|
- PostgreSQL
|
||||||
|
- 사용자: `kcg-app`
|
||||||
|
- 스키마: `kcg`
|
||||||
|
|
||||||
|
## 배포 환경
|
||||||
|
|
||||||
|
| 서비스 | 서버 (SSH) | 포트 | 관리 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 프론트엔드 | rocky-211 | nginx 443 | Gitea Actions 자동배포 |
|
||||||
|
| 백엔드 | rocky-211 | 18080 | `systemctl restart kcg-ai-backend` |
|
||||||
|
| prediction | redis-211 | 18092 | `systemctl restart kcg-ai-prediction` |
|
||||||
|
|
||||||
|
- **URL**: https://kcg-ai-monitoring.gc-si.dev
|
||||||
|
- **배포 상세**: `deploy/README.md` 참조
|
||||||
|
- **CI/CD**: `.gitea/workflows/deploy.yml` (프론트만 자동, 백엔드/prediction 수동)
|
||||||
|
|
||||||
|
## 권한 체계
|
||||||
|
|
||||||
|
좌측 탭(메뉴) = 권한 그룹, 내부 패널/액션 = 자식 자원, CRUD 단위 개별 제어.
|
||||||
|
상세는 `.Codex/plans/vast-tinkering-knuth.md` 참조.
|
||||||
|
|
||||||
|
## 팀 컨벤션
|
||||||
|
|
||||||
|
- 팀 규칙: `.Codex/rules/`
|
||||||
|
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
|
||||||
|
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
|
||||||
|
|
||||||
|
## 디자인 시스템 (필수 준수)
|
||||||
|
|
||||||
|
프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다.
|
||||||
|
모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다.
|
||||||
|
|
||||||
|
### 쇼케이스 진입
|
||||||
|
- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개)
|
||||||
|
- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/`
|
||||||
|
- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
|
||||||
|
- 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사
|
||||||
|
- URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md`
|
||||||
|
- **단축키 `A`**: 다크/라이트 테마 토글
|
||||||
|
|
||||||
|
### 공통 컴포넌트 (반드시 사용)
|
||||||
|
|
||||||
|
| 컴포넌트 | 위치 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** |
|
||||||
|
| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) |
|
||||||
|
| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) |
|
||||||
|
| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant |
|
||||||
|
| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant |
|
||||||
|
| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) |
|
||||||
|
| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) |
|
||||||
|
| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 |
|
||||||
|
|
||||||
|
### 카탈로그 기반 라벨/색상
|
||||||
|
|
||||||
|
분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다.
|
||||||
|
중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
|
||||||
|
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
|
||||||
|
{getAlertLevelLabel(event.level, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용.
|
||||||
|
숫자 위험도는 `getRiskIntent(0~100)` 사용.
|
||||||
|
|
||||||
|
### CSS 작성 규칙
|
||||||
|
|
||||||
|
1. **인라인 색상 금지** — `style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지
|
||||||
|
- 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`)
|
||||||
|
2. **하드코딩 Tailwind 색상 금지** — `bg-red-500/20 text-red-400` 같은 직접 작성 금지
|
||||||
|
- 반드시 Badge intent 또는 카탈로그 API 호출
|
||||||
|
3. **className override 정책**
|
||||||
|
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
|
||||||
|
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
|
||||||
|
4. **시맨틱 토큰 우선** — `theme.css @layer utilities`의 토큰 사용
|
||||||
|
- `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright`
|
||||||
|
- `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background`
|
||||||
|
5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결
|
||||||
|
6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동)
|
||||||
|
|
||||||
|
### 페이지 작성 표준 템플릿
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
import { Shield, Plus } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export function MyPage() {
|
||||||
|
const { t, i18n } = useTranslation('common');
|
||||||
|
const lang = i18n.language as 'ko' | 'en';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Shield}
|
||||||
|
iconColor="text-blue-400"
|
||||||
|
title="페이지 제목"
|
||||||
|
description="페이지 설명"
|
||||||
|
actions={
|
||||||
|
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Section title="데이터 목록">
|
||||||
|
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
|
||||||
|
{getAlertLevelLabel('HIGH', t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</Section>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 접근성 (a11y) 필수
|
||||||
|
|
||||||
|
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
|
||||||
|
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
|
||||||
|
- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제
|
||||||
|
- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고
|
||||||
|
|
||||||
|
### 변경 사이클
|
||||||
|
|
||||||
|
1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증
|
||||||
|
2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정
|
||||||
|
3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가
|
||||||
|
4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
|
||||||
|
|
||||||
|
### 금지 패턴 체크리스트
|
||||||
|
|
||||||
|
- ❌ `<Badge className="bg-red-500/20 text-red-400">` → `<Badge intent="critical">`
|
||||||
|
- ❌ `<button className="bg-blue-600 ...">` → `<Button variant="primary">`
|
||||||
|
- ❌ `<input className="bg-surface ...">` → `<Input>`
|
||||||
|
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
|
||||||
|
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
|
||||||
|
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
|
||||||
|
- ❌ `!important` → `cn()` 활용
|
||||||
|
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
|
||||||
|
|
||||||
|
## System Flow 뷰어 (개발 단계용)
|
||||||
|
|
||||||
|
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
|
||||||
|
- **소스**: `frontend/system-flow.html` + `frontend/src/systemFlowMain.tsx` + `frontend/src/flow/`
|
||||||
|
- **매니페스트**: `frontend/src/flow/manifest/` (10개 카테고리 JSON + meta.json + edges.json)
|
||||||
|
- **노드 ID 명명**: `<category>.<snake_case>` (예: `output.event_generator`, `ui.parent_review`)
|
||||||
|
- **딥링크**: `/system-flow.html#node=<node_id>` — 산출문서에서 노드 직접 참조
|
||||||
|
- **가이드**: `docs/system-flow-guide.md` 참조
|
||||||
|
|
||||||
|
### `/version` 스킬 사후 처리 (필수)
|
||||||
|
|
||||||
|
`/version` 스킬을 실행하여 새 SemVer 버전이 결정되면, Codex는 이어서 다음 작업을 **자동으로** 수행한다 (`/version` 스킬 자체는 팀 공통 파일이라 직접 수정하지 않음):
|
||||||
|
|
||||||
|
1. **manifest 동기화**: `/version`이 결정한 새 버전을 `frontend/src/flow/manifest/meta.json`에 반영
|
||||||
|
- `version`: 새 SemVer (예: `"1.2.0"`)
|
||||||
|
- `updatedAt`: 현재 ISO datetime (`new Date().toISOString()`)
|
||||||
|
- `releaseDate`: 오늘 날짜 (`YYYY-MM-DD`)
|
||||||
|
2. **같은 커밋에 포함**: `frontend/src/flow/manifest/meta.json`을 `/version` 스킬이 만든 커밋에 amend하거나, `docs: VERSION-HISTORY 갱신 + system-flow manifest 동기화`로 통합 커밋
|
||||||
|
3. **서버 archive는 CI/CD가 자동 처리**: 별도 작업 불필요. main 머지 후 Gitea Actions가 빌드 + dist 배포 + `/deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/`에 스냅샷 영구 보존
|
||||||
|
|
||||||
|
### 노드 ID 안정성
|
||||||
|
|
||||||
|
- **노드 ID는 절대 변경 금지** (산출문서가 참조하므로 깨짐)
|
||||||
|
- 노드 제거 시 `status: 'deprecated'`로 마킹 (1~2 릴리즈 유지 후 삭제)
|
||||||
|
- 새 노드 추가 시 `status: 'implemented'` 또는 `'planned'`
|
||||||
51
CLAUDE.md
51
CLAUDE.md
@ -2,6 +2,45 @@
|
|||||||
|
|
||||||
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
|
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
|
||||||
|
|
||||||
|
## 🚨 절대 지침 (Absolute Rules)
|
||||||
|
|
||||||
|
아래 두 지침은 모든 작업에 우선 적용된다. 사용자가 명시적으로 해제하지 않는 한 우회 금지.
|
||||||
|
|
||||||
|
### 1. 신규 기능 설계·구현 착수 시: 원격 develop 동기화 필수
|
||||||
|
|
||||||
|
신규 기능/버그 수정/리팩터 등 **어떤 작업이든 브랜치를 새로 만들기 전**에는 아래 절차를 반드시 수행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin --prune
|
||||||
|
# origin/develop이 로컬 develop보다 앞서 있는지 확인
|
||||||
|
git log --oneline develop..origin/develop | head
|
||||||
|
```
|
||||||
|
|
||||||
|
- **로컬 develop이 뒤처진 경우** → 사용자에게 다음을 권유하고 동의를 받은 후 진행:
|
||||||
|
> "`origin/develop`이 로컬보다 N개 커밋 앞서 있습니다. 최신화 후 신규 브랜치를 생성하는 것을 권장합니다. 진행할까요?"
|
||||||
|
승인 시: `git checkout develop && git pull --ff-only origin develop` → 그 위에서 `git checkout -b <new-branch>`
|
||||||
|
- **로컬 develop이 최신인 경우** → 그대로 develop에서 신규 브랜치 분기
|
||||||
|
- **로컬 develop이 없는 경우** → `git checkout -b develop origin/develop`로 tracking branch 먼저 생성
|
||||||
|
- **로컬에 unstaged/uncommitted 변경이 있을 때** → 사용자에게 먼저 알리고 stash/commit 여부 확인 후 진행. 임의로 폐기 금지.
|
||||||
|
|
||||||
|
**이유**: 오래된 develop 위에서 작업하면 머지 충돌·리베이스 비용이 커지고, 이미 해결된 이슈를 중복 해결할 위험이 있다. 브랜치 분기 시점의 기반을 항상 최신으로 맞춘다.
|
||||||
|
|
||||||
|
**적용 범위**: `/push`, `/mr`, `/create-mr`, `/release`, `/fix-issue` 스킬 실행 시, 그리고 Claude가 자발적으로 새 브랜치를 만들 때 모두.
|
||||||
|
|
||||||
|
### 2. 프론트엔드 개발 시: `design-system.html` 쇼케이스 규칙 전면 준수
|
||||||
|
|
||||||
|
`frontend/` 하위의 모든 페이지·컴포넌트·스타일 작성은 `design-system.html`(쇼케이스)에 정의된 컴포넌트·토큰·카탈로그만 사용한다. 이 문서 하단 **"디자인 시스템 (필수 준수)"** 섹션의 규칙을 **예외 없이** 따른다.
|
||||||
|
|
||||||
|
핵심 요약 (상세는 하단 섹션 참조):
|
||||||
|
- 공통 컴포넌트 우선 사용: `Badge`, `Button`, `Input`, `Select`, `TabBar`, `Card`, `PageContainer`, `PageHeader`, `Section`
|
||||||
|
- 라벨/색상은 `shared/constants/` 카탈로그 API(`getAlertLevelIntent` 등) 경유, ad-hoc 문자열 매핑 금지
|
||||||
|
- **인라인 색상·하드코딩 Tailwind 색상·`!important` 전면 금지**
|
||||||
|
- 접근성: `<button type="button">`, 아이콘 전용은 `aria-label`, 폼 요소는 `aria-label`/`<label>` 필수
|
||||||
|
|
||||||
|
위반 시 리뷰 단계에서 반려 대상. 신규 페이지는 하단의 **"페이지 작성 표준 템플릿"** 을 시작점으로 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 모노레포 구조
|
## 모노레포 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -24,14 +63,14 @@ kcg-ai-monitoring/
|
|||||||
```
|
```
|
||||||
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
|
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
|
||||||
↑ write
|
↑ write
|
||||||
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
|
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
|
||||||
↑ read ↑ read
|
↑ read
|
||||||
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
|
[SNPDB PostgreSQL] (AIS 원본)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
|
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
|
||||||
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
|
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
|
||||||
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
|
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
|
||||||
|
|
||||||
## 명령어
|
## 명령어
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ src/
|
|||||||
| [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 |
|
| [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 |
|
||||||
| [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 |
|
| [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 |
|
||||||
| [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) |
|
| [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) |
|
||||||
|
| [docs/prediction-analysis.md](docs/prediction-analysis.md) | Prediction 모듈 구조/방향 심층 분석 (2026-04-17, opus 4.7 독립 리뷰) |
|
||||||
|
|
||||||
## SFR 요구사항 대응 현황
|
## SFR 요구사항 대응 현황
|
||||||
|
|
||||||
@ -67,7 +68,7 @@ src/
|
|||||||
| SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 |
|
| SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 |
|
||||||
| SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 |
|
| SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 |
|
||||||
| SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 |
|
| SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 |
|
||||||
| SFR-10 | 어구 탐지 | `/gear-detection` | UI 완료 |
|
| SFR-10 | 어구 탐지 | `/gear-detection`, `/gear-collision`(V030) | UI 완료 |
|
||||||
| SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 |
|
| SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 |
|
||||||
| SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 |
|
| SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 |
|
||||||
| SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 |
|
| SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 |
|
||||||
|
|||||||
@ -1,18 +1,88 @@
|
|||||||
# Backend (Spring Boot)
|
# Backend (Spring Boot)
|
||||||
|
|
||||||
Phase 2에서 초기화 예정.
|
운영 배포 중 — rocky-211 `:18080` (`kcg-ai-backend` systemd).
|
||||||
|
|
||||||
## 계획된 구성
|
## 구성
|
||||||
- Spring Boot 3.x + Java 21
|
|
||||||
- PostgreSQL + Flyway
|
- **Runtime**: Spring Boot 3.5.7 + Java 21
|
||||||
- Spring Security + JWT
|
- **DB**: PostgreSQL (kcgaidb) + Flyway V001~V030 (51 테이블)
|
||||||
- Caffeine 캐시
|
- **Auth**: Spring Security + JWT 쿠키 + BCrypt
|
||||||
- 트리 기반 RBAC 권한 체계 (wing 패턴)
|
- **Cache**: Caffeine (권한 트리 10분 TTL)
|
||||||
|
- **Permission**: 트리 기반 RBAC (47 리소스 × 5 operation)
|
||||||
|
- **HTTP client**: `RestClient` + 명시적 `@Bean` 주입 (`predictionRestClient`, `signalBatchRestClient`)
|
||||||
|
|
||||||
## 책임
|
## 책임
|
||||||
- 자체 인증/권한/감사로그
|
|
||||||
- 운영자 의사결정 (모선 확정/제외/학습)
|
|
||||||
- iran 백엔드 분석 데이터 프록시
|
|
||||||
- 관리자 화면 API
|
|
||||||
|
|
||||||
상세 설계: `.claude/plans/vast-tinkering-knuth.md`
|
- 자체 인증/권한/감사 로그 + 관리자 API
|
||||||
|
- 운영자 의사결정 (모선 확정·제외·학습, 어구 정체성 충돌 분류)
|
||||||
|
- prediction 분석 결과 조회 (`/api/analysis/*`) + 이벤트 허브 (`/api/events`, `/api/alerts`)
|
||||||
|
- prediction 실시간 상태 프록시 (`/api/prediction/*`)
|
||||||
|
|
||||||
|
## 빌드 · 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬 실행
|
||||||
|
./mvnw spring-boot:run
|
||||||
|
|
||||||
|
# 프로덕션 빌드
|
||||||
|
./mvnw clean package -DskipTests
|
||||||
|
# → target/kcg-ai-backend-*.jar
|
||||||
|
|
||||||
|
# 운영 배포 (수동)
|
||||||
|
scp target/kcg-ai-backend-*.jar rocky-211:/opt/kcg-ai-backend/
|
||||||
|
ssh rocky-211 "sudo systemctl restart kcg-ai-backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필수 컴파일 설정 (PR #79 hotfix)
|
||||||
|
|
||||||
|
Spring 6.1 의 parameter-level `@Qualifier` 주입이 동작하려면 두 가지가 **필수**:
|
||||||
|
|
||||||
|
1. **`pom.xml`** — `maven-compiler-plugin` 의 `default-compile` 과 `default-testCompile` 양쪽 execution 에
|
||||||
|
`<parameters>true</parameters>` 설정. 파라미터 이름을 바이트코드에 보존해야 `@Qualifier` resolve 가 가능.
|
||||||
|
2. **`src/main/java/lombok.config`** — `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier`
|
||||||
|
설정. Lombok `@RequiredArgsConstructor` 가 필드의 `@Qualifier` 를 생성자 파라미터에 복사해야 Spring 이 인식.
|
||||||
|
|
||||||
|
둘 중 하나라도 누락되면 `PredictionProxyController` 같은 다중 `RestClient` bean 주입 컨트롤러가 기동 시점에
|
||||||
|
`NoUniqueBeanDefinitionException` 으로 크래시 루프에 빠진다. 로컬에서 `./mvnw spring-boot:run` 실패는
|
||||||
|
운영 restart 시 동일하게 재현되므로 **MR 범위 밖이어도 우선 해결**.
|
||||||
|
|
||||||
|
## 주요 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/gc/mda/kcg/
|
||||||
|
├── config/ # RestClientConfig, SecurityConfig, OpenApi, CorsConfig, CaffeineConfig 등
|
||||||
|
├── auth/ # LoginController, JWT, 비밀번호 정책
|
||||||
|
├── permission/ # 트리 RBAC, @RequirePermission AOP, 캐시
|
||||||
|
├── audit/ # @Auditable AOP, AuditLogService, AccessLogFilter
|
||||||
|
├── domain/
|
||||||
|
│ ├── analysis/ # VesselAnalysisController, GearCollisionController (V030), PredictionProxyController 등
|
||||||
|
│ ├── fleet/ # ParentInferenceWorkflowController, FleetService
|
||||||
|
│ ├── event/ # EventController, AlertController
|
||||||
|
│ ├── enforcement/ # EnforcementController, EnforcementPlanController
|
||||||
|
│ ├── master/ # MasterDataController (CodeMaster, GearTypeMaster, VesselPermit 등)
|
||||||
|
│ ├── admin/ # AdminLogController, AdminStatsController, UserManagementController
|
||||||
|
│ └── stats/ # StatsController (KPI 집계)
|
||||||
|
└── Application.java
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 컨트롤러는 `controller → service → repository` 계층을 준수하며, 쓰기 액션은
|
||||||
|
`@RequirePermission` + `@Auditable` 로 권한·감사 일관 적용.
|
||||||
|
|
||||||
|
## Flyway 마이그레이션
|
||||||
|
|
||||||
|
- 경로: [src/main/resources/db/migration/V001__*.sql ~ V030__gear_identity_collision.sql](src/main/resources/db/migration/)
|
||||||
|
- 최신: **V030** (2026-04-17) — `gear_identity_collisions` 테이블 + `detection:gear-collision` 권한 트리
|
||||||
|
- Flyway 자동 적용: Spring Boot 기동 시점
|
||||||
|
|
||||||
|
## 디렉토리 밖 의존성
|
||||||
|
|
||||||
|
- **prediction → kcgaidb**: prediction 이 직접 write. backend 는 HTTP 호출 없이 DB 조회로만 연동
|
||||||
|
- **signal-batch**: `http://192.168.1.18:18090/signal-batch` (정적정보 보강, `signalBatchRestClient` 주입)
|
||||||
|
- **prediction (FastAPI)**: `http://redis-211:18092` (실시간 상태/채팅 프록시 전용)
|
||||||
|
|
||||||
|
## 운영 체크리스트
|
||||||
|
|
||||||
|
1. 빌드 성공 → local `./mvnw spring-boot:run` 기동 확인 → curl `/api/auth/me` 200 확인
|
||||||
|
2. scp 배포 → `systemctl restart kcg-ai-backend` → `journalctl -u kcg-ai-backend -n 100`
|
||||||
|
3. 응답 확인: `curl -k https://kcg-ai-monitoring.gc-si.dev/api/analysis/vessels?hours=1`
|
||||||
|
4. Flyway 에러 시: `backend.application` 로그에서 `Migration ... failed` 확인
|
||||||
|
|||||||
@ -142,6 +142,7 @@
|
|||||||
<goal>compile</goal>
|
<goal>compile</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<parameters>true</parameters>
|
||||||
<annotationProcessorPaths>
|
<annotationProcessorPaths>
|
||||||
<path>
|
<path>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@ -161,6 +162,7 @@
|
|||||||
<goal>testCompile</goal>
|
<goal>testCompile</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<parameters>true</parameters>
|
||||||
<annotationProcessorPaths>
|
<annotationProcessorPaths>
|
||||||
<path>
|
<path>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
package gc.mda.kcg.admin;
|
package gc.mda.kcg.admin;
|
||||||
|
|
||||||
import gc.mda.kcg.audit.AccessLogRepository;
|
|
||||||
import gc.mda.kcg.audit.AuditLogRepository;
|
|
||||||
import gc.mda.kcg.auth.LoginHistoryRepository;
|
|
||||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,127 +22,23 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminStatsController {
|
public class AdminStatsController {
|
||||||
|
|
||||||
private final AuditLogRepository auditLogRepository;
|
private final AdminStatsService adminStatsService;
|
||||||
private final AccessLogRepository accessLogRepository;
|
|
||||||
private final LoginHistoryRepository loginHistoryRepository;
|
|
||||||
private final JdbcTemplate jdbc;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 감사 로그 통계.
|
|
||||||
* - total: 전체 건수
|
|
||||||
* - last24h: 24시간 내 건수
|
|
||||||
* - failed24h: 24시간 내 FAILED 건수
|
|
||||||
* - byAction: 액션별 카운트 (top 10)
|
|
||||||
* - hourly24: 시간별 24시간 추세
|
|
||||||
*/
|
|
||||||
@GetMapping("/audit")
|
@GetMapping("/audit")
|
||||||
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
|
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
|
||||||
public Map<String, Object> auditStats() {
|
public Map<String, Object> auditStats() {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
return adminStatsService.auditStats();
|
||||||
result.put("total", auditLogRepository.count());
|
|
||||||
result.put("last24h", jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
|
||||||
result.put("failed24h", jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
|
|
||||||
|
|
||||||
List<Map<String, Object>> byAction = jdbc.queryForList(
|
|
||||||
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
|
|
||||||
"WHERE created_at > now() - interval '7 days' " +
|
|
||||||
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
|
|
||||||
result.put("byAction", byAction);
|
|
||||||
|
|
||||||
List<Map<String, Object>> hourly = jdbc.queryForList(
|
|
||||||
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
|
|
||||||
"FROM kcg.auth_audit_log " +
|
|
||||||
"WHERE created_at > now() - interval '24 hours' " +
|
|
||||||
"GROUP BY hour ORDER BY hour");
|
|
||||||
result.put("hourly24", hourly);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 접근 로그 통계.
|
|
||||||
* - total: 전체 건수
|
|
||||||
* - last24h: 24시간 내
|
|
||||||
* - error4xx, error5xx: 24시간 내 에러
|
|
||||||
* - avgDurationMs: 24시간 내 평균 응답 시간
|
|
||||||
* - topPaths: 24시간 내 호출 많은 경로
|
|
||||||
*/
|
|
||||||
@GetMapping("/access")
|
@GetMapping("/access")
|
||||||
@RequirePermission(resource = "admin:access-logs", operation = "READ")
|
@RequirePermission(resource = "admin:access-logs", operation = "READ")
|
||||||
public Map<String, Object> accessStats() {
|
public Map<String, Object> accessStats() {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
return adminStatsService.accessStats();
|
||||||
result.put("total", accessLogRepository.count());
|
|
||||||
result.put("last24h", jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
|
||||||
result.put("error4xx", jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
|
|
||||||
result.put("error5xx", jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
|
|
||||||
|
|
||||||
Double avg = jdbc.queryForObject(
|
|
||||||
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
|
|
||||||
Double.class);
|
|
||||||
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
|
|
||||||
|
|
||||||
List<Map<String, Object>> topPaths = jdbc.queryForList(
|
|
||||||
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
|
|
||||||
"FROM kcg.auth_access_log " +
|
|
||||||
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
|
|
||||||
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
|
|
||||||
result.put("topPaths", topPaths);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 통계.
|
|
||||||
* - total: 전체 건수
|
|
||||||
* - success24h: 24시간 내 성공
|
|
||||||
* - failed24h: 24시간 내 실패
|
|
||||||
* - locked24h: 24시간 내 잠금
|
|
||||||
* - successRate: 성공률 (24시간 내, %)
|
|
||||||
* - byUser: 사용자별 성공 카운트 (top 10)
|
|
||||||
* - daily7d: 7일 일별 추세
|
|
||||||
*/
|
|
||||||
@GetMapping("/login")
|
@GetMapping("/login")
|
||||||
@RequirePermission(resource = "admin:login-history", operation = "READ")
|
@RequirePermission(resource = "admin:login-history", operation = "READ")
|
||||||
public Map<String, Object> loginStats() {
|
public Map<String, Object> loginStats() {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
return adminStatsService.loginStats();
|
||||||
result.put("total", loginHistoryRepository.count());
|
|
||||||
|
|
||||||
Long success24h = jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
|
|
||||||
Long failed24h = jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
|
|
||||||
Long locked24h = jdbc.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
|
|
||||||
|
|
||||||
result.put("success24h", success24h);
|
|
||||||
result.put("failed24h", failed24h);
|
|
||||||
result.put("locked24h", locked24h);
|
|
||||||
|
|
||||||
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
|
|
||||||
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
|
|
||||||
result.put("successRate", Math.round(rate * 10) / 10.0);
|
|
||||||
|
|
||||||
List<Map<String, Object>> byUser = jdbc.queryForList(
|
|
||||||
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
|
|
||||||
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
|
|
||||||
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
|
|
||||||
result.put("byUser", byUser);
|
|
||||||
|
|
||||||
List<Map<String, Object>> daily = jdbc.queryForList(
|
|
||||||
"SELECT date_trunc('day', login_dtm) AS day, " +
|
|
||||||
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
|
|
||||||
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
|
|
||||||
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
|
|
||||||
"FROM kcg.auth_login_hist " +
|
|
||||||
"WHERE login_dtm > now() - interval '7 days' " +
|
|
||||||
"GROUP BY day ORDER BY day");
|
|
||||||
result.put("daily7d", daily);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
124
backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java
Normal file
124
backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package gc.mda.kcg.admin;
|
||||||
|
|
||||||
|
import gc.mda.kcg.audit.AccessLogRepository;
|
||||||
|
import gc.mda.kcg.audit.AuditLogRepository;
|
||||||
|
import gc.mda.kcg.auth.LoginHistoryRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 관리 대시보드 메트릭 서비스.
|
||||||
|
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AdminStatsService {
|
||||||
|
|
||||||
|
private final AuditLogRepository auditLogRepository;
|
||||||
|
private final AccessLogRepository accessLogRepository;
|
||||||
|
private final LoginHistoryRepository loginHistoryRepository;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 로그 통계.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> auditStats() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("total", auditLogRepository.count());
|
||||||
|
result.put("last24h", jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||||
|
result.put("failed24h", jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
|
||||||
|
|
||||||
|
List<Map<String, Object>> byAction = jdbc.queryForList(
|
||||||
|
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
|
||||||
|
"WHERE created_at > now() - interval '7 days' " +
|
||||||
|
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
|
||||||
|
result.put("byAction", byAction);
|
||||||
|
|
||||||
|
List<Map<String, Object>> hourly = jdbc.queryForList(
|
||||||
|
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
|
||||||
|
"FROM kcg.auth_audit_log " +
|
||||||
|
"WHERE created_at > now() - interval '24 hours' " +
|
||||||
|
"GROUP BY hour ORDER BY hour");
|
||||||
|
result.put("hourly24", hourly);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근 로그 통계.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> accessStats() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("total", accessLogRepository.count());
|
||||||
|
result.put("last24h", jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||||
|
result.put("error4xx", jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
|
||||||
|
result.put("error5xx", jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
|
||||||
|
|
||||||
|
Double avg = jdbc.queryForObject(
|
||||||
|
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
|
||||||
|
Double.class);
|
||||||
|
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
|
||||||
|
|
||||||
|
List<Map<String, Object>> topPaths = jdbc.queryForList(
|
||||||
|
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
|
||||||
|
"FROM kcg.auth_access_log " +
|
||||||
|
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
|
||||||
|
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
|
||||||
|
result.put("topPaths", topPaths);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 통계.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> loginStats() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("total", loginHistoryRepository.count());
|
||||||
|
|
||||||
|
Long success24h = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
|
||||||
|
Long failed24h = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
|
||||||
|
Long locked24h = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
|
||||||
|
|
||||||
|
result.put("success24h", success24h);
|
||||||
|
result.put("failed24h", failed24h);
|
||||||
|
result.put("locked24h", locked24h);
|
||||||
|
|
||||||
|
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
|
||||||
|
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
|
||||||
|
result.put("successRate", Math.round(rate * 10) / 10.0);
|
||||||
|
|
||||||
|
List<Map<String, Object>> byUser = jdbc.queryForList(
|
||||||
|
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
|
||||||
|
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
|
||||||
|
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
|
||||||
|
result.put("byUser", byUser);
|
||||||
|
|
||||||
|
List<Map<String, Object>> daily = jdbc.queryForList(
|
||||||
|
"SELECT date_trunc('day', login_dtm) AS day, " +
|
||||||
|
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
|
||||||
|
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
|
||||||
|
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
|
||||||
|
"FROM kcg.auth_login_hist " +
|
||||||
|
"WHERE login_dtm > now() - interval '7 days' " +
|
||||||
|
"GROUP BY day ORDER BY day");
|
||||||
|
result.put("daily7d", daily);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
public class AppProperties {
|
public class AppProperties {
|
||||||
|
|
||||||
private Prediction prediction = new Prediction();
|
private Prediction prediction = new Prediction();
|
||||||
private IranBackend iranBackend = new IranBackend();
|
private SignalBatch signalBatch = new SignalBatch();
|
||||||
private Cors cors = new Cors();
|
private Cors cors = new Cors();
|
||||||
private Jwt jwt = new Jwt();
|
private Jwt jwt = new Jwt();
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ public class AppProperties {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Getter @Setter
|
@Getter @Setter
|
||||||
public static class IranBackend {
|
public static class SignalBatch {
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
package gc.mda.kcg.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 서비스용 RestClient Bean 정의.
|
||||||
|
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 를 일원화한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RestClientConfig {
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prediction FastAPI 서비스 호출용.
|
||||||
|
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RestClient predictionRestClient(RestClient.Builder builder) {
|
||||||
|
String baseUrl = appProperties.getPrediction().getBaseUrl();
|
||||||
|
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
|
||||||
|
log.info("predictionRestClient initialized: baseUrl={}", resolved);
|
||||||
|
return builder
|
||||||
|
.baseUrl(resolved)
|
||||||
|
.defaultHeader("Accept", "application/json")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* signal-batch 선박 항적 서비스 호출용.
|
||||||
|
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RestClient signalBatchRestClient(RestClient.Builder builder) {
|
||||||
|
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
|
||||||
|
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
|
||||||
|
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
|
||||||
|
return builder
|
||||||
|
.baseUrl(resolved)
|
||||||
|
.defaultHeader("Accept", "application/json")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vessel_analysis_results 집계 응답.
|
||||||
|
* MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다.
|
||||||
|
*/
|
||||||
|
public record AnalysisStatsResponse(
|
||||||
|
long total,
|
||||||
|
long darkCount,
|
||||||
|
long spoofingCount,
|
||||||
|
long transshipCount,
|
||||||
|
long criticalCount,
|
||||||
|
long highCount,
|
||||||
|
long mediumCount,
|
||||||
|
long lowCount,
|
||||||
|
long territorialCount,
|
||||||
|
long contiguousCount,
|
||||||
|
long eezCount,
|
||||||
|
long fishingCount,
|
||||||
|
BigDecimal avgRiskScore,
|
||||||
|
OffsetDateTime windowStart,
|
||||||
|
OffsetDateTime windowEnd
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
|
||||||
|
*
|
||||||
|
* 경로: /api/analysis/gear-collisions
|
||||||
|
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
|
||||||
|
* - GET /stats status/severity 집계
|
||||||
|
* - GET /{id} 단건 상세
|
||||||
|
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/analysis/gear-collisions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GearCollisionController {
|
||||||
|
|
||||||
|
private static final String RESOURCE = "detection:gear-collision";
|
||||||
|
|
||||||
|
private final GearIdentityCollisionService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public Page<GearCollisionResponse> list(
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String severity,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
@RequestParam(defaultValue = "48") int hours,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size
|
||||||
|
) {
|
||||||
|
return service.list(status, severity, name, hours,
|
||||||
|
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
|
||||||
|
.map(GearCollisionResponse::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
|
||||||
|
return service.stats(hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public GearCollisionResponse get(@PathVariable Long id) {
|
||||||
|
return GearCollisionResponse.from(service.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/resolve")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
|
||||||
|
public GearCollisionResponse resolve(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody GearCollisionResolveRequest req
|
||||||
|
) {
|
||||||
|
return GearCollisionResponse.from(service.resolve(id, req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 분류(해결) 액션 요청.
|
||||||
|
*
|
||||||
|
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
|
||||||
|
* note : 선택 (운영자 메모)
|
||||||
|
*/
|
||||||
|
public record GearCollisionResolveRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
|
||||||
|
String action,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 조회 응답 DTO.
|
||||||
|
*/
|
||||||
|
public record GearCollisionResponse(
|
||||||
|
Long id,
|
||||||
|
String name,
|
||||||
|
String mmsiLo,
|
||||||
|
String mmsiHi,
|
||||||
|
String parentName,
|
||||||
|
Long parentVesselId,
|
||||||
|
OffsetDateTime firstSeenAt,
|
||||||
|
OffsetDateTime lastSeenAt,
|
||||||
|
Integer coexistenceCount,
|
||||||
|
Integer swapCount,
|
||||||
|
BigDecimal maxDistanceKm,
|
||||||
|
BigDecimal lastLatLo,
|
||||||
|
BigDecimal lastLonLo,
|
||||||
|
BigDecimal lastLatHi,
|
||||||
|
BigDecimal lastLonHi,
|
||||||
|
String severity,
|
||||||
|
String status,
|
||||||
|
String resolutionNote,
|
||||||
|
List<Map<String, Object>> evidence,
|
||||||
|
OffsetDateTime updatedAt
|
||||||
|
) {
|
||||||
|
public static GearCollisionResponse from(GearIdentityCollision e) {
|
||||||
|
return new GearCollisionResponse(
|
||||||
|
e.getId(),
|
||||||
|
e.getName(),
|
||||||
|
e.getMmsiLo(),
|
||||||
|
e.getMmsiHi(),
|
||||||
|
e.getParentName(),
|
||||||
|
e.getParentVesselId(),
|
||||||
|
e.getFirstSeenAt(),
|
||||||
|
e.getLastSeenAt(),
|
||||||
|
e.getCoexistenceCount(),
|
||||||
|
e.getSwapCount(),
|
||||||
|
e.getMaxDistanceKm(),
|
||||||
|
e.getLastLatLo(),
|
||||||
|
e.getLastLonLo(),
|
||||||
|
e.getLastLatHi(),
|
||||||
|
e.getLastLonHi(),
|
||||||
|
e.getSeverity(),
|
||||||
|
e.getStatus(),
|
||||||
|
e.getResolutionNote(),
|
||||||
|
e.getEvidence(),
|
||||||
|
e.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions status/severity 별 집계 응답.
|
||||||
|
*/
|
||||||
|
public record GearCollisionStatsResponse(
|
||||||
|
long total,
|
||||||
|
Map<String, Long> byStatus,
|
||||||
|
Map<String, Long> bySeverity,
|
||||||
|
int hours
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prediction 자동 어구 탐지 결과 응답 DTO.
|
||||||
|
* gear_code / gear_judgment 가 NOT NULL 인 row의 핵심 필드만 노출.
|
||||||
|
*/
|
||||||
|
public record GearDetectionResponse(
|
||||||
|
Long id,
|
||||||
|
String mmsi,
|
||||||
|
OffsetDateTime analyzedAt,
|
||||||
|
String vesselType,
|
||||||
|
String gearCode,
|
||||||
|
String gearJudgment,
|
||||||
|
String permitStatus,
|
||||||
|
String riskLevel,
|
||||||
|
Integer riskScore,
|
||||||
|
String zoneCode,
|
||||||
|
List<String> violationCategories
|
||||||
|
) {
|
||||||
|
public static GearDetectionResponse from(VesselAnalysisResult e) {
|
||||||
|
return new GearDetectionResponse(
|
||||||
|
e.getId(),
|
||||||
|
e.getMmsi(),
|
||||||
|
e.getAnalyzedAt(),
|
||||||
|
e.getVesselType(),
|
||||||
|
e.getGearCode(),
|
||||||
|
e.getGearJudgment(),
|
||||||
|
e.getPermitStatus(),
|
||||||
|
e.getRiskLevel(),
|
||||||
|
e.getRiskScore(),
|
||||||
|
e.getZoneCode(),
|
||||||
|
e.getViolationCategories()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
|
||||||
|
*
|
||||||
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 내 동시 송출되는 공존 이력.
|
||||||
|
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 및 운영자 분류(status) 만 갱신.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "gear_identity_collisions", schema = "kcg")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class GearIdentityCollision {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 200)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_lo", nullable = false, length = 20)
|
||||||
|
private String mmsiLo;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_hi", nullable = false, length = 20)
|
||||||
|
private String mmsiHi;
|
||||||
|
|
||||||
|
@Column(name = "parent_name", length = 100)
|
||||||
|
private String parentName;
|
||||||
|
|
||||||
|
@Column(name = "parent_vessel_id")
|
||||||
|
private Long parentVesselId;
|
||||||
|
|
||||||
|
@Column(name = "first_seen_at", nullable = false)
|
||||||
|
private OffsetDateTime firstSeenAt;
|
||||||
|
|
||||||
|
@Column(name = "last_seen_at", nullable = false)
|
||||||
|
private OffsetDateTime lastSeenAt;
|
||||||
|
|
||||||
|
@Column(name = "coexistence_count", nullable = false)
|
||||||
|
private Integer coexistenceCount;
|
||||||
|
|
||||||
|
@Column(name = "swap_count", nullable = false)
|
||||||
|
private Integer swapCount;
|
||||||
|
|
||||||
|
@Column(name = "max_distance_km", precision = 8, scale = 2)
|
||||||
|
private BigDecimal maxDistanceKm;
|
||||||
|
|
||||||
|
@Column(name = "last_lat_lo", precision = 9, scale = 6)
|
||||||
|
private BigDecimal lastLatLo;
|
||||||
|
|
||||||
|
@Column(name = "last_lon_lo", precision = 10, scale = 6)
|
||||||
|
private BigDecimal lastLonLo;
|
||||||
|
|
||||||
|
@Column(name = "last_lat_hi", precision = 9, scale = 6)
|
||||||
|
private BigDecimal lastLatHi;
|
||||||
|
|
||||||
|
@Column(name = "last_lon_hi", precision = 10, scale = 6)
|
||||||
|
private BigDecimal lastLonHi;
|
||||||
|
|
||||||
|
@Column(name = "severity", nullable = false, length = 20)
|
||||||
|
private String severity;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 30)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "resolved_by")
|
||||||
|
private UUID resolvedBy;
|
||||||
|
|
||||||
|
@Column(name = "resolved_at")
|
||||||
|
private OffsetDateTime resolvedAt;
|
||||||
|
|
||||||
|
@Column(name = "resolution_note", columnDefinition = "text")
|
||||||
|
private String resolutionNote;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "evidence", columnDefinition = "jsonb")
|
||||||
|
private List<Map<String, Object>> evidence;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface GearIdentityCollisionRepository
|
||||||
|
extends JpaRepository<GearIdentityCollision, Long>,
|
||||||
|
JpaSpecificationExecutor<GearIdentityCollision> {
|
||||||
|
|
||||||
|
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
|
||||||
|
OffsetDateTime after, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* status 별 카운트 집계 (hours 윈도우).
|
||||||
|
* 반환: [{status, count}, ...] — Object[] {String status, Long count}
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.status AS status, COUNT(g) AS cnt
|
||||||
|
FROM GearIdentityCollision g
|
||||||
|
WHERE g.lastSeenAt > :after
|
||||||
|
GROUP BY g.status
|
||||||
|
""")
|
||||||
|
List<Object[]> countByStatus(OffsetDateTime after);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* severity 별 카운트 집계 (hours 윈도우).
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.severity AS severity, COUNT(g) AS cnt
|
||||||
|
FROM GearIdentityCollision g
|
||||||
|
WHERE g.lastSeenAt > :after
|
||||||
|
GROUP BY g.severity
|
||||||
|
""")
|
||||||
|
List<Object[]> countBySeverity(OffsetDateTime after);
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import gc.mda.kcg.audit.annotation.Auditable;
|
||||||
|
import gc.mda.kcg.auth.AuthPrincipal;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
|
||||||
|
*
|
||||||
|
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable} 로
|
||||||
|
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GearIdentityCollisionService {
|
||||||
|
|
||||||
|
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
|
||||||
|
|
||||||
|
private final GearIdentityCollisionRepository repository;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<GearIdentityCollision> list(
|
||||||
|
String status,
|
||||||
|
String severity,
|
||||||
|
String name,
|
||||||
|
int hours,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||||
|
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
|
||||||
|
List<Predicate> preds = new ArrayList<>();
|
||||||
|
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
preds.add(cb.equal(root.get("status"), status));
|
||||||
|
}
|
||||||
|
if (severity != null && !severity.isBlank()) {
|
||||||
|
preds.add(cb.equal(root.get("severity"), severity));
|
||||||
|
}
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
preds.add(cb.like(root.get("name"), "%" + name + "%"));
|
||||||
|
}
|
||||||
|
return cb.and(preds.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
return repository.findAll(spec, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GearIdentityCollision get(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GearCollisionStatsResponse stats(int hours) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||||
|
Map<String, Long> byStatus = new HashMap<>();
|
||||||
|
long total = 0;
|
||||||
|
for (Object[] row : repository.countByStatus(after)) {
|
||||||
|
String s = (String) row[0];
|
||||||
|
long c = ((Number) row[1]).longValue();
|
||||||
|
byStatus.put(s, c);
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
Map<String, Long> bySeverity = new HashMap<>();
|
||||||
|
for (Object[] row : repository.countBySeverity(after)) {
|
||||||
|
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
|
||||||
|
@Transactional
|
||||||
|
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
|
||||||
|
GearIdentityCollision row = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
|
||||||
|
AuthPrincipal principal = currentPrincipal();
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
switch (req.action().toUpperCase()) {
|
||||||
|
case "REVIEWED" -> {
|
||||||
|
row.setStatus("REVIEWED");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "CONFIRMED_ILLEGAL" -> {
|
||||||
|
row.setStatus("CONFIRMED_ILLEGAL");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "FALSE_POSITIVE" -> {
|
||||||
|
row.setStatus("FALSE_POSITIVE");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "REOPEN" -> {
|
||||||
|
row.setStatus("OPEN");
|
||||||
|
row.setResolvedBy(null);
|
||||||
|
row.setResolvedAt(null);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
|
||||||
|
}
|
||||||
|
row.setUpdatedAt(now);
|
||||||
|
return repository.save(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthPrincipal currentPrincipal() {
|
||||||
|
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,70 +0,0 @@
|
|||||||
package gc.mda.kcg.domain.analysis;
|
|
||||||
|
|
||||||
import gc.mda.kcg.config.AppProperties;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
import org.springframework.web.client.RestClientException;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* iran 백엔드 REST 클라이언트.
|
|
||||||
*
|
|
||||||
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
|
|
||||||
* 호출 실패 시 graceful degradation: null 반환 → 프론트에 빈 응답.
|
|
||||||
*
|
|
||||||
* 향후 prediction 이관 시 IranBackendClient를 PredictionDirectClient로 교체하면 됨.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
public class IranBackendClient {
|
|
||||||
|
|
||||||
private final RestClient restClient;
|
|
||||||
private final boolean enabled;
|
|
||||||
|
|
||||||
public IranBackendClient(AppProperties appProperties) {
|
|
||||||
String baseUrl = appProperties.getIranBackend().getBaseUrl();
|
|
||||||
this.enabled = baseUrl != null && !baseUrl.isBlank();
|
|
||||||
this.restClient = enabled
|
|
||||||
? RestClient.builder()
|
|
||||||
.baseUrl(baseUrl)
|
|
||||||
.defaultHeader("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
: RestClient.create();
|
|
||||||
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isEnabled() {
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET 호출 (Map 반환). 실패 시 null 반환.
|
|
||||||
*/
|
|
||||||
public Map<String, Object> getJson(String path) {
|
|
||||||
if (!enabled) return null;
|
|
||||||
try {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
|
|
||||||
return body;
|
|
||||||
} catch (RestClientException e) {
|
|
||||||
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 임의 타입 GET 호출.
|
|
||||||
*/
|
|
||||||
public <T> T getAs(String path, Class<T> responseType) {
|
|
||||||
if (!enabled) return null;
|
|
||||||
try {
|
|
||||||
return restClient.get().uri(path).retrieve().body(responseType);
|
|
||||||
} catch (RestClientException e) {
|
|
||||||
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,66 +2,129 @@ package gc.mda.kcg.domain.analysis;
|
|||||||
|
|
||||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prediction (Python FastAPI) 서비스 프록시.
|
* Prediction FastAPI 서비스 프록시.
|
||||||
* 현재는 stub - Phase 5에서 실 연결.
|
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
|
||||||
|
*
|
||||||
|
* 엔드포인트:
|
||||||
|
* GET /api/prediction/health → FastAPI /health
|
||||||
|
* GET /api/prediction/status → FastAPI /api/v1/analysis/status
|
||||||
|
* POST /api/prediction/trigger → FastAPI /api/v1/analysis/trigger
|
||||||
|
* POST /api/prediction/chat → stub (Phase 9)
|
||||||
|
* GET /api/prediction/groups/{key}/history → FastAPI /api/v1/groups/{key}/history?hours=
|
||||||
|
* GET /api/prediction/correlation/{key}/tracks → FastAPI /api/v1/correlation/{key}/tracks?hours=&min_score=
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/prediction")
|
@RequestMapping("/api/prediction")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PredictionProxyController {
|
public class PredictionProxyController {
|
||||||
|
|
||||||
private final IranBackendClient iranClient;
|
@Qualifier("predictionRestClient")
|
||||||
|
private final RestClient predictionClient;
|
||||||
|
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
public ResponseEntity<?> health() {
|
public ResponseEntity<?> health() {
|
||||||
Map<String, Object> data = iranClient.getJson("/api/prediction/health");
|
return proxyGet("/health", Map.of(
|
||||||
if (data == null) {
|
|
||||||
return ResponseEntity.ok(Map.of(
|
|
||||||
"status", "DISCONNECTED",
|
"status", "DISCONNECTED",
|
||||||
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
|
"message", "Prediction 서비스 미연결"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||||
public ResponseEntity<?> status() {
|
public ResponseEntity<?> status() {
|
||||||
Map<String, Object> data = iranClient.getJson("/api/prediction/status");
|
return proxyGet("/api/v1/analysis/status", Map.of("status", "DISCONNECTED"));
|
||||||
if (data == null) {
|
|
||||||
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/trigger")
|
@PostMapping("/trigger")
|
||||||
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
|
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
|
||||||
public ResponseEntity<?> trigger() {
|
public ResponseEntity<?> trigger() {
|
||||||
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
|
return proxyPost("/api/v1/analysis/trigger", null,
|
||||||
|
Map.of("ok", false, "message", "Prediction 서비스 미연결"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 채팅 프록시 (POST).
|
* AI 채팅 프록시 (POST) — Phase 9에서 실 연결.
|
||||||
* 향후 prediction 인증 통과 후 SSE 스트리밍으로 전환.
|
|
||||||
*/
|
*/
|
||||||
@PostMapping("/chat")
|
@PostMapping("/chat")
|
||||||
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
|
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
|
||||||
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
|
public ResponseEntity<?> chat(@RequestBody Map<String, Object> body) {
|
||||||
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
|
|
||||||
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"ok", false,
|
"ok", false,
|
||||||
"serviceAvailable", false,
|
"serviceAvailable", false,
|
||||||
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "")
|
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: "
|
||||||
|
+ body.getOrDefault("message", "")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 스냅샷 이력 (FastAPI 위임).
|
||||||
|
*/
|
||||||
|
@GetMapping("/groups/{groupKey}/history")
|
||||||
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
|
public ResponseEntity<?> groupHistory(
|
||||||
|
@PathVariable String groupKey,
|
||||||
|
@RequestParam(defaultValue = "24") int hours
|
||||||
|
) {
|
||||||
|
return proxyGet("/api/v1/groups/" + groupKey + "/history?hours=" + hours,
|
||||||
|
Map.of("serviceAvailable", false, "groupKey", groupKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상관관계 궤적 (FastAPI 위임).
|
||||||
|
*/
|
||||||
|
@GetMapping("/correlation/{groupKey}/tracks")
|
||||||
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
|
public ResponseEntity<?> correlationTracks(
|
||||||
|
@PathVariable String groupKey,
|
||||||
|
@RequestParam(defaultValue = "24") int hours,
|
||||||
|
@RequestParam(name = "min_score", required = false) Double minScore
|
||||||
|
) {
|
||||||
|
String path = "/api/v1/correlation/" + groupKey + "/tracks?hours=" + hours;
|
||||||
|
if (minScore != null) path += "&min_score=" + minScore;
|
||||||
|
return proxyGet(path, Map.of("serviceAvailable", false, "groupKey", groupKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 헬퍼 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private ResponseEntity<?> proxyGet(String path, Map<String, Object> fallback) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = predictionClient.get()
|
||||||
|
.uri(path)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
return ResponseEntity.ok(body != null ? body : fallback);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
log.debug("Prediction 호출 실패 GET {}: {}", path, e.getMessage());
|
||||||
|
return ResponseEntity.ok(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private ResponseEntity<?> proxyPost(String path, Object requestBody, Map<String, Object> fallback) {
|
||||||
|
try {
|
||||||
|
var spec = predictionClient.post().uri(path);
|
||||||
|
Map<String, Object> body;
|
||||||
|
if (requestBody != null) {
|
||||||
|
body = spec.body(requestBody).retrieve().body(Map.class);
|
||||||
|
} else {
|
||||||
|
body = spec.retrieve().body(Map.class);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(body != null ? body : fallback);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
log.debug("Prediction 호출 실패 POST {}: {}", path, e.getMessage());
|
||||||
|
return ResponseEntity.ok(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,13 +7,13 @@ import org.springframework.data.domain.PageRequest;
|
|||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* vessel_analysis_results 직접 조회 API.
|
* vessel_analysis_results 직접 조회 API.
|
||||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
|
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
|
||||||
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
|
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/analysis")
|
@RequestMapping("/api/analysis")
|
||||||
@ -33,17 +33,52 @@ public class VesselAnalysisController {
|
|||||||
@RequestParam(required = false) String zoneCode,
|
@RequestParam(required = false) String zoneCode,
|
||||||
@RequestParam(required = false) String riskLevel,
|
@RequestParam(required = false) String riskLevel,
|
||||||
@RequestParam(required = false) Boolean isDark,
|
@RequestParam(required = false) Boolean isDark,
|
||||||
|
@RequestParam(required = false) String mmsiPrefix,
|
||||||
|
@RequestParam(required = false) Integer minRiskScore,
|
||||||
|
@RequestParam(required = false) BigDecimal minFishingPct,
|
||||||
@RequestParam(defaultValue = "1") int hours,
|
@RequestParam(defaultValue = "1") int hours,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "50") int size
|
@RequestParam(defaultValue = "50") int size
|
||||||
) {
|
) {
|
||||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||||
return service.getAnalysisResults(
|
return service.getAnalysisResults(
|
||||||
mmsi, zoneCode, riskLevel, isDark, after,
|
mmsi, zoneCode, riskLevel, isDark,
|
||||||
|
mmsiPrefix, minRiskScore, minFishingPct, after,
|
||||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
||||||
).map(VesselAnalysisResponse::from);
|
).map(VesselAnalysisResponse::from);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
|
||||||
|
* - hours: 윈도우 (기본 1시간)
|
||||||
|
* - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택)
|
||||||
|
*/
|
||||||
|
@GetMapping("/stats")
|
||||||
|
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||||
|
public AnalysisStatsResponse getStats(
|
||||||
|
@RequestParam(defaultValue = "1") int hours,
|
||||||
|
@RequestParam(required = false) String mmsiPrefix
|
||||||
|
) {
|
||||||
|
return service.getStats(hours, mmsiPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prediction 자동 어구 탐지 결과 목록.
|
||||||
|
* gear_code/gear_judgment NOT NULL 인 row만 MMSI 중복 제거 후 반환.
|
||||||
|
*/
|
||||||
|
@GetMapping("/gear-detections")
|
||||||
|
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||||
|
public Page<GearDetectionResponse> listGearDetections(
|
||||||
|
@RequestParam(defaultValue = "1") int hours,
|
||||||
|
@RequestParam(required = false) String mmsiPrefix,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size
|
||||||
|
) {
|
||||||
|
return service.getGearDetections(hours, mmsiPrefix,
|
||||||
|
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
||||||
|
).map(GearDetectionResponse::from);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 선박 최신 분석 결과 (features 포함).
|
* 특정 선박 최신 분석 결과 (features 포함).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,373 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.audit.annotation.Auditable;
|
||||||
|
import gc.mda.kcg.domain.fleet.ParentResolution;
|
||||||
|
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 그룹/상관관계 직접 DB 조회 서비스.
|
||||||
|
* kcg.group_polygon_snapshots, kcg.gear_correlation_scores,
|
||||||
|
* kcg.correlation_param_models 를 JdbcTemplate으로 직접 쿼리.
|
||||||
|
* ParentResolution 합성은 JPA를 통해 수행.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VesselAnalysisGroupService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ParentResolutionRepository parentResolutionRepo;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 목록 (최신 스냅샷 per group_key+sub_cluster_id) + parentResolution 합성.
|
||||||
|
* 목록에서는 polygon/members를 제외하여 응답 크기를 최소화.
|
||||||
|
* polygon·members는 detail API에서만 반환.
|
||||||
|
*
|
||||||
|
* @param groupType null이면 전체, "GEAR"면 GEAR_IN_ZONE+GEAR_OUT_ZONE만
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getGroups(String groupType) {
|
||||||
|
List<Map<String, Object>> rows;
|
||||||
|
|
||||||
|
// LATERAL JOIN으로 최신 스냅샷만 빠르게 조회 (DISTINCT ON 대비 60x 개선)
|
||||||
|
String typeFilter = "GEAR".equals(groupType)
|
||||||
|
? "AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT g.*
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT group_key, sub_cluster_id
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE snapshot_time > NOW() - INTERVAL '1 hour'
|
||||||
|
%s
|
||||||
|
) keys
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||||
|
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
|
||||||
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
|
area_sq_nm, member_count, zone_id, zone_name, members::text, color
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE group_key = keys.group_key AND sub_cluster_id = keys.sub_cluster_id
|
||||||
|
ORDER BY snapshot_time DESC LIMIT 1
|
||||||
|
) g ON true
|
||||||
|
""".formatted(typeFilter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
rows = jdbc.queryForList(sql);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("group_polygon_snapshots 조회 실패: {}", e.getMessage());
|
||||||
|
return Map.of("serviceAvailable", false, "items", List.of(), "count", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentResolution 인덱싱
|
||||||
|
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
|
||||||
|
for (ParentResolution r : parentResolutionRepo.findAll()) {
|
||||||
|
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// correlation_scores 실시간 최고 점수 + 후보 수 일괄 조회
|
||||||
|
Map<String, Object[]> corrTopByGroup = new HashMap<>();
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> corrRows = jdbc.queryForList("""
|
||||||
|
SELECT group_key, sub_cluster_id,
|
||||||
|
MAX(current_score) AS max_score,
|
||||||
|
COUNT(*) FILTER (WHERE current_score > 0.3) AS candidate_count
|
||||||
|
FROM kcg.gear_correlation_scores
|
||||||
|
WHERE model_id = (SELECT id FROM kcg.correlation_param_models WHERE is_default = true LIMIT 1)
|
||||||
|
AND freeze_state = 'ACTIVE'
|
||||||
|
GROUP BY group_key, sub_cluster_id
|
||||||
|
""");
|
||||||
|
for (Map<String, Object> cr : corrRows) {
|
||||||
|
String ck = cr.get("group_key") + "::" + cr.get("sub_cluster_id");
|
||||||
|
corrTopByGroup.put(ck, new Object[]{cr.get("max_score"), cr.get("candidate_count")});
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("correlation top score 조회 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
Map<String, Object> item = buildGroupItem(row);
|
||||||
|
|
||||||
|
String groupKey = String.valueOf(row.get("group_key"));
|
||||||
|
Object subRaw = row.get("sub_cluster_id");
|
||||||
|
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
|
||||||
|
String compositeKey = groupKey + "::" + sub;
|
||||||
|
ParentResolution res = resolutionByKey.get(compositeKey);
|
||||||
|
|
||||||
|
// 실시간 최고 점수 (correlation_scores)
|
||||||
|
Object[] corrTop = corrTopByGroup.get(compositeKey);
|
||||||
|
if (corrTop != null) {
|
||||||
|
item.put("liveTopScore", corrTop[0]);
|
||||||
|
item.put("candidateCount", corrTop[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res != null) {
|
||||||
|
Map<String, Object> resolution = new LinkedHashMap<>();
|
||||||
|
resolution.put("status", res.getStatus());
|
||||||
|
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
|
||||||
|
resolution.put("selectedParentName", res.getSelectedParentName());
|
||||||
|
resolution.put("topScore", res.getTopScore());
|
||||||
|
resolution.put("confidence", res.getConfidence());
|
||||||
|
resolution.put("secondScore", res.getSecondScore());
|
||||||
|
resolution.put("scoreMargin", res.getScoreMargin());
|
||||||
|
resolution.put("decisionSource", res.getDecisionSource());
|
||||||
|
resolution.put("stableCycles", res.getStableCycles());
|
||||||
|
resolution.put("approvedAt", res.getApprovedAt());
|
||||||
|
resolution.put("manualComment", res.getManualComment());
|
||||||
|
item.put("resolution", resolution);
|
||||||
|
} else {
|
||||||
|
item.put("resolution", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of("serviceAvailable", true, "count", items.size(), "items", items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 그룹 상세 (최신 스냅샷 + 24시간 이력).
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getGroupDetail(String groupKey) {
|
||||||
|
String latestSql = """
|
||||||
|
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||||
|
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
|
||||||
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
|
area_sq_nm, member_count, zone_id, zone_name, members::text, color
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE group_key = ?
|
||||||
|
ORDER BY snapshot_time DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
String historySql = """
|
||||||
|
SELECT snapshot_time,
|
||||||
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
|
member_count,
|
||||||
|
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
|
||||||
|
members::text, sub_cluster_id, area_sq_nm
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE group_key = ? AND snapshot_time > NOW() - INTERVAL '24 hours'
|
||||||
|
ORDER BY snapshot_time ASC
|
||||||
|
""";
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> latestRows = jdbc.queryForList(latestSql, groupKey);
|
||||||
|
if (latestRows.isEmpty()) {
|
||||||
|
return Map.of("serviceAvailable", false, "groupKey", groupKey,
|
||||||
|
"message", "그룹을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> latest = buildGroupItem(latestRows.get(0));
|
||||||
|
|
||||||
|
List<Map<String, Object>> historyRows = jdbc.queryForList(historySql, groupKey);
|
||||||
|
List<Map<String, Object>> history = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : historyRows) {
|
||||||
|
Map<String, Object> entry = new LinkedHashMap<>();
|
||||||
|
entry.put("snapshotTime", row.get("snapshot_time"));
|
||||||
|
entry.put("centerLat", row.get("center_lat"));
|
||||||
|
entry.put("centerLon", row.get("center_lon"));
|
||||||
|
entry.put("memberCount", row.get("member_count"));
|
||||||
|
entry.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
|
||||||
|
entry.put("members", parseJsonArray((String) row.get("members")));
|
||||||
|
entry.put("subClusterId", row.get("sub_cluster_id"));
|
||||||
|
entry.put("areaSqNm", row.get("area_sq_nm"));
|
||||||
|
history.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("groupKey", groupKey);
|
||||||
|
result.put("latest", latest);
|
||||||
|
result.put("history", history);
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("getGroupDetail 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
|
||||||
|
return Map.of("serviceAvailable", false, "groupKey", groupKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹별 상관관계 점수 목록 (활성 모델 기준).
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getGroupCorrelations(String groupKey, Double minScore) {
|
||||||
|
String sql = """
|
||||||
|
SELECT s.target_mmsi, s.target_type, s.target_name,
|
||||||
|
s.current_score AS score, s.streak_count AS streak,
|
||||||
|
s.observation_count AS observations, s.freeze_state,
|
||||||
|
0 AS shadow_bonus, s.sub_cluster_id,
|
||||||
|
s.proximity_ratio, s.visit_score, s.heading_coherence,
|
||||||
|
m.id AS model_id, m.name AS model_name, m.is_default
|
||||||
|
FROM kcg.gear_correlation_scores s
|
||||||
|
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
||||||
|
WHERE s.group_key = ? AND m.is_active = true
|
||||||
|
AND (? IS NULL OR s.current_score >= ?)
|
||||||
|
ORDER BY s.current_score DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||||
|
sql, groupKey, minScore, minScore);
|
||||||
|
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("targetMmsi", row.get("target_mmsi"));
|
||||||
|
item.put("targetType", row.get("target_type"));
|
||||||
|
item.put("targetName", row.get("target_name"));
|
||||||
|
item.put("score", row.get("score"));
|
||||||
|
item.put("streak", row.get("streak"));
|
||||||
|
item.put("observations", row.get("observations"));
|
||||||
|
item.put("freezeState", row.get("freeze_state"));
|
||||||
|
item.put("subClusterId", row.get("sub_cluster_id"));
|
||||||
|
item.put("proximityRatio", row.get("proximity_ratio"));
|
||||||
|
item.put("visitScore", row.get("visit_score"));
|
||||||
|
item.put("headingCoherence", row.get("heading_coherence"));
|
||||||
|
item.put("modelId", row.get("model_id"));
|
||||||
|
item.put("modelName", row.get("model_name"));
|
||||||
|
item.put("isDefault", row.get("is_default"));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of("groupKey", groupKey, "count", items.size(), "items", items);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("getGroupCorrelations 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
|
||||||
|
return Map.of("serviceAvailable", false, "groupKey", groupKey, "items", List.of(), "count", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 후보 상세 raw metrics (최근 N건).
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getCandidateMetrics(String groupKey, String targetMmsi) {
|
||||||
|
String sql = """
|
||||||
|
SELECT observed_at, proximity_ratio, visit_score, activity_sync,
|
||||||
|
dtw_similarity, speed_correlation, heading_coherence, drift_similarity,
|
||||||
|
shadow_stay, shadow_return, gear_group_active_ratio
|
||||||
|
FROM kcg.gear_correlation_raw_metrics
|
||||||
|
WHERE group_key = ? AND target_mmsi = ?
|
||||||
|
ORDER BY observed_at DESC LIMIT 20
|
||||||
|
""";
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> rows = jdbc.queryForList(sql, groupKey, targetMmsi);
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("observedAt", row.get("observed_at"));
|
||||||
|
item.put("proximityRatio", row.get("proximity_ratio"));
|
||||||
|
item.put("visitScore", row.get("visit_score"));
|
||||||
|
item.put("activitySync", row.get("activity_sync"));
|
||||||
|
item.put("dtwSimilarity", row.get("dtw_similarity"));
|
||||||
|
item.put("speedCorrelation", row.get("speed_correlation"));
|
||||||
|
item.put("headingCoherence", row.get("heading_coherence"));
|
||||||
|
item.put("driftSimilarity", row.get("drift_similarity"));
|
||||||
|
item.put("shadowStay", row.get("shadow_stay"));
|
||||||
|
item.put("shadowReturn", row.get("shadow_return"));
|
||||||
|
item.put("gearGroupActiveRatio", row.get("gear_group_active_ratio"));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "count", items.size(), "items", items);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("getCandidateMetrics 실패: {}", e.getMessage());
|
||||||
|
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "items", List.of(), "count", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모선 확정/제외 처리.
|
||||||
|
*/
|
||||||
|
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
|
||||||
|
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
|
||||||
|
try {
|
||||||
|
// 먼저 resolution 존재 확인
|
||||||
|
List<Map<String, Object>> existing = jdbc.queryForList(
|
||||||
|
"SELECT id, sub_cluster_id FROM kcg.gear_group_parent_resolution WHERE group_key = ? LIMIT 1",
|
||||||
|
groupKey);
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
return Map.of("ok", false, "message", "resolution을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long id = ((Number) existing.get(0).get("id")).longValue();
|
||||||
|
|
||||||
|
if ("confirm".equals(action)) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE kcg.gear_group_parent_resolution
|
||||||
|
SET status = 'MANUAL_CONFIRMED', selected_parent_mmsi = ?,
|
||||||
|
manual_comment = ?, approved_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
""", targetMmsi, comment, id);
|
||||||
|
} else if ("reject".equals(action)) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE kcg.gear_group_parent_resolution
|
||||||
|
SET rejected_candidate_mmsi = ?, manual_comment = ?,
|
||||||
|
rejected_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
""", targetMmsi, comment, id);
|
||||||
|
} else {
|
||||||
|
return Map.of("ok", false, "message", "알 수 없는 액션: " + action);
|
||||||
|
}
|
||||||
|
return Map.of("ok", true, "action", action, "groupKey", groupKey, "targetMmsi", targetMmsi);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("resolveParent 실패: {}", e.getMessage());
|
||||||
|
return Map.of("ok", false, "message", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 헬퍼 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Map<String, Object> buildGroupItem(Map<String, Object> row) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("groupType", row.get("group_type"));
|
||||||
|
item.put("groupKey", row.get("group_key"));
|
||||||
|
item.put("groupLabel", row.get("group_label"));
|
||||||
|
item.put("subClusterId", row.get("sub_cluster_id"));
|
||||||
|
item.put("snapshotTime", row.get("snapshot_time"));
|
||||||
|
item.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
|
||||||
|
item.put("centerLat", row.get("center_lat"));
|
||||||
|
item.put("centerLon", row.get("center_lon"));
|
||||||
|
item.put("areaSqNm", row.get("area_sq_nm"));
|
||||||
|
item.put("memberCount", row.get("member_count"));
|
||||||
|
item.put("zoneId", row.get("zone_id"));
|
||||||
|
item.put("zoneName", row.get("zone_name"));
|
||||||
|
item.put("members", parseJsonArray((String) row.get("members")));
|
||||||
|
item.put("color", row.get("color"));
|
||||||
|
item.put("resolution", null);
|
||||||
|
item.put("candidateCount", null);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> parseGeoJson(String geoJson) {
|
||||||
|
if (geoJson == null || geoJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(geoJson, new TypeReference<Map<String, Object>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("GeoJSON 파싱 실패: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Object> parseJsonArray(String json) {
|
||||||
|
if (json == null || json.isBlank()) return List.of();
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, new TypeReference<List<Object>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("JSON 배열 파싱 실패: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,109 +1,70 @@
|
|||||||
package gc.mda.kcg.domain.analysis;
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
import gc.mda.kcg.domain.fleet.ParentResolution;
|
|
||||||
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
|
|
||||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID).
|
* 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
|
||||||
|
* + signal-batch 선박 항적 프록시.
|
||||||
*
|
*
|
||||||
* 라우팅:
|
* 라우팅:
|
||||||
* GET /api/vessel-analysis → 전체 분석결과 + 통계 (단순 프록시)
|
|
||||||
* GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성
|
* GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성
|
||||||
* GET /api/vessel-analysis/groups/{key}/detail → 단일 그룹 상세
|
* GET /api/vessel-analysis/groups/{key}/detail → 단일 그룹 상세 + 24h 이력
|
||||||
* GET /api/vessel-analysis/groups/{key}/correlations → 상관관계 점수
|
* GET /api/vessel-analysis/groups/{key}/correlations → 상관관계 점수
|
||||||
*
|
*
|
||||||
* 권한: detection / detection:gear-detection (READ)
|
* 권한: detection:gear-detection (READ)
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/vessel-analysis")
|
@RequestMapping("/api/vessel-analysis")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class VesselAnalysisProxyController {
|
public class VesselAnalysisProxyController {
|
||||||
|
|
||||||
private final IranBackendClient iranClient;
|
private final VesselAnalysisGroupService groupService;
|
||||||
private final ParentResolutionRepository resolutionRepository;
|
|
||||||
|
@Qualifier("signalBatchRestClient")
|
||||||
|
private final RestClient signalBatchClient;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||||
public ResponseEntity<?> getVesselAnalysis() {
|
public ResponseEntity<?> getVesselAnalysis() {
|
||||||
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
|
// vessel_analysis_results 직접 조회는 /api/analysis/vessels 를 사용.
|
||||||
if (data == null) {
|
// 이 엔드포인트는 하위 호환을 위해 빈 구조 반환.
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"serviceAvailable", false,
|
"serviceAvailable", true,
|
||||||
"message", "iran 백엔드 미연결",
|
"message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
|
||||||
"items", List.of(),
|
"items", List.of(),
|
||||||
"stats", Map.of(),
|
"stats", Map.of(),
|
||||||
"count", 0
|
"count", 0
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// 통과 + 메타데이터 추가
|
|
||||||
Map<String, Object> enriched = new LinkedHashMap<>(data);
|
|
||||||
enriched.put("serviceAvailable", true);
|
|
||||||
return ResponseEntity.ok(enriched);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그룹 목록 + 자체 DB의 parentResolution 합성.
|
* 그룹 목록 + 자체 DB의 parentResolution 합성.
|
||||||
* 각 그룹에 resolution 필드 추가.
|
|
||||||
*/
|
*/
|
||||||
@GetMapping("/groups")
|
@GetMapping("/groups")
|
||||||
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
public ResponseEntity<?> getGroups() {
|
public ResponseEntity<?> getGroups(
|
||||||
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
|
@RequestParam(required = false) String groupType
|
||||||
if (data == null) {
|
) {
|
||||||
return ResponseEntity.ok(Map.of(
|
Map<String, Object> result = groupService.getGroups(groupType);
|
||||||
"serviceAvailable", false,
|
|
||||||
"items", List.of(),
|
|
||||||
"count", 0
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.getOrDefault("items", List.of());
|
|
||||||
|
|
||||||
// 자체 DB의 모든 resolution을 group_key로 인덱싱
|
|
||||||
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
|
|
||||||
for (ParentResolution r : resolutionRepository.findAll()) {
|
|
||||||
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 그룹에 합성
|
|
||||||
for (Map<String, Object> item : items) {
|
|
||||||
String groupKey = String.valueOf(item.get("groupKey"));
|
|
||||||
Object subRaw = item.get("subClusterId");
|
|
||||||
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
|
|
||||||
ParentResolution res = resolutionByKey.get(groupKey + "::" + sub);
|
|
||||||
if (res != null) {
|
|
||||||
Map<String, Object> resolution = new LinkedHashMap<>();
|
|
||||||
resolution.put("status", res.getStatus());
|
|
||||||
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
|
|
||||||
resolution.put("approvedAt", res.getApprovedAt());
|
|
||||||
resolution.put("manualComment", res.getManualComment());
|
|
||||||
item.put("resolution", resolution);
|
|
||||||
} else {
|
|
||||||
item.put("resolution", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>(data);
|
|
||||||
result.put("items", items);
|
|
||||||
result.put("serviceAvailable", true);
|
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/groups/{groupKey}/detail")
|
@GetMapping("/groups/{groupKey}/detail")
|
||||||
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
|
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
|
||||||
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail");
|
Map<String, Object> result = groupService.getGroupDetail(groupKey);
|
||||||
if (data == null) {
|
return ResponseEntity.ok(result);
|
||||||
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/groups/{groupKey}/correlations")
|
@GetMapping("/groups/{groupKey}/correlations")
|
||||||
@ -112,12 +73,57 @@ public class VesselAnalysisProxyController {
|
|||||||
@PathVariable String groupKey,
|
@PathVariable String groupKey,
|
||||||
@RequestParam(required = false) Double minScore
|
@RequestParam(required = false) Double minScore
|
||||||
) {
|
) {
|
||||||
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations";
|
Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
|
||||||
if (minScore != null) path += "?minScore=" + minScore;
|
return ResponseEntity.ok(result);
|
||||||
Map<String, Object> data = iranClient.getJson(path);
|
}
|
||||||
if (data == null) {
|
|
||||||
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
|
/**
|
||||||
|
* 후보 상세 raw metrics (최근 20건 관측 이력).
|
||||||
|
*/
|
||||||
|
@GetMapping("/groups/{groupKey}/candidates/{targetMmsi}/metrics")
|
||||||
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
|
public ResponseEntity<?> getCandidateMetrics(
|
||||||
|
@PathVariable String groupKey,
|
||||||
|
@PathVariable String targetMmsi
|
||||||
|
) {
|
||||||
|
return ResponseEntity.ok(groupService.getCandidateMetrics(groupKey, targetMmsi));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모선 확정/제외.
|
||||||
|
* POST body: { "action": "confirm"|"reject", "targetMmsi": "...", "comment": "..." }
|
||||||
|
*/
|
||||||
|
@PostMapping("/groups/{groupKey}/resolve")
|
||||||
|
@RequirePermission(resource = "detection:gear-detection", operation = "UPDATE")
|
||||||
|
public ResponseEntity<?> resolveParent(
|
||||||
|
@PathVariable String groupKey,
|
||||||
|
@RequestBody Map<String, String> body
|
||||||
|
) {
|
||||||
|
String action = body.getOrDefault("action", "");
|
||||||
|
String targetMmsi = body.getOrDefault("targetMmsi", "");
|
||||||
|
String comment = body.getOrDefault("comment", "");
|
||||||
|
return ResponseEntity.ok(groupService.resolveParent(groupKey, action, targetMmsi, comment));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 항적 일괄 조회 (signal-batch 프록시).
|
||||||
|
* POST /api/vessel-analysis/tracks → signal-batch /api/v2/tracks/vessels
|
||||||
|
*/
|
||||||
|
@PostMapping("/tracks")
|
||||||
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
|
public ResponseEntity<?> vesselTracks(@RequestBody Map<String, Object> body) {
|
||||||
|
try {
|
||||||
|
String json = signalBatchClient.post()
|
||||||
|
.uri("/api/v2/tracks/vessels")
|
||||||
|
.body(body)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(json != null ? json : "[]");
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
log.warn("signal-batch 항적 조회 실패: {}", e.getMessage());
|
||||||
|
return ResponseEntity.ok("[]");
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
|
|||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,4 +58,61 @@ public interface VesselAnalysisRepository
|
|||||||
""")
|
""")
|
||||||
Page<VesselAnalysisResult> findLatestTransshipSuspects(
|
Page<VesselAnalysisResult> findLatestTransshipSuspects(
|
||||||
@Param("after") OffsetDateTime after, Pageable pageable);
|
@Param("after") OffsetDateTime after, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거).
|
||||||
|
* mmsiPrefix 가 null 이면 전체, 아니면 LIKE ':prefix%'.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT v FROM VesselAnalysisResult v
|
||||||
|
WHERE v.gearCode IS NOT NULL
|
||||||
|
AND v.gearJudgment IS NOT NULL
|
||||||
|
AND v.analyzedAt > :after
|
||||||
|
AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%'))
|
||||||
|
AND v.analyzedAt = (
|
||||||
|
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
|
||||||
|
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
|
||||||
|
)
|
||||||
|
ORDER BY v.analyzedAt DESC
|
||||||
|
""")
|
||||||
|
Page<VesselAnalysisResult> findLatestGearDetections(
|
||||||
|
@Param("after") OffsetDateTime after,
|
||||||
|
@Param("mmsiPrefix") String mmsiPrefix,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
|
||||||
|
* mmsiPrefix 가 null 이면 전체.
|
||||||
|
* 반환 Map 키: total, dark_count, spoofing_count, transship_count,
|
||||||
|
* critical_count, high_count, medium_count, low_count,
|
||||||
|
* territorial_count, contiguous_count, eez_count,
|
||||||
|
* fishing_count, avg_risk_score
|
||||||
|
*/
|
||||||
|
@Query(value = """
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (mmsi) *
|
||||||
|
FROM kcg.vessel_analysis_results
|
||||||
|
WHERE analyzed_at > :after
|
||||||
|
AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%')
|
||||||
|
ORDER BY mmsi, analyzed_at DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count,
|
||||||
|
COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count,
|
||||||
|
COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count,
|
||||||
|
COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count,
|
||||||
|
COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count,
|
||||||
|
COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count,
|
||||||
|
COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count,
|
||||||
|
COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count,
|
||||||
|
COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count,
|
||||||
|
COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count,
|
||||||
|
COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count,
|
||||||
|
COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score
|
||||||
|
FROM latest
|
||||||
|
""", nativeQuery = true)
|
||||||
|
Map<String, Object> aggregateStats(
|
||||||
|
@Param("after") OffsetDateTime after,
|
||||||
|
@Param("mmsiPrefix") String mmsiPrefix);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,6 +17,7 @@ public record VesselAnalysisResponse(
|
|||||||
String vesselType,
|
String vesselType,
|
||||||
BigDecimal confidence,
|
BigDecimal confidence,
|
||||||
BigDecimal fishingPct,
|
BigDecimal fishingPct,
|
||||||
|
Integer clusterId,
|
||||||
String season,
|
String season,
|
||||||
// 위치
|
// 위치
|
||||||
Double lat,
|
Double lat,
|
||||||
@ -24,11 +26,14 @@ public record VesselAnalysisResponse(
|
|||||||
BigDecimal distToBaselineNm,
|
BigDecimal distToBaselineNm,
|
||||||
// 행동
|
// 행동
|
||||||
String activityState,
|
String activityState,
|
||||||
|
BigDecimal ucafScore,
|
||||||
|
BigDecimal ucftScore,
|
||||||
// 위협
|
// 위협
|
||||||
Boolean isDark,
|
Boolean isDark,
|
||||||
Integer gapDurationMin,
|
Integer gapDurationMin,
|
||||||
String darkPattern,
|
String darkPattern,
|
||||||
BigDecimal spoofingScore,
|
BigDecimal spoofingScore,
|
||||||
|
BigDecimal bd09OffsetM,
|
||||||
Integer speedJumpCount,
|
Integer speedJumpCount,
|
||||||
// 환적
|
// 환적
|
||||||
Boolean transshipSuspect,
|
Boolean transshipSuspect,
|
||||||
@ -45,6 +50,7 @@ public record VesselAnalysisResponse(
|
|||||||
String gearCode,
|
String gearCode,
|
||||||
String gearJudgment,
|
String gearJudgment,
|
||||||
String permitStatus,
|
String permitStatus,
|
||||||
|
List<String> violationCategories,
|
||||||
// features
|
// features
|
||||||
Map<String, Object> features
|
Map<String, Object> features
|
||||||
) {
|
) {
|
||||||
@ -56,16 +62,20 @@ public record VesselAnalysisResponse(
|
|||||||
e.getVesselType(),
|
e.getVesselType(),
|
||||||
e.getConfidence(),
|
e.getConfidence(),
|
||||||
e.getFishingPct(),
|
e.getFishingPct(),
|
||||||
|
e.getClusterId(),
|
||||||
e.getSeason(),
|
e.getSeason(),
|
||||||
e.getLat(),
|
e.getLat(),
|
||||||
e.getLon(),
|
e.getLon(),
|
||||||
e.getZoneCode(),
|
e.getZoneCode(),
|
||||||
e.getDistToBaselineNm(),
|
e.getDistToBaselineNm(),
|
||||||
e.getActivityState(),
|
e.getActivityState(),
|
||||||
|
e.getUcafScore(),
|
||||||
|
e.getUcftScore(),
|
||||||
e.getIsDark(),
|
e.getIsDark(),
|
||||||
e.getGapDurationMin(),
|
e.getGapDurationMin(),
|
||||||
e.getDarkPattern(),
|
e.getDarkPattern(),
|
||||||
e.getSpoofingScore(),
|
e.getSpoofingScore(),
|
||||||
|
e.getBd09OffsetM(),
|
||||||
e.getSpeedJumpCount(),
|
e.getSpeedJumpCount(),
|
||||||
e.getTransshipSuspect(),
|
e.getTransshipSuspect(),
|
||||||
e.getTransshipPairMmsi(),
|
e.getTransshipPairMmsi(),
|
||||||
@ -78,6 +88,7 @@ public record VesselAnalysisResponse(
|
|||||||
e.getGearCode(),
|
e.getGearCode(),
|
||||||
e.getGearJudgment(),
|
e.getGearJudgment(),
|
||||||
e.getPermitStatus(),
|
e.getPermitStatus(),
|
||||||
|
e.getViolationCategories(),
|
||||||
e.getFeatures()
|
e.getFeatures()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,6 +126,10 @@ public class VesselAnalysisResult {
|
|||||||
@Column(name = "permit_status", length = 20)
|
@Column(name = "permit_status", length = 20)
|
||||||
private String permitStatus;
|
private String permitStatus;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
|
@Column(name = "violation_categories", columnDefinition = "text[]")
|
||||||
|
private List<String> violationCategories;
|
||||||
|
|
||||||
// features JSONB
|
// features JSONB
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(name = "features", columnDefinition = "jsonb")
|
@Column(name = "features", columnDefinition = "jsonb")
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import org.springframework.data.jpa.domain.Specification;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* vessel_analysis_results 직접 조회 서비스.
|
* vessel_analysis_results 직접 조회 서비스.
|
||||||
@ -26,9 +28,10 @@ public class VesselAnalysisService {
|
|||||||
*/
|
*/
|
||||||
public Page<VesselAnalysisResult> getAnalysisResults(
|
public Page<VesselAnalysisResult> getAnalysisResults(
|
||||||
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
|
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
|
||||||
|
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
|
||||||
OffsetDateTime after, Pageable pageable
|
OffsetDateTime after, Pageable pageable
|
||||||
) {
|
) {
|
||||||
Specification<VesselAnalysisResult> spec = Specification.where(null);
|
Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
|
||||||
|
|
||||||
if (after != null) {
|
if (after != null) {
|
||||||
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
|
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
|
||||||
@ -36,6 +39,10 @@ public class VesselAnalysisService {
|
|||||||
if (mmsi != null && !mmsi.isBlank()) {
|
if (mmsi != null && !mmsi.isBlank()) {
|
||||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
|
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
|
||||||
}
|
}
|
||||||
|
if (mmsiPrefix != null && !mmsiPrefix.isBlank()) {
|
||||||
|
final String prefix = mmsiPrefix;
|
||||||
|
spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%"));
|
||||||
|
}
|
||||||
if (zoneCode != null && !zoneCode.isBlank()) {
|
if (zoneCode != null && !zoneCode.isBlank()) {
|
||||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
|
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
|
||||||
}
|
}
|
||||||
@ -45,10 +52,66 @@ public class VesselAnalysisService {
|
|||||||
if (isDark != null && isDark) {
|
if (isDark != null && isDark) {
|
||||||
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
|
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
|
||||||
}
|
}
|
||||||
|
if (minRiskScore != null) {
|
||||||
|
spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore));
|
||||||
|
}
|
||||||
|
if (minFishingPct != null) {
|
||||||
|
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct));
|
||||||
|
}
|
||||||
|
|
||||||
return repository.findAll(spec, pageable);
|
return repository.findAll(spec, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMSI별 최신 row 기준 집계 (단일 쿼리).
|
||||||
|
*/
|
||||||
|
public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) {
|
||||||
|
OffsetDateTime windowEnd = OffsetDateTime.now();
|
||||||
|
OffsetDateTime windowStart = windowEnd.minusHours(hours);
|
||||||
|
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
|
||||||
|
|
||||||
|
Map<String, Object> row = repository.aggregateStats(windowStart, prefix);
|
||||||
|
return new AnalysisStatsResponse(
|
||||||
|
longOf(row, "total"),
|
||||||
|
longOf(row, "dark_count"),
|
||||||
|
longOf(row, "spoofing_count"),
|
||||||
|
longOf(row, "transship_count"),
|
||||||
|
longOf(row, "critical_count"),
|
||||||
|
longOf(row, "high_count"),
|
||||||
|
longOf(row, "medium_count"),
|
||||||
|
longOf(row, "low_count"),
|
||||||
|
longOf(row, "territorial_count"),
|
||||||
|
longOf(row, "contiguous_count"),
|
||||||
|
longOf(row, "eez_count"),
|
||||||
|
longOf(row, "fishing_count"),
|
||||||
|
bigDecimalOf(row, "avg_risk_score"),
|
||||||
|
windowStart,
|
||||||
|
windowEnd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prediction 자동 어구 탐지 결과 목록.
|
||||||
|
*/
|
||||||
|
public Page<VesselAnalysisResult> getGearDetections(int hours, String mmsiPrefix, Pageable pageable) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||||
|
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
|
||||||
|
return repository.findLatestGearDetections(after, prefix, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longOf(Map<String, Object> row, String key) {
|
||||||
|
Object v = row.get(key);
|
||||||
|
if (v == null) return 0L;
|
||||||
|
return ((Number) v).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal bigDecimalOf(Map<String, Object> row, String key) {
|
||||||
|
Object v = row.get(key);
|
||||||
|
if (v == null) return BigDecimal.ZERO;
|
||||||
|
if (v instanceof BigDecimal bd) return bd;
|
||||||
|
return new BigDecimal(v.toString());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 선박 최신 분석 결과.
|
* 특정 선박 최신 분석 결과.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package gc.mda.kcg.domain.enforcement;
|
package gc.mda.kcg.domain.enforcement;
|
||||||
|
|
||||||
|
import gc.mda.kcg.audit.annotation.Auditable;
|
||||||
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
|
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
|
||||||
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
||||||
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
|
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
|
||||||
@ -48,6 +49,7 @@ public class EnforcementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
|
||||||
public EnforcementRecord createRecord(CreateRecordRequest req) {
|
public EnforcementRecord createRecord(CreateRecordRequest req) {
|
||||||
EnforcementRecord record = EnforcementRecord.builder()
|
EnforcementRecord record = EnforcementRecord.builder()
|
||||||
.enfUid(generateEnfUid())
|
.enfUid(generateEnfUid())
|
||||||
@ -87,6 +89,7 @@ public class EnforcementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
|
||||||
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
|
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
|
||||||
EnforcementRecord record = getRecord(id);
|
EnforcementRecord record = getRecord(id);
|
||||||
if (req.result() != null) record.setResult(req.result());
|
if (req.result() != null) record.setResult(req.result());
|
||||||
@ -107,6 +110,7 @@ public class EnforcementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
|
||||||
public EnforcementPlan createPlan(CreatePlanRequest req) {
|
public EnforcementPlan createPlan(CreatePlanRequest req) {
|
||||||
EnforcementPlan plan = EnforcementPlan.builder()
|
EnforcementPlan plan = EnforcementPlan.builder()
|
||||||
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())
|
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())
|
||||||
|
|||||||
@ -2,12 +2,9 @@ package gc.mda.kcg.domain.event;
|
|||||||
|
|
||||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알림 조회 API.
|
* 알림 조회 API.
|
||||||
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공.
|
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공.
|
||||||
@ -17,7 +14,7 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AlertController {
|
public class AlertController {
|
||||||
|
|
||||||
private final PredictionAlertRepository alertRepository;
|
private final AlertService alertService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
|
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
|
||||||
@ -30,10 +27,8 @@ public class AlertController {
|
|||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size
|
||||||
) {
|
) {
|
||||||
if (eventId != null) {
|
if (eventId != null) {
|
||||||
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
|
return alertService.findByEventId(eventId);
|
||||||
}
|
}
|
||||||
return alertRepository.findAllByOrderBySentAtDesc(
|
return alertService.findAll(PageRequest.of(page, size));
|
||||||
PageRequest.of(page, size)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package gc.mda.kcg.domain.event;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예측 알림 조회 서비스.
|
||||||
|
* 이벤트에 대해 발송된 알림(SMS/푸시 등) 이력을 조회한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AlertService {
|
||||||
|
|
||||||
|
private final PredictionAlertRepository alertRepository;
|
||||||
|
|
||||||
|
public List<PredictionAlert> findByEventId(Long eventId) {
|
||||||
|
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<PredictionAlert> findAll(Pageable pageable) {
|
||||||
|
return alertRepository.findAllByOrderBySentAtDesc(pageable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모선 워크플로우 핵심 서비스 (HYBRID).
|
* 모선 워크플로우 핵심 서비스.
|
||||||
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
|
* - 후보 데이터: prediction이 kcgaidb 에 저장한 분석 결과를 참조
|
||||||
* - 운영자 결정: 자체 DB (gear_group_parent_resolution 등)
|
* - 운영자 결정: 자체 DB (gear_group_parent_resolution 등)
|
||||||
*
|
*
|
||||||
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
|
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 모선 확정 결과 (운영자 의사결정).
|
* 모선 확정 결과 (운영자 의사결정).
|
||||||
* iran 백엔드의 후보 데이터(prediction이 생성)와 별도로 운영자 결정만 자체 DB에 저장.
|
* prediction이 생성한 후보 데이터와 별도로 운영자 결정만 자체 DB에 저장.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "gear_group_parent_resolution", schema = "kcg",
|
@Table(name = "gear_group_parent_resolution", schema = "kcg",
|
||||||
@ -34,6 +34,27 @@ public class ParentResolution {
|
|||||||
@Column(name = "selected_parent_mmsi", length = 20)
|
@Column(name = "selected_parent_mmsi", length = 20)
|
||||||
private String selectedParentMmsi;
|
private String selectedParentMmsi;
|
||||||
|
|
||||||
|
@Column(name = "selected_parent_name", length = 200)
|
||||||
|
private String selectedParentName;
|
||||||
|
|
||||||
|
@Column(name = "confidence", columnDefinition = "numeric(7,4)")
|
||||||
|
private java.math.BigDecimal confidence;
|
||||||
|
|
||||||
|
@Column(name = "top_score", columnDefinition = "numeric(7,4)")
|
||||||
|
private java.math.BigDecimal topScore;
|
||||||
|
|
||||||
|
@Column(name = "second_score", columnDefinition = "numeric(7,4)")
|
||||||
|
private java.math.BigDecimal secondScore;
|
||||||
|
|
||||||
|
@Column(name = "score_margin", columnDefinition = "numeric(7,4)")
|
||||||
|
private java.math.BigDecimal scoreMargin;
|
||||||
|
|
||||||
|
@Column(name = "decision_source", length = 30)
|
||||||
|
private String decisionSource;
|
||||||
|
|
||||||
|
@Column(name = "stable_cycles", columnDefinition = "integer default 0")
|
||||||
|
private Integer stableCycles;
|
||||||
|
|
||||||
@Column(name = "rejected_candidate_mmsi", length = 20)
|
@Column(name = "rejected_candidate_mmsi", length = 20)
|
||||||
private String rejectedCandidateMmsi;
|
private String rejectedCandidateMmsi;
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,7 @@ import gc.mda.kcg.permission.annotation.RequirePermission;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -18,10 +16,7 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MasterDataController {
|
public class MasterDataController {
|
||||||
|
|
||||||
private final CodeMasterRepository codeMasterRepository;
|
private final MasterDataService masterDataService;
|
||||||
private final GearTypeRepository gearTypeRepository;
|
|
||||||
private final PatrolShipRepository patrolShipRepository;
|
|
||||||
private final VesselPermitRepository vesselPermitRepository;
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 코드 마스터 (인증만, 권한 불필요)
|
// 코드 마스터 (인증만, 권한 불필요)
|
||||||
@ -29,12 +24,12 @@ public class MasterDataController {
|
|||||||
|
|
||||||
@GetMapping("/api/codes")
|
@GetMapping("/api/codes")
|
||||||
public List<CodeMaster> listCodes(@RequestParam String group) {
|
public List<CodeMaster> listCodes(@RequestParam String group) {
|
||||||
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
|
return masterDataService.listCodes(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/codes/{codeId}/children")
|
@GetMapping("/api/codes/{codeId}/children")
|
||||||
public List<CodeMaster> listChildren(@PathVariable String codeId) {
|
public List<CodeMaster> listChildren(@PathVariable String codeId) {
|
||||||
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
|
return masterDataService.listChildren(codeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@ -43,35 +38,24 @@ public class MasterDataController {
|
|||||||
|
|
||||||
@GetMapping("/api/gear-types")
|
@GetMapping("/api/gear-types")
|
||||||
public List<GearType> listGearTypes() {
|
public List<GearType> listGearTypes() {
|
||||||
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
|
return masterDataService.listGearTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/gear-types/{gearCode}")
|
@GetMapping("/api/gear-types/{gearCode}")
|
||||||
public GearType getGearType(@PathVariable String gearCode) {
|
public GearType getGearType(@PathVariable String gearCode) {
|
||||||
return gearTypeRepository.findById(gearCode)
|
return masterDataService.getGearType(gearCode);
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
|
||||||
"어구 유형을 찾을 수 없습니다: " + gearCode));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/gear-types")
|
@PostMapping("/api/gear-types")
|
||||||
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
|
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
|
||||||
public GearType createGearType(@RequestBody GearType gearType) {
|
public GearType createGearType(@RequestBody GearType gearType) {
|
||||||
if (gearTypeRepository.existsById(gearType.getGearCode())) {
|
return masterDataService.createGearType(gearType);
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
|
||||||
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
|
|
||||||
}
|
|
||||||
return gearTypeRepository.save(gearType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/api/gear-types/{gearCode}")
|
@PutMapping("/api/gear-types/{gearCode}")
|
||||||
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
|
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
|
||||||
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
|
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
|
||||||
if (!gearTypeRepository.existsById(gearCode)) {
|
return masterDataService.updateGearType(gearCode, gearType);
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
|
||||||
"어구 유형을 찾을 수 없습니다: " + gearCode);
|
|
||||||
}
|
|
||||||
gearType.setGearCode(gearCode);
|
|
||||||
return gearTypeRepository.save(gearType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@ -81,7 +65,7 @@ public class MasterDataController {
|
|||||||
@GetMapping("/api/patrol-ships")
|
@GetMapping("/api/patrol-ships")
|
||||||
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
|
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
|
||||||
public List<PatrolShip> listPatrolShips() {
|
public List<PatrolShip> listPatrolShips() {
|
||||||
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
|
return masterDataService.listPatrolShips();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/api/patrol-ships/{id}/status")
|
@PatchMapping("/api/patrol-ships/{id}/status")
|
||||||
@ -90,47 +74,28 @@ public class MasterDataController {
|
|||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestBody PatrolShipStatusRequest request
|
@RequestBody PatrolShipStatusRequest request
|
||||||
) {
|
) {
|
||||||
PatrolShip ship = patrolShipRepository.findById(id)
|
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
|
||||||
"함정을 찾을 수 없습니다: " + id));
|
));
|
||||||
|
|
||||||
if (request.status() != null) ship.setCurrentStatus(request.status());
|
|
||||||
if (request.lat() != null) ship.setCurrentLat(request.lat());
|
|
||||||
if (request.lon() != null) ship.setCurrentLon(request.lon());
|
|
||||||
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
|
|
||||||
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
|
|
||||||
|
|
||||||
return patrolShipRepository.save(ship);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 선박 허가 (vessel 권한)
|
// 선박 허가 (인증만, 공통 마스터 데이터)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
@GetMapping("/api/vessel-permits")
|
@GetMapping("/api/vessel-permits")
|
||||||
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
|
|
||||||
public Page<VesselPermit> listVesselPermits(
|
public Page<VesselPermit> listVesselPermits(
|
||||||
@RequestParam(required = false) String flag,
|
@RequestParam(required = false) String flag,
|
||||||
@RequestParam(required = false) String permitStatus,
|
@RequestParam(required = false) String permitStatus,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size
|
||||||
) {
|
) {
|
||||||
PageRequest pageable = PageRequest.of(page, size);
|
return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
|
||||||
if (flag != null) {
|
|
||||||
return vesselPermitRepository.findByFlagCountry(flag, pageable);
|
|
||||||
}
|
|
||||||
if (permitStatus != null) {
|
|
||||||
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
|
|
||||||
}
|
|
||||||
return vesselPermitRepository.findAll(pageable);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/vessel-permits/{mmsi}")
|
@GetMapping("/api/vessel-permits/{mmsi}")
|
||||||
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
|
|
||||||
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
|
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
|
||||||
return vesselPermitRepository.findByMmsi(mmsi)
|
return masterDataService.getVesselPermit(mmsi);
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
|
||||||
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
115
backend/src/main/java/gc/mda/kcg/master/MasterDataService.java
Normal file
115
backend/src/main/java/gc/mda/kcg/master/MasterDataService.java
Normal file
@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
6
backend/src/main/java/lombok.config
Normal file
6
backend/src/main/java/lombok.config
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
config.stopBubbling = true
|
||||||
|
|
||||||
|
# @RequiredArgsConstructor 가 생성하는 constructor parameter 에 필드의 @Qualifier 를 복사한다.
|
||||||
|
# Spring 6.1+ 의 bean 이름 기반 fallback 은 parameter-level annotation 을 요구하므로,
|
||||||
|
# 필수 처리하지 않으면 여러 bean 중 모호성이 발생해 기동이 실패한다.
|
||||||
|
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
|
||||||
@ -40,6 +40,10 @@ spring:
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
|
compression:
|
||||||
|
enabled: true
|
||||||
|
min-response-size: 1024
|
||||||
|
mime-types: application/json,application/xml,text/html,text/plain
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
@ -60,9 +64,8 @@ logging:
|
|||||||
app:
|
app:
|
||||||
prediction:
|
prediction:
|
||||||
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
||||||
iran-backend:
|
signal-batch:
|
||||||
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
|
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
|
||||||
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
|
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V025: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
|
||||||
|
-- 시스템관리 > AI 플랫폼 / 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────
|
||||||
|
-- 1. LGCNS MLOps (시스템관리 > AI 플랫폼, MLOps와 LLM 사이)
|
||||||
|
-- ──────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:lgcns-mlops', 'admin', 'LGCNS MLOps', 1, 36)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/lgcns-mlops',
|
||||||
|
label_key = 'nav.lgcnsMlops',
|
||||||
|
component_key = 'features/ai-operations/LGCNSMLOpsPage',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = 'AI 플랫폼',
|
||||||
|
nav_sort = 350,
|
||||||
|
labels = '{"ko":"LGCNS MLOps","en":"LGCNS MLOps"}'
|
||||||
|
WHERE rsrc_cd = 'admin:lgcns-mlops';
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:lgcns-mlops', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────
|
||||||
|
-- 2. AI 보안 (SER-10) (시스템관리 > 감사·보안, 로그인 이력 뒤)
|
||||||
|
-- ──────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:ai-security', 'admin', 'AI 보안', 1, 55)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/ai-security',
|
||||||
|
label_key = 'nav.aiSecurity',
|
||||||
|
component_key = 'features/admin/AISecurityPage',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 1800,
|
||||||
|
labels = '{"ko":"AI 보안","en":"AI Security"}'
|
||||||
|
WHERE rsrc_cd = 'admin:ai-security';
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:ai-security', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────
|
||||||
|
-- 3. AI Agent 보안 (SER-11) (시스템관리 > 감사·보안, AI 보안 뒤)
|
||||||
|
-- ──────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:ai-agent-security', 'admin', 'AI Agent 보안', 1, 56)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/ai-agent-security',
|
||||||
|
label_key = 'nav.aiAgentSecurity',
|
||||||
|
component_key = 'features/admin/AIAgentSecurityPage',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 1900,
|
||||||
|
labels = '{"ko":"AI Agent 보안","en":"AI Agent Security"}'
|
||||||
|
WHERE rsrc_cd = 'admin:ai-agent-security';
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:ai-agent-security', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V026: 데이터 보관기간 및 파기 정책 (DAR-10) 메뉴 추가
|
||||||
|
-- 시스템관리 > 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 권한 트리 노드 등록
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:data-retention', 'admin', '데이터 보관·파기', 1, 57)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 메뉴 메타데이터 갱신
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/data-retention',
|
||||||
|
label_key = 'nav.dataRetentionPolicy',
|
||||||
|
component_key = 'features/admin/DataRetentionPolicy',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 2000,
|
||||||
|
labels = '{"ko":"데이터 보관·파기","en":"Data Retention"}'
|
||||||
|
WHERE rsrc_cd = 'admin:data-retention';
|
||||||
|
|
||||||
|
-- 3. ADMIN 역할에 전체 권한 부여
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:data-retention', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V027: 데이터 모델 검증 (DAR-11) 메뉴 추가
|
||||||
|
-- 시스템관리 > 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 권한 트리 노드 등록
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:data-model-verification', 'admin', '데이터 모델 검증', 1, 58)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 메뉴 메타데이터 갱신
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/data-model-verification',
|
||||||
|
label_key = 'nav.dataModelVerification',
|
||||||
|
component_key = 'features/admin/DataModelVerification',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 2100,
|
||||||
|
labels = '{"ko":"데이터 모델 검증","en":"Model Verification"}'
|
||||||
|
WHERE rsrc_cd = 'admin:data-model-verification';
|
||||||
|
|
||||||
|
-- 3. ADMIN 역할에 전체 권한 부여
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:data-model-verification', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V028: 성능 모니터링 (PER-01~06) 메뉴 추가
|
||||||
|
-- 시스템관리 > 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 권한 트리 노드 등록
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:performance-monitoring', 'admin', '성능 모니터링', 1, 59)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 메뉴 메타데이터 갱신
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/performance-monitoring',
|
||||||
|
label_key = 'nav.performanceMonitoring',
|
||||||
|
component_key = 'features/admin/PerformanceMonitoring',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 2110,
|
||||||
|
labels = '{"ko":"성능 모니터링","en":"Performance Monitoring"}'
|
||||||
|
WHERE rsrc_cd = 'admin:performance-monitoring';
|
||||||
|
|
||||||
|
-- 3. ADMIN 역할에 전체 권한 부여
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:performance-monitoring', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
-- V026: 한중어업협정 중국어선 허가현황 원본 테이블 + fleet_vessels 연도 컬럼
|
||||||
|
-- 출처: docs/중국어선_허가현황_YYYYMMDD.xls (연단위 갱신)
|
||||||
|
|
||||||
|
-- ===== 1. fishery_permit_cn : 허가현황 원본 스냅샷 =====
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.fishery_permit_cn (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
permit_year INTEGER NOT NULL,
|
||||||
|
permit_no VARCHAR(30) NOT NULL,
|
||||||
|
fishery_type VARCHAR(60), -- 업종 (2척식저인망어업 등)
|
||||||
|
fishery_code VARCHAR(10) NOT NULL, -- 업종코드 (PT/PT-S/GN/FC/PS/OT)
|
||||||
|
name_cn VARCHAR(100) NOT NULL,
|
||||||
|
name_en VARCHAR(200) NOT NULL,
|
||||||
|
applicant_cn VARCHAR(100),
|
||||||
|
applicant_en VARCHAR(200),
|
||||||
|
applicant_addr_cn VARCHAR(300),
|
||||||
|
applicant_addr_en VARCHAR(300),
|
||||||
|
registration_no VARCHAR(100),
|
||||||
|
tonnage NUMERIC(10,2),
|
||||||
|
port_cn VARCHAR(100),
|
||||||
|
port_en VARCHAR(200),
|
||||||
|
callsign VARCHAR(40),
|
||||||
|
engine_power NUMERIC(10,2),
|
||||||
|
length_m NUMERIC(6,2),
|
||||||
|
beam_m NUMERIC(6,2),
|
||||||
|
depth_m NUMERIC(6,2),
|
||||||
|
fishing_zones VARCHAR(30), -- Ⅱ,Ⅲ 등
|
||||||
|
fishing_period_1 VARCHAR(50),
|
||||||
|
fishing_period_2 VARCHAR(50),
|
||||||
|
catch_quota_t NUMERIC(10,2),
|
||||||
|
cumulative_quota_t NUMERIC(10,2),
|
||||||
|
refrig_hold_count INTEGER,
|
||||||
|
freezer_hold_count INTEGER,
|
||||||
|
admin_sanction TEXT,
|
||||||
|
parent_permit_no VARCHAR(30), -- 부속선(PT-S)이 참조하는 본선 허가번호
|
||||||
|
volume_enclosed NUMERIC(10,2),
|
||||||
|
volume_above_deck NUMERIC(10,2),
|
||||||
|
volume_below_deck NUMERIC(10,2),
|
||||||
|
volume_excluded NUMERIC(10,2),
|
||||||
|
raw_data JSONB,
|
||||||
|
source_file VARCHAR(255),
|
||||||
|
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (permit_year, permit_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_fishery_permit_cn_name_cn ON kcg.fishery_permit_cn(permit_year, name_cn);
|
||||||
|
CREATE INDEX idx_fishery_permit_cn_name_en ON kcg.fishery_permit_cn(permit_year, LOWER(name_en));
|
||||||
|
CREATE INDEX idx_fishery_permit_cn_code ON kcg.fishery_permit_cn(permit_year, fishery_code);
|
||||||
|
CREATE INDEX idx_fishery_permit_cn_parent ON kcg.fishery_permit_cn(permit_year, parent_permit_no);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.fishery_permit_cn IS '한중어업협정 중국어선 허가현황 원본 스냅샷 (연단위 갱신)';
|
||||||
|
COMMENT ON COLUMN kcg.fishery_permit_cn.permit_year IS '허가 연도 (파일명 YYYY에서 추출)';
|
||||||
|
COMMENT ON COLUMN kcg.fishery_permit_cn.fishery_code IS 'PT(쌍끌이 본선)/PT-S(부속선)/GN(자망)/FC(운반)/PS(선망)/OT(외끌이)';
|
||||||
|
COMMENT ON COLUMN kcg.fishery_permit_cn.parent_permit_no IS 'PT-S(부속선)가 소속된 본선의 허가번호';
|
||||||
|
|
||||||
|
-- ===== 2. fleet_vessels 확장 : 연도 + 업종코드 추적 =====
|
||||||
|
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS permit_year INTEGER;
|
||||||
|
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS fishery_code VARCHAR(10);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_permit_year ON kcg.fleet_vessels(permit_year);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_fishery_code ON kcg.fleet_vessels(fishery_code);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN kcg.fleet_vessels.permit_year IS '허가 연도. fleet_tracker는 현재 연도만 조회';
|
||||||
|
COMMENT ON COLUMN kcg.fleet_vessels.fishery_code IS 'fishery_permit_cn.fishery_code 복제 (PT/PT-S/GN/FC/PS/OT)';
|
||||||
|
|
||||||
|
-- ===== 3. V014 데모 seed 제거 =====
|
||||||
|
-- 기존 6행 데모 vessels 제거 (실제 허가현황만 남김).
|
||||||
|
-- fleet_companies id=1,2는 vessel_permit_master가 FK로 참조하여 삭제 불가 — 잔존 허용
|
||||||
|
-- (loader 실행 시 실 허가 신청인 회사가 별도 id로 upsert됨)
|
||||||
|
DELETE FROM kcg.fleet_vessels WHERE permit_no IN (
|
||||||
|
'ZY-2024-001','ZY-2024-002','ZY-2024-003',
|
||||||
|
'ZY-2024-010','ZY-2024-011','ZY-2024-012'
|
||||||
|
);
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
|
||||||
|
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
|
||||||
|
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. 충돌 이력 테이블
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
|
||||||
|
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
|
||||||
|
mmsi_hi VARCHAR(20) NOT NULL,
|
||||||
|
parent_name VARCHAR(100),
|
||||||
|
parent_vessel_id BIGINT, -- fleet_vessels.id
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
|
||||||
|
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
|
||||||
|
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
|
||||||
|
last_lat_lo NUMERIC(9,6),
|
||||||
|
last_lon_lo NUMERIC(10,6),
|
||||||
|
last_lat_hi NUMERIC(9,6),
|
||||||
|
last_lon_hi NUMERIC(10,6),
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
|
||||||
|
resolved_by UUID,
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution_note TEXT,
|
||||||
|
evidence JSONB, -- 최근 관측 요약
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
|
||||||
|
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_status
|
||||||
|
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_severity
|
||||||
|
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_parent
|
||||||
|
ON kcg.gear_identity_collisions(parent_vessel_id)
|
||||||
|
WHERE parent_vessel_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_name
|
||||||
|
ON kcg.gear_identity_collisions(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
|
||||||
|
ON kcg.gear_identity_collisions(last_seen_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.gear_identity_collisions IS
|
||||||
|
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree
|
||||||
|
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||||
|
url_path, label_key, component_key, nav_sort, labels)
|
||||||
|
VALUES
|
||||||
|
('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
|
||||||
|
'/gear-collision', 'nav.gearCollision',
|
||||||
|
'features/detection/GearCollisionDetection', 950,
|
||||||
|
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. 권한 부여
|
||||||
|
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
|
||||||
|
-- OPERATOR: READ + UPDATE (분류 액션)
|
||||||
|
-- VIEWER/ANALYST/FIELD: READ
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'OPERATOR'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
-- V031: gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30) → VARCHAR(100)
|
||||||
|
--
|
||||||
|
-- 원인: prediction/algorithms/gear_parent_inference.py 의 _rank_candidates 가
|
||||||
|
-- candidate_source = ','.join(sorted(meta['sources'])) (line 875)
|
||||||
|
-- 로 여러 source 라벨(CORRELATION / EPISODE / LABEL / LINEAGE / MATCH 등) 을 쉼표로
|
||||||
|
-- join 해 저장한다. 모든 source 가 맞춰지면 약 39자(5개 라벨 + 쉼표) 에 달해
|
||||||
|
-- VARCHAR(30) 를 초과하며 psycopg2.errors.StringDataRightTruncation 을 유발.
|
||||||
|
--
|
||||||
|
-- 영향: prediction 사이클의 gear_correlation 스테이지에서 `_insert_candidate_snapshots`
|
||||||
|
-- 실패 → `gear_group_parent_candidate_snapshots` 테이블 갱신 전체 스킵.
|
||||||
|
-- Phase 0-1 의 사이클 스테이지 에러 격리 덕분에 후속 스테이지(pair_detection /
|
||||||
|
-- per-vessel analysis / upsert_results / 출력 모듈들)는 정상 완주.
|
||||||
|
--
|
||||||
|
-- 대안 검토:
|
||||||
|
-- (A) VARCHAR(100) — 여유치. 채택.
|
||||||
|
-- (B) TEXT — 제약 없음. 컬럼 의미가 "짧은 라벨 집합" 이라 VARCHAR 가 자연스러움.
|
||||||
|
-- (C) 코드에서 [:30] trim — 의미 손실(source 목록 절단). 부적합.
|
||||||
|
|
||||||
|
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||||
|
ALTER COLUMN candidate_source TYPE VARCHAR(100);
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed
|
||||||
|
--
|
||||||
|
-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종
|
||||||
|
-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는
|
||||||
|
-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행.
|
||||||
|
--
|
||||||
|
-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째.
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
|
||||||
|
-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree
|
||||||
|
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||||
|
url_path, label_key, component_key, nav_sort, labels)
|
||||||
|
VALUES
|
||||||
|
('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45,
|
||||||
|
'/illegal-fishing', 'nav.illegalFishing',
|
||||||
|
'features/detection/IllegalFishingPattern', 920,
|
||||||
|
'{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. 권한 부여
|
||||||
|
-- READ 전용 페이지 — 모든 역할에 READ만 부여.
|
||||||
|
-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동.
|
||||||
|
-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도).
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed
|
||||||
|
--
|
||||||
|
-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
|
||||||
|
-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용
|
||||||
|
-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고,
|
||||||
|
-- 이 페이지는 전체 목록·통계 운영 대시보드.
|
||||||
|
--
|
||||||
|
-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을
|
||||||
|
-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가.
|
||||||
|
-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음.
|
||||||
|
-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
|
||||||
|
-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR).
|
||||||
|
--
|
||||||
|
-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL)
|
||||||
|
-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree
|
||||||
|
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||||
|
url_path, label_key, component_key, nav_sort, labels)
|
||||||
|
VALUES
|
||||||
|
('detection:transshipment', NULL, '환적 의심 탐지', 1, 48,
|
||||||
|
'/transshipment', 'nav.transshipment',
|
||||||
|
'features/detection/TransshipmentDetection', 910,
|
||||||
|
'{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. 권한 부여 — READ 전용 대시보드
|
||||||
|
-- ADMIN: 5 ops 전부 (메뉴 관리 일관성)
|
||||||
|
-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:transshipment', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:transshipment', 'READ', 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
-- V034: Detection Model Registry 기반 스키마 (Phase 1-1)
|
||||||
|
--
|
||||||
|
-- prediction 모듈의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고,
|
||||||
|
-- 운영자가 프론트엔드에서 파라미터·버전을 관리할 수 있도록 하는 기반 인프라.
|
||||||
|
-- 핵심 개념:
|
||||||
|
-- * Model — 독립 탐지 단위 (dark_suspicion / gear_violation_g01_g06 등)
|
||||||
|
-- * Version — 같은 model 의 파라미터 스냅샷. 라이프사이클 DRAFT→ACTIVE→ARCHIVED
|
||||||
|
-- * Role — PRIMARY(운영 반영, 최대 1개) / SHADOW·CHALLENGER(관측용, N개)
|
||||||
|
-- * DAG — model_id 간 선행·후행 의존성 (선행 PRIMARY 결과만 후행에 주입)
|
||||||
|
--
|
||||||
|
-- 기존 V014 correlation_param_models 패턴의 일반화 — JSONB params + is_active 를
|
||||||
|
-- model_id × version × role 차원으로 확장. V014 는 Phase 2 에서 이 스키마로
|
||||||
|
-- 이주 후 2~3 릴리즈 후 deprecate 예정.
|
||||||
|
--
|
||||||
|
-- 참고: docs/prediction-analysis.md §7 P1/P2 + .claude/plans/vast-tinkering-knuth.md
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. detection_models — 모델 카탈로그 (고정 메타)
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_models (
|
||||||
|
model_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
display_name VARCHAR(200) NOT NULL,
|
||||||
|
tier INT NOT NULL, -- 1(Primitive) ~ 5(Meta)
|
||||||
|
category VARCHAR(40), -- DARK_VESSEL/GEAR/PATTERN/TRANSSHIP/META
|
||||||
|
description TEXT,
|
||||||
|
entry_module VARCHAR(200) NOT NULL, -- 'prediction.algorithms.dark_vessel'
|
||||||
|
entry_callable VARCHAR(100) NOT NULL, -- 'compute_dark_suspicion'
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 전역 ON/OFF 킬스위치
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
CHECK (tier BETWEEN 1 AND 5)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_models_tier
|
||||||
|
ON kcg.detection_models(tier, category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_models_enabled
|
||||||
|
ON kcg.detection_models(is_enabled) WHERE is_enabled = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.detection_models IS
|
||||||
|
'탐지 모델 카탈로그 — prediction Model 인터페이스의 단위 정의';
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. detection_model_dependencies — 모델 간 DAG 엣지
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_model_dependencies (
|
||||||
|
model_id VARCHAR(64) NOT NULL
|
||||||
|
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
|
||||||
|
depends_on VARCHAR(64) NOT NULL
|
||||||
|
REFERENCES kcg.detection_models(model_id) ON DELETE RESTRICT,
|
||||||
|
input_key VARCHAR(64) NOT NULL, -- 'gap_info' / 'pair_result' 등
|
||||||
|
PRIMARY KEY (model_id, depends_on, input_key),
|
||||||
|
CHECK (model_id <> depends_on) -- self-loop 금지
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_model_deps_reverse
|
||||||
|
ON kcg.detection_model_dependencies(depends_on);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.detection_model_dependencies IS
|
||||||
|
'모델 실행 DAG — 선행 model PRIMARY 결과의 어떤 key 를 후행이 입력으로 쓰는지';
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_model_versions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
model_id VARCHAR(64) NOT NULL
|
||||||
|
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(32) NOT NULL, -- SemVer 권장 '1.0.0', 자유도 허용
|
||||||
|
status VARCHAR(20) NOT NULL, -- DRAFT / TESTING / ACTIVE / ARCHIVED
|
||||||
|
role VARCHAR(20), -- PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
|
||||||
|
params JSONB NOT NULL, -- 임계값·가중치·상수
|
||||||
|
notes TEXT,
|
||||||
|
traffic_weight INT DEFAULT 0, -- CHALLENGER split (0~100, 후속 기능)
|
||||||
|
parent_version_id BIGINT
|
||||||
|
REFERENCES kcg.detection_model_versions(id) ON DELETE SET NULL,
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (model_id, version),
|
||||||
|
CHECK (status IN ('DRAFT','TESTING','ACTIVE','ARCHIVED')),
|
||||||
|
CHECK (role IS NULL OR role IN ('PRIMARY','SHADOW','CHALLENGER')),
|
||||||
|
CHECK (status <> 'ACTIVE' OR role IS NOT NULL), -- ACTIVE → role 필수
|
||||||
|
CHECK (traffic_weight BETWEEN 0 AND 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 한 model_id 에 PRIMARY×ACTIVE 는 최대 1건 (운영 반영 보호)
|
||||||
|
-- SHADOW/CHALLENGER×ACTIVE 는 N 건 허용
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_detection_model_primary
|
||||||
|
ON kcg.detection_model_versions(model_id)
|
||||||
|
WHERE status = 'ACTIVE' AND role = 'PRIMARY';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_active
|
||||||
|
ON kcg.detection_model_versions(model_id, status)
|
||||||
|
WHERE status = 'ACTIVE';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_status
|
||||||
|
ON kcg.detection_model_versions(status, model_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.detection_model_versions IS
|
||||||
|
'모델 버전 + 파라미터 + 라이프사이클. ACTIVE 는 role=PRIMARY 1개 + SHADOW/CHALLENGER N개';
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. detection_model_run_outputs — 버전별 입력·출력 비교 (파티션)
|
||||||
|
-- 같은 (cycle, model_id, input_ref) 로 PRIMARY vs SHADOW JOIN 비교
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs (
|
||||||
|
id BIGSERIAL,
|
||||||
|
cycle_started_at TIMESTAMPTZ NOT NULL, -- 같은 사이클 식별 (모든 버전 공유)
|
||||||
|
model_id VARCHAR(64) NOT NULL,
|
||||||
|
version_id BIGINT NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL, -- PRIMARY / SHADOW / CHALLENGER
|
||||||
|
input_ref JSONB NOT NULL, -- {mmsi, analyzed_at} 등
|
||||||
|
outputs JSONB NOT NULL, -- 모델 반환값 snapshot
|
||||||
|
cycle_duration_ms INT,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (id, cycle_started_at),
|
||||||
|
CHECK (role IN ('PRIMARY','SHADOW','CHALLENGER'))
|
||||||
|
) PARTITION BY RANGE (cycle_started_at);
|
||||||
|
|
||||||
|
-- 2026-04 파티션 (이후는 partition_manager 가 월별 자동 생성)
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_04
|
||||||
|
PARTITION OF kcg.detection_model_run_outputs
|
||||||
|
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_05
|
||||||
|
PARTITION OF kcg.detection_model_run_outputs
|
||||||
|
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_run_outputs_compare
|
||||||
|
ON kcg.detection_model_run_outputs(model_id, cycle_started_at DESC, role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_run_outputs_version
|
||||||
|
ON kcg.detection_model_run_outputs(version_id, cycle_started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_run_outputs_input
|
||||||
|
ON kcg.detection_model_run_outputs USING GIN (input_ref jsonb_path_ops);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.detection_model_run_outputs IS
|
||||||
|
'버전별 실행 결과 원시 snapshot — PRIMARY vs SHADOW diff 분석용. 월별 파티션, 기본 7일 retention';
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. detection_model_metrics — 사이클 단위 집계 메트릭
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.detection_model_metrics (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
model_id VARCHAR(64) NOT NULL,
|
||||||
|
version_id BIGINT NOT NULL
|
||||||
|
REFERENCES kcg.detection_model_versions(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
metric_key VARCHAR(64) NOT NULL, -- cycle_duration_ms/detected_count/tier_critical_count
|
||||||
|
metric_value NUMERIC,
|
||||||
|
cycle_started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_lookup
|
||||||
|
ON kcg.detection_model_metrics(model_id, version_id, cycle_started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_cycle
|
||||||
|
ON kcg.detection_model_metrics(cycle_started_at DESC, model_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.detection_model_metrics IS
|
||||||
|
'모델 실행 집계 메트릭 — dashboard/compare API 의 관측 소스';
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 6. Compare VIEW — PRIMARY vs SHADOW 결과를 같은 입력 단위로 JOIN
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
CREATE OR REPLACE VIEW kcg.v_detection_model_comparison AS
|
||||||
|
SELECT
|
||||||
|
p.cycle_started_at,
|
||||||
|
p.model_id,
|
||||||
|
p.input_ref,
|
||||||
|
p.outputs AS primary_outputs,
|
||||||
|
s.outputs AS shadow_outputs,
|
||||||
|
p.version_id AS primary_version_id,
|
||||||
|
s.version_id AS shadow_version_id,
|
||||||
|
s.role AS shadow_role
|
||||||
|
FROM kcg.detection_model_run_outputs p
|
||||||
|
JOIN kcg.detection_model_run_outputs s
|
||||||
|
ON p.cycle_started_at = s.cycle_started_at
|
||||||
|
AND p.model_id = s.model_id
|
||||||
|
AND p.input_ref = s.input_ref
|
||||||
|
WHERE p.role = 'PRIMARY'
|
||||||
|
AND s.role IN ('SHADOW', 'CHALLENGER');
|
||||||
|
|
||||||
|
COMMENT ON VIEW kcg.v_detection_model_comparison IS
|
||||||
|
'PRIMARY × SHADOW/CHALLENGER 동일 입력 결과 비교 — backend Compare API 의 집계 소스';
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 7. 권한 트리 / 메뉴 슬롯
|
||||||
|
-- ai-operations:detection-models — AI 모델관리 하위 혹은 별도 노드.
|
||||||
|
-- 기존 ai-operations:ai-model(nav=200) 과 구분하기 위해 별도 nav_sort=250.
|
||||||
|
-- parent_cd='admin' (AI 운영 그룹 평탄화 패턴 따름)
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
INSERT INTO kcg.auth_perm_tree
|
||||||
|
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||||
|
url_path, label_key, component_key, nav_sort, labels)
|
||||||
|
VALUES
|
||||||
|
('ai-operations:detection-models', 'admin', '탐지 모델 관리', 1, 25,
|
||||||
|
'/detection-models', 'nav.detectionModels',
|
||||||
|
'features/ai-operations/DetectionModelManagement', 250,
|
||||||
|
'{"ko":"탐지 모델 관리","en":"Detection Models"}'::jsonb)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
-- 8. 권한 부여
|
||||||
|
-- ADMIN : 5 ops 전부 (모델 카탈로그 관리)
|
||||||
|
-- OPERATOR : READ + UPDATE (SHADOW activate 정도. promote-primary 는 ADMIN 만)
|
||||||
|
-- ANALYST/VIEWER: READ (파라미터 조회 + Compare 분석)
|
||||||
|
-- FIELD : (생략 — 현장 단속 담당, 모델 관리 불필요)
|
||||||
|
-- ══════════════════════════════════════════════════════════════════
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'OPERATOR'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'ai-operations:detection-models', 'READ', 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
WHERE r.role_cd IN ('ANALYST', 'VIEWER')
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -31,7 +31,7 @@
|
|||||||
| 서비스 | systemd | 포트 | 로그 |
|
| 서비스 | systemd | 포트 | 로그 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
|
||||||
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
|
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
|
||||||
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
|
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
|
||||||
|
|
||||||
## 디렉토리 구조
|
## 디렉토리 구조
|
||||||
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
|
|||||||
| 443 | nginx (HTTPS) | rocky-211 |
|
| 443 | nginx (HTTPS) | rocky-211 |
|
||||||
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
|
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
|
||||||
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
|
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
|
||||||
| 8001 | kcg-prediction (기존 iran) | redis-211 |
|
| 8001 | kcg-prediction (레거시) | redis-211 |
|
||||||
| 18091 | kcg-prediction-lab | redis-211 |
|
| 18091 | kcg-prediction-lab | redis-211 |
|
||||||
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
|
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
|
||||||
| 6379 | Redis | redis-211 |
|
| 6379 | Redis | redis-211 |
|
||||||
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
|
|||||||
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
|
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
|
||||||
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
|
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
|
||||||
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
|
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
|
||||||
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
|
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
|
||||||
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |
|
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |
|
||||||
|
|||||||
@ -4,7 +4,178 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2026-04-09.2]
|
## [2026-04-20]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **Detection Model Registry DB 스키마 (V034, Phase 1-1)** — prediction 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서 파라미터·버전·가중치를 관리할 수 있는 기반 인프라. 테이블 4종(`detection_models` 카탈로그 / `detection_model_dependencies` DAG / `detection_model_versions` 파라미터 스냅샷·라이프사이클·role / `detection_model_run_outputs` 월별 파티션) + 뷰 1개(`v_detection_model_comparison` PRIMARY×SHADOW JOIN). 한 모델을 서로 다른 파라미터로 **동시 실행**(PRIMARY 1 + SHADOW/CHALLENGER N) 지원, ACTIVE×PRIMARY 는 UNIQUE partial index 로 1건 보호. 권한 트리 `ai-operations:detection-models`(nav_sort=250) + ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ. (후속: Phase 1-2 Model Registry + DAG Executor, Phase 2 PoC 5 모델 마이그레이션)
|
||||||
|
- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)** — `/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2)
|
||||||
|
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **Prediction 5분 사이클 스테이지 에러 경계 도입 (Phase 0-1)** — `prediction/pipeline/stage_runner.py` 신설해 `run_stage(name, fn, required=False)` 유틸 제공. `scheduler.py run_analysis_cycle()` 의 출력 6모듈(violation_classifier / event_generator / kpi_writer / stats_aggregate_hourly / stats_aggregate_daily / alert_dispatcher)을 한 try/except 로 묶던 구조를 스테이지별 독립 실행으로 분리, 한 모듈이 깨져도 다른 모듈이 계속 돌아가도록 개선. `upsert_results` 는 required=True 로 실패 시 사이클 abort. 내부 try/except 의 `logger.warning` 을 `logger.exception` 으로 업그레이드(fetch_dark_history / gear collision event promotion / group polygon / gear correlation / pair detection / chat cache)하여 `journalctl -u kcg-ai-prediction` 에서 stacktrace 로 실패 지점 즉시 특정 가능. (docs/prediction-analysis.md P1 권고)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- **모니터링/디자인시스템 런타임 에러 해소** — `/monitoring` 의 `SystemStatusPanel` 에서 `stats.total.toLocaleString()` 호출이 백엔드 응답 shape 이슈로 `stats.total` 이 undefined 일 때 Uncaught TypeError 로 크래시하던 문제 null-safe 로 해소(`stats?.total != null`). `/design-system.html` 의 `CatalogBadges` 가 `PERFORMANCE_STATUS_META` 의 `label: {ko, en}` 객체를 그대로 Badge children 으로 주입해 "Objects are not valid as a React child" 를 던지고 `code` 필드 부재로 key 중복 경고가 함께 뜨던 문제 해소 — `Object.entries` 순회 + `AnyMeta.label` 을 `string | {ko,en}` 로 확장 + getKoLabel/getEnLabel 에 label 객체 케이스 추가
|
||||||
|
- **gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30)→(100) 확장 (V031)** — prediction `gear_parent_inference` 가 여러 source 라벨을 쉼표로 join 한 값(최대 ~39자)이 VARCHAR(30) 제약을 넘어 매 사이클 `StringDataRightTruncation` 으로 gear correlation 스테이지 전체가 실패하던 기존 버그. Phase 0-1 (PR #83) 의 `logger.exception` 전환으로 풀 stacktrace 가 journal 에 찍히면서 원인 특정. backend JPA 엔티티 미참조로 재빌드 불필요, Flyway 자동 적용, prediction 재기동만으로 해소
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- **Prediction 모듈 심층 분석 리포트 신설** — `docs/prediction-analysis.md` (9개 섹션, 250 라인). opus 4.7 독립 리뷰 관점에서 현재 17 알고리즘의 레이어 분리·5분 사이클 시퀀스·4대 도메인 커버리지를 평가하고, 6축(관심사 분리/재사용성/테스트 가능성/에러 격리/동시성/설정 가능성)으로 구조 채점 + P1~P4 개선 제안·임계값 전수표 제공
|
||||||
|
- **루트·SFR 문서 drift 해소** — V001~V016 → V030 + 51 테이블, Python 3.9 → 3.11+, 14 → 17 알고리즘 모듈 실측 반영. SFR-10 에 GEAR_IDENTITY_COLLISION 패턴 + GearCollisionDetection 페이지 섹션 추가 (sfr-traceability/sfr-user-guide), `/gear-collision` 라우트 architecture.md 포함, system-flow-guide 노드 수 102→115 + V030 manifest 미반영 경고, backend/README "Phase 2 예정" 상태 → 실제 운영 구성 전면 재작성 (PR #79 hotfix 요구사항 명시)
|
||||||
|
|
||||||
|
## [2026-04-17]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴** — 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 스푸핑/복제 의심 패턴을 신규 탐지. prediction `fleet_tracker.track_gear_identity()` 가 공존(simultaneous) / 교체(sequential) 경로를 분리해 공존 쌍은 `gear_identity_collisions` 에 UPSERT (누적 공존 횟수, 최대 거리, 양측 좌표, evidence JSONB append). 심각도는 거리/누적/스왑 기반으로 CRITICAL/HIGH/MEDIUM/LOW 자동 재계산, 운영자 확정 상태(CONFIRMED_ILLEGAL/FALSE_POSITIVE)는 보존. CRITICAL/HIGH 승격 시 `prediction_events` 허브에 `GEAR_IDENTITY_COLLISION` 카테고리 등록(dedup 367분). `/api/analysis/gear-collisions` READ + resolve 액션(REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN, `@Auditable GEAR_COLLISION_RESOLVE`). 좌측 메뉴 "어구 정체성 충돌" 자동 노출(nav_sort=950, detection:gear-collision)
|
||||||
|
- **gearCollisionStatuses 카탈로그** — `shared/constants/gearCollisionStatuses.ts` + `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출. OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE 4단계 Badge intent 매핑
|
||||||
|
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **디자인 시스템 SSOT 일괄 준수 (30파일)** — `frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>` → `<Button variant>` / raw `<input>` → `<Input>` / raw `<select>` → `<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
|
||||||
|
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)** — `common.json` 에 `aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환
|
||||||
|
- **iran 백엔드 프록시 잔재 제거** — `IranBackendClient` dead class 삭제, `application.yml` 의 `iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)` → `AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹
|
||||||
|
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
|
||||||
|
- **감사 로그 보강** — `EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent` 에 `PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
|
||||||
|
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
|
||||||
|
- **prediction 5분 사이클 안정화** — `gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores` 의 `target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- **백엔드 RestClient bean 모호성으로 기동 실패 해소** — rocky-211 `kcg-ai-backend` 가 restart 시 `No qualifying bean of type RestClient, but 2 were found: predictionRestClient, signalBatchRestClient` 로 크래시 루프 진입하던 문제. PR #A(2026-04-17) 의 RestClientConfig 도입 이후 잠복해 있던 버그로, `@RequiredArgsConstructor` 가 생성한 constructor parameter 에 필드의 `@Qualifier` 가 복사되지 않아 Spring 6.1 의 parameter-level annotation 기반 주입이 실패한 것. 수정: `backend/pom.xml` 의 `maven-compiler-plugin` 실행 설정에 `<parameters>true</parameters>` 명시 + `backend/src/main/java/lombok.config` 신설해 `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier` 등록
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- **절대 지침 섹션 추가** — CLAUDE.md 최상단에 "절대 지침(Absolute Rules)" 섹션 신설. (1) 신규 브랜치 생성 전 `git fetch` 후 `origin/develop` 대비 뒤처지면 사용자 확인 → `git pull --ff-only` → 분기하는 동기화 절차 명시, (2) `frontend/` 작업 시 `design-system.html` 쇼케이스 규칙 전면 준수(공통 컴포넌트 우선 사용, 인라인/하드코딩 Tailwind 색상·`!important` 금지, 접근성 필수 체크리스트) 요약. 하단 기존 "디자인 시스템 (필수 준수)" 상세 섹션과 연결
|
||||||
|
- **프로젝트 산출문서 2026-04-17 기준 정비** — `architecture.md` shared/components/ui 9개·i18n 네임스페이스 갱신, `sfr-traceability.md` v3.0 전면 재작성(운영 상태 기반 531라인), `sfr-user-guide.md` 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신, stale 3건 제거
|
||||||
|
|
||||||
|
## [2026-04-16.7]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **경량 분석 riskScore 해상도 개선** — `compute_lightweight_risk_score` 에 `dark_suspicion_score`(0~100 패턴 기반 의심도) / `dist_from_baseline_nm`(EEZ 외 기선 근접도 12·24NM 차등) / `dark_history_24h`(반복 이력) 반영. 허가·반복 이중계산 방지 축소 로직. 배포 후 실측: 45점 60.8% 고정 수렴 → **0%** (11~40 구간 고르게 분산)
|
||||||
|
- **vessel_type 매핑** — fleet_vessels 등록선 `fishery_code` (PT/PT-S/OT/GN/PS/FC) 를 `TRAWL/GILLNET/PURSE/CARGO` 로 매핑하는 `vessel_type_mapping.py` 신설. 경량 경로의 `vessel_type='UNKNOWN'` 하드코딩 제거. 실측: UNKNOWN 98.6% → **89.1%** (886척이 구체 유형으로 전환)
|
||||||
|
- **VesselType 값 확장** — 기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN 에 `GILLNET`(유자망) / `CARGO`(운반선) 2종 추가
|
||||||
|
- **중국 선박 분석 그리드 정합성** — Tab 1 상단 `RealAllVessels` 편의 export 를 `mmsiPrefix='412'` 로 고정 + 제목 "중국 선박 전체 분석 결과 (실시간)" 로 변경. 상단/하단 모두 중국 선박 기준으로 일관 표시
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **선박 유형 한글 카탈로그** — `shared/constants/vesselTypes.ts` 신설. 저인망/선망/유자망/연승/통발/운반선/미분류 한글 라벨 + Badge intent. 기존 `alertLevels` 패턴 답습, `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출
|
||||||
|
|
||||||
|
## [2026-04-16.6]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시
|
||||||
|
- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필
|
||||||
|
- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등)
|
||||||
|
- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용
|
||||||
|
- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가
|
||||||
|
- **VesselAnalysisResponse 필드 확장** — `violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출
|
||||||
|
- **prediction 분석 시점 좌표 저장** — `AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인)
|
||||||
|
|
||||||
|
## [2026-04-16.5]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **Admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)** — 129건 Tailwind 색상 → 시맨틱 토큰(text-label/text-heading/text-hint) + Badge intent 치환. raw `<button>` → `<Button>` 컴포넌트 교체. 미사용 import 정리
|
||||||
|
|
||||||
|
## [2026-04-16.4]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **G-02 금어기 조업 탐지** — `fishery_permit_cn.fishing_period_1/2` 파싱(YYYY/MM/DD 범위) + `classify_gear_violations()` 에 permit_periods/observation_ts 인자 추가. 허가기간 밖 조업 시 `CLOSED_SEASON_FISHING` judgment
|
||||||
|
- **G-03 미등록/허가외 어구 탐지** — 감지 어구와 `fishery_code` 허용 목록 대조(PT→trawl, GN→gillnet, FC→금지 등). 불일치 시 `UNREGISTERED_GEAR` judgment
|
||||||
|
- **NAME_FUZZY 매칭** — 선박명 정규화(공백/대소문자/'NO.' 마커 통일, 선박번호 유지) + name_en 기반 fuzzy lookup. 동명이 중복 방지. 매칭률 9.1% → 53.1%
|
||||||
|
- **서버 스크립트 tier/G-02/G-03/match_method 추적** — diagnostic(5분) + hourly(1시간) 에 pair_tier 분포, reject 사유 카운터, fishery_code×match_method 교차, G-02/G-03 상세 섹션
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **pair_trawl tier 분류** — AND 게이트(스펙 100% 2시간) 대신 STRONG/PROBABLE/SUSPECT 3단계. 완화 임계(800m/SOG 1.5-5/sog_delta 1.0/cog 20°)로 실제 공조 신호 포착. G-06 판정은 STRONG/PROBABLE 만
|
||||||
|
- **pair_trawl join key** — raw AIS timestamp → `time_bucket`(5분 리샘플). sog/cog on-demand 계산(`_ensure_sog_cog`)으로 vessel_store._tracks 직접 사용
|
||||||
|
- **pair base 확장** — classification 500척 → 전체 중국 MID(412/413/414) 조업 속력대 선박. candidates 61→1,668, detected 0→57
|
||||||
|
- **match_ais_to_registry 대상 확장** — vessel_dfs(500척) → vessel_store._tracks 전체 중국 선박(8k+)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- **violation_classifier** — `CLOSED_SEASON_FISHING`, `UNREGISTERED_GEAR` judgment → `ILLEGAL_GEAR` 카테고리 매핑 추가
|
||||||
|
|
||||||
|
## [2026-04-16.3]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **Admin 3개 페이지 디자인 시스템 준수 리팩토링 (Phase 1-A)** — PerformanceMonitoring/DataRetentionPolicy/DataModelVerification 자체 탭 네비 → `TabBar/TabButton` 공통 컴포넌트, 원시 `<button>` → `TabButton`, PerformanceMonitoring 정적 hex 9건 → `performanceStatus` 카탈로그 경유
|
||||||
|
- **신규 카탈로그** `shared/constants/performanceStatus.ts` — PerformanceStatus(good/warning/critical/running/passed/failed/active/scheduled/archived) → {intent, hex, label} + utilizationStatus(ratio) 헬퍼
|
||||||
|
- **RBAC skeleton** — 3개 페이지 최상단 `useAuth().hasPermission('admin:{resource}', 'OP')` 호출 배치 (Phase 3 액션 버튼 추가 시 가드로 연결)
|
||||||
|
|
||||||
|
## [2026-04-16.2]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **성능 모니터링(PER-01~06) 메뉴** — 시스템관리 > 감사·보안에 성능 모니터링 페이지 추가 (PerformanceMonitoring 컴포넌트 + V028 메뉴 마이그레이션)
|
||||||
|
- **데이터 모델 검증(DAR-11) 메뉴** — DataModelVerification 페이지 + V027 메뉴
|
||||||
|
- **데이터 보관·파기 정책(DAR-10) 메뉴** — DataRetentionPolicy 페이지 + V026 메뉴
|
||||||
|
- **DAR-03 5종 어구 구조 비교** — AI 모델관리 어구 탐지 탭에 저층 트롤 / 스토우넷 / 자망 / 통발 / 쌍끌이 이미지 및 설명 추가
|
||||||
|
- **단속 계획 탭 확장** — 단일 함정 순찰 작전 / 다함정 순찰 작전 탭 추가 (EnforcementPlan)
|
||||||
|
|
||||||
|
## [2026-04-16]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **한중어업협정 중국어선 허가현황 레지스트리** — `kcg.fishery_permit_cn` 신규 테이블(29컬럼, 연단위 스냅샷). V029 마이그레이션 + `fleet_vessels.permit_year/fishery_code` 컬럼 추가. `load_fishery_permit_cn.py`로 연도별 XLS → DB 적재(906척/497 신청인사)
|
||||||
|
- **페어 탐색 재설계** — `find_pair_candidates()` bbox 1차(인접 9 cell) + 궤적 유사도 2차(location/sog_corr/cog_alignment). 동종 어선 페어도 허용, role 가점(PT_REGISTERED/COOP_FISHING/TRANSSHIP_LIKE)
|
||||||
|
- **fleet_tracker API 3개** — `get_pt_registered_mmsis` / `get_gear_episodes` / `get_gear_positions`
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- **DAR-03 G-04/G-05/G-06 Dead code 해결** — `classify_gear_violations()` scheduler 호출 연결. `if 'pair_results' in dir()` 버그 제거. 사이클당 G-05 303건 / G-04 1건 탐지 시작
|
||||||
|
- **spoofing 산식** — 24h 희석 버그 → 최근 1h 윈도우 + teleport 절대 가점(건당 0.20) + extreme(>50kn) 단독 발견 시 score=max(0.6) 확정
|
||||||
|
- **gear_code DB write 경로** — `AnalysisResult.gear_code` 필드 + `kcgdb.upsert_results()` INSERT/UPDATE + scheduler 두 경로에서 `fleet_tracker.get_vessel_gear_code()` 호출
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **transshipment 선종 완화** — `_CARRIER_HINTS`(cargo/tanker/supply/carrier/reefer) 부분일치 + 412* 중국어선 FISHING 간주
|
||||||
|
- **gear drift 임계** — 750m → **500m** (DAR-03 스펙 정합)
|
||||||
|
- **fleet_tracker 현재 연도 필터** — `WHERE permit_year = EXTRACT(YEAR FROM now())::int OR permit_year IS NULL`
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- cron 스크립트 신규 섹션: hourly P1~P5(허가/매칭/gear_code/fleet_role) + D3.5(pair_type) / diagnostic PART 7.5 + 4-5.5
|
||||||
|
|
||||||
|
## [2026-04-15]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **DAR-03 G-code 위반 분류** — prediction에 G-01(수역×어구 위반)/G-04(MMSI 사이클링)/G-05(고정어구 표류)/G-06(쌍끌이 공조) 4개 위반 유형 자동 분류 + 점수 합산
|
||||||
|
- **쌍끌이 공조 탐지 알고리즘** — pair_trawl.py 신규 (cell-key 파티션 O(n) 스캔, 500m 근접·0.5kn 속도차·10° COG 일치·2h 지속 임계값)
|
||||||
|
- **모선 검토 워크플로우** — 어구 판정 상세 패널에 후보 검토 UI 추가 (관측 지표 7종 평균 + 보정 지표 + 모선 확정/제외 버튼). 별도 화면 진입 없이 어구 탐지 페이지 내에서 의사결정
|
||||||
|
- **24시간 궤적 리플레이** — TripsLayer fade trail 애니메이션, 멤버별 개별 타임라인 보간(빈 구간 자연 연속), convex hull 폴리곤 실시간 생성, 후보 선박 항적 동시 재생 (signal-batch /api/v2/tracks/vessels 프록시 연동)
|
||||||
|
- **어구 탐지 그리드 UX** — 다중 선택 필터 패널(설치 해역/판정/위험도/모선 상태/허가/멤버 수 슬라이더, localStorage 영속화), 행 클릭 시 지도 flyTo, 후보 일치율 칼럼 + 정렬
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **그리드 후보 일치율 정확도** — resolution.top_score(평가 시점 고정) 대신 correlation_scores.current_score(실시간 갱신)의 최댓값 사용 → 최신 점수 반영
|
||||||
|
- **어구 그룹 칼럼 표시** — 모선 후보 MMSI가 그룹명 자리에 표시되던 버그 수정 (groupLabel/groupKey 우선 표시)
|
||||||
|
- **ParentResolution Entity 확장** — top_score/confidence/score_margin/decision_source/stable_cycles 등 점수 근거 7개 필드 추가
|
||||||
|
- **백엔드 correlation API 응답 정규화** — snake_case 컬럼을 camelCase로 명시 매핑 (프론트 매핑 누락 방지)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- **궤적 리플레이 깜박임** — useMapLayers와 useGearReplayLayers가 동시에 overlay.setProps()를 호출하던 경쟁 조건 제거. 리플레이 활성 시 useMapLayers 비활성화 (단일 렌더링 경로)
|
||||||
|
- **멤버-중심 연결선 제거** — 어구 그룹 선택 모드에서 불필요하게 그려지던 dashed 연결선 코드 삭제
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- **루트 .venv/ gitignore 추가**
|
||||||
|
|
||||||
|
## [2026-04-14]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **DarkVesselDetection 판정 상세 패널** — 테이블 행 클릭 시 점수 산출 내역(P1~P11), GAP 상세, 7일 이력 차트 사이드 패널 표시
|
||||||
|
- **ScoreBreakdown 공통 컴포넌트** — 가점/감점 분리 점수 내역 시각화
|
||||||
|
- **darkVesselPatterns 카탈로그 확장** — prediction 실제 판정 패턴 18종 한국어 라벨+점수+설명 + buildScoreBreakdown() 유틸
|
||||||
|
- **TransferDetection 환적 운영 화면** — 5단계 파이프라인 기반 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
|
||||||
|
- **GearDetection 모선 추론 연동** — 모선 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 컬럼
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **DarkVesselDetection 위치 표시 수정** — lat/lon null 시 features.gap_start_lat/lon fallback, 클릭 시 지도 하이라이트
|
||||||
|
- **EnforcementPlan 탐지 기반 단속 대상** — CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 통합 표시
|
||||||
|
- **LGCNS 3개 페이지 디자인 시스템 전환** — LGCNSMLOps/AISecurityPage/AIAgentSecurityPage 공통 구조 적용
|
||||||
|
|
||||||
|
## [2026-04-13]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성
|
||||||
|
- **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인
|
||||||
|
- **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드
|
||||||
|
- **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드
|
||||||
|
- **prediction 알고리즘 재설계** — dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계, vessel_store/scheduler 개선
|
||||||
|
- **프론트엔드 지도 레이어 구조 정리** — BaseMap, useMapLayers, static layers 리팩토링
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁)
|
||||||
|
- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁)
|
||||||
|
|
||||||
|
## [2026-04-09]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
|
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
|
||||||
@ -27,7 +198,6 @@
|
|||||||
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
|
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
|
||||||
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
|
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
|
||||||
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
|
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
|
||||||
|
|
||||||
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
|
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
|
||||||
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
|
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
|
||||||
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
|
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
|
||||||
@ -43,23 +213,6 @@
|
|||||||
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
|
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
|
||||||
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
|
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
|
||||||
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
|
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
|
||||||
|
|
||||||
### 변경
|
|
||||||
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
|
|
||||||
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
|
|
||||||
- **EnforcementController** vesselMmsi 필터 파라미터 추가
|
|
||||||
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
|
|
||||||
- system-flow 08-frontend.json 누락 노드 14개 추가
|
|
||||||
|
|
||||||
### 문서
|
|
||||||
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
|
|
||||||
|
|
||||||
## [2026-04-09]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류
|
- **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류
|
||||||
- P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap
|
- P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap
|
||||||
- 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점
|
- 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점
|
||||||
@ -72,6 +225,10 @@
|
|||||||
- pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성)
|
- pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
|
||||||
|
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
|
||||||
|
- **EnforcementController** vesselMmsi 필터 파라미터 추가
|
||||||
|
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
|
||||||
- **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가
|
- **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가
|
||||||
- **event_generator 룰 전면 재편**:
|
- **event_generator 룰 전면 재편**:
|
||||||
- EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰
|
- EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰
|
||||||
@ -86,6 +243,8 @@
|
|||||||
- `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원
|
- `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
|
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
|
||||||
|
- system-flow 08-frontend.json 누락 노드 14개 추가
|
||||||
- `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움
|
- `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움
|
||||||
- `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영
|
- `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영
|
||||||
- `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상
|
- `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상
|
||||||
@ -94,6 +253,9 @@
|
|||||||
- dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화)
|
- dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화)
|
||||||
- transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반
|
- transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
|
||||||
|
|
||||||
## [2026-04-08]
|
## [2026-04-08]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -50,6 +50,7 @@ src/
|
|||||||
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
||||||
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
||||||
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
|
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
|
||||||
|
│ │ # 2026-04-17: common.json 에 aria(36)/error(7)/dialog(4)/success(2)/message(5) 네임스페이스 추가
|
||||||
│ └── theme/ # tokens, colors, variants (CVA)
|
│ └── theme/ # tokens, colors, variants (CVA)
|
||||||
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
||||||
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
||||||
@ -89,20 +90,28 @@ src/
|
|||||||
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
||||||
│ └── index.ts # 배럴 export
|
│ └── index.ts # 배럴 export
|
||||||
│
|
│
|
||||||
├── shared/components/ # 공유 UI 컴포넌트
|
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
|
||||||
│ ├── ui/
|
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
|
||||||
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
|
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
|
||||||
│ │ └── badge.tsx # Badge(CVA intent/size)
|
│ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
|
||||||
|
│ │ ├── button.tsx # Button (variant 5종 × size 3단계, icon/trailingIcon prop)
|
||||||
|
│ │ ├── input.tsx # Input (size/state, forwardRef)
|
||||||
|
│ │ ├── select.tsx # Select (aria-label|aria-labelledby|title TS union 강제)
|
||||||
|
│ │ ├── textarea.tsx # Textarea
|
||||||
|
│ │ ├── checkbox.tsx # Checkbox (native input 래퍼)
|
||||||
|
│ │ ├── radio.tsx # Radio
|
||||||
|
│ │ └── tabs.tsx # TabBar + TabButton (underline/pill/segmented 3 variant)
|
||||||
|
│ ├── layout/ # PageContainer / PageHeader / Section (표준 페이지 루트)
|
||||||
│ └── common/
|
│ └── common/
|
||||||
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
||||||
│ ├── Pagination.tsx # 페이지네이션
|
│ ├── Pagination.tsx # 페이지네이션
|
||||||
│ ├── SearchInput.tsx # 검색 입력
|
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
|
||||||
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
||||||
│ ├── FileUpload.tsx # 파일 업로드
|
│ ├── FileUpload.tsx # 파일 업로드
|
||||||
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
||||||
│ ├── PrintButton.tsx # 인쇄 버튼
|
│ ├── PrintButton.tsx # 인쇄 버튼
|
||||||
│ ├── SaveButton.tsx # 저장 버튼
|
│ ├── SaveButton.tsx # 저장 버튼
|
||||||
│ └── NotificationBanner.tsx # 알림 배너
|
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
|
||||||
│
|
│
|
||||||
├── features/ # 13 도메인 그룹 (31 페이지)
|
├── features/ # 13 도메인 그룹 (31 페이지)
|
||||||
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
||||||
@ -309,7 +318,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 라우팅 구조 (26 보호 경로 + login)
|
## 라우팅 구조 (27 보호 경로 + login)
|
||||||
|
|
||||||
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
|
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
|
||||||
|
|
||||||
@ -322,6 +331,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
|
|||||||
- `/enforcement-plan` — 단속계획 (SFR-06)
|
- `/enforcement-plan` — 단속계획 (SFR-06)
|
||||||
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
|
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
|
||||||
- `/gear-detection` — 어구 탐지 (SFR-10)
|
- `/gear-detection` — 어구 탐지 (SFR-10)
|
||||||
|
- `/gear-collision` — 어구 정체성 충돌 (SFR-10, V030 — 동일 어구 이름 × 복수 MMSI 공존 감지)
|
||||||
- `/china-fishing` — 중국어선 탐지
|
- `/china-fishing` — 중국어선 탐지
|
||||||
- `/patrol-route` — 순찰경로 (SFR-07)
|
- `/patrol-route` — 순찰경로 (SFR-07)
|
||||||
- `/fleet-optimization` — 함대 최적화 (SFR-08)
|
- `/fleet-optimization` — 함대 최적화 (SFR-08)
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
# Mock 데이터 공유 현황 분석 및 통합 결과
|
|
||||||
|
|
||||||
> 최초 작성일: 2026-04-06
|
|
||||||
> 마지막 업데이트: 2026-04-06
|
|
||||||
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
|
|
||||||
> 상태: **통합 완료**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 선박 데이터 교차참조
|
|
||||||
|
|
||||||
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
|
|
||||||
|
|
||||||
| 선박명 | 등장 파일 수 | 파일 목록 |
|
|
||||||
|---|---|---|
|
|
||||||
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
|
|
||||||
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
|
|
||||||
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
|
|
||||||
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
|
|
||||||
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
|
|
||||||
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
|
|
||||||
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
|
|
||||||
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 위험도 스케일 불일치
|
|
||||||
|
|
||||||
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
|
|
||||||
|
|
||||||
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
|
|
||||||
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
|
|
||||||
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
|
|
||||||
|
|
||||||
### 원인 분석
|
|
||||||
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
|
|
||||||
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
|
|
||||||
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
|
|
||||||
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
|
|
||||||
- EventList는 레벨 문자열 (`AlertLevel`)
|
|
||||||
|
|
||||||
### 통합 방안
|
|
||||||
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
|
|
||||||
|
|
||||||
```
|
|
||||||
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. KPI 수치 중복
|
|
||||||
|
|
||||||
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
|
|
||||||
|
|
||||||
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|
|
||||||
|---|---|---|
|
|
||||||
| 실시간 탐지 | 47 | 47 |
|
|
||||||
| EEZ 침범 | 18 | 18 |
|
|
||||||
| 다크베셀 | 12 | 12 |
|
|
||||||
| 불법환적 의심 | 8 | 8 |
|
|
||||||
| 추적 중 | 15 | 15 |
|
|
||||||
| 나포/검문(금일 단속) | 3 | 3 |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
|
|
||||||
- 수치 변경 시 양쪽 모두 수정해야 함
|
|
||||||
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 이벤트 타임라인 중복
|
|
||||||
|
|
||||||
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
|
|
||||||
|
|
||||||
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
|
|
||||||
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
|
|
||||||
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
|
|
||||||
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
|
|
||||||
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
|
|
||||||
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
|
|
||||||
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
|
|
||||||
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
|
|
||||||
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 환적 데이터 100% 중복
|
|
||||||
|
|
||||||
`TransferDetection.tsx`와 `ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
|
|
||||||
|
|
||||||
```
|
|
||||||
TransferDetection.tsx:
|
|
||||||
const transferData = [
|
|
||||||
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
|
|
||||||
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
|
|
||||||
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
|
|
||||||
];
|
|
||||||
|
|
||||||
ChinaFishing.tsx:
|
|
||||||
const TRANSFER_DATA = [
|
|
||||||
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
|
|
||||||
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
|
|
||||||
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
|
|
||||||
- 한쪽만 수정하면 다른 쪽과 불일치 발생
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 함정 상태 불일치
|
|
||||||
|
|
||||||
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
|
|
||||||
|
|
||||||
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
|
|
||||||
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
|
|
||||||
| 1503함 | **미배포** | - | - | **정비중** |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
|
|
||||||
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
|
|
||||||
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 현재 상태: 통합 완료
|
|
||||||
|
|
||||||
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
|
|
||||||
|
|
||||||
### 7.1 완료된 아키텍처: mock -> store -> page
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ src/data/mock/ (7개 공유 모듈) │
|
|
||||||
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
|
|
||||||
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
|
|
||||||
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
|
|
||||||
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
|
|
||||||
│ │ │ │ │ │ │
|
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
|
|
||||||
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
|
|
||||||
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
|
|
||||||
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
|
|
||||||
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
|
|
||||||
│ │ │ │ │ │ │
|
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ src/features/*/ (페이지 컴포넌트) │
|
|
||||||
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
|
|
||||||
|
|
||||||
| 스토어 | 소비 페이지 |
|
|
||||||
|---|---|
|
|
||||||
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
|
|
||||||
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
|
|
||||||
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
|
|
||||||
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
|
|
||||||
| `useTransferStore` | TransferDetection, ChinaFishing |
|
|
||||||
| `useGearStore` | GearDetection |
|
|
||||||
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
|
|
||||||
|
|
||||||
### 7.3 페이지 전용 인라인 데이터 (미통합)
|
|
||||||
|
|
||||||
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
|
|
||||||
|
|
||||||
| 페이지 | 인라인 데이터 | 사유 |
|
|
||||||
|---|---|---|
|
|
||||||
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
|
|
||||||
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
|
|
||||||
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
|
|
||||||
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
|
|
||||||
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
|
|
||||||
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
|
|
||||||
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
|
|
||||||
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
|
|
||||||
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
|
|
||||||
|
|
||||||
### 7.4 설계 원칙 (구현 완료)
|
|
||||||
|
|
||||||
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
|
|
||||||
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
|
|
||||||
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
|
|
||||||
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
|
|
||||||
|
|
||||||
### 7.5 Mock 모듈 상세 (참고용)
|
|
||||||
|
|
||||||
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
|
|
||||||
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
|
|
||||||
|
|
||||||
| # | 모듈 파일 | 스토어 | 내용 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
|
|
||||||
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
|
|
||||||
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
|
|
||||||
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
|
|
||||||
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
|
|
||||||
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
|
|
||||||
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 작업 완료 요약
|
|
||||||
|
|
||||||
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|
|
||||||
|---|---|---|
|
|
||||||
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
|
|
||||||
| `events.ts` | **완료** | 6개 (useEventStore) |
|
|
||||||
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
|
|
||||||
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
|
|
||||||
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
|
|
||||||
| `gear.ts` | **완료** | 1개 (useGearStore) |
|
|
||||||
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
|
|
||||||
|
|
||||||
### 실제 작업 결과
|
|
||||||
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
|
|
||||||
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
|
|
||||||
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
|
|
||||||
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 결론
|
|
||||||
|
|
||||||
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
|
|
||||||
|
|
||||||
달성한 효과:
|
|
||||||
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
|
|
||||||
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
|
|
||||||
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
|
|
||||||
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
|
|
||||||
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
|
|
||||||
|
|
||||||
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
|
|
||||||
|
|
||||||
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
|
|
||||||
|
|
||||||
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
|
|
||||||
|
|
||||||
- `vesselStore` — 선박 목록, 선택, 필터
|
|
||||||
- `patrolStore` — 순찰 경로/함정
|
|
||||||
- `eventStore` — 탐지/경보 이벤트
|
|
||||||
- `kpiStore` — KPI 메트릭, 추세
|
|
||||||
- `transferStore` — 전재(환적)
|
|
||||||
- `gearStore` — 어구 탐지
|
|
||||||
- `enforcementStore` — 단속 이력
|
|
||||||
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
|
|
||||||
|
|
||||||
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
|
|
||||||
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
|
|
||||||
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
|
|
||||||
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
|
|
||||||
|
|
||||||
### 남은 작업
|
|
||||||
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
|
|
||||||
- [ ] Axios 인터셉터:
|
|
||||||
- Request: Authorization 헤더 자동 주입
|
|
||||||
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
|
|
||||||
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
|
|
||||||
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
|
|
||||||
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- `services/ws.ts`에 `connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
|
|
||||||
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
|
|
||||||
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
|
|
||||||
|
|
||||||
### 남은 작업
|
|
||||||
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
|
|
||||||
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
|
|
||||||
- [ ] 구독 채널 설계:
|
|
||||||
- `/topic/ais-positions` — 실시간 AIS 위치
|
|
||||||
- `/topic/alerts` — 경보/이벤트
|
|
||||||
- `/topic/detections` — 탐지 결과
|
|
||||||
- `/user/queue/notifications` — 개인 알림
|
|
||||||
- [ ] 재연결 로직 (지수 백오프)
|
|
||||||
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
|
|
||||||
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
|
|
||||||
|
|
||||||
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
|
|
||||||
|
|
||||||
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay`를 `useImperativeHandle`로 외부 노출
|
|
||||||
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
|
|
||||||
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
|
|
||||||
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
|
|
||||||
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
|
|
||||||
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
|
|
||||||
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
|
|
||||||
|
|
||||||
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. ✅ 더미 데이터 통합 — COMPLETED
|
|
||||||
|
|
||||||
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
|
|
||||||
|
|
||||||
```
|
|
||||||
data/mock/
|
|
||||||
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
|
|
||||||
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
|
|
||||||
├── transfers.ts # 전재(환적) 데이터
|
|
||||||
├── patrols.ts # PatrolShip — 순찰 경로/함정
|
|
||||||
├── gear.ts # 어구 탐지 데이터
|
|
||||||
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
|
|
||||||
└── enforcement.ts # 단속 이력 데이터
|
|
||||||
```
|
|
||||||
|
|
||||||
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
|
|
||||||
- 인터페이스가 API 응답 타입 계약 역할 수행
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
|
|
||||||
- ko/en 각 10파일 (총 20 JSON)
|
|
||||||
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
|
|
||||||
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
|
|
||||||
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
|
|
||||||
|
|
||||||
### 남은 작업
|
|
||||||
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
|
|
||||||
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
|
|
||||||
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
|
|
||||||
|
|
||||||
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
|
|
||||||
|
|
||||||
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
|
|
||||||
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
|
|
||||||
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
|
|
||||||
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
|
|
||||||
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 코드 스플리팅 — 미착수
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
|
|
||||||
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
|
|
||||||
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
|
|
||||||
|
|
||||||
### 필요한 이유
|
|
||||||
- 초기 로딩 성능 개선 (FCP, LCP)
|
|
||||||
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
|
|
||||||
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
|
|
||||||
|
|
||||||
### 구현 계획
|
|
||||||
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
|
|
||||||
```typescript
|
|
||||||
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
|
|
||||||
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
|
|
||||||
```
|
|
||||||
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
|
|
||||||
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
|
|
||||||
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
|
|
||||||
```typescript
|
|
||||||
manualChunks: {
|
|
||||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
||||||
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
|
|
||||||
'vendor-chart': ['echarts'],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- [ ] 목표: 초기 번들 < 300KB (gzip), 각 feature chunk < 100KB
|
|
||||||
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Light 테마 하드코딩 정리
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
|
|
||||||
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
|
|
||||||
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
|
|
||||||
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
|
|
||||||
|
|
||||||
### 구현 계획
|
|
||||||
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
|
|
||||||
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
|
|
||||||
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 우선순위 및 의존관계
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ 완료 ─────────────────────────────────────
|
|
||||||
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
|
|
||||||
|
|
||||||
진행 중 / 남은 작업 ──────────────────────────
|
|
||||||
[6. i18n 내부 텍스트] ──┐
|
|
||||||
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
|
|
||||||
[9. Light 테마 정리] ───┘
|
|
||||||
|
|
||||||
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 권장 진행 순서
|
|
||||||
|
|
||||||
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
|
|
||||||
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
|
|
||||||
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)
|
|
||||||
@ -1,436 +0,0 @@
|
|||||||
# 페이지 역할표 및 업무 파이프라인
|
|
||||||
|
|
||||||
> 최초 작성일: 2026-04-06
|
|
||||||
> 마지막 업데이트: 2026-04-06
|
|
||||||
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 공통 아키텍처
|
|
||||||
|
|
||||||
### 디렉토리 구조
|
|
||||||
|
|
||||||
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
|
|
||||||
|
|
||||||
```
|
|
||||||
src/features/
|
|
||||||
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
|
|
||||||
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
|
|
||||||
auth/ LoginPage
|
|
||||||
dashboard/ Dashboard
|
|
||||||
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
|
|
||||||
enforcement/ EnforcementHistory, EventList
|
|
||||||
field-ops/ AIAlert, MobileService, ShipAgent
|
|
||||||
monitoring/ MonitoringDashboard
|
|
||||||
patrol/ FleetOptimization, PatrolRoute
|
|
||||||
risk-assessment/ EnforcementPlan, RiskMap
|
|
||||||
statistics/ ExternalService, ReportManagement, Statistics
|
|
||||||
surveillance/ LiveMapView, MapControl
|
|
||||||
vessel/ TransferDetection, VesselDetail
|
|
||||||
```
|
|
||||||
|
|
||||||
### 데이터 흐름
|
|
||||||
|
|
||||||
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
|
|
||||||
|
|
||||||
```
|
|
||||||
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
|
|
||||||
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
|
|
||||||
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
|
|
||||||
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
|
|
||||||
|
|
||||||
### 지도 렌더링
|
|
||||||
|
|
||||||
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
|
|
||||||
|
|
||||||
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
|
|
||||||
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
|
|
||||||
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
|
|
||||||
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
|
|
||||||
|
|
||||||
### 다국어 (i18n)
|
|
||||||
|
|
||||||
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
|
|
||||||
- 지원 언어: 한국어 (ko), 영어 (en)
|
|
||||||
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
|
|
||||||
|
|
||||||
### 테마
|
|
||||||
|
|
||||||
- `settingsStore`에서 dark/light 테마 전환 지원
|
|
||||||
- 기본값: dark (해양 감시 시스템 특성상)
|
|
||||||
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 31개 페이지 역할표
|
|
||||||
|
|
||||||
### 1.1 인증/관리 (4개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
|
|
||||||
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
|
|
||||||
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
|
|
||||||
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
|
|
||||||
|
|
||||||
### 1.2 데이터 수집/연계 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
|
|
||||||
|
|
||||||
### 1.3 AI 모델/운영 (3개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
|
|
||||||
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
|
|
||||||
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
|
|
||||||
|
|
||||||
### 1.4 탐지 (4개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
|
|
||||||
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
|
|
||||||
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
|
|
||||||
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
|
|
||||||
|
|
||||||
### 1.5 환적 탐지 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
|
|
||||||
|
|
||||||
### 1.6 위험도 평가/계획 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
|
|
||||||
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
|
|
||||||
|
|
||||||
### 1.7 순찰/함대 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
|
|
||||||
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
|
|
||||||
|
|
||||||
### 1.8 감시/지도 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
|
|
||||||
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
|
|
||||||
|
|
||||||
### 1.9 대시보드/모니터링 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
|
|
||||||
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
|
|
||||||
|
|
||||||
### 1.10 이벤트/이력 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
|
|
||||||
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
|
|
||||||
|
|
||||||
### 1.11 현장 대응 (3개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
|
|
||||||
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
|
|
||||||
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
|
|
||||||
|
|
||||||
### 1.12 통계/외부연계/보고 (3개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
|
|
||||||
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
|
|
||||||
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
|
|
||||||
|
|
||||||
### 1.13 선박 상세 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
|
|
||||||
|
|
||||||
### 1.14 시스템 관리 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 업무 파이프라인 (4개)
|
|
||||||
|
|
||||||
### 2.1 탐지 파이프라인
|
|
||||||
|
|
||||||
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
AIS/레이더/위성 신호
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────┐
|
|
||||||
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
|
|
||||||
└────┬────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────┐
|
|
||||||
│ AI 탐지 엔진 (AIModelManagement 관리) │
|
|
||||||
│ │
|
|
||||||
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
|
|
||||||
│ GearDetection ─────── 불법 어구 탐지 │
|
|
||||||
│ ChinaFishing ──────── 중국어선 통합 감시 │
|
|
||||||
│ TransferDetection ─── 환적 행위 탐지 │
|
|
||||||
│ GearIdentification ── 어구 국적 판별 │
|
|
||||||
└──────────────┬───────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────┐ ┌───────────────────┐
|
|
||||||
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
|
|
||||||
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
|
|
||||||
│ └───────────────────┘
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ EnforcementPlan │ ← 단속 우선지역 예보
|
|
||||||
└────────┬─────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐ ┌───────────────────┐
|
|
||||||
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
|
|
||||||
└──────┬───────┘ └─────────┬─────────┘
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌──────────┐
|
|
||||||
│ AIAlert │ ← 함정/관제 자동 알림 발송
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
현장 작전 (MobileService, ShipAgent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 대응 파이프라인
|
|
||||||
|
|
||||||
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┐
|
|
||||||
│ AIAlert │ ← AI 탐지 알림 자동 발송
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ 현장 대응 │
|
|
||||||
│ │
|
|
||||||
│ MobileService ── 모바일 경보 수신│
|
|
||||||
│ ShipAgent ────── 함정 Agent 연동 │
|
|
||||||
└──────────────┬───────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
현장 단속 수행
|
|
||||||
(정선/검문/나포/퇴거)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
|
|
||||||
└──────────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ ReportManagement │ ← 증거 패키징, 보고서 생성
|
|
||||||
└──────────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
검찰/외부기관 (ExternalService 통해 연계)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 분석 파이프라인
|
|
||||||
|
|
||||||
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────┐
|
|
||||||
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
|
|
||||||
└──────┬───────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
|
|
||||||
└──────┬───────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
전략 수립 (순찰 패턴, 탐지 규칙 조정)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 관리 파이프라인
|
|
||||||
|
|
||||||
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────┐
|
|
||||||
│ AccessControl │ ← RBAC 역할/권한 설정
|
|
||||||
└───────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────┐
|
|
||||||
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
|
|
||||||
└──────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────┐
|
|
||||||
│ 시스템 설정/관리 │
|
|
||||||
│ │
|
|
||||||
│ SystemConfig ──── 공통코드/환경설정 │
|
|
||||||
│ NoticeManagement ── 공지/배너/팝업 │
|
|
||||||
│ DataHub ────────── 데이터 수집 관리 │
|
|
||||||
│ AdminPanel ────── 서버/인프라 모니터 │
|
|
||||||
└──────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 사용자 역할별 페이지 접근 매트릭스
|
|
||||||
|
|
||||||
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
|
|
||||||
|
|
||||||
### 3.1 역할 정의
|
|
||||||
|
|
||||||
| 역할 | 코드 | 설명 | 인원(시뮬) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
|
|
||||||
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
|
|
||||||
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
|
|
||||||
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
|
|
||||||
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
|
|
||||||
|
|
||||||
### 3.2 접근 매트릭스
|
|
||||||
|
|
||||||
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| **인증/관리** | | | | | |
|
|
||||||
| LoginPage | O | O | O | O | O |
|
|
||||||
| AccessControl | O | - | - | - | - |
|
|
||||||
| SystemConfig | O | - | - | - | - |
|
|
||||||
| NoticeManagement | O | - | - | - | - |
|
|
||||||
| AdminPanel | O | - | - | - | - |
|
|
||||||
| **데이터/AI** | | | | | |
|
|
||||||
| DataHub | O | - | - | - | - |
|
|
||||||
| AIModelManagement | O | - | O | - | - |
|
|
||||||
| MLOpsPage | O | - | O | - | - |
|
|
||||||
| AIAssistant | O | O | O | - | - |
|
|
||||||
| **탐지** | | | | | |
|
|
||||||
| DarkVesselDetection | O | - | O | - | - |
|
|
||||||
| GearDetection | O | - | O | - | - |
|
|
||||||
| ChinaFishing | O | O | O | - | - |
|
|
||||||
| TransferDetection | O | - | O | - | - |
|
|
||||||
| **위험도/계획** | | | | | |
|
|
||||||
| RiskMap | O | O | O | - | - |
|
|
||||||
| EnforcementPlan | O | O | - | - | - |
|
|
||||||
| **순찰** | | | | | |
|
|
||||||
| PatrolRoute | O | O | - | - | - |
|
|
||||||
| FleetOptimization | O | O | - | - | - |
|
|
||||||
| **감시/지도** | | | | | |
|
|
||||||
| LiveMapView | O | O | O | - | - |
|
|
||||||
| MapControl | O | O | - | - | - |
|
|
||||||
| **대시보드** | | | | | |
|
|
||||||
| Dashboard | O | O | O | O | O |
|
|
||||||
| MonitoringDashboard | O | O | - | - | - |
|
|
||||||
| **이벤트/이력** | | | | | |
|
|
||||||
| EventList | O | O | O | O | - |
|
|
||||||
| EnforcementHistory | O | - | O | - | - |
|
|
||||||
| **현장 대응** | | | | | |
|
|
||||||
| MobileService | O | - | - | O | - |
|
|
||||||
| ShipAgent | O | - | - | O | - |
|
|
||||||
| AIAlert | O | O | - | O | - |
|
|
||||||
| **통계/보고** | | | | | |
|
|
||||||
| Statistics | O | O | O | - | - |
|
|
||||||
| ExternalService | O | - | - | - | O |
|
|
||||||
| ReportManagement | O | O | O | - | - |
|
|
||||||
| **선박 상세** | | | | | |
|
|
||||||
| VesselDetail | O | O | O | - | - |
|
|
||||||
|
|
||||||
### 3.3 역할별 요약
|
|
||||||
|
|
||||||
| 역할 | 접근 가능 페이지 | 페이지 수 |
|
|
||||||
|---|---|---|
|
|
||||||
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
|
|
||||||
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
|
|
||||||
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
|
|
||||||
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
|
|
||||||
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 페이지 간 데이터 흐름 요약
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐
|
|
||||||
│ LoginPage │
|
|
||||||
│ (인증 게이트) │
|
|
||||||
└────────┬─────────┘
|
|
||||||
│
|
|
||||||
┌────────────────────┬┴──────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
|
|
||||||
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ AccessControl│ │ DataHub │ │ MobileSvc │
|
|
||||||
│ SystemConfig │ │ ↓ │ │ ShipAgent │
|
|
||||||
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
|
|
||||||
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
|
|
||||||
│ AdminPanel │ │ ↓ │ │
|
|
||||||
└──────────────┘ │ RiskMap │ │
|
|
||||||
│ ↓ │ ▼
|
|
||||||
│ EnforcementPlan │ ┌──────────────┐
|
|
||||||
│ ↓ │ │ 대응 파이프라인│
|
|
||||||
│ PatrolRoute │ │ │
|
|
||||||
│ FleetOptim │ │ Enforcement │
|
|
||||||
│ ↓ │ │ History │
|
|
||||||
│ LiveMapView │ │ ReportManage │
|
|
||||||
│ Monitoring │ │ ExternalSvc │
|
|
||||||
└────────┬────────┘ └──────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ 분석 파이프라인 │
|
|
||||||
│ │
|
|
||||||
│ Statistics │
|
|
||||||
│ VesselDetail │
|
|
||||||
│ AIAssistant │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 미할당 SFR 참고
|
|
||||||
|
|
||||||
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
|
|
||||||
|
|
||||||
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
|
|
||||||
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
|
|
||||||
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
|
|
||||||
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
|
|
||||||
- **VesselDetail**: SFR 번호 미부여, 선박 상세
|
|
||||||
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
|
|
||||||
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
|
|
||||||
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트
|
|
||||||
250
docs/prediction-analysis.md
Normal file
250
docs/prediction-analysis.md
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
# Prediction 모듈 심층 분석 — 구조·방향 리뷰
|
||||||
|
|
||||||
|
**대상:** `prediction/` (Python 3.11+, FastAPI, APScheduler, 59 `.py` 파일)
|
||||||
|
**작성일:** 2026-04-17
|
||||||
|
**작성 관점:** opus 4.7 독립 리뷰 — 정밀도 튜닝이 아닌 **방향성·코드 구조**
|
||||||
|
**전제:** 프로토타입·데모 단계. 정밀도 미흡은 인지된 상태.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — 3줄 요약
|
||||||
|
|
||||||
|
1. **뼈대는 튼튼하다.** 레이어 분리(algorithms/pipeline/output/db/cache), 순수함수 위주 알고리즘, 카테고리별 dedup 윈도우 분리까지 프로토타입치고는 일관된 설계.
|
||||||
|
2. **약한 고리는 오케스트레이터.** [scheduler.py run_analysis_cycle()](../prediction/scheduler.py) 한 함수가 700+ 라인, 지역 try/except + `logger.warning` 로 흡수된 실패가 많아 "어디서 깨졌는지 조용히 묻힌다". 상태 누적(모듈 전역 `_transship_pair_history`)도 여기 묶여 있음.
|
||||||
|
3. **커버리지 매트릭스는 4/4 이지만 UI 비대칭.** prediction 이 생산하는 결과 중 `ILLEGAL_FISHING_PATTERN` 이벤트·환적 의심은 DB·백엔드까지 도달하지만 전용 detection UI 가 없다. prediction 품질 개선과 무관하게 운영자가 쓸 수 없는 상태.
|
||||||
|
|
||||||
|
**권고 최우선 3가지** — 신규 알고리즘 추가보다 아래가 선행:
|
||||||
|
- **P1:** 사이클 스테이지 단위 에러 경계(`_stage(...)` 유틸)로 교체해 실패 스테이지 명시 로깅 + 부분 실패 시에도 후속 단계 진행
|
||||||
|
- **P1:** 하드코딩 임계값(MID prefix, 커버리지 박스, SOG band, 11 pattern 점수) 을 `correlation_param_models` 패턴처럼 DB/config 로 외부화
|
||||||
|
- **P1:** 환적 전용 + ILLEGAL_FISHING_PATTERN 전용 프론트 페이지 추가 — 이미 DB·API 는 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 아키텍처 레이어 — 책임과 결합도
|
||||||
|
|
||||||
|
```
|
||||||
|
prediction/
|
||||||
|
├── config.py Pydantic Settings + qualified_table() — SSOT 설정
|
||||||
|
├── scheduler.py APScheduler + run_analysis_cycle() (단일 엔트리)
|
||||||
|
├── main.py FastAPI app + /health /status /chat 등
|
||||||
|
├── fleet_tracker.py 상태 보유 (선단 레지스트리, 어구 정체성)
|
||||||
|
├── time_bucket.py 안전 지연 12분 + backfill 3 버킷
|
||||||
|
├── algorithms/ 17개 모듈 — 순수함수 중심
|
||||||
|
├── pipeline/ 8개 모듈 — 7단계 분류 파이프라인
|
||||||
|
├── output/ 5개 모듈 — event/violation/kpi/stats/alert
|
||||||
|
├── db/ 4개 모듈 — snpdb / kcgdb / signal_api / partition_manager
|
||||||
|
├── cache/ vessel_store.py — 24h sliding window 인메모리
|
||||||
|
├── chat/ Ollama + RAG 스텁
|
||||||
|
├── models/ result.py — AnalysisResult dataclass
|
||||||
|
├── data/ monitoring zones JSON 정적 설정
|
||||||
|
└── tests/ time_bucket / gear_parent_episode / gear_parent_inference 3종
|
||||||
|
```
|
||||||
|
|
||||||
|
| 레이어 | 책임 | 결합도 평가 |
|
||||||
|
|---|---|---|
|
||||||
|
| `config.py` | 환경 + SQL identifier 검증 | ✅ SSOT, `qualified_table()` 로 스키마 주입 공격 방지 |
|
||||||
|
| `algorithms/` | 탐지 로직 (순수) | ✅ 대부분 `df, params -> dict/tuple`. 상호 의존 적음 |
|
||||||
|
| `pipeline/` | 7단계 sequential | ✅ `orchestrator.ChineseFishingVesselPipeline.run()` 이 DF 를 그대로 파이프 |
|
||||||
|
| `output/` | 룰 엔진 + DB write | ✅ 룰을 lambda 리스트(event_generator.RULES)로 선언적 관리 |
|
||||||
|
| `db/` | Connection pool + SQL | ⚠️ `kcgdb.upsert_results(results)` 가 한 트랜잭션에 전부 묶임 (파티션 unique index 활용은 적절) |
|
||||||
|
| `cache/vessel_store.py` | 전역 싱글턴, 24h 궤적 인메모리 | ⚠️ 모듈 싱글턴 → 테스트 시 mock 어려움 |
|
||||||
|
| `fleet_tracker.py` | 레지스트리·어구 정체성 상태 | ⚠️ 싱글턴 + 모듈 전역 캐시 |
|
||||||
|
| `scheduler.py` | 전체 오케스트레이션 | ❌ **700+ 라인 모놀리식** — 아래 §2 상세 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 5분 사이클 시퀀스 — [scheduler.py:80-804](../prediction/scheduler.py#L80-L804)
|
||||||
|
|
||||||
|
사이클 전체가 **한 함수** 안에서 9단계로 진행된다.
|
||||||
|
|
||||||
|
| 단계 | 라인 | 역할 | 실패 처리 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1. 증분 로드 | [97-106](../prediction/scheduler.py#L97-L106) | `snpdb.fetch_incremental()` → vessel_store merge | 전체 try/except 포함 |
|
||||||
|
| 2. 정적 보강 | [108-112](../prediction/scheduler.py#L108-L112) | signal-batch API 호출 | 전체 try/except |
|
||||||
|
| 3. 대상 선별 | [114-128](../prediction/scheduler.py#L114-L128) | SOG/COG 계산 + 0건 시 조기 return | ✅ 조기 반환 |
|
||||||
|
| 4. 파이프라인 | [122-128](../prediction/scheduler.py#L122-L128) | `ChineseFishingVesselPipeline.run()` | 전체 try/except |
|
||||||
|
| 5. 선단 분석 | [131-177](../prediction/scheduler.py#L131-L177) | fleet_tracker + gear_identity 충돌 감지 | ⚠️ 내부 try/except 로 warning, 전진 |
|
||||||
|
| 5.5. 어구 그룹·상관·부모 추론 | [181-229](../prediction/scheduler.py#L181-L229) | polygon_builder + gear_correlation | ⚠️ 내부 try/except, 결과 없이 진행 |
|
||||||
|
| 5.9. 쌍끌이 후보·판정 | [231-303](../prediction/scheduler.py#L231-L303) | pair_trawl STRONG/PROBABLE/SUSPECT | ⚠️ 내부 try/except |
|
||||||
|
| 6. 개별 선박 분석 | [305-515](../prediction/scheduler.py#L305-L515) | AnalysisResult 생성 (파이프라인 통과자) | 루프 내 continue |
|
||||||
|
| 6.5. 경량 분석 | [523-682](../prediction/scheduler.py#L523-L682) | 파이프라인 미통과 중국 MID — `compute_lightweight_risk_score` | 루프 내 try/except |
|
||||||
|
| 7. 환적 의심 | [685-713](../prediction/scheduler.py#L685-L713) | `detect_transshipment` + `_transship_pair_history` 누적 | 전체 try/except |
|
||||||
|
| 8. DB upsert | [716-717](../prediction/scheduler.py#L716-L717) | `kcgdb.upsert_results()` + `cleanup_old(48h)` | 전체 try/except |
|
||||||
|
| 9. 출력 모듈 | [720-745](../prediction/scheduler.py#L720-L745) | violation_classifier → event_generator → kpi_writer → aggregate_hourly/daily → alert_dispatcher | ⚠️ **5개 단계를 한 try/except 로 묶음** → 어디서 실패했는지 단일 warning 으로 흡수 |
|
||||||
|
| 10. 채팅 컨텍스트 캐싱 | [748-791](../prediction/scheduler.py#L748-L791) | Redis | 전체 try/except |
|
||||||
|
|
||||||
|
### 구조적 관찰
|
||||||
|
|
||||||
|
- **전체 try/except 는 있다** ([97](../prediction/scheduler.py#L97) ~ [803](../prediction/scheduler.py#L803)) — 치명 실패가 다음 사이클을 막지는 않음
|
||||||
|
- **그러나 내부 스테이지가 너무 무겁다.** 9번째 출력 단계가 5개 모듈을 한 덩어리로 묶어 `logger.warning('output modules failed (non-fatal): %s', e)` 로 흡수. 어느 모듈이 깨졌는지 디버깅하려면 stacktrace 를 `logger.exception` 으로 바꿔야 함
|
||||||
|
- **Lazy import** 가 스테이지마다 반복 (`from output.event_generator import ...` 등). 시작 시간 단축에는 도움이지만 import 오류가 **첫 사이클 실행 시점에만 드러남** — 배포 후 5분 지연 발견 경험 다수
|
||||||
|
|
||||||
|
### 권고 (사이클 구조 재정비)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _stage(name: str, fn, *args, required=False, **kwargs):
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
result = fn(*args, **kwargs)
|
||||||
|
logger.info('stage %s ok in %.2fs', name, time.time() - t0)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('stage %s failed: %s', name, e)
|
||||||
|
if required:
|
||||||
|
raise
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
- 각 스테이지를 `_stage('pair_detection', _run_pair_detection, ...)` 로 감싸면 실패 스테이지 명시 로깅 + stacktrace + 부분 실패 허용 정책을 일관화.
|
||||||
|
- 9번 단계의 5개 모듈은 각각 별도 `_stage(...)` 호출로 쪼갤 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 알고리즘 카탈로그 (17 모듈 × 담당 도메인)
|
||||||
|
|
||||||
|
| 파일 | 주 역할 | 입력 | 출력 | 주요 상수 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [dark_vessel.py](../prediction/algorithms/dark_vessel.py) | AIS gap + 11 패턴 점수화 | 선박 DF(timestamp, lat, lon, sog, cog) | (score 0~100, patterns[], tier) | GAP_SUSPICIOUS=6000s, VIOLATION=86400s, KR_COVERAGE box |
|
||||||
|
| [spoofing.py](../prediction/algorithms/spoofing.py) | 텔레포트·극속·BD09 오프셋 | 선박 DF | spoofing_score 0~1 | EXTREME_SPEED=50kn (주석 기준), fishing max=25kn |
|
||||||
|
| [risk.py](../prediction/algorithms/risk.py) | 종합 risk + 경량 risk 2종 | DF, zone, is_permitted, 외부 점수 | (risk 0~100, level) | tier: 70+=CRITICAL, 50+=HIGH, 30+=MEDIUM |
|
||||||
|
| [fishing_pattern.py](../prediction/algorithms/fishing_pattern.py) | UCAF/UCFT gear SOG band | DF, gear | (ucaf, ucft) | PT 2.5~4.5, OT 2.0~4.0, GN 0.5~2.5 |
|
||||||
|
| [transshipment.py](../prediction/algorithms/transshipment.py) | 5단계 필터 파이프라인 | DF targets, pair_history, zone_fn | list of dict | PROXIMITY ~220m, RENDEZVOUS 90min, WATCH 제외 |
|
||||||
|
| [location.py](../prediction/algorithms/location.py) | zone 분류, haversine_nm, BD09 | (lat, lon) | zone, dist_nm | 12/24 NM 밴드 |
|
||||||
|
| [gear_correlation.py](../prediction/algorithms/gear_correlation.py) | 멀티모델 EMA + streak | vessel_store, gear_groups, conn | UPDATE gear_correlation_scores | α_base=0.30, polygon=0.70 |
|
||||||
|
| [gear_identity.py](../prediction/algorithms/gear_identity.py) | 공존 쌍 추출 (V030/PR #73) | gear_signals | collisions[] | CRITICAL_DIST=50km, COEXIST=3회 |
|
||||||
|
| [gear_parent_inference.py](../prediction/algorithms/gear_parent_inference.py) | 어구 → 모선 assignment | gear_groups + tracks | parent 후보 + confidence | 2-pass: direct-match → candidates |
|
||||||
|
| [gear_parent_episode.py](../prediction/algorithms/gear_parent_episode.py) | 에피소드 delineation (first_seen~last_seen) | gear_signals 시계열 | episodes[] | gap tolerance |
|
||||||
|
| [gear_violation.py](../prediction/algorithms/gear_violation.py) | G-01~G-06 통합 판정 (DAR-03) | DF + zone + pair_result + permits | g_codes[], evidence, score | G-06=20pts, G-02=18pts, G-01=15pts |
|
||||||
|
| [gear_name_rules.py](../prediction/algorithms/gear_name_rules.py) | 어구 이름 정규표현식 | string | parent_code (Optional) | regex set |
|
||||||
|
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | 쌍끌이 tier 분류 | DF×2, 6h | (pair_detected, tier, pair_type) | PROXIMITY=0.27NM, MIN_SYNC=2h |
|
||||||
|
| [track_similarity.py](../prediction/algorithms/track_similarity.py) | DTW 궤적 유사도 | DF×2 | 0~1 | - |
|
||||||
|
| [fleet.py](../prediction/algorithms/fleet.py) | leader/follower/independent | DF, tracker | role | - |
|
||||||
|
| [polygon_builder.py](../prediction/algorithms/polygon_builder.py) | gear group convex hull | vessel_store, companies | 스냅샷[] | 시간버킷 |
|
||||||
|
| [vessel_type_mapping.py](../prediction/algorithms/vessel_type_mapping.py) | fishery_code → vessel_type 폴백 | fishery_code | 'TRAWL'/'PT'/... | - |
|
||||||
|
|
||||||
|
**관찰:**
|
||||||
|
- 대부분 **순수 함수** → 재사용·단위테스트 용이. 단 `gear_correlation.run_gear_correlation` 은 `conn` 을 받아 DB 를 직접 UPDATE 함 (알고리즘 + I/O 혼재)
|
||||||
|
- **상수가 모듈 상단에 모여 있는 것은 좋으나 config 외부화는 안 됨**. 현장 운영자가 임계값을 바꾸려면 코드 배포 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 4대 도메인 커버리지 매트릭스
|
||||||
|
|
||||||
|
| 도메인 | 담당 파일 | 접근 방식 | 구조적 강점 | 구조적 공백 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Dark Vessel** | `dark_vessel.py` + `risk.py` + `compute_dark_suspicion` | gap(100분 임계) + 11 패턴 점수 + 4-tier (CRITICAL≥70) | 패턴 분리(P1~P11) + 한국 커버리지 감점(−50pt)으로 자연 gap 구분 | MID/커버리지 박스 하드코딩. 감점 로직은 탐지 누락 위험(coverage box 바깥=자연 gap 가정) — 중국 EEZ 해역 근접 공해에서 의도적 OFF 가 감점 맞을 수 있음 |
|
||||||
|
| **Gear Tracking** | `gear_correlation` + `gear_identity` + `gear_parent_inference` + `polygon_builder` | 어구 이름 regex → 공존/교체 분기 + 멀티모델 EMA + 모선 추론 + 폴리곤 | 공존(GEAR_IDENTITY_COLLISION)을 1순위 증거로 재정의 (V030). SAVEPOINT 로 PK 충돌 격리 | 어구 신호는 **이름 regex 의존**. 이름이 GEAR_PATTERN 에 맞지 않으면 파이프라인 전체 진입 실패 |
|
||||||
|
| **Chinese Fishing** | `pipeline/` 7단계 + MID 412/413/414 필터 | preprocess → behavior → resample → feature → classify → cluster → seasonal + rule-based 분류 | 룰 + 군집으로 해석 가능 | MID prefix 하드코딩 2곳 중복. 전체 인구(55k)대비 500척만 파이프라인 통과 — 경량 분석(§6)으로 보완하나 여전히 <10% |
|
||||||
|
| **Illegal Pattern** | `gear_violation` (G-01~G-06) + `event_generator.RULES` (15+ 룰) + `transshipment` 5단계 | 임계값 기반 독립 룰 × 카테고리별 dedup 윈도우 | 룰이 lambda 리스트로 선언적 | **UI 미노출** — DB `prediction_events` 카테고리 `ILLEGAL_*` 는 생산되나 전용 detection UI 없음. 운영자는 EventList(`/event-list`) 에서만 조회. 환적도 동일 문제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 코드 구조 평가 (6축)
|
||||||
|
|
||||||
|
| 축 | 평가 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| **관심사 분리** | B+ | algorithms / pipeline / output / db 레이어는 깔끔. 단 scheduler 는 오케스트레이터가 아니라 **메가 함수** |
|
||||||
|
| **재사용성** | A- | 17 알고리즘 모듈 중 ~15개가 순수함수. `run_gear_correlation` 만 conn 혼재 |
|
||||||
|
| **테스트 가능성** | C+ | unit test 3개만 (time_bucket / gear_parent_episode / gear_parent_inference). `vessel_store` / `fleet_tracker` 싱글턴 → integration test 어려움 |
|
||||||
|
| **에러 격리** | C | 사이클 전체 try/except + 내부 지역 try/except 혼재. 출력 5모듈이 한 덩어리 → 실패 지점 특정 불가 |
|
||||||
|
| **동시성** | A- | `ThreadedConnectionPool(1,5)`, `max_instances=1` 스케줄러 — 단일 프로세스 가정 하에서 안전 |
|
||||||
|
| **설정 가능성** | C- | 임계값 대부분 파일 상수. `correlation_param_models` 패턴만 DB 기반 (예외) |
|
||||||
|
|
||||||
|
### 주목할 잘된 점
|
||||||
|
- **Dedup 윈도우 카테고리별 차등** ([event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39)) — 5분 boundary 집단 만료를 피하기 위해 33/67/89/127/131/181/367분 등 **의도적으로 5의 배수 회피**. 룰 기반 탐지의 대표적인 "튜닝 knob" 이 코드에 명시.
|
||||||
|
- **gear_identity 공존/교체 분기** ([fleet_tracker.py](../prediction/fleet_tracker.py) 트랜잭션 설계) — SAVEPOINT 로 부분 실패를 사이클 전체 abort 와 분리. 이전 13h 공백 사고의 재발 방지 설계가 구조에 반영됨.
|
||||||
|
- **Lightweight path** — 파이프라인 통과 못한 중국 MID 를 경량 경로로 계속 커버 ([scheduler.py:523-682](../prediction/scheduler.py#L523-L682)). "정밀 vs 커버리지" 를 두 경로로 나누는 의사결정 자체는 탁월.
|
||||||
|
|
||||||
|
### 구조적 채무
|
||||||
|
- **환경 분기 부재**: `config.py` 에 dev/prod 분기가 없음. `.env` 파일 하나에 의존. 로컬 실행 시 운영 DB 를 건드리는 위험 ([config.py:8-22](../prediction/config.py#L8-L22))
|
||||||
|
- **상태 있는 모듈 전역 변수**: `_transship_pair_history`, `_last_run`, `_scheduler` ([scheduler.py:16-26](../prediction/scheduler.py#L16-L26)). 테스트 격리 어렵고, 재시작 시 pair 누적 상태 증발
|
||||||
|
- **DB 쓰기 산재**: `kcgdb.upsert_results` / `save_group_snapshots` / `gear_correlation UPDATE` / `gear_identity UPSERT` / `prediction_events INSERT` 가 서로 다른 트랜잭션. 한 사이클 원자성 X — 의도적일 수 있으나 명시 설계 문서 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 방향성 진단 — 프로토타입 → MVP → 운영
|
||||||
|
|
||||||
|
### 지금 강점
|
||||||
|
- **룰 기반 탐지를 탄탄히 다져둔 토대** — 임계값이 드러나 있고 dedup 설계가 명시적. 향후 ML overlay 를 얹을 때 "어디에 얹을지" 가 명확 (dark_suspicion score, transship score, gear_violation score 가 모두 연속값으로 산출)
|
||||||
|
- **운영자 의사결정 통합 설계** — V030 GEAR_IDENTITY_COLLISION 에서 status(OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE) 가 severity 재계산을 억제하는 패턴 — 사람 loop back 이 설계된 유일한 자리. 다른 도메인에도 이 패턴 확장 가능
|
||||||
|
|
||||||
|
### 지금 약점
|
||||||
|
- **ML 부재** — sklearn/torch 없음. 2026 기준 프로토타입으로는 적절하나, sequence anomaly (dark gap 의 시계열 반복 패턴) 나 behavioral classifier 는 룰만으론 한계
|
||||||
|
- **하드코딩 지대**: MID prefix(4개소), KR coverage box, SOG band, 11 패턴 점수, 5단계 transship 임계 — 모두 "이 프로젝트에서 튜닝해야 할 핫스팟" 인데 DB/config 분리 안 됨
|
||||||
|
- **UI 비대칭**: `prediction_events.category='ILLEGAL_FISHING_PATTERN'` 이 생산되지만 전용 UI 없음. 환적도 동일. 결과적으로 **prediction 이 만드는 가치의 일부가 운영자에게 도달하지 못함**
|
||||||
|
- **테스트 빈곤**: 17 알고리즘 중 3개만 유닛테스트. 사이클 단위 integration test 전혀 없음 — 사이클 회귀가 항상 운영 로그로만 드러남 (13h 공백 사고가 대표 사례)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구조적 개선 제안 (우선순위별)
|
||||||
|
|
||||||
|
### P1 — 지금 해야 할 것 (운영 안정성)
|
||||||
|
|
||||||
|
1. **사이클 스테이지 단위 에러 경계** — `_stage(name, fn, required=False)` 유틸로 9번 출력 5모듈을 쪼갤 것. `logger.exception` 으로 stacktrace 보존. `required=True` 를 `fetch_incremental` 같은 fatal 에만 적용 → 실패 시 조기 반환
|
||||||
|
2. **임계값 외부화** — `correlation_param_models` 패턴을 확장해 `detection_params` 테이블 신설 (algo_name, param_key, value, active_from, active_to). 배포 없이 해상도 튜닝 가능. 운영자 권한으로 접근 시 감사 로그
|
||||||
|
3. **ILLEGAL_FISHING_PATTERN 전용 페이지** + **환적 전용 페이지** — 백엔드 API·DB 는 이미 존재. 프론트만 GearCollisionDetection 패턴으로 추가 (`PageContainer` + `DataTable` + `Badge intent`)
|
||||||
|
4. **사이클 부분 원자성 명시** — DB 쓰기 경계 문서화 (어디까지가 한 트랜잭션인지). 최소한 [architecture.md](architecture.md) 또는 신설 `docs/prediction-transactions.md` 에 다이어그램
|
||||||
|
|
||||||
|
### P2 — 다음 (품질 확보)
|
||||||
|
|
||||||
|
5. **알고리즘 유닛테스트 커버리지** — 17 모듈 중 최소 10 개 (dark_vessel 11 패턴 / transshipment 5단계 / gear_violation 6 G-code / spoofing / risk) 에 fixture 기반 테스트. `tests/fixtures/` 에 AIS DF CSV 샘플
|
||||||
|
6. **DB fixture integration test** — testcontainers-python 으로 PostgreSQL 띄워 한 사이클 실행 + 결과 테이블 assert. CI 에서 돌릴 수 있도록 데이터 10 척 x 1h 정도 경량
|
||||||
|
7. **`vessel_store` / `fleet_tracker` 의존성 주입 개편** — 모듈 싱글턴 → `AnalysisContext` dataclass 로 명시 주입. 테스트 mock 가능
|
||||||
|
8. **MID prefix·커버리지 box 를 `monitoring_zones` JSON 연장** — 이미 `data/monitoring_zones.json` 이 있음. 동일 포맷으로 `mid_prefixes.json` / `kr_coverage.json` 추가
|
||||||
|
|
||||||
|
### P3 — 중기 (가치 확장)
|
||||||
|
|
||||||
|
9. **ML overlay 타겟 설정** — dark_suspicion score (11 패턴 합산) 은 classifier training target 으로 최적. GCN/Transformer 로 **"gap 시퀀스가 의도적인가"** 를 학습. 룰 유지 + 게이트만 ML 로 대체 (shadow mode 로 비교)
|
||||||
|
10. **correlation 파라미터 MLOps 연동** — `correlation_param_models` 를 MLflow 로 실험 기록 → 성능 좋은 모델 자동 active 전환
|
||||||
|
11. **AIS 벤치마크 데이터셋** — 한중어업협정 906척 중 과거 단속 이력 있는 선박을 positive label. 현재 매칭률 53%+ 이므로 샘플 확보 가능. tier 별 precision/recall 산출
|
||||||
|
|
||||||
|
### P4 — 장기 (스케일)
|
||||||
|
|
||||||
|
12. **multi-process / async** — APScheduler + 단일 스레드 한계. 현 55k 선박 / 2.3M points / 110초 사이클에서 8k 중국 증가 + 한국 확장 시 5분 주기 내 완료 불가 예측. asyncio + ray / dask 로 스테이지 병렬
|
||||||
|
13. **Event bus 분리** — 지금은 `prediction_events` INSERT 가 동기. outbox 패턴으로 비동기 분리 시 백엔드/프론트 실시간 push 기반 (WebSocket) 로 진화 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 부록 — 임계값 전수표 (외부화 우선순위)
|
||||||
|
|
||||||
|
| 위치 | 상수 | 현재값 | P1 외부화 | 비고 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [scheduler.py:28](../prediction/scheduler.py#L28) | `_KR_DOMESTIC_PREFIXES` | `('440','441')` | ✅ | 한국 MID |
|
||||||
|
| [scheduler.py:140,247](../prediction/scheduler.py#L140) | 중국 MID prefix | `'412' '413' '414'` 하드코딩 2곳 | ✅ | `mid_prefixes.json` |
|
||||||
|
| [scheduler.py:256](../prediction/scheduler.py#L256) | pair pool SOG band | `1.5 <= mean_sog <= 5.0` | ✅ | 조업 속력대 |
|
||||||
|
| [dark_vessel.py:6-8](../prediction/algorithms/dark_vessel.py#L6-L8) | GAP 임계 3종 | 6000/10800/86400s | ✅ | 100분/3h/24h |
|
||||||
|
| [dark_vessel.py:11-12](../prediction/algorithms/dark_vessel.py#L11-L12) | `_KR_COVERAGE_LAT/LON` | 32.0~39.5 / 124.0~132.0 | ✅ | AIS 수신 박스 |
|
||||||
|
| [dark_vessel.py:257-359](../prediction/algorithms/dark_vessel.py#L257-L359) | 11 패턴 점수 P1~P11 | 10~30 pt 분산 | ⚠️ | 탐지 정책 튜닝 대상 |
|
||||||
|
| [event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39) | DEDUP_WINDOWS 12 카테고리 | 33~367분 | ⚠️ | 이미 의도적 |
|
||||||
|
| [config.py:25-37](../prediction/config.py#L25-L37) | 파이프라인 주기 등 | 5/24/60/30/12/3 | ✅ env | `.env` 로 이미 가능 |
|
||||||
|
| [transshipment.py](../prediction/algorithms/transshipment.py) | PROXIMITY / RENDEZVOUS | 0.002deg / 90min | ✅ | 환적 민감도 |
|
||||||
|
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | PROXIMITY / SOG_Δ / COG_Δ / MIN_SYNC | 0.27NM / 0.5kn / 10° / 2h | ⚠️ | tier 재분류 기준 |
|
||||||
|
|
||||||
|
**P1:** 배포 없이 튜닝 가능해야 할 것
|
||||||
|
**⚠️:** 튜닝 자체가 탐지 정책 변경 → 릴리즈 노트 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 관련 파일 인덱스
|
||||||
|
|
||||||
|
- 오케스트레이터: [scheduler.py](../prediction/scheduler.py)
|
||||||
|
- 설정: [config.py](../prediction/config.py)
|
||||||
|
- 상태 컴포넌트: [fleet_tracker.py](../prediction/fleet_tracker.py), [cache/vessel_store.py](../prediction/cache/vessel_store.py)
|
||||||
|
- 알고리즘: [algorithms/](../prediction/algorithms/)
|
||||||
|
- 파이프라인: [pipeline/orchestrator.py](../prediction/pipeline/orchestrator.py)
|
||||||
|
- 출력: [output/event_generator.py](../prediction/output/event_generator.py), [output/violation_classifier.py](../prediction/output/violation_classifier.py), [output/kpi_writer.py](../prediction/output/kpi_writer.py), [output/stats_aggregator.py](../prediction/output/stats_aggregator.py), [output/alert_dispatcher.py](../prediction/output/alert_dispatcher.py)
|
||||||
|
- DB: [db/kcgdb.py](../prediction/db/kcgdb.py), [db/snpdb.py](../prediction/db/snpdb.py), [db/partition_manager.py](../prediction/db/partition_manager.py)
|
||||||
|
|
||||||
|
연관 운영 문서:
|
||||||
|
- [architecture.md](architecture.md) — 프론트엔드 아키텍처
|
||||||
|
- [sfr-traceability.md](sfr-traceability.md) — SFR 매트릭스
|
||||||
|
- [system-flow-guide.md](system-flow-guide.md) — system-flow 노드 명세
|
||||||
|
- `backend/README.md` — 백엔드 구성 (V030 + PR #79 hotfix 요구사항 명시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 일자 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 2026-04-17 | 초판 — opus 4.7 독립 리뷰. 구조/방향 중심 + 우선순위별 개선 제안 |
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,7 +1,8 @@
|
|||||||
# SFR 요구사항별 화면 사용 가이드
|
# SFR 요구사항별 화면 사용 가이드
|
||||||
|
|
||||||
> **문서 작성일:** 2026-04-06
|
> **문서 작성일:** 2026-04-06
|
||||||
> **시스템 버전:** v0.1.0 (프로토타입)
|
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
|
||||||
|
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
|
||||||
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
||||||
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
||||||
|
|
||||||
@ -11,7 +12,12 @@
|
|||||||
|
|
||||||
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
||||||
|
|
||||||
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
|
### 시스템 현황 (2026-04-17 기준)
|
||||||
|
- **프런트엔드·백엔드·분석엔진(prediction) 운영 배포 완료** — 자체 JWT 인증 + 트리 기반 RBAC + 감사 로그 + 65+ API
|
||||||
|
- **AI 분석 엔진(prediction)**: 5분 주기로 AIS 원천 데이터(snpdb)를 분석하여 결과를 `kcgaidb` 에 자동 저장 (14 알고리즘 + DAR-03 G-01~G-06)
|
||||||
|
- **실시간 연동 화면**: Dashboard / MonitoringDashboard / ChinaFishing / DarkVesselDetection / GearDetection / EnforcementHistory / EventList / AIAlert / Statistics / AccessControl / PermissionsPanel / Audit 등 **15+ 화면이 실 API + prediction 결과를 실시간으로 표시**
|
||||||
|
- **Mock 화면**: DataHub / AIModelManagement / RiskMap / PatrolRoute / FleetOptimization / ExternalService / ShipAgent / MLOpsPage / AIAssistant 는 UI 완성, 백엔드/AI 엔진 연동은 단계적 추가 중
|
||||||
|
- **자세한 추적 매트릭스**: `docs/sfr-traceability.md` v3.0 참조
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -55,17 +61,18 @@
|
|||||||
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
||||||
- 로그인 후 역할에 따른 메뉴 접근 제어
|
- 로그인 후 역할에 따른 메뉴 접근 제어
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
|
- ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
|
||||||
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
|
- ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
|
||||||
|
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
|
||||||
|
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
|
||||||
|
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정 (기업 환경 연동):**
|
||||||
- 🔲 SSO(Single Sign-On) 연동
|
- 🔲 SSO(해양경찰 통합인증) 연동
|
||||||
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
||||||
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
|
- 🔲 공무원증 기반 인증 연동
|
||||||
|
- 🔲 인사 시스템 연동 역할 자동 부여
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -83,16 +90,17 @@
|
|||||||
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
||||||
- 사용자 목록 조회 및 역할 할당
|
- 사용자 목록 조회 및 역할 할당
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
|
- ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
|
||||||
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
|
- ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 Y → N → 상속 사이클)
|
||||||
|
- ✅ 부모 READ 거부 시 자식 강제 거부, 상속 표시
|
||||||
|
- ✅ 역할 CRUD (admin:role-management) + 권한 매트릭스 갱신 (admin:permission-management)
|
||||||
|
- ✅ 사용자-역할 할당 다이얼로그 (admin:user-management)
|
||||||
|
- ✅ 모든 권한 변경은 `auth_audit_log` 에 자동 기록 (ROLE_CREATE/UPDATE/DELETE/PERM_UPDATE/USER_ROLE_ASSIGN)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
|
- 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
|
||||||
- 🔲 감사 로그(권한 변경 이력) 기록
|
- 🔲 역할 템플릿 복제 기능
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -369,17 +377,18 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
||||||
- 위험도 등급별 분류 표시
|
- 위험도 등급별 분류 표시
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 의심 선박 7척 목록/지도 시각화
|
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
|
||||||
- ✅ 5가지 행동 패턴 분석 결과 UI
|
- ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
|
||||||
|
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
|
||||||
|
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
|
||||||
|
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
|
||||||
|
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 AI Dark Vessel 탐지 엔진 연동
|
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
|
||||||
- 🔲 실시간 AIS 데이터 분석 연동
|
|
||||||
- 🔲 SAR(위성영상) 기반 탐지 연동
|
- 🔲 SAR(위성영상) 기반 탐지 연동
|
||||||
|
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -398,16 +407,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 해역별 중국 어선 밀집도 분석
|
- 해역별 중국 어선 밀집도 분석
|
||||||
- 시계열 활동 패턴 분석
|
- 시계열 활동 패턴 분석
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 중국 어선 분석 종합 대시보드 UI
|
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동** — `/api/analysis/*` 경유, MMSI prefix `412` 고정
|
||||||
- ✅ 지도 기반 활동 현황 시각화
|
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
|
||||||
|
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
|
||||||
|
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
|
||||||
|
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
|
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
|
||||||
- 🔲 실시간 데이터 기반 분석 갱신
|
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
|
||||||
|
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -426,17 +436,48 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
||||||
- 탐지 이미지 확인
|
- 탐지 이미지 확인
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 어구 6건 탐지 결과 목록/지도 UI
|
- ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
|
||||||
- ✅ 어구 식별 결정트리 시각화
|
- ✅ G코드별 탐지: G-01(수역-어구 불일치) / G-02(금어기) / G-03(미등록 어구) / G-04(MMSI cycling) / G-05(고정어구 drift) / G-06(쌍끌이 — STRONG/PROBABLE/SUSPECT tier)
|
||||||
|
- ✅ 어구 그룹 지도 (ZONE_I~IV 폴리곤 + GeoJsonLayer + IconLayer) + 세부 필터 패널(해역/판정/위험도/모선 상태/허가/멤버 수) + localStorage 영속화
|
||||||
|
- ✅ GearDetailPanel — 후보 클릭 → 점수 근거(관측 7종 + 보정 3종) + 모선 확정/제외 버튼
|
||||||
|
- ✅ 24h 궤적 리플레이 (GearReplayController + TripsLayer, SPEED_FACTOR=2880, 24h→30s)
|
||||||
|
- ✅ 어구/어망 판별 화면 — 허가코드/어구물리특성/발견위치 입력 → 국적 판별(한/중/미확인) + 판별 근거·경고·AI 탐지 Rule·교차 검증 파이프라인
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
|
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
|
||||||
- 🔲 실시간 CCTV/SAR 영상 분석 연동
|
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
|
||||||
- 🔲 탐지 결과 자동 분류 및 알림
|
|
||||||
|
|
||||||
**보완 필요:**
|
---
|
||||||
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
|
|
||||||
|
### 어구 정체성 충돌 (V030, 2026-04-17 추가)
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 어구 정체성 충돌
|
||||||
|
**URL:** `/gear-collision`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER (READ) / ADMIN, OPERATOR (UPDATE)
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
동일한 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 송출되는 공존 케이스를 탐지해
|
||||||
|
어구 복제/스푸핑 증거로 기록·분류하는 화면입니다. 이전에는 "MMSI 교체"로 오해해 덮어쓰는 바람에
|
||||||
|
`gear_correlation_scores_pkey` 제약 충돌로 prediction 사이클 전체가 실패하던 이슈(PR #73)를
|
||||||
|
"공존 패턴"으로 재정의하면서 신설되었습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 충돌 쌍(name, MMSI 저/고) 목록 + 공존 누적 횟수 + 양 위치 최대 거리 + severity tier
|
||||||
|
- 운영자 분류 워크플로우: OPEN → REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE (REOPEN 가능)
|
||||||
|
- severity 자동 산정: CRITICAL(거리≥50km OR 공존≥3회) / HIGH(거리≥10km OR 공존≥2회) / MEDIUM / LOW
|
||||||
|
- 상세 패널 — 양 MMSI 최근 궤적, 어구 이름 파싱(선박명+어구코드), 매칭된 모선(fleet_vessels)
|
||||||
|
|
||||||
|
**구현 완료 (V030 / PR #73, 2026-04-17):**
|
||||||
|
- ✅ `gear_identity_collisions` 테이블 + UNIQUE(name, mmsi_lo, mmsi_hi) 제약 + 5 인덱스
|
||||||
|
- ✅ prediction `algorithms/gear_identity.py` 순수함수 + `fleet_tracker.track_gear_identity()` 공존/교체 분기
|
||||||
|
- ✅ 백엔드 4 엔드포인트 `/api/analysis/gear-collisions` + `GEAR_COLLISION_RESOLVE` @Auditable
|
||||||
|
- ✅ 프론트 페이지 `GearCollisionDetection.tsx` (DataTable + KPI 5장 + SidePanel + 상태/심각도 필터)
|
||||||
|
- ✅ 이벤트 허브 연동 — HIGH/CRITICAL 은 `prediction_events` 에 자동 등록 (dedup 367분)
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 FALSE_POSITIVE 반복 쌍 자동 화이트리스트 (v2 학습 피드백 루프)
|
||||||
|
- 🔲 KPI 테이블 (`prediction_stats_*`) 에 severity 별 집계 반영
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -455,17 +496,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 이력 상세 정보 조회 및 검색/필터
|
- 이력 상세 정보 조회 및 검색/필터
|
||||||
- 이력 데이터 엑셀 내보내기
|
- 이력 데이터 엑셀 내보내기
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 단속 이력 6건 목록/상세 UI
|
- ✅ **실시간 이벤트 조회** — `/api/events` 페이징/필터/확인(ACK)/상태 변경
|
||||||
- ✅ AI 매칭 검증 결과 표시
|
- ✅ **단속 이력 CRUD** — `/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
|
||||||
|
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
|
||||||
|
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
|
||||||
|
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
|
||||||
|
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
|
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
|
||||||
- 🔲 AI 매칭 검증 엔진 연동
|
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
|
||||||
- 🔲 탐지-단속 연계 자동 분석
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -487,17 +528,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 함정 배치 현황 요약
|
- 함정 배치 현황 요약
|
||||||
- 실시간 경보 알림 표시
|
- 실시간 경보 알림 표시
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
|
- ✅ **실시간 KPI 카드** — `/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
|
||||||
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
|
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
|
||||||
|
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
|
||||||
|
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실시간 데이터 연동 (WebSocket 등)
|
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
|
||||||
- 🔲 KPI 수치 실시간 갱신
|
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
|
||||||
- 🔲 히트맵/타임라인 실시간 업데이트
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -516,17 +555,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 경보 처리(확인/대응/종결) 워크플로우
|
- 경보 처리(확인/대응/종결) 워크플로우
|
||||||
- 경보 발생 이력 조회
|
- 경보 발생 이력 조회
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 경보 등급별 현황판 UI
|
- ✅ **실시간 경보 수신** — `/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
|
||||||
- ✅ 경보 목록/상세 조회 화면
|
- ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
|
||||||
|
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
|
||||||
|
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실시간 경보 수신 연동
|
- 🔲 경보 자동 에스컬레이션 정책
|
||||||
- 🔲 경보 처리 워크플로우 DB 연동
|
- 🔲 경보 룰 커스터마이즈 UI
|
||||||
- 🔲 경보 자동 에스컬레이션
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -545,17 +582,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 선박/이벤트 클릭 시 상세 정보 팝업
|
- 선박/이벤트 클릭 시 상세 정보 팝업
|
||||||
- 지도 확대/축소 및 해역 필터링
|
- 지도 확대/축소 및 해역 필터링
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ LiveMap 기반 실시간 감시 지도 UI
|
- ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
|
||||||
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
|
- ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
|
||||||
|
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
|
||||||
|
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실시간 AIS/VMS 데이터 연동
|
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
|
||||||
- 🔲 WebSocket 기반 실시간 위치 업데이트
|
- 🔲 SAR 위성영상 오버레이
|
||||||
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -601,17 +636,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 기간별/해역별/유형별 필터링
|
- 기간별/해역별/유형별 필터링
|
||||||
- 통계 데이터 엑셀 내보내기 및 인쇄
|
- 통계 데이터 엑셀 내보내기 및 인쇄
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
|
- ✅ **실시간 통계 데이터** — `/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
|
||||||
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
|
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
|
||||||
|
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
|
||||||
|
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 통계 데이터 DB 연동
|
- 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
|
||||||
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
|
- 🔲 맞춤형 지표 대시보드 설정
|
||||||
- 🔲 맞춤형 보고서 생성 기능
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -743,17 +776,15 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
|
|||||||
- 알림 수신자 설정 및 발송
|
- 알림 수신자 설정 및 발송
|
||||||
- 알림 전송 결과(성공/실패) 확인
|
- 알림 전송 결과(성공/실패) 확인
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 알림 5건 전송 현황 UI
|
- ✅ **AI 알림 이력 실 API 조회** — `/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
|
||||||
- ✅ 알림 유형별 분류 및 상세 조회
|
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
|
||||||
|
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
|
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
|
||||||
- 🔲 AI 분석 결과 기반 자동 알림 트리거
|
- 🔲 알림 템플릿 엔진
|
||||||
- 🔲 알림 발송 이력 DB 연동
|
- 🔲 수신자 그룹 관리
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -857,15 +888,27 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 부록: 현재 시스템 상태 요약
|
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
|
||||||
|
|
||||||
| 항목 | 상태 |
|
| 항목 | 상태 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| UI 구현 | 모든 SFR 완료 |
|
| UI 구현 | 모든 SFR 완료 |
|
||||||
| 백엔드 연동 | 미구현 (전체) |
|
| **백엔드 연동** | **15+ 화면 실 API 연동 완료** (Auth/RBAC/Audit/Events/Alerts/Enforcement/Stats/Analysis/Master 등 65+ API) |
|
||||||
| 데이터 | 시연용 샘플 데이터 |
|
| **AI 분석 엔진 (prediction)** | **운영 중** — 5분 주기로 snpdb 분석 → kcgaidb 저장, 14 알고리즘 + DAR-03 G-01~G-06 |
|
||||||
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
|
| **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
|
||||||
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
|
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
|
||||||
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
|
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
|
||||||
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
|
| **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
|
||||||
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |
|
| **외부 시스템** | snpdb / gc-signal-batch 연동 완료. 유관기관 OpenAPI(GICOMS/MTIS 등)는 미연동 |
|
||||||
|
| **디자인 시스템** | `design-system.html` 쇼케이스 SSOT 전영역 준수, 라이트/다크 모드 완전 대응 |
|
||||||
|
| **다국어** | 한/영 alert/confirm/aria-label 전수 치환 완료 (JSX placeholder 35건은 후속 과제) |
|
||||||
|
| **모바일 앱** | 웹 시뮬레이션만 제공 (PWA/네이티브 앱 미개발) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 일자 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-04-06 | 초기 작성 (프론트엔드 프로토타입 v0.1.0 기준) |
|
||||||
|
| 2026-04-17 | 헤더 + SFR-01/02/09/10/11/12/13/17 주요 섹션 업데이트. 실 API 연동 / prediction 운영 상태 / 2026-04-17 PR #A/#B/#C 반영 |
|
||||||
|
|||||||
@ -6,10 +6,15 @@ KCG AI Monitoring 시스템 워크플로우 플로우차트 뷰어 사용법.
|
|||||||
|
|
||||||
`/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다.
|
`/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다.
|
||||||
|
|
||||||
- 102개 노드 + 133개 엣지 (v1.0.0 기준)
|
- 115개 노드 + 133개 엣지 (manifest 현재 상태, `meta.json` 은 아직 v1.0.0/2026-04-07 로 미갱신)
|
||||||
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
|
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
|
||||||
- 메뉴/링크 노출 없음 — 직접 URL 접근만
|
- 메뉴/링크 노출 없음 — 직접 URL 접근만
|
||||||
|
|
||||||
|
> ⚠️ **V030 미반영 경고**: 2026-04-17 V030 로 추가된 GEAR_IDENTITY_COLLISION 파이프라인 (
|
||||||
|
> `algo.gear_identity_collision`, `storage.gear_identity_collisions`, `api.gear_collisions_*`,
|
||||||
|
> `ui.gear_collision`, `decision.gear_collision_resolve`) 노드가 아직 manifest 에 등록되지
|
||||||
|
> 않았다. 다음 `/version` 릴리즈 시 매니페스트 동기화 필요.
|
||||||
|
|
||||||
## 접근 URL
|
## 접근 URL
|
||||||
|
|
||||||
- **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html
|
- **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html
|
||||||
|
|||||||
BIN
docs/중국어선_허가현황_20260106.xls
Normal file
BIN
docs/중국어선_허가현황_20260106.xls
Normal file
Binary file not shown.
BIN
frontend/public/dar03/bottom-trawl.png
Normal file
BIN
frontend/public/dar03/bottom-trawl.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 72 KiB |
BIN
frontend/public/dar03/gillnet.png
Normal file
BIN
frontend/public/dar03/gillnet.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 51 KiB |
BIN
frontend/public/dar03/pair-trawl.png
Normal file
BIN
frontend/public/dar03/pair-trawl.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 69 KiB |
BIN
frontend/public/dar03/pot-trap.png
Normal file
BIN
frontend/public/dar03/pot-trap.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 67 KiB |
BIN
frontend/public/dar03/stow-net.png
Normal file
BIN
frontend/public/dar03/stow-net.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 54 KiB |
@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
|
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi';
|
||||||
import { useMenuStore } from '@stores/menuStore';
|
import { useMenuStore } from '@stores/menuStore';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -39,6 +39,15 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
|||||||
'features/detection/ChinaFishing': lazy(() =>
|
'features/detection/ChinaFishing': lazy(() =>
|
||||||
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
||||||
),
|
),
|
||||||
|
'features/detection/GearCollisionDetection': lazy(() =>
|
||||||
|
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
|
||||||
|
),
|
||||||
|
'features/detection/IllegalFishingPattern': lazy(() =>
|
||||||
|
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
|
||||||
|
),
|
||||||
|
'features/detection/TransshipmentDetection': lazy(() =>
|
||||||
|
import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })),
|
||||||
|
),
|
||||||
// ── 단속·이벤트 ──
|
// ── 단속·이벤트 ──
|
||||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||||
@ -80,6 +89,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
|||||||
'features/ai-operations/MLOpsPage': lazy(() =>
|
'features/ai-operations/MLOpsPage': lazy(() =>
|
||||||
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
|
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
|
||||||
),
|
),
|
||||||
|
'features/ai-operations/LGCNSMLOpsPage': lazy(() =>
|
||||||
|
import('@features/ai-operations').then((m) => ({ default: m.LGCNSMLOpsPage })),
|
||||||
|
),
|
||||||
'features/ai-operations/LLMOpsPage': lazy(() =>
|
'features/ai-operations/LLMOpsPage': lazy(() =>
|
||||||
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
|
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
|
||||||
),
|
),
|
||||||
@ -113,6 +125,21 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
|||||||
default: m.LoginHistoryView,
|
default: m.LoginHistoryView,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
'features/admin/AISecurityPage': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.AISecurityPage })),
|
||||||
|
),
|
||||||
|
'features/admin/AIAgentSecurityPage': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
|
||||||
|
),
|
||||||
|
'features/admin/DataRetentionPolicy': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.DataRetentionPolicy })),
|
||||||
|
),
|
||||||
|
'features/admin/DataModelVerification': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.DataModelVerification })),
|
||||||
|
),
|
||||||
|
'features/admin/PerformanceMonitoring': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.PerformanceMonitoring })),
|
||||||
|
),
|
||||||
// ── 모선 워크플로우 ──
|
// ── 모선 워크플로우 ──
|
||||||
'features/parent-inference/ParentReview': lazy(() =>
|
'features/parent-inference/ParentReview': lazy(() =>
|
||||||
import('@features/parent-inference/ParentReview').then((m) => ({
|
import('@features/parent-inference/ParentReview').then((m) => ({
|
||||||
|
|||||||
@ -282,8 +282,9 @@ export function MainLayout() {
|
|||||||
{/* 언어 토글 */}
|
{/* 언어 토글 */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleLanguage}
|
onClick={toggleLanguage}
|
||||||
|
aria-label={t('aria.languageToggle')}
|
||||||
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
|
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
|
||||||
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
|
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
|
||||||
>
|
>
|
||||||
{language === 'ko' ? 'EN' : '한국어'}
|
{language === 'ko' ? 'EN' : '한국어'}
|
||||||
</button>
|
</button>
|
||||||
@ -338,7 +339,7 @@ export function MainLayout() {
|
|||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
aria-label="페이지 내 검색"
|
aria-label={t('aria.searchInPage')}
|
||||||
value={pageSearch}
|
value={pageSearch}
|
||||||
onChange={(e) => setPageSearch(e.target.value)}
|
onChange={(e) => setPageSearch(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@ -11,19 +11,30 @@ import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRe
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
interface AnyMeta {
|
interface AnyMeta {
|
||||||
code: string;
|
/** 일부 카탈로그는 code 없이 Record key 만 사용 (예: PERFORMANCE_STATUS_META) */
|
||||||
|
code?: string;
|
||||||
intent?: BadgeIntent;
|
intent?: BadgeIntent;
|
||||||
fallback?: { ko: string; en: string };
|
fallback?: { ko: string; en: string };
|
||||||
classes?: string | { bg?: string; text?: string; border?: string };
|
classes?: string | { bg?: string; text?: string; border?: string };
|
||||||
label?: string;
|
/** 문자열 라벨 또는 { ko, en } 객체 라벨 양쪽 지원 */
|
||||||
|
label?: string | { ko: string; en: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKoLabel(meta: AnyMeta): string {
|
function getKoLabel(meta: AnyMeta, fallbackKey: string): string {
|
||||||
return meta.fallback?.ko ?? meta.label ?? meta.code;
|
if (meta.fallback?.ko) return meta.fallback.ko;
|
||||||
|
if (meta.label && typeof meta.label === 'object' && 'ko' in meta.label) {
|
||||||
|
return meta.label.ko;
|
||||||
|
}
|
||||||
|
if (typeof meta.label === 'string') return meta.label;
|
||||||
|
return meta.code ?? fallbackKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnLabel(meta: AnyMeta): string | undefined {
|
function getEnLabel(meta: AnyMeta): string | undefined {
|
||||||
return meta.fallback?.en;
|
if (meta.fallback?.en) return meta.fallback.en;
|
||||||
|
if (meta.label && typeof meta.label === 'object' && 'en' in meta.label) {
|
||||||
|
return meta.label.en;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackClasses(meta: AnyMeta): string | undefined {
|
function getFallbackClasses(meta: AnyMeta): string | undefined {
|
||||||
@ -55,17 +66,19 @@ function renderBadge(meta: AnyMeta, label: string): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
|
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
|
||||||
const items = Object.values(entry.items) as AnyMeta[];
|
// Record key 를 안정적 식별자로 사용 (일부 카탈로그는 meta.code 없음)
|
||||||
|
const items = Object.entries(entry.items) as [string, AnyMeta][];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{items.map((meta) => {
|
{items.map(([key, meta]) => {
|
||||||
const koLabel = getKoLabel(meta);
|
const displayCode = meta.code ?? key;
|
||||||
|
const koLabel = getKoLabel(meta, key);
|
||||||
const enLabel = getEnLabel(meta);
|
const enLabel = getEnLabel(meta);
|
||||||
const trkId = `${entry.showcaseId}-${meta.code}`;
|
const trkId = `${entry.showcaseId}-${displayCode}`;
|
||||||
return (
|
return (
|
||||||
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm">
|
<Trk key={key} id={trkId} className="flex items-center gap-3 rounded-sm">
|
||||||
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
|
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
|
||||||
{meta.code}
|
{displayCode}
|
||||||
</code>
|
</code>
|
||||||
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
|
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
299
frontend/src/features/admin/AIAgentSecurityPage.tsx
Normal file
299
frontend/src/features/admin/AIAgentSecurityPage.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||||||
|
import { getAgentPermTypeIntent, getThreatLevelIntent, getAgentExecResultIntent } from '@shared/constants/aiSecurityStatuses';
|
||||||
|
import {
|
||||||
|
Bot, Activity, AlertTriangle,
|
||||||
|
CheckCircle, FileText,
|
||||||
|
Users,
|
||||||
|
Key, Layers, Hand,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SER-11: AI Agent 구축 운영
|
||||||
|
*
|
||||||
|
* AI Agent 보안 대책 관리 페이지:
|
||||||
|
* ① Agent 현황 ② 화이트리스트 도구 관리 ③ 자동 중단·민감명령 승인
|
||||||
|
* ④ 에이전트 권한·신원 ⑤ MCP Tool 최소권한 ⑥ 감사 로그
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'whitelist' | 'killswitch' | 'identity' | 'mcp' | 'audit';
|
||||||
|
|
||||||
|
const TABS: { key: Tab; icon: React.ElementType; label: string }[] = [
|
||||||
|
{ key: 'overview', icon: Activity, label: 'Agent 현황' },
|
||||||
|
{ key: 'whitelist', icon: CheckCircle, label: '화이트리스트 도구' },
|
||||||
|
{ key: 'killswitch', icon: AlertTriangle, label: '자동 중단·승인' },
|
||||||
|
{ key: 'identity', icon: Users, label: 'Agent 신원·권한' },
|
||||||
|
{ key: 'mcp', icon: Layers, label: 'MCP Tool 권한' },
|
||||||
|
{ key: 'audit', icon: FileText, label: '감사 로그' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Agent 현황 ──────────────────
|
||||||
|
const AGENTS = [
|
||||||
|
{ name: '위험도 분석 Agent', type: '조회 전용', tools: 4, status: '활성', calls24h: 1240, lastCall: '04-10 09:28' },
|
||||||
|
{ name: '법령 Q&A Agent', type: '조회 전용', tools: 3, status: '활성', calls24h: 856, lastCall: '04-10 09:25' },
|
||||||
|
{ name: '단속 이력 Agent', type: '조회 전용', tools: 5, status: '활성', calls24h: 432, lastCall: '04-10 09:20' },
|
||||||
|
{ name: '모선 추론 Agent', type: '조회+쓰기', tools: 6, status: '활성', calls24h: 128, lastCall: '04-10 09:15' },
|
||||||
|
{ name: '데이터 관리 Agent', type: '관리자', tools: 8, status: '대기', calls24h: 0, lastCall: '04-09 16:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_KPI = [
|
||||||
|
{ label: '활성 Agent', value: '4', color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
|
{ label: '등록 Tool', value: '26', color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: '24h 호출', value: '2,656', color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||||
|
{ label: '차단 건수', value: '3', color: 'text-heading', bg: 'bg-red-500/10' },
|
||||||
|
{ label: '승인 대기', value: '0', color: 'text-label', bg: 'bg-yellow-500/10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 화이트리스트 도구 ──────────────────
|
||||||
|
const WHITELIST_TOOLS = [
|
||||||
|
{ tool: 'db_read_vessel', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '선박 정보 조회' },
|
||||||
|
{ tool: 'db_read_analysis', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '분석 결과 조회' },
|
||||||
|
{ tool: 'search_law', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '법령·판례 검색' },
|
||||||
|
{ tool: 'search_cases', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '유사 사례 검색' },
|
||||||
|
{ tool: 'read_enforcement', agent: '단속 이력', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '단속 이력 조회' },
|
||||||
|
{ tool: 'write_parent_result', agent: '모선 추론', permission: 'CREATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 추론 결과 저장' },
|
||||||
|
{ tool: 'update_parent_status', agent: '모선 추론', permission: 'UPDATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 상태 갱신' },
|
||||||
|
{ tool: 'db_delete_any', agent: '-', permission: 'DELETE', mcp: '-', status: '차단', desc: 'DB 삭제 (금지 도구)' },
|
||||||
|
{ tool: 'system_exec', agent: '-', permission: 'ADMIN', mcp: '-', status: '차단', desc: '시스템 명령 실행 (금지)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 자동 중단 설정 ──────────────────
|
||||||
|
const KILL_SWITCH_RULES = [
|
||||||
|
{ rule: '유해·금지행위 탐지', desc: '유해·금지행위, 잘못된 목표 설정 시 Agent 자동 중단', threshold: '즉시', status: '활성' },
|
||||||
|
{ rule: '자원 소비 임계값 초과', desc: 'GPU/메모리/API 호출 임계값 초과 시 자동 중단', threshold: 'GPU 90% / 메모리 85%', status: '활성' },
|
||||||
|
{ rule: '이상 호출 패턴', desc: '비정상적으로 빈번한 Tool 호출 탐지', threshold: '100회/분', status: '활성' },
|
||||||
|
{ rule: '응답 시간 초과', desc: 'Agent 응답 타임아웃', threshold: '30초', status: '활성' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 민감명령 승인 ──────────────────
|
||||||
|
const SENSITIVE_COMMANDS = [
|
||||||
|
{ command: 'DB 데이터 수정/삭제', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' },
|
||||||
|
{ command: '모델 배포/롤백', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' },
|
||||||
|
{ command: '사용자 권한 변경', level: '높음', approval: '관리자 승인 필수', hitl: true, status: '적용' },
|
||||||
|
{ command: '외부 시스템 연계 호출', level: '중간', approval: '자동 승인 (로그)', hitl: false, status: '적용' },
|
||||||
|
{ command: '분석 결과 조회', level: '낮음', approval: '자동 승인', hitl: false, status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 에이전트 신원 확인 ──────────────────
|
||||||
|
const IDENTITY_POLICIES = [
|
||||||
|
{ policy: '미승인 권한 위임 차단', desc: '명시적으로 승인되지 않은 AI 에이전트로 권한 위임 제한', status: '적용' },
|
||||||
|
{ policy: 'Agent 간 신원 확인', desc: '협업할 AI 에이전트가 적합한 인증 혹은 신원 보유 중인지 상호 검증', status: '적용' },
|
||||||
|
{ policy: 'Agent 인증 방식', desc: 'Agent 간 인증은 표준 방식으로 제안, 발주처 간 협의를 통해 최종 선정', status: '적용' },
|
||||||
|
{ policy: '과도한 호출 방지', desc: 'AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비 방지', status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── MCP Tool 권한 ──────────────────
|
||||||
|
const MCP_PERMISSIONS = [
|
||||||
|
{ server: 'kcg-db-server', tools: 8, principle: '최소 권한', detail: '조회 Agent는 DB READ만, 쓰기 Agent는 특정 테이블 C/U만', status: '적용' },
|
||||||
|
{ server: 'kcg-rag-server', tools: 5, principle: '최소 권한', detail: 'RAG 검색만 허용, 인덱스 수정 불가', status: '적용' },
|
||||||
|
{ server: 'kcg-api-server', tools: 6, principle: '최소 권한', detail: '외부 API 호출은 Rate Limiting + Caching', status: '적용' },
|
||||||
|
{ server: 'kcg-notify-server', tools: 3, principle: '최소 권한', detail: '알림 발송만 허용, 수신자 목록 수정 불가', status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 감사 로그 ──────────────────
|
||||||
|
const AUDIT_LOG_SAMPLE = [
|
||||||
|
{ time: '09:28:15', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '위험도 분석', tool: 'db_read_vessel', result: '성공', latency: '120ms' },
|
||||||
|
{ time: '09:25:42', chain: 'User → LLM → MCP Client → kcg-rag-server → Milvus', agent: '법령 Q&A', tool: 'search_law', result: '성공', latency: '850ms' },
|
||||||
|
{ time: '09:20:10', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '단속 이력', tool: 'read_enforcement', result: '성공', latency: '95ms' },
|
||||||
|
{ time: '09:15:33', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '모선 추론', tool: 'write_parent_result', result: '승인→성공', latency: '230ms' },
|
||||||
|
{ time: '08:42:00', chain: 'User → LLM → Kill Switch', agent: '데이터 관리', tool: 'db_delete_any', result: '차단', latency: '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AIAgentSecurityPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Bot}
|
||||||
|
iconColor="text-heading"
|
||||||
|
title="AI Agent 구축·운영 보안"
|
||||||
|
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<TabBar variant="underline">
|
||||||
|
{TABS.map(tt => (
|
||||||
|
<TabButton key={tt.key} active={tab === tt.key} icon={<tt.icon className="w-3.5 h-3.5" />} onClick={() => setTab(tt.key)}>
|
||||||
|
{tt.label}
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* ── ① Agent 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{AGENT_KPI.map(k => (
|
||||||
|
<div key={k.label} className={`flex-1 px-4 py-3 rounded-xl border border-border ${k.bg}`}>
|
||||||
|
<div className={`text-xl font-bold ${k.color}`}>{k.value}</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">등록 Agent 목록</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">Agent명</th><th className="text-center py-2">유형</th>
|
||||||
|
<th className="text-center py-2">도구</th><th className="text-center py-2">24h 호출</th>
|
||||||
|
<th className="text-center py-2">최근 호출</th><th className="text-center py-2">상태</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{AGENTS.map(a => (
|
||||||
|
<tr key={a.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-medium">{a.name}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getAgentPermTypeIntent(a.type)} size="sm">{a.type}</Badge></td>
|
||||||
|
<td className="py-2 text-center text-heading">{a.tools}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{a.calls24h.toLocaleString()}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{a.lastCall}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getStatusIntent(a.status)} size="sm">{a.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 화이트리스트 도구 ── */}
|
||||||
|
{tab === 'whitelist' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">화이트리스트 기반 도구 관리</div>
|
||||||
|
<p className="text-[10px] text-hint mb-3">AI가 사용할 수 있는 도구를 화이트리스트로 지정·관리하여 잘못된 도구를 사용하지 못하도록 구성</p>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">Tool ID</th><th className="text-left py-2">설명</th>
|
||||||
|
<th className="text-center py-2">Agent</th><th className="text-center py-2">권한</th>
|
||||||
|
<th className="text-left py-2">MCP Server</th><th className="text-center py-2">상태</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{WHITELIST_TOOLS.map(t => (
|
||||||
|
<tr key={t.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-mono text-[9px]">{t.tool}</td>
|
||||||
|
<td className="py-2 text-hint">{t.desc}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{t.agent}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getAgentPermTypeIntent(t.permission)} size="sm">{t.permission}</Badge></td>
|
||||||
|
<td className="py-2 text-muted-foreground font-mono text-[9px]">{t.mcp}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getStatusIntent(t.status)} size="sm">{t.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 자동 중단·민감명령 승인 ── */}
|
||||||
|
{tab === 'killswitch' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">AI 에이전트 자동 중단 (Kill Switch)</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{KILL_SWITCH_RULES.map(r => (
|
||||||
|
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-heading" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
||||||
|
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground">임계값: {r.threshold}</span>
|
||||||
|
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">민감명령 승인 절차 (Human-in-the-loop)</div>
|
||||||
|
<p className="text-[10px] text-hint mb-3">국가안보·사회안전에 영향을 미칠 수 있는 민감한 명령은 전 담당자 승인 절차 마련</p>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">명령 유형</th><th className="text-center py-2">위험도</th>
|
||||||
|
<th className="text-left py-2">승인 방식</th><th className="text-center py-2">HITL</th><th className="text-center py-2">상태</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{SENSITIVE_COMMANDS.map(c => (
|
||||||
|
<tr key={c.command} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-medium">{c.command}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getThreatLevelIntent(c.level)} size="sm">{c.level}</Badge></td>
|
||||||
|
<td className="py-2 text-muted-foreground">{c.approval}</td>
|
||||||
|
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-label mx-auto" /> : <span className="text-hint">-</span>}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ Agent 신원·권한 ── */}
|
||||||
|
{tab === 'identity' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">에이전트 권한 위임 차단 및 신원 확인</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{IDENTITY_POLICIES.map(p => (
|
||||||
|
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<Key className="w-4 h-4 text-label" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||||
|
<div className="text-[9px] text-hint">{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="success" size="sm">{p.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ MCP Tool 권한 ── */}
|
||||||
|
{tab === 'mcp' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">MCP Tool 최소 권한 원칙</div>
|
||||||
|
<p className="text-[10px] text-hint mb-3">MCP를 통해 연결된 각 도구(Tool)는 최소 권한 원칙에 따라 실행 권한을 부여 (예: 조회 전용 Agent는 DB Update 권한을 가진 MCP Tool 호출 불가)</p>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">MCP Server</th><th className="text-center py-2">Tool 수</th>
|
||||||
|
<th className="text-center py-2">원칙</th><th className="text-left py-2">상세</th><th className="text-center py-2">상태</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{MCP_PERMISSIONS.map(m => (
|
||||||
|
<tr key={m.server} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-mono text-[9px]">{m.server}</td>
|
||||||
|
<td className="py-2.5 text-center text-heading">{m.tools}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="info" size="sm">{m.principle}</Badge></td>
|
||||||
|
<td className="py-2.5 text-hint text-[9px]">{m.detail}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-4 p-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-heading font-medium mb-1">Rate Limiting & Caching</div>
|
||||||
|
<div className="text-[9px] text-hint">AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비를 방지하기 위해, MCP 서버 단에서 Rate Limiting(요청 제한) 및 Caching(캐싱) 기능을 필수적으로 구현</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑥ 감사 로그 ── */}
|
||||||
|
{tab === 'audit' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">감사 로그 (Audit Log)</div>
|
||||||
|
<p className="text-[10px] text-hint mb-3">모든 MCP Tool 호출 내역(User Request → LLM → MCP Client → MCP Server → Legacy)은 식별 가능한 형태로 기록, A2A 통신 과정의 의사결정 추적성 보장</p>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">시각</th><th className="text-left py-2">호출 체인</th>
|
||||||
|
<th className="text-center py-2">Agent</th><th className="text-left py-2">Tool</th><th className="text-center py-2">결과</th><th className="text-right py-2 px-2">지연</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{AUDIT_LOG_SAMPLE.map(l => (
|
||||||
|
<tr key={l.time + l.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-muted-foreground font-mono">{l.time}</td>
|
||||||
|
<td className="py-2 text-hint text-[9px]">{l.chain}</td>
|
||||||
|
<td className="py-2 text-center text-heading">{l.agent}</td>
|
||||||
|
<td className="py-2 text-heading font-mono text-[9px]">{l.tool}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getAgentExecResultIntent(l.result)} size="sm">{l.result}</Badge></td>
|
||||||
|
<td className="py-2 px-2 text-right text-muted-foreground">{l.latency}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
frontend/src/features/admin/AISecurityPage.tsx
Normal file
357
frontend/src/features/admin/AISecurityPage.tsx
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||||||
|
import {
|
||||||
|
Shield, Database, Brain, Lock, Eye, Activity,
|
||||||
|
AlertTriangle, CheckCircle,
|
||||||
|
Server, Search, RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SER-10: AI 보안
|
||||||
|
*
|
||||||
|
* AI 보안 대책 관리 페이지:
|
||||||
|
* ① 데이터 수집 보안 ② AI 학습 보안 ③ 시스템 구축운영 보안
|
||||||
|
* ④ 입출력 보안 ⑤ 경계보안 ⑥ 취약점 점검
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'data' | 'training' | 'io' | 'boundary' | 'vulnerability';
|
||||||
|
|
||||||
|
const TABS: { key: Tab; icon: React.ComponentType<{ className?: string }>; label: string }[] = [
|
||||||
|
{ key: 'overview', icon: Activity, label: '보안 현황' },
|
||||||
|
{ key: 'data', icon: Database, label: '데이터 수집 보안' },
|
||||||
|
{ key: 'training', icon: Brain, label: 'AI 학습 보안' },
|
||||||
|
{ key: 'io', icon: Lock, label: '입출력 보안' },
|
||||||
|
{ key: 'boundary', icon: Server, label: '경계 보안' },
|
||||||
|
{ key: 'vulnerability', icon: Search, label: '취약점 점검' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 보안 현황 KPI ──────────────────
|
||||||
|
const SECURITY_KPI = [
|
||||||
|
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
|
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||||
|
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-label', bg: 'bg-yellow-500/10' },
|
||||||
|
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-label', bg: 'bg-cyan-500/10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 데이터 수집 보안 ──────────────────
|
||||||
|
const DATA_SOURCES = [
|
||||||
|
{ name: 'AIS 원본 (SNPDB)', provider: '해경', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-10', status: '정상' },
|
||||||
|
{ name: 'V-PASS 위치정보', provider: '해수부', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-09', status: '정상' },
|
||||||
|
{ name: '기상 데이터', provider: '기상청', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-08', status: '정상' },
|
||||||
|
{ name: '위성영상', provider: '해양조사원', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-07', status: '정상' },
|
||||||
|
{ name: '법령·판례', provider: '법무부', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-05', status: '정상' },
|
||||||
|
{ name: '단속 이력', provider: '해경', trust: '내부', encryption: 'AES-256', lastAudit: '2026-04-10', status: '정상' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTAMINATION_CHECKS = [
|
||||||
|
{ check: '오염데이터 탐지', desc: 'AI 학습·재학습 시 오염데이터 검사·관리', status: '활성', lastRun: '04-10 09:15' },
|
||||||
|
{ check: 'RAG 오염 차단', desc: '신규데이터 참조 시 오염데이터 유입 차단·방지', status: '활성', lastRun: '04-10 09:00' },
|
||||||
|
{ check: '출처 검증', desc: '공신력 있는 출처·배포 정보 구분 제출', status: '활성', lastRun: '04-10 08:30' },
|
||||||
|
{ check: '악성코드 사전검사', desc: '신뢰 출처 데이터라도 오염 가능, 사전 검사 필요', status: '활성', lastRun: '04-10 08:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── AI 학습 보안 ──────────────────
|
||||||
|
const TRAINING_POLICIES = [
|
||||||
|
{ policy: '보안등급별 데이터 분류', desc: 'AI시스템 활용목적 및 등급분류에 맞게 기밀·민감·공개등급 데이터 활용', status: '적용' },
|
||||||
|
{ policy: '사용자별 접근통제', desc: 'AI시스템이 사용자·부서별 권한에 맞는 학습데이터만 사용하도록 세분화', status: '적용' },
|
||||||
|
{ policy: '비인가자 접근 차단', desc: 'AI가 비인가자에게 기밀·민감등급 데이터를 제공하지 않도록 통제', status: '적용' },
|
||||||
|
{ policy: '저장소·DB 접근통제', desc: '보관된 학습데이터에 대한 사용자 접근통제', status: '적용' },
|
||||||
|
{ policy: '최소 접근권한 설계', desc: '사용자, 그룹, 데이터별로 최소 접근권한만 부여하도록 설계', status: '적용' },
|
||||||
|
{ policy: '다중 보안 인증', desc: '학습데이터 관리자 권한에 대해서는 다중 보안 인증 등 활용 방안 적용', status: '적용' },
|
||||||
|
{ policy: '오픈소스 모델 신뢰성', desc: '공신력 있는 출처·배포자가 제공하는 AI 모델·라이브러리 사용', status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 입출력 보안 ──────────────────
|
||||||
|
const IO_FILTERS = [
|
||||||
|
{ name: '입력 필터링', desc: '민감정보·적대적 공격 문구 포함여부 확인 및 차단', status: '활성', blocked: 23, total: 15420 },
|
||||||
|
{ name: '출력 필터링', desc: '응답 내 민감정보 노출 차단', status: '활성', blocked: 8, total: 15420 },
|
||||||
|
{ name: '입력 길이·형식 제한', desc: '공격용 프롬프트 과도 입력 방지', status: '활성', blocked: 5, total: 15420 },
|
||||||
|
{ name: '요청 속도 제한', desc: '호출 횟수, 동시 처리 요청수, 출력 용량 등 제한', status: '활성', blocked: 12, total: 15420 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 경계 보안 ──────────────────
|
||||||
|
const BOUNDARY_ITEMS = [
|
||||||
|
{ item: 'DMZ·중계서버', desc: 'AI시스템에 접근하는 사용자·시스템 식별 및 통제', status: '적용' },
|
||||||
|
{ item: '인가 시스템 제한', desc: 'AI시스템이 인가된 내·외부 시스템·데이터만 활용하도록 제한', status: '적용' },
|
||||||
|
{ item: '권한 부여 제한', desc: 'AI시스템에 과도한 권한 부여 제한', status: '적용' },
|
||||||
|
{ item: '민감작업 승인절차', desc: '데이터 수정·시스템 제어 등 민감한 작업 수행 시 담당자 검토·승인', status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPLAINABILITY = [
|
||||||
|
{ item: '추론 시각화', desc: '데이터 수정·시스템 제어 시 추론 과정·결과를 설명하거나 판단 근거 시각화', status: '구현' },
|
||||||
|
{ item: 'Feature Importance', desc: '모델 결정에 영향을 미친 주요 피처 표시', status: '구현' },
|
||||||
|
{ item: '판단 근거 제공', desc: '위험도 산출 시 기여 요인 설명', status: '구현' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 취약점 점검 ──────────────────
|
||||||
|
const VULN_CHECKS = [
|
||||||
|
{ target: 'AI 모델 서빙 (PyTorch)', version: '2.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' },
|
||||||
|
{ target: 'FastAPI 서버', version: '0.115.0', lastScan: '2026-04-10', vulns: 0, status: '안전' },
|
||||||
|
{ target: 'LangChain (RAG)', version: '0.3.2', lastScan: '2026-04-09', vulns: 1, status: '주의' },
|
||||||
|
{ target: 'Milvus 벡터DB', version: '2.4.0', lastScan: '2026-04-09', vulns: 0, status: '안전' },
|
||||||
|
{ target: 'Spring Boot 백엔드', version: '3.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' },
|
||||||
|
{ target: 'Node.js (Vite)', version: '22.x', lastScan: '2026-04-10', vulns: 0, status: '안전' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECOVERY_PLANS = [
|
||||||
|
{ plan: '모델 백업 저장소', desc: '이상행위 탐지 시 정상 모델·학습데이터 등으로 복원', status: '활성', detail: 'S3 버전별 백업 24개' },
|
||||||
|
{ plan: '버전정보 관리', desc: '모델·데이터·설정 버전 이력 추적', status: '활성', detail: 'Git + DVC 연동' },
|
||||||
|
{ plan: '자동 롤백', desc: '성능 저하 감지 시 이전 안정 버전으로 자동 복구', status: '활성', detail: '임계치 기반 트리거' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AISecurityPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Shield}
|
||||||
|
iconColor="text-heading"
|
||||||
|
title="AI 보안 관리"
|
||||||
|
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<TabBar variant="underline">
|
||||||
|
{TABS.map(tt => (
|
||||||
|
<TabButton
|
||||||
|
key={tt.key}
|
||||||
|
active={tab === tt.key}
|
||||||
|
icon={<tt.icon className="w-3.5 h-3.5" />}
|
||||||
|
onClick={() => setTab(tt.key)}
|
||||||
|
>
|
||||||
|
{tt.label}
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* ── ① 보안 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{SECURITY_KPI.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className={`p-1.5 rounded-lg ${k.bg}`}>
|
||||||
|
<k.icon className={`w-3.5 h-3.5 ${k.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-bold ${k.color}`}>{k.score}</span>
|
||||||
|
<span className="text-[9px] text-hint">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">보안 정책 준수 현황</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[
|
||||||
|
['데이터 출처 인증', '6/6 소스 인증 완료', '완료'],
|
||||||
|
['오염데이터 검사', '4/4 검사 활성화', '완료'],
|
||||||
|
['학습데이터 접근통제', '7/7 정책 적용', '완료'],
|
||||||
|
['입출력 필터링', '4/4 필터 활성', '완료'],
|
||||||
|
['경계 보안 설정', '4/4 항목 적용', '완료'],
|
||||||
|
['취약점 점검', '5/6 안전 (1건 주의)', '주의'],
|
||||||
|
['복구 계획', '3/3 활성', '완료'],
|
||||||
|
].map(([k, v, s]) => (
|
||||||
|
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
{s === '완료' ? <CheckCircle className="w-3 h-3 text-label" /> : <AlertTriangle className="w-3 h-3 text-label" />}
|
||||||
|
<span className="text-heading flex-1">{k}</span>
|
||||||
|
<span className="text-hint">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">최근 보안 이벤트</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[
|
||||||
|
['04-10 09:15', '오염데이터 검사 완료', '정상', '0건 탐지'],
|
||||||
|
['04-10 08:42', '입력 필터링 차단', '경고', '공격 패턴 1건 차단'],
|
||||||
|
['04-09 14:30', '취약점 스캔 완료', '주의', 'LangChain CVE-2026-1234'],
|
||||||
|
['04-09 10:00', '학습데이터 접근 감사', '정상', '비정상 접근 0건'],
|
||||||
|
['04-08 16:00', '모델 백업 완료', '정상', 'v2.1.0 → S3'],
|
||||||
|
['04-08 09:00', 'RAG 오염 차단 검사', '정상', '0건 탐지'],
|
||||||
|
].map(([time, event, level, detail]) => (
|
||||||
|
<div key={time + event} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground w-24">{time}</span>
|
||||||
|
<span className="text-heading flex-1">{event}</span>
|
||||||
|
<Badge intent={getStatusIntent(level)} size="sm">{level}</Badge>
|
||||||
|
<span className="text-hint text-[9px]">{detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 데이터 수집 보안 ── */}
|
||||||
|
{tab === 'data' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">데이터 소스 신뢰성 관리</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">데이터 소스</th><th className="text-left py-2">제공기관</th>
|
||||||
|
<th className="text-center py-2">신뢰등급</th><th className="text-center py-2">암호화</th>
|
||||||
|
<th className="text-center py-2">최근 감사</th><th className="text-center py-2">상태</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{DATA_SOURCES.map(d => (
|
||||||
|
<tr key={d.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-medium">{d.name}</td>
|
||||||
|
<td className="py-2 text-muted-foreground">{d.provider}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent="success" size="sm">{d.trust}</Badge></td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{d.encryption}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{d.lastAudit}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getStatusIntent(d.status)} size="sm">{d.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">오염데이터 방지 검사</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{CONTAMINATION_CHECKS.map(c => (
|
||||||
|
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<CheckCircle className="w-4 h-4 text-label" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{c.check}</div>
|
||||||
|
<div className="text-[9px] text-hint">{c.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge>
|
||||||
|
<span className="text-[9px] text-muted-foreground">최근: {c.lastRun}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ AI 학습 보안 ── */}
|
||||||
|
{tab === 'training' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">학습 데이터 보안 정책</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{TRAINING_POLICIES.map(p => (
|
||||||
|
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<Lock className="w-4 h-4 text-label" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||||
|
<div className="text-[9px] text-hint">{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="success" size="sm">{p.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 입출력 보안 ── */}
|
||||||
|
{tab === 'io' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 입·출력 보안 대책</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">필터</th><th className="text-left py-2">설명</th>
|
||||||
|
<th className="text-center py-2">상태</th><th className="text-center py-2">차단</th><th className="text-center py-2">총 요청</th>
|
||||||
|
<th className="text-center py-2">차단율</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{IO_FILTERS.map(f => (
|
||||||
|
<tr key={f.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{f.name}</td>
|
||||||
|
<td className="py-2.5 text-hint text-[9px]">{f.desc}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{f.status}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-heading font-bold">{f.blocked}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{f.total.toLocaleString()}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 경계 보안 ── */}
|
||||||
|
{tab === 'boundary' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 경계 보안</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{BOUNDARY_ITEMS.map(b => (
|
||||||
|
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<Server className="w-4 h-4 text-label" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{b.item}</div>
|
||||||
|
<div className="text-[9px] text-hint">{b.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="success" size="sm">{b.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">설명 가능한 AI (XAI)</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{EXPLAINABILITY.map(e => (
|
||||||
|
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<Eye className="w-4 h-4 text-heading" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{e.item}</div>
|
||||||
|
<div className="text-[9px] text-hint">{e.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="purple" size="sm">{e.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑥ 취약점 점검 ── */}
|
||||||
|
{tab === 'vulnerability' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 소프트웨어·라이브러리 취약점 점검</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">대상</th><th className="text-center py-2">버전</th>
|
||||||
|
<th className="text-center py-2">최근 스캔</th><th className="text-center py-2">취약점</th><th className="text-center py-2">상태</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{VULN_CHECKS.map(v => (
|
||||||
|
<tr key={v.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-medium">{v.target}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground font-mono">{v.version}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{v.lastScan}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<Badge intent={v.vulns > 0 ? 'warning' : 'success'} size="xs">{v.vulns}건</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getStatusIntent(v.status)} size="sm">{v.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">AI 시스템 복구 방안</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{RECOVERY_PLANS.map(r => (
|
||||||
|
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<RefreshCw className="w-4 h-4 text-label" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
|
||||||
|
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{r.detail}</span>
|
||||||
|
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -94,20 +94,20 @@ export function AccessControl() {
|
|||||||
}, [tab, loadUsers, loadAudit]);
|
}, [tab, loadUsers, loadAudit]);
|
||||||
|
|
||||||
const handleUnlock = async (userId: string, acnt: string) => {
|
const handleUnlock = async (userId: string, acnt: string) => {
|
||||||
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
|
if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
|
||||||
try {
|
try {
|
||||||
await unlockUser(userId);
|
await unlockUser(userId);
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 사용자 테이블 컬럼 ──────────────
|
// ── 사용자 테이블 컬럼 ──────────────
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
||||||
{ key: 'userAcnt', label: '계정', width: '90px',
|
{ key: 'userAcnt', label: '계정', width: '90px',
|
||||||
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
render: (v) => <span className="text-label font-mono text-[11px]">{v as string}</span> },
|
||||||
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
|
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
|
||||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
{ key: 'rnkpNm', label: '직급', width: '60px',
|
{ key: 'rnkpNm', label: '직급', width: '60px',
|
||||||
@ -133,7 +133,7 @@ export function AccessControl() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
|
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
|
||||||
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-red-400' : 'text-hint'}`}>{v as number}</span> },
|
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-heading' : 'text-hint'}`}>{v as number}</span> },
|
||||||
{ key: 'authProvider', label: '인증', width: '70px',
|
{ key: 'authProvider', label: '인증', width: '70px',
|
||||||
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||||
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
||||||
@ -146,15 +146,23 @@ export function AccessControl() {
|
|||||||
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
|
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
|
||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button type="button" onClick={() => setAssignTarget(row)}
|
<Button
|
||||||
className="p-1 text-hint hover:text-purple-400" title="역할 배정">
|
variant="ghost"
|
||||||
<UserCog className="w-3 h-3" />
|
size="sm"
|
||||||
</button>
|
onClick={() => setAssignTarget(row)}
|
||||||
|
aria-label="역할 배정"
|
||||||
|
title="역할 배정"
|
||||||
|
icon={<UserCog className="w-3 h-3" />}
|
||||||
|
/>
|
||||||
{row.userSttsCd === 'LOCKED' && (
|
{row.userSttsCd === 'LOCKED' && (
|
||||||
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
<Button
|
||||||
className="p-1 text-hint hover:text-green-400" title="잠금 해제">
|
variant="ghost"
|
||||||
<Key className="w-3 h-3" />
|
size="sm"
|
||||||
</button>
|
onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
||||||
|
aria-label="잠금 해제"
|
||||||
|
title="잠금 해제"
|
||||||
|
icon={<Key className="w-3 h-3" />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -166,7 +174,7 @@ export function AccessControl() {
|
|||||||
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
||||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
|
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
|
||||||
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
||||||
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
|
render: (v) => <span className="text-label font-mono">{(v as string) || '-'}</span> },
|
||||||
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
||||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
{ key: 'resourceType', label: '리소스', width: '110px',
|
{ key: 'resourceType', label: '리소스', width: '110px',
|
||||||
@ -180,24 +188,24 @@ export function AccessControl() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'failReason', label: '실패 사유',
|
{ key: 'failReason', label: '실패 사유',
|
||||||
render: (v) => <span className="text-red-400 text-[10px]">{(v as string) || '-'}</span> },
|
render: (v) => <span className="text-heading text-[10px]">{(v as string) || '-'}</span> },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
iconColor="text-blue-400"
|
iconColor="text-label"
|
||||||
title={t('accessControl.title')}
|
title={t('accessControl.title')}
|
||||||
description={t('accessControl.desc')}
|
description={t('accessControl.desc')}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{userStats && (
|
{userStats && (
|
||||||
<div className="flex items-center gap-2 text-[10px] text-hint">
|
<div className="flex items-center gap-2 text-[10px] text-hint">
|
||||||
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
<UserCheck className="w-3.5 h-3.5 text-label" />
|
||||||
활성 <span className="text-green-400 font-bold">{userStats.active}</span>명
|
활성 <span className="text-label font-bold">{userStats.active}</span>명
|
||||||
<span className="mx-1">|</span>
|
<span className="mx-1">|</span>
|
||||||
잠금 <span className="text-red-400 font-bold">{userStats.locked}</span>
|
잠금 <span className="text-heading font-bold">{userStats.locked}</span>
|
||||||
<span className="mx-1">|</span>
|
<span className="mx-1">|</span>
|
||||||
총 <span className="text-heading font-bold">{userStats.total}</span>
|
총 <span className="text-heading font-bold">{userStats.total}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -237,7 +245,7 @@ export function AccessControl() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||||
|
|
||||||
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
|
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
|
||||||
{tab === 'roles' && <PermissionsPanel />}
|
{tab === 'roles' && <PermissionsPanel />}
|
||||||
@ -249,8 +257,8 @@ export function AccessControl() {
|
|||||||
{userStats && (
|
{userStats && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
|
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
|
||||||
<StatCard label="활성" value={userStats.active} color="text-green-400" />
|
<StatCard label="활성" value={userStats.active} color="text-label" />
|
||||||
<StatCard label="잠금" value={userStats.locked} color="text-red-400" />
|
<StatCard label="잠금" value={userStats.locked} color="text-heading" />
|
||||||
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
|
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -278,9 +286,9 @@ export function AccessControl() {
|
|||||||
{auditStats && (
|
{auditStats && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
|
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
|
||||||
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" />
|
<StatCard label="24시간" value={auditStats.last24h} color="text-label" />
|
||||||
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" />
|
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-heading" />
|
||||||
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" />
|
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-heading" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -292,7 +300,7 @@ export function AccessControl() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{auditStats.byAction.map((a) => (
|
{auditStats.byAction.map((a) => (
|
||||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
|
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
|
||||||
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
|
{a.action} <span className="text-label font-bold ml-1">{a.count}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export function AccessLogs() {
|
|||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
iconColor="text-cyan-400"
|
iconColor="text-label"
|
||||||
title="접근 이력"
|
title="접근 이력"
|
||||||
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
|
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
|
||||||
actions={
|
actions={
|
||||||
@ -50,10 +50,10 @@ export function AccessLogs() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
||||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
|
||||||
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
|
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-heading" />
|
||||||
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
|
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-heading" />
|
||||||
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
|
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-heading" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -73,7 +73,7 @@ export function AccessLogs() {
|
|||||||
{stats.topPaths.map((p) => (
|
{stats.topPaths.map((p) => (
|
||||||
<tr key={p.path} className="border-t border-border">
|
<tr key={p.path} className="border-t border-border">
|
||||||
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
|
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
|
||||||
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td>
|
<td className="py-1.5 text-right text-label font-bold">{p.count}</td>
|
||||||
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
|
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -83,7 +83,7 @@ export function AccessLogs() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||||
|
|
||||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
@ -109,8 +109,8 @@ export function AccessLogs() {
|
|||||||
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||||
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
|
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
<td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
|
||||||
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
|
<td className="px-3 py-2 text-heading font-mono">{it.httpMethod}</td>
|
||||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export function AdminPanel() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-2">
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-blue-400" />데이터베이스</CardTitle>
|
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-label" />데이터베이스</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
{[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => (
|
{[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => (
|
||||||
@ -77,7 +77,7 @@ export function AdminPanel() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-2">
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-green-400" />보안 현황</CardTitle>
|
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-label" />보안 현황</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (
|
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export function AuditLogs() {
|
|||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={FileSearch}
|
icon={FileSearch}
|
||||||
iconColor="text-blue-400"
|
iconColor="text-label"
|
||||||
title="감사 로그"
|
title="감사 로그"
|
||||||
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
|
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
|
||||||
actions={
|
actions={
|
||||||
@ -50,9 +50,9 @@ export function AuditLogs() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
|
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
|
||||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
|
||||||
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
|
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-heading" />
|
||||||
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
|
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-heading" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ export function AuditLogs() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stats.byAction.map((a) => (
|
{stats.byAction.map((a) => (
|
||||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
|
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
|
||||||
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
|
{a.action} <span className="text-label font-bold ml-1">{a.count}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +72,7 @@ export function AuditLogs() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||||
|
|
||||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ export function AuditLogs() {
|
|||||||
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
|
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||||
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
|
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
<td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
|
||||||
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
|
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
@ -107,7 +107,7 @@ export function AuditLogs() {
|
|||||||
{it.result || '-'}
|
{it.result || '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
<td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
|
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
|
||||||
{it.detail ? JSON.stringify(it.detail) : '-'}
|
{it.detail ? JSON.stringify(it.detail) : '-'}
|
||||||
|
|||||||
@ -1,29 +1,27 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { Button } from '@shared/components/ui/button';
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
|
||||||
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
|
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
|
||||||
import type { BadgeIntent } from '@lib/theme/variants';
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
|
import {
|
||||||
|
Database, RefreshCw, Wifi, WifiOff, Radio,
|
||||||
|
Activity, Server, ArrowDownToLine, AlertTriangle,
|
||||||
|
CheckCircle, BarChart3, Layers, Plus, Play, Square,
|
||||||
|
Trash2, Edit2, Eye, FileText, HardDrive, FolderOpen,
|
||||||
|
Network,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */
|
|
||||||
function jobStatusIntent(s: string): BadgeIntent {
|
function jobStatusIntent(s: string): BadgeIntent {
|
||||||
if (s === '수행중') return 'success';
|
if (s === '수행중') return 'success';
|
||||||
if (s === '대기중') return 'warning';
|
if (s === '대기중') return 'warning';
|
||||||
if (s === '장애발생') return 'critical';
|
if (s === '장애발생') return 'critical';
|
||||||
return 'muted';
|
return 'muted';
|
||||||
}
|
}
|
||||||
import {
|
|
||||||
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
|
|
||||||
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
|
|
||||||
CheckCircle, XCircle, BarChart3, Layers, Plus, Play, Square,
|
|
||||||
Trash2, Edit2, Eye, FileText, HardDrive, Upload, FolderOpen,
|
|
||||||
Network, X, ChevronRight, Info,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SFR-03: 통합데이터 허브 수집·연계 관리
|
* SFR-03: 통합데이터 허브 수집·연계 관리
|
||||||
@ -115,7 +113,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
|||||||
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
|
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
|
||||||
},
|
},
|
||||||
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
|
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
|
||||||
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span>,
|
render: (v) => <span className="text-label font-medium">{v as string}</span>,
|
||||||
},
|
},
|
||||||
{ key: 'linkInfo', label: '연계정보', width: '65px' },
|
{ key: 'linkInfo', label: '연계정보', width: '65px' },
|
||||||
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
||||||
@ -126,7 +124,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
|||||||
render: (v) => {
|
render: (v) => {
|
||||||
const s = v as string;
|
const s = v as string;
|
||||||
return s === '수신대기중'
|
return s === '수신대기중'
|
||||||
? <span className="text-orange-400 text-[9px]">{s}</span>
|
? <Badge intent="warning" size="xs">{s}</Badge>
|
||||||
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
|
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -157,13 +155,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
<div className="w-16 shrink-0 text-right">
|
<div className="w-16 shrink-0 text-right">
|
||||||
<div className="text-[11px] font-bold" style={{
|
<div className="text-[11px] font-bold text-label">{source.name}</div>
|
||||||
color: source.name === 'VTS' ? '#22c55e'
|
|
||||||
: source.name === 'VTS-AIS' ? '#3b82f6'
|
|
||||||
: source.name === 'V-PASS' ? '#a855f7'
|
|
||||||
: source.name === 'E-NAVI' ? '#ef4444'
|
|
||||||
: '#eab308',
|
|
||||||
}}>{source.name}</div>
|
|
||||||
<div className="text-[10px] text-hint">{source.rate}%</div>
|
<div className="text-[10px] text-hint">{source.rate}%</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 타임라인 바 */}
|
{/* 타임라인 바 */}
|
||||||
@ -232,20 +224,21 @@ const collectColumns: DataColumn<CollectJob>[] = [
|
|||||||
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
|
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const n = v as number;
|
const n = v as number;
|
||||||
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint';
|
if (n === 0) return <span className="font-bold text-[11px] text-hint">-</span>;
|
||||||
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</span>;
|
const intent: BadgeIntent = n >= 90 ? 'success' : n >= 70 ? 'warning' : 'critical';
|
||||||
|
return <Badge intent={intent} size="xs">{n}%</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{row.status === '정지' ? (
|
{row.status === '정지' ? (
|
||||||
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-heading" title="시작"><Play className="w-3 h-3" /></button>
|
||||||
) : row.status !== '장애발생' ? (
|
) : row.status !== '장애발생' ? (
|
||||||
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-heading" title="정지"><Square className="w-3 h-3" /></button>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||||
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -291,9 +284,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
|
|||||||
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||||
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
|
||||||
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-heading" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -348,6 +341,7 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
|
|||||||
|
|
||||||
export function DataHub() {
|
export function DataHub() {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
const [tab, setTab] = useState<Tab>('signal');
|
const [tab, setTab] = useState<Tab>('signal');
|
||||||
const [selectedDate, setSelectedDate] = useState('2026-04-02');
|
const [selectedDate, setSelectedDate] = useState('2026-04-02');
|
||||||
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
|
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
|
||||||
@ -387,7 +381,7 @@ export function DataHub() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Database}
|
icon={Database}
|
||||||
iconColor="text-cyan-400"
|
iconColor="text-label"
|
||||||
title={t('dataHub.title')}
|
title={t('dataHub.title')}
|
||||||
description={t('dataHub.desc')}
|
description={t('dataHub.desc')}
|
||||||
demo
|
demo
|
||||||
@ -402,11 +396,11 @@ export function DataHub() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
|
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
|
||||||
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-heading', bg: 'bg-red-500/10' },
|
||||||
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' },
|
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||||
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-heading', bg: 'bg-orange-500/10' },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
@ -449,7 +443,7 @@ export function DataHub() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
aria-label="수신 현황 기준일"
|
aria-label={tc('aria.receiptDate')}
|
||||||
type="date"
|
type="date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
@ -509,11 +503,11 @@ export function DataHub() {
|
|||||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasPartialOff ? (
|
{hasPartialOff ? (
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
|
<AlertTriangle className="w-3.5 h-3.5 text-heading" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
|
<CheckCircle className="w-3.5 h-3.5 text-label" />
|
||||||
)}
|
)}
|
||||||
<span className={`text-[11px] font-bold ${hasPartialOff ? 'text-orange-400' : 'text-green-400'}`}>
|
<span className="text-[11px] font-bold text-heading">
|
||||||
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
|
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -653,9 +647,9 @@ export function DataHub() {
|
|||||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
|
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
|
||||||
<span className="text-[9px] text-hint">작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)}</span>
|
<span className="text-[9px] text-hint">작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-label" title="상태 상세"><Eye className="w-3 h-3" /></button>
|
||||||
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-heading" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
|
||||||
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
<button type="button" className="p-1 text-hint hover:text-heading" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
385
frontend/src/features/admin/DataModelVerification.tsx
Normal file
385
frontend/src/features/admin/DataModelVerification.tsx
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||||||
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
|
import {
|
||||||
|
Database, CheckCircle, AlertTriangle, FileText, Users,
|
||||||
|
Layers, Table2, Search, ChevronRight, GitBranch,
|
||||||
|
Shield, Eye, ListChecks, ClipboardCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DAR-11: 데이터 모델 검증
|
||||||
|
*
|
||||||
|
* 설계된 데이터 모델을 설계자·개발자·해양경찰청·전문가 등이 참여하여
|
||||||
|
* 각 단계별 데이터 모델 검증 시 기준을 정의·실시한다.
|
||||||
|
*
|
||||||
|
* ① 검증 현황 ② 논리 모델 검증 ③ 물리 모델 검증 ④ 중복·정합성 점검 ⑤ 검증 결과 이력
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'logical' | 'physical' | 'duplication' | 'history';
|
||||||
|
|
||||||
|
// ─── 검증 현황 KPI ──────────────────
|
||||||
|
const VERIFICATION_KPI = [
|
||||||
|
{ label: '전체 테이블', value: '48개', icon: Table2, color: '#3b82f6' },
|
||||||
|
{ label: '논리 모델 검증', value: '완료', icon: GitBranch, color: '#10b981' },
|
||||||
|
{ label: '물리 모델 검증', value: '완료', icon: Database, color: '#8b5cf6' },
|
||||||
|
{ label: '중복 테이블', value: '0건', icon: Search, color: '#06b6d4' },
|
||||||
|
{ label: '미해결 이슈', value: '1건', icon: AlertTriangle, color: '#f59e0b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 검증 참여자 ──────────────────
|
||||||
|
const PARTICIPANTS = [
|
||||||
|
{ role: '데이터 설계자', name: '정해진 (주무관)', responsibility: '논리·물리 모델 설계, ERD 작성', badge: 'critical' as const },
|
||||||
|
{ role: '백엔드 개발자', name: '이상호 (경위)', responsibility: '마이그레이션 구현, 인덱스·성능 최적화', badge: 'info' as const },
|
||||||
|
{ role: '해양경찰청 담당관', name: '김영수 (사무관)', responsibility: '요구사항 대비 완전성 검토·승인', badge: 'warning' as const },
|
||||||
|
{ role: 'DB 전문가', name: '외부 자문위원', responsibility: '정규화·반정규화 타당성, 성능 검증', badge: 'success' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 검증 절차 (4단계) ──────────────────
|
||||||
|
const VERIFICATION_PHASES = [
|
||||||
|
{ phase: '① 검증 계획 수립', actions: ['검증 범위·기준·일정 확정', '참여자 역할 분담', '체크리스트 작성'], responsible: '데이터 설계자', icon: FileText },
|
||||||
|
{ phase: '② 논리 모델 검증', actions: ['요구사항 대비 완전성 확인', '엔티티·속성·관계 정합성', '정규화 수준 적정성 검토'], responsible: '설계자 + 담당관', icon: GitBranch },
|
||||||
|
{ phase: '③ 물리 모델 검증', actions: ['테이블·컬럼·인덱스 구조 확인', '데이터 타입·제약조건 적정성', '성능 관점 반정규화 타당성'], responsible: '개발자 + DB전문가', icon: Database },
|
||||||
|
{ phase: '④ 결과 보고·조치', actions: ['검증 결과서 작성', '미해결 이슈 추적·조치', '최종 승인 및 이력 등록'], responsible: '전체 참여자', icon: ClipboardCheck },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 논리 모델 검증 항목 ──────────────────
|
||||||
|
const LOGICAL_CHECKS = [
|
||||||
|
{ category: '완전성', item: '요구사항 커버리지', desc: '48개 테이블이 SFR/DAR 전체 요구사항을 충족하는지 매핑 확인', result: '100% (48/48)', status: '통과' },
|
||||||
|
{ category: '완전성', item: '엔티티 누락 여부', desc: '식별된 비즈니스 영역(인증·탐지·단속·통계·관리) 대비 누락 엔티티 점검', result: '누락 0건', status: '통과' },
|
||||||
|
{ category: '정합성', item: '엔티티 관계 정의', desc: '외래키 관계, 참조 무결성, 카디널리티(1:N, M:N) 적정성', result: '78개 관계 확인', status: '통과' },
|
||||||
|
{ category: '정합성', item: '속성 정의 명확성', desc: '속성명 명명규칙, 도메인 정의, NULL 허용 정책 준수', result: '전체 준수', status: '통과' },
|
||||||
|
{ category: '정규화', item: '제3정규형 준수', desc: '함수 종속성 분석, 이행 종속 제거, 제3정규형(3NF) 이상 달성', result: '48/48 테이블', status: '통과' },
|
||||||
|
{ category: '정규화', item: '반정규화 타당성', desc: '성능 목적 반정규화 항목의 타당성 및 정합성 관리 방안', result: '3건 (타당)', status: '통과' },
|
||||||
|
{ category: '표준', item: '명명규칙 준수', desc: '테이블·컬럼·인덱스 명명규칙 (snake_case, kcg 스키마 접두어)', result: '전체 준수', status: '통과' },
|
||||||
|
{ category: '표준', item: '코드성 데이터 표준화', desc: '상태코드·유형코드 등 코드 마스터 테이블 일원화', result: 'code_master 통합', status: '통과' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 물리 모델 검증 항목 ──────────────────
|
||||||
|
const PHYSICAL_CHECKS = [
|
||||||
|
{ category: '구조', item: '테이블 스페이스 배치', desc: 'PostgreSQL kcg 스키마 내 48개 테이블 배치 적정성', result: '적정', status: '통과' },
|
||||||
|
{ category: '구조', item: '파티셔닝 전략', desc: 'AIS 위치 로그 등 대용량 테이블 파티셔닝 적용 여부', result: 'TimescaleDB hypertable', status: '통과' },
|
||||||
|
{ category: '데이터 타입', item: '컬럼 타입 적정성', desc: 'VARCHAR 길이, NUMERIC 정밀도, TIMESTAMP 타임존 설정', result: '전체 적정', status: '통과' },
|
||||||
|
{ category: '데이터 타입', item: 'JSON vs 정규 컬럼', desc: 'JSONB 사용 항목의 타당성 (스키마 유연성 vs 쿼리 성능)', result: '3개 테이블 JSONB (타당)', status: '통과' },
|
||||||
|
{ category: '인덱스', item: '기본키·외래키 인덱스', desc: 'PK/FK 인덱스 자동 생성 확인, 복합키 순서 적정성', result: '전체 생성 확인', status: '통과' },
|
||||||
|
{ category: '인덱스', item: '조회 성능 인덱스', desc: '고빈도 조회 패턴 분석 기반 추가 인덱스 설계', result: '12개 추가 인덱스', status: '통과' },
|
||||||
|
{ category: '제약조건', item: 'NOT NULL·CHECK·UNIQUE', desc: '필수값 제약, 범위 체크, 유일성 제약 적용 현황', result: '전체 적용', status: '통과' },
|
||||||
|
{ category: '제약조건', item: '참조 무결성 (FK)', desc: '외래키 ON DELETE/UPDATE 정책 (CASCADE/RESTRICT)', result: '정책 수립 완료', status: '통과' },
|
||||||
|
{ category: '성능', item: '쿼리 실행 계획 검증', desc: '주요 조회 쿼리 EXPLAIN ANALYZE 검증', result: 'P95 < 100ms', status: '통과' },
|
||||||
|
{ category: '성능', item: '대량 INSERT 성능', desc: 'AIS 5분 주기 배치 INSERT 성능 검증 (bulk insert)', result: '10K rows/sec', status: '주의' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 중복·정합성 점검 ──────────────────
|
||||||
|
const DUPLICATION_CHECKS = [
|
||||||
|
{ target: '테이블 중복', desc: '동일 목적의 테이블이 중복 존재하는지 점검', scope: '48개 테이블', result: '중복 0건', status: '통과' },
|
||||||
|
{ target: '컬럼 중복', desc: '동일 비즈니스 의미의 컬럼이 다른 이름으로 존재하는지 점검', scope: '전체 컬럼', result: '중복 0건', status: '통과' },
|
||||||
|
{ target: '반정규화 정합성', desc: '반정규화로 인한 중복 데이터의 동기화 방안 확인', scope: '3개 반정규화 항목', result: '트리거 기반 동기화', status: '통과' },
|
||||||
|
{ target: '코드값 일관성', desc: '상태코드·유형코드가 code_master 통해 일원 관리되는지 확인', scope: '19개 코드 카탈로그', result: '전체 일원화', status: '통과' },
|
||||||
|
{ target: '외래키 정합성', desc: '참조 대상 레코드 삭제 시 고아 레코드 발생 여부 점검', scope: '78개 FK 관계', result: '고아 0건', status: '통과' },
|
||||||
|
{ target: '스키마 간 정합성', desc: 'prediction 분석 결과 → kcgaidb 연계 시 데이터 타입 일치', scope: 'SNPDB ↔ kcgaidb', result: '타입 일치 확인', status: '통과' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 데이터 주제영역 ──────────────────
|
||||||
|
const SUBJECT_AREAS = [
|
||||||
|
{ area: '인증·권한', tables: 'auth_user, auth_role, auth_perm_tree, auth_perm, auth_user_role', count: 5, desc: '사용자·역할·권한 트리 기반 RBAC' },
|
||||||
|
{ area: 'AIS·선박', tables: 'vessel_master, ais_position, vessel_permit', count: 3, desc: 'AIS 수신 데이터, 선박 기본정보, 허가 현황' },
|
||||||
|
{ area: '탐지·분석', tables: 'vessel_analysis, dark_vessel, gear_detection, transship_detection, ...', count: 12, desc: '14개 알고리즘 분석 결과 저장' },
|
||||||
|
{ area: '단속·이벤트', tables: 'prediction_event, enforcement_plan, enforcement_record, ...', count: 8, desc: '이벤트 발생·단속 계획·단속 이력' },
|
||||||
|
{ area: '모선 워크플로우', tables: 'parent_review, parent_exclusion, label_session, ...', count: 5, desc: '모선 확정·제외·학습 워크플로우' },
|
||||||
|
{ area: '순찰·함정', tables: 'patrol_ship, patrol_route, fleet_optimization, ...', count: 6, desc: '함정 관리·순찰 경로·최적화' },
|
||||||
|
{ area: '통계·감사', tables: 'statistics_daily, audit_log, access_log, login_history, ...', count: 5, desc: '통계 집계·감사 로그·접근 이력' },
|
||||||
|
{ area: '시스템·관리', tables: 'code_master, zone_polygon, gear_type_master, notice, ...', count: 4, desc: '코드 마스터·구역 정보·공지사항' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 검증 결과 이력 ──────────────────
|
||||||
|
const VERIFICATION_HISTORY = [
|
||||||
|
{ id: 'VER-2026-005', date: '2026-04-10', phase: '물리 모델 검증', reviewer: '이상호, 외부 자문', target: 'V015 NUMERIC 정밀도 조정', issues: 0, result: '통과' },
|
||||||
|
{ id: 'VER-2026-004', date: '2026-04-07', phase: '중복·정합성 점검', reviewer: '정해진', target: '반정규화 3건 정합성', issues: 0, result: '통과' },
|
||||||
|
{ id: 'VER-2026-003', date: '2026-04-03', phase: '논리 모델 검증', reviewer: '김영수, 정해진', target: 'V014 함정·예측 테이블 추가', issues: 1, result: '조건부 통과' },
|
||||||
|
{ id: 'VER-2026-002', date: '2026-03-28', phase: '물리 모델 검증', reviewer: '이상호', target: 'V012~V013 이벤트·단속 테이블', issues: 0, result: '통과' },
|
||||||
|
{ id: 'VER-2026-001', date: '2026-03-20', phase: '논리 모델 검증', reviewer: '전체 참여', target: '초기 V001~V011 전체 구조', issues: 3, result: '조건부 통과' },
|
||||||
|
{ id: 'VER-2025-012', date: '2025-12-15', phase: '검증 계획 수립', reviewer: '김영수', target: '검증 계획서 v1.0 확정', issues: 0, result: '승인' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DataModelVerification() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
// 향후 Phase 3 에서 검증 승인·이력 등록 버튼 추가 시 가드로 연결
|
||||||
|
void hasPermission('admin:data-model-verification', 'CREATE');
|
||||||
|
void hasPermission('admin:data-model-verification', 'UPDATE');
|
||||||
|
|
||||||
|
const TABS: Array<{ key: Tab; icon: typeof Eye; label: string }> = [
|
||||||
|
{ key: 'overview', icon: Eye, label: '검증 현황' },
|
||||||
|
{ key: 'logical', icon: GitBranch, label: '논리 모델 검증' },
|
||||||
|
{ key: 'physical', icon: Database, label: '물리 모델 검증' },
|
||||||
|
{ key: 'duplication', icon: Search, label: '중복·정합성 점검' },
|
||||||
|
{ key: 'history', icon: FileText, label: '검증 결과 이력' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={ListChecks}
|
||||||
|
iconColor="text-green-600 dark:text-green-400"
|
||||||
|
title="데이터 모델 검증"
|
||||||
|
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabBar variant="underline">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<TabButton key={t.key} variant="underline" active={tab === t.key}
|
||||||
|
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* ── ① 검증 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{VERIFICATION_KPI.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||||
|
<k.icon className="w-5 h-5" style={{ color: k.color }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: k.color }}>{k.value}</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검증 절차 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ClipboardCheck className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">검증 절차 (4단계)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{VERIFICATION_PHASES.map((s, i) => (
|
||||||
|
<div key={s.phase} className="relative">
|
||||||
|
{i < VERIFICATION_PHASES.length - 1 && (
|
||||||
|
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<s.icon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-[11px] font-bold text-green-600 dark:text-green-400">{s.phase}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{s.actions.map(a => (
|
||||||
|
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{a}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 참여자 + 주제영역 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">검증 참여자</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PARTICIPANTS.map(p => (
|
||||||
|
<div key={p.role} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<Badge intent={p.badge} size="sm">{p.role}</Badge>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{p.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{p.responsibility}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Layers className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">데이터 주제영역 ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)}개 테이블)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{SUBJECT_AREAS.map(a => (
|
||||||
|
<div key={a.area} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded text-[10px]">
|
||||||
|
<Badge intent="muted" size="xs">{a.count}개</Badge>
|
||||||
|
<span className="text-heading font-medium w-24 shrink-0">{a.area}</span>
|
||||||
|
<span className="text-hint text-[9px] truncate flex-1">{a.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 논리 모델 검증 ── */}
|
||||||
|
{tab === 'logical' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<GitBranch className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">논리 데이터 모델 검증 기준 및 결과</span>
|
||||||
|
<Badge intent="success" size="xs">{LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} 통과</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">분류</th>
|
||||||
|
<th className="text-left py-2">검증 항목</th>
|
||||||
|
<th className="text-left py-2">상세 기준</th>
|
||||||
|
<th className="text-center py-2">검증 결과</th>
|
||||||
|
<th className="text-center py-2">판정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{LOGICAL_CHECKS.map(c => (
|
||||||
|
<tr key={c.item} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
|
||||||
|
<td className="py-2.5 text-heading font-medium">{c.item}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">데이터 주제영역별 엔티티 구성</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{SUBJECT_AREAS.map(a => (
|
||||||
|
<div key={a.area} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-bold text-heading">{a.area}</span>
|
||||||
|
<Badge intent="info" size="xs">{a.count}개</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] text-hint mb-1.5">{a.desc}</div>
|
||||||
|
<div className="text-[8px] text-muted-foreground font-mono leading-relaxed">{a.tables}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 물리 모델 검증 ── */}
|
||||||
|
{tab === 'physical' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">물리 데이터 모델 검증 기준 및 결과</span>
|
||||||
|
<Badge intent="success" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} 통과</Badge>
|
||||||
|
{PHYSICAL_CHECKS.some(c => c.status === '주의') && (
|
||||||
|
<Badge intent="warning" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '주의').length}건 주의</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">분류</th>
|
||||||
|
<th className="text-left py-2">검증 항목</th>
|
||||||
|
<th className="text-left py-2">상세 기준</th>
|
||||||
|
<th className="text-center py-2">검증 결과</th>
|
||||||
|
<th className="text-center py-2">판정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PHYSICAL_CHECKS.map(c => (
|
||||||
|
<tr key={c.item} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
|
||||||
|
<td className="py-2.5 text-heading font-medium">{c.item}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 중복·정합성 점검 ── */}
|
||||||
|
{tab === 'duplication' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">중복 테이블·컬럼 및 데이터 정합성 점검</span>
|
||||||
|
<Badge intent="success" size="xs">{DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} 통과</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">점검 항목</th>
|
||||||
|
<th className="text-left py-2">상세 내용</th>
|
||||||
|
<th className="text-center py-2">점검 범위</th>
|
||||||
|
<th className="text-center py-2">결과</th>
|
||||||
|
<th className="text-center py-2">판정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DUPLICATION_CHECKS.map(c => (
|
||||||
|
<tr key={c.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{c.target}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{c.scope}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 검증 결과 이력 ── */}
|
||||||
|
{tab === 'history' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">검증 결과 이력</span>
|
||||||
|
<Badge intent="info" size="xs">{VERIFICATION_HISTORY.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">검증 ID</th>
|
||||||
|
<th className="text-center py-2">일시</th>
|
||||||
|
<th className="text-center py-2">단계</th>
|
||||||
|
<th className="text-left py-2">검증자</th>
|
||||||
|
<th className="text-left py-2">대상</th>
|
||||||
|
<th className="text-center py-2">이슈</th>
|
||||||
|
<th className="text-center py-2">결과</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{VERIFICATION_HISTORY.map(h => (
|
||||||
|
<tr key={h.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{h.id}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{h.date}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{h.phase}</Badge></td>
|
||||||
|
<td className="py-2.5 text-muted-foreground">{h.reviewer}</td>
|
||||||
|
<td className="py-2.5 text-heading font-medium text-[9px]">{h.target}</td>
|
||||||
|
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-600 dark:text-yellow-400 font-bold">{h.issues}건</span> : <span className="text-green-600 dark:text-green-400">0건</span>}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(h.result)} size="sm">{h.result}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
439
frontend/src/features/admin/DataRetentionPolicy.tsx
Normal file
439
frontend/src/features/admin/DataRetentionPolicy.tsx
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||||||
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
|
import {
|
||||||
|
Database, Clock, Trash2, ShieldCheck, FileText, AlertTriangle,
|
||||||
|
CheckCircle, Archive, CalendarClock, UserCheck, Search,
|
||||||
|
ChevronRight, Lock, Eye, Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DAR-10: 데이터 보관기간 및 파기 정책
|
||||||
|
*
|
||||||
|
* 법령 내 규정 및 유관기관 협의에 따라 데이터 유형별 보관기간 및
|
||||||
|
* 파기 절차를 수립하고 체계적으로 관리하는 정책 페이지.
|
||||||
|
*
|
||||||
|
* ① 보관 현황 ② 유형별 보관기간 ③ 파기 절차 ④ 예외·연장 ⑤ 파기 감사 대장
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'retention' | 'disposal' | 'exception' | 'audit';
|
||||||
|
|
||||||
|
// ─── 보관 현황 KPI ──────────────────
|
||||||
|
const RETENTION_KPI = [
|
||||||
|
{ label: '관리 데이터 유형', value: '6종', icon: Database, color: '#3b82f6' },
|
||||||
|
{ label: '보관기간 초과', value: '0건', icon: Clock, color: '#10b981' },
|
||||||
|
{ label: '파기 대기', value: '3건', icon: Trash2, color: '#f59e0b' },
|
||||||
|
{ label: '보존 연장 중', value: '1건', icon: ShieldCheck, color: '#8b5cf6' },
|
||||||
|
{ label: '금월 파기 완료', value: '12건', icon: CheckCircle, color: '#06b6d4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 유형별 보관기간 기준표 ──────────────────
|
||||||
|
const RETENTION_TABLE = [
|
||||||
|
{ type: '선박 위치 로그 (AIS)', category: '운항 데이터', basis: '해사안전법 시행규칙 제42조', period: '5년', format: 'PostgreSQL + TimescaleDB', volume: '약 2.1TB/년', status: '정상' },
|
||||||
|
{ type: '단속 자료', category: '법집행 기록', basis: '해양경비법 제18조, 공공기록물법', period: '10년 (영구 가능)', format: 'PostgreSQL + 파일 스토리지', volume: '약 150GB/년', status: '정상' },
|
||||||
|
{ type: '수사 관련 자료', category: '수사 기록', basis: '형사소송법 제198조, 수사기록 보존규칙', period: '영구 (종결 후 30년)', format: '암호화 스토리지', volume: '약 50GB/년', status: '정상' },
|
||||||
|
{ type: 'AI 학습용 임시 데이터', category: 'AI 학습 데이터', basis: '개인정보보호법 제21조', period: '학습 완료 후 90일', format: 'S3 + DVC', volume: '약 500GB/주기', status: '정상' },
|
||||||
|
{ type: 'CCTV·영상 증거', category: '영상 데이터', basis: '개인정보보호법 제25조', period: '30일 (증거 채택 시 영구)', format: 'NAS + HLS', volume: '약 3TB/월', status: '주의' },
|
||||||
|
{ type: '시스템 접근 로그', category: '감사 로그', basis: '정보통신망법 제45조, 전자금융감독규정', period: '5년', format: 'Elasticsearch', volume: '약 80GB/년', status: '정상' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 파기 방식 정의 ──────────────────
|
||||||
|
const DISPOSAL_METHODS = [
|
||||||
|
{ method: '완전 삭제 (Secure Erase)', desc: 'DoD 5220.22-M 기준 3회 덮어쓰기 후 삭제', target: 'DB 레코드, 파일', encryption: '해당 없음', recovery: '복구 불가능', status: '적용' },
|
||||||
|
{ method: '암호화 키 폐기', desc: '암호화된 데이터의 복호화 키를 영구 폐기하여 접근 차단', target: '암호화 스토리지', encryption: 'AES-256 키 폐기', recovery: '복구 불가능', status: '적용' },
|
||||||
|
{ method: '논리적 삭제 + 만료', desc: 'soft-delete 마킹 후 보관기간 만료 시 물리 삭제 전환', target: '운영 DB', encryption: '-', recovery: '만료 전 복구 가능', status: '적용' },
|
||||||
|
{ method: '물리적 파기', desc: '디가우저(Degausser) 또는 물리적 파쇄로 매체 파기', target: '이동식 매체, 하드디스크', encryption: '해당 없음', recovery: '복구 불가능', status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 파기 승인 절차 ──────────────────
|
||||||
|
const DISPOSAL_WORKFLOW = [
|
||||||
|
{ phase: '① 파기 대상 선별', actions: ['보관기간 만료 데이터 자동 탐색', '유형별 파기 대상 목록 생성', '백업 데이터 포함 여부 확인'], responsible: '시스템 자동', icon: Search },
|
||||||
|
{ phase: '② 파기 신청', actions: ['파기 대상 목록 검토 및 승인 요청', '수사·소송 보존 연장 대상 제외 확인', '파기 방식 지정 (완전삭제/키폐기/물리파기)'], responsible: '데이터 관리자', icon: FileText },
|
||||||
|
{ phase: '③ 승인 및 집행', actions: ['보안담당관 파기 승인', '파기 실행 (이중 확인 절차)', '백업 데이터 동시 파기'], responsible: '보안담당관', icon: UserCheck },
|
||||||
|
{ phase: '④ 결과 기록', actions: ['파기 결과 로그 자동 기록', '파기 대장 등록 (대상·일시·담당자·방식)', '감사 보고서 생성 및 보관'], responsible: '시스템 자동', icon: Archive },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 보존 연장 예외 현황 ──────────────────
|
||||||
|
const EXCEPTIONS = [
|
||||||
|
{ id: 'EXC-2026-001', dataType: '단속 자료 #2024-1892', reason: '수사 진행 중 (인천해경)', originalExpiry: '2026-03-15', extendedTo: '수사 종결 시까지', approver: '수사과장', status: '연장 중' },
|
||||||
|
{ id: 'EXC-2026-002', dataType: 'AIS 로그 (2021-Q2)', reason: '재판 증거 제출 (서울중앙지법)', originalExpiry: '2026-06-30', extendedTo: '판결 확정 시까지', approver: '법무담당관', status: '연장 중' },
|
||||||
|
{ id: 'EXC-2025-015', dataType: 'CCTV 영상 #V-2025-0342', reason: '감사원 감사 대상', originalExpiry: '2025-12-01', extendedTo: '2026-06-30', approver: '감사담당관', status: '해제 예정' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXCEPTION_RULES = [
|
||||||
|
{ rule: '수사·소송 보존', desc: '수사 개시 또는 소송 진행 중인 데이터는 종결 시까지 파기 유예', authority: '수사과장 / 법무담당관' },
|
||||||
|
{ rule: '감사 보존', desc: '내부·외부 감사 대상 데이터는 감사 완료 후 6개월까지 보존 연장', authority: '감사담당관' },
|
||||||
|
{ rule: '재난·사고 보존', desc: '해양 사고 관련 데이터는 사고 조사 종결 시까지 보존', authority: '안전관리관' },
|
||||||
|
{ rule: '정보공개 청구', desc: '정보공개 청구 접수된 데이터는 처리 완료 시까지 보존', authority: '정보공개담당관' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 파기 감사 대장 ──────────────────
|
||||||
|
const DISPOSAL_AUDIT_LOG = [
|
||||||
|
{ id: 'DSP-2026-012', date: '2026-04-10', target: 'AI 학습 임시 데이터 (배치 #B-0392)', type: 'AI 학습 데이터', method: '완전 삭제', volume: '48.2GB', operator: '정해진', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-011', date: '2026-04-08', target: 'CCTV 영상 2026-03월분 (미채택)', type: '영상 데이터', method: '완전 삭제', volume: '2.8TB', operator: '시스템', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-010', date: '2026-04-05', target: '시스템 접근 로그 (2021-Q1)', type: '감사 로그', method: '완전 삭제', volume: '12.5GB', operator: '시스템', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-009', date: '2026-04-03', target: 'AI 학습 임시 데이터 (배치 #B-0391)', type: 'AI 학습 데이터', method: '암호화 키 폐기', volume: '51.7GB', operator: '정해진', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-008', date: '2026-04-01', target: 'AIS 위치 로그 (2021-03)', type: '운항 데이터', method: '완전 삭제', volume: '180GB', operator: '시스템', approver: '이상호', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-007', date: '2026-03-28', target: '이동식 매체 (USB-0021~0025)', type: '물리 매체', method: '물리적 파기', volume: '5개', operator: '박민수', approver: '김영수', result: '완료' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 보관 구조 요약 ──────────────────
|
||||||
|
const STORAGE_ARCHITECTURE = [
|
||||||
|
{ tier: '운영 스토리지', desc: '실시간 조회·분석 대상 (최근 1년)', tech: 'PostgreSQL + TimescaleDB', encryption: 'TDE (AES-256)', backup: '일일 증분 + 주간 전체', icon: Database },
|
||||||
|
{ tier: '아카이브 스토리지', desc: '장기 보관 대상 (1~10년)', tech: 'S3 Compatible (Glacier 등급)', encryption: 'SSE-KMS', backup: '월간 무결성 검증', icon: Archive },
|
||||||
|
{ tier: '백업 스토리지', desc: '재해 복구용 (이중화)', tech: '원격지 NAS + 테이프', encryption: 'AES-256', backup: '분기별 복구 테스트', icon: Lock },
|
||||||
|
{ tier: '파기 대기 영역', desc: 'soft-delete 후 파기 승인 대기', tech: '격리 스토리지 (접근 제한)', encryption: 'AES-256', backup: '미백업 (파기 예정)', icon: Trash2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DataRetentionPolicy() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
// 향후 Phase 3 에서 파기 승인/예외 등록 시 disabled 가드로 활용
|
||||||
|
void hasPermission('admin:data-retention', 'UPDATE');
|
||||||
|
void hasPermission('admin:data-retention', 'DELETE');
|
||||||
|
|
||||||
|
const TABS: Array<{ key: Tab; icon: typeof Eye; label: string }> = [
|
||||||
|
{ key: 'overview', icon: Eye, label: '보관 현황' },
|
||||||
|
{ key: 'retention', icon: CalendarClock, label: '유형별 보관기간' },
|
||||||
|
{ key: 'disposal', icon: Trash2, label: '파기 절차' },
|
||||||
|
{ key: 'exception', icon: ShieldCheck, label: '예외·연장' },
|
||||||
|
{ key: 'audit', icon: FileText, label: '파기 감사 대장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Database}
|
||||||
|
iconColor="text-blue-600 dark:text-blue-400"
|
||||||
|
title="데이터 보관기간 및 파기 정책"
|
||||||
|
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabBar variant="underline">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<TabButton key={t.key} variant="underline" active={tab === t.key}
|
||||||
|
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* ── ① 보관 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RETENTION_KPI.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||||
|
<k.icon className="w-5 h-5" style={{ color: k.color }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: k.color }}>{k.value}</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보관 구조 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Settings className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">전체 보관 구조 (4-Tier)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{STORAGE_ARCHITECTURE.map(s => (
|
||||||
|
<div key={s.tier} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">{s.tier}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-hint mb-2">{s.desc}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
['기술', s.tech],
|
||||||
|
['암호화', s.encryption],
|
||||||
|
['백업', s.backup],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="text-label">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 정책 준수 현황 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">정책 준수 점검</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[
|
||||||
|
['유형별 보관기간 기준표', '6/6종 수립 완료', '완료'],
|
||||||
|
['파기 방식 정의', '4/4 방식 적용', '완료'],
|
||||||
|
['파기 승인 절차', '4단계 절차 운영 중', '완료'],
|
||||||
|
['보존 연장 예외 관리', '3건 관리 중 (1건 해제 예정)', '정상'],
|
||||||
|
['백업 데이터 동시 파기', '파기 시 백업 포함 확인', '완료'],
|
||||||
|
['파기 감사 대장', '12건 기록 (금월)', '완료'],
|
||||||
|
['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'],
|
||||||
|
].map(([k, v, s]) => (
|
||||||
|
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-500" />}
|
||||||
|
<span className="text-heading flex-1">{k}</span>
|
||||||
|
<span className="text-hint">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">최근 파기 이력</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{DISPOSAL_AUDIT_LOG.slice(0, 6).map(d => (
|
||||||
|
<div key={d.id} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground w-20">{d.date}</span>
|
||||||
|
<span className="text-heading flex-1 truncate">{d.target}</span>
|
||||||
|
<Badge intent={getStatusIntent(d.result)} size="sm">{d.method}</Badge>
|
||||||
|
<span className="text-hint text-[9px]">{d.volume}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 유형별 보관기간 ── */}
|
||||||
|
{tab === 'retention' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<CalendarClock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">데이터 유형별 보관기간 기준표</span>
|
||||||
|
<Badge intent="info" size="xs">{RETENTION_TABLE.length}종 관리</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">데이터 유형</th>
|
||||||
|
<th className="text-left py-2">분류</th>
|
||||||
|
<th className="text-left py-2">법적 근거</th>
|
||||||
|
<th className="text-center py-2">보관기간</th>
|
||||||
|
<th className="text-left py-2">저장 형식</th>
|
||||||
|
<th className="text-center py-2">연간 용량</th>
|
||||||
|
<th className="text-center py-2">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{RETENTION_TABLE.map(r => (
|
||||||
|
<tr key={r.type} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{r.type}</td>
|
||||||
|
<td className="py-2.5"><Badge intent="muted" size="xs">{r.category}</Badge></td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{r.basis}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-bold">{r.period}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{r.format}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{r.volume}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">관련 법령 요약</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ law: '해사안전법 시행규칙', article: '제42조', content: 'AIS 장치 기록 보존 의무 (5년)' },
|
||||||
|
{ law: '해양경비법', article: '제18조', content: '단속 기록 작성·보존 의무' },
|
||||||
|
{ law: '공공기록물 관리에 관한 법률', article: '제19조', content: '기록물 보존기간 준수 의무' },
|
||||||
|
{ law: '개인정보보호법', article: '제21조', content: '목적 달성 후 지체 없이 파기' },
|
||||||
|
{ law: '정보통신망법', article: '제45조', content: '접속기록 5년 보관 의무' },
|
||||||
|
{ law: '형사소송법', article: '제198조', content: '수사기록 보존 의무' },
|
||||||
|
].map(l => (
|
||||||
|
<div key={l.law} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="text-[11px] font-bold text-heading mb-1">{l.law}</div>
|
||||||
|
<Badge intent="muted" size="xs">{l.article}</Badge>
|
||||||
|
<div className="text-[9px] text-hint mt-1.5">{l.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 파기 절차 ── */}
|
||||||
|
{tab === 'disposal' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 파기 승인 워크플로우 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">파기 승인 절차 (4단계)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{DISPOSAL_WORKFLOW.map((s, i) => (
|
||||||
|
<div key={s.phase} className="relative">
|
||||||
|
{i < DISPOSAL_WORKFLOW.length - 1 && (
|
||||||
|
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-blue-600 dark:text-blue-400">{s.phase}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{s.actions.map(a => (
|
||||||
|
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{a}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 파기 방식 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Lock className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">파기 방식 정의</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">파기 방식</th>
|
||||||
|
<th className="text-left py-2">설명</th>
|
||||||
|
<th className="text-left py-2">적용 대상</th>
|
||||||
|
<th className="text-center py-2">암호화</th>
|
||||||
|
<th className="text-center py-2">복구 가능성</th>
|
||||||
|
<th className="text-center py-2">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DISPOSAL_METHODS.map(m => (
|
||||||
|
<tr key={m.method} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{m.method}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{m.desc}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground">{m.target}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={m.encryption.includes('AES') ? 'success' : 'muted'} size="xs">{m.encryption}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-red-600 dark:text-red-400 text-[9px] font-medium">{m.recovery}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 예외·연장 ── */}
|
||||||
|
{tab === 'exception' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">보존 연장 예외 현황</span>
|
||||||
|
<Badge intent="warning" size="xs">{EXCEPTIONS.filter(e => e.status === '연장 중').length}건 연장 중</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">예외 ID</th>
|
||||||
|
<th className="text-left py-2">대상 데이터</th>
|
||||||
|
<th className="text-left py-2">사유</th>
|
||||||
|
<th className="text-center py-2">원래 만료</th>
|
||||||
|
<th className="text-center py-2">연장 기한</th>
|
||||||
|
<th className="text-center py-2">승인자</th>
|
||||||
|
<th className="text-center py-2">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{EXCEPTIONS.map(e => (
|
||||||
|
<tr key={e.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{e.id}</td>
|
||||||
|
<td className="py-2.5 text-heading font-medium">{e.dataType}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{e.reason}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{e.originalExpiry}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{e.approver}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">보존 연장 사유 유형</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{EXCEPTION_RULES.map(r => (
|
||||||
|
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
||||||
|
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="muted" size="sm">{r.authority}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 파기 감사 대장 ── */}
|
||||||
|
{tab === 'audit' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">파기 감사 대장</span>
|
||||||
|
<Badge intent="info" size="xs">{DISPOSAL_AUDIT_LOG.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">파기 ID</th>
|
||||||
|
<th className="text-center py-2">일시</th>
|
||||||
|
<th className="text-left py-2">파기 대상</th>
|
||||||
|
<th className="text-center py-2">유형</th>
|
||||||
|
<th className="text-center py-2">방식</th>
|
||||||
|
<th className="text-center py-2">용량</th>
|
||||||
|
<th className="text-center py-2">수행자</th>
|
||||||
|
<th className="text-center py-2">승인자</th>
|
||||||
|
<th className="text-center py-2">결과</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DISPOSAL_AUDIT_LOG.map(d => (
|
||||||
|
<tr key={d.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{d.id}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.date}</td>
|
||||||
|
<td className="py-2.5 text-heading font-medium text-[9px]">{d.target}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{d.type}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.method}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-mono">{d.volume}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.operator}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.approver}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{d.result}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -41,7 +41,7 @@ export function LoginHistoryView() {
|
|||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={LogIn}
|
icon={LogIn}
|
||||||
iconColor="text-green-400"
|
iconColor="text-label"
|
||||||
title="로그인 이력"
|
title="로그인 이력"
|
||||||
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
|
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
|
||||||
actions={
|
actions={
|
||||||
@ -55,10 +55,10 @@ export function LoginHistoryView() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
|
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
|
||||||
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" />
|
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-label" />
|
||||||
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" />
|
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-label" />
|
||||||
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" />
|
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-heading" />
|
||||||
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" />
|
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-label" suffix="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ export function LoginHistoryView() {
|
|||||||
<CardContent className="px-4 pb-4 space-y-1">
|
<CardContent className="px-4 pb-4 space-y-1">
|
||||||
{stats.byUser.map((u) => (
|
{stats.byUser.map((u) => (
|
||||||
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
|
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
|
||||||
<span className="text-cyan-400 font-mono">{u.user_acnt}</span>
|
<span className="text-label font-mono">{u.user_acnt}</span>
|
||||||
<span className="text-heading font-bold">{u.count}회</span>
|
<span className="text-heading font-bold">{u.count}회</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -86,9 +86,9 @@ export function LoginHistoryView() {
|
|||||||
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
||||||
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
|
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span className="text-green-400">성공 {d.success}</span>
|
<span className="text-label">성공 {d.success}</span>
|
||||||
<span className="text-orange-400">실패 {d.failed}</span>
|
<span className="text-label">실패 {d.failed}</span>
|
||||||
<span className="text-red-400">잠금 {d.locked}</span>
|
<span className="text-heading">잠금 {d.locked}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -98,7 +98,7 @@ export function LoginHistoryView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||||
|
|
||||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
@ -123,11 +123,11 @@ export function LoginHistoryView() {
|
|||||||
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
|
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||||
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
|
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
|
||||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
|
<td className="px-3 py-2 text-label">{it.userAcnt}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
<td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
|
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { Button } from '@shared/components/ui/button';
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
import type { BadgeIntent } from '@lib/theme/variants';
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
import {
|
import {
|
||||||
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
|
Bell, Plus, Edit2, Trash2, Eye,
|
||||||
Users, Megaphone, AlertTriangle, Info, Search, Filter,
|
Megaphone, AlertTriangle, Info,
|
||||||
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
|
Clock, Pin, Monitor, MessageSquare, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
|
||||||
import { toDateParam } from '@shared/utils/dateFormat';
|
import { toDateParam } from '@shared/utils/dateFormat';
|
||||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||||
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
||||||
@ -58,10 +58,10 @@ const INITIAL_NOTICES: SystemNotice[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
|
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
|
||||||
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' },
|
{ key: 'info', label: '정보', icon: Info, color: 'text-label' },
|
||||||
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' },
|
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-label' },
|
||||||
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' },
|
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-label' },
|
||||||
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' },
|
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-label' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
|
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
|
||||||
@ -74,6 +74,11 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
|
|||||||
|
|
||||||
export function NoticeManagement() {
|
export function NoticeManagement() {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canCreate = hasPermission('admin:notices', 'CREATE');
|
||||||
|
const canUpdate = hasPermission('admin:notices', 'UPDATE');
|
||||||
|
const canDelete = hasPermission('admin:notices', 'DELETE');
|
||||||
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
|
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -141,12 +146,12 @@ export function NoticeManagement() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
iconColor="text-yellow-400"
|
iconColor="text-label"
|
||||||
title={t('notices.title')}
|
title={t('notices.title')}
|
||||||
description={t('notices.desc')}
|
description={t('notices.desc')}
|
||||||
demo
|
demo
|
||||||
actions={
|
actions={
|
||||||
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}>
|
<Button variant="primary" size="md" onClick={openNew} disabled={!canCreate} title={!canCreate ? '등록 권한이 필요합니다' : undefined} icon={<Plus className="w-3.5 h-3.5" />}>
|
||||||
새 알림 등록
|
새 알림 등록
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@ -156,9 +161,9 @@ export function NoticeManagement() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
|
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
|
||||||
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-green-400', bg: 'bg-green-500/10' },
|
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-label', bg: 'bg-red-500/10' },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
@ -233,14 +238,14 @@ export function NoticeManagement() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-1.5 text-center">
|
<td className="px-1 py-1.5 text-center">
|
||||||
{n.pinned && <Pin className="w-3 h-3 text-yellow-400 inline" />}
|
{n.pinned && <Pin className="w-3 h-3 text-label inline" />}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-1.5">
|
<td className="px-1 py-1.5">
|
||||||
<div className="flex items-center justify-center gap-0.5">
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
|
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
|
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -261,7 +266,7 @@ export function NoticeManagement() {
|
|||||||
<span className="text-sm font-bold text-heading">
|
<span className="text-sm font-bold text-heading">
|
||||||
{editingId ? '알림 수정' : '새 알림 등록'}
|
{editingId ? '알림 수정' : '새 알림 등록'}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
|
<button type="button" aria-label={tc('aria.close')} onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -271,7 +276,7 @@ export function NoticeManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">제목</label>
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">제목</label>
|
||||||
<input
|
<input
|
||||||
aria-label="알림 제목"
|
aria-label={tc('aria.noticeTitle')}
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
|
||||||
@ -283,7 +288,7 @@ export function NoticeManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">내용</label>
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">내용</label>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="알림 내용"
|
aria-label={tc('aria.noticeContent')}
|
||||||
value={form.message}
|
value={form.message}
|
||||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
@ -322,7 +327,7 @@ export function NoticeManagement() {
|
|||||||
onClick={() => setForm({ ...form, display: opt.key })}
|
onClick={() => setForm({ ...form, display: opt.key })}
|
||||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||||
form.display === opt.key
|
form.display === opt.key
|
||||||
? 'bg-blue-600/20 text-blue-400 font-bold'
|
? 'bg-blue-600/20 text-label font-bold'
|
||||||
: 'text-hint hover:bg-surface-overlay'
|
: 'text-hint hover:bg-surface-overlay'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -339,7 +344,7 @@ export function NoticeManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">시작일</label>
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">시작일</label>
|
||||||
<input
|
<input
|
||||||
aria-label="시작일"
|
aria-label={tc('aria.dateFrom')}
|
||||||
type="date"
|
type="date"
|
||||||
value={form.startDate}
|
value={form.startDate}
|
||||||
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||||
@ -349,7 +354,7 @@ export function NoticeManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">종료일</label>
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">종료일</label>
|
||||||
<input
|
<input
|
||||||
aria-label="종료일"
|
aria-label={tc('aria.dateTo')}
|
||||||
type="date"
|
type="date"
|
||||||
value={form.endDate}
|
value={form.endDate}
|
||||||
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
||||||
@ -370,7 +375,7 @@ export function NoticeManagement() {
|
|||||||
onClick={() => toggleRole(role)}
|
onClick={() => toggleRole(role)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||||
form.targetRoles.includes(role)
|
form.targetRoles.includes(role)
|
||||||
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 font-bold'
|
? 'bg-surface-overlay text-heading border border-border font-bold'
|
||||||
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
|
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -414,7 +419,7 @@ export function NoticeManagement() {
|
|||||||
<SaveButton
|
<SaveButton
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
label={editingId ? '수정' : '등록'}
|
label={editingId ? '수정' : '등록'}
|
||||||
disabled={!form.title.trim() || !form.message.trim()}
|
disabled={!form.title.trim() || !form.message.trim() || (editingId ? !canUpdate : !canCreate)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
651
frontend/src/features/admin/PerformanceMonitoring.tsx
Normal file
651
frontend/src/features/admin/PerformanceMonitoring.tsx
Normal file
@ -0,0 +1,651 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import {
|
||||||
|
getPerformanceStatusHex,
|
||||||
|
getPerformanceStatusIntent,
|
||||||
|
utilizationStatus,
|
||||||
|
type PerformanceStatus,
|
||||||
|
} from '@shared/constants/performanceStatus';
|
||||||
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
|
import {
|
||||||
|
Activity, Gauge, Users, Database, Brain, Server,
|
||||||
|
CheckCircle, AlertTriangle, TrendingUp, Clock,
|
||||||
|
Zap, Shield, BarChart3, Cpu, HardDrive, Wifi,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 성능 모니터링 (PER-01 ~ PER-06)
|
||||||
|
*
|
||||||
|
* 총 사용자 3,000명 (본청 200명·상황실 100명 24/7 + 지방청·관할서·함정)
|
||||||
|
* 통합게이트웨이(V-PASS·VTS·E-nav) + S&P Global AIS A/B 클래스 전제
|
||||||
|
*
|
||||||
|
* ① 성능 현황 ② 응답성(PER-01) ③ 처리용량(PER-02·03)
|
||||||
|
* ④ AI 모델 성능(PER-04) ⑤ 가용성·확장성(PER-05·06)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'response' | 'capacity' | 'aiModel' | 'availability';
|
||||||
|
|
||||||
|
// ─── 성능 KPI ──────────────────
|
||||||
|
const PERF_KPI: Array<{ label: string; value: string; unit: string; icon: typeof Users; status: PerformanceStatus }> = [
|
||||||
|
{ label: '현재 동시접속', value: '342', unit: '명', icon: Users, status: 'normal' },
|
||||||
|
{ label: '대시보드 p95', value: '1.8', unit: '초', icon: Gauge, status: 'good' },
|
||||||
|
{ label: '시스템 가동률', value: '99.87', unit: '%', icon: Shield, status: 'good' },
|
||||||
|
{ label: 'AI 추론 p95', value: '1.4', unit: '초', icon: Brain, status: 'good' },
|
||||||
|
{ label: '배치 SLA 준수', value: '100', unit: '%', icon: CheckCircle, status: 'good' },
|
||||||
|
{ label: '이벤트 경보', value: '0', unit: '건', icon: AlertTriangle, status: 'normal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── SLO 적용 그룹 ──────────────────
|
||||||
|
const USER_GROUPS = [
|
||||||
|
{ group: '본청 상황실', users: 100, concurrent: '100 (100%)', sla: '≤ 2초 대시보드 / ≤ 1초 지도', priority: 'critical' as const, note: '24/7 상시 접속 · 최상위 SLO' },
|
||||||
|
{ group: '본청 기타', users: 100, concurrent: '50 (50%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '주간 업무 시간' },
|
||||||
|
{ group: '지방청(5개)', users: 400, concurrent: '120 (30%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '관할해역 상황실' },
|
||||||
|
{ group: '관할서', users: 1500, concurrent: '300 (20%)', sla: '≤ 1.5초 조회', priority: 'info' as const, note: '주간 피크' },
|
||||||
|
{ group: '함정·파출소', users: 800, concurrent: '120 (15%)', sla: '≤ 1초 API', priority: 'info' as const, note: '모바일 Agent · 저대역폭' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-01 응답성 SLO vs 실측 ──────────────────
|
||||||
|
const RESPONSE_SLO = [
|
||||||
|
{ target: '메인 대시보드 초기 로드', slo: '2.0초', p50: '0.9초', p95: '1.8초', p99: '2.4초', status: 'good' as const },
|
||||||
|
{ target: '위험도 지도 격자 조회', slo: '2.0초', p50: '0.7초', p95: '1.6초', p99: '2.1초', status: 'good' as const },
|
||||||
|
{ target: '의심 선박·어구 단순 조회', slo: '1.5초', p50: '0.4초', p95: '1.1초', p99: '1.7초', status: 'good' as const },
|
||||||
|
{ target: '복합 분석·시각화', slo: '5.0초', p50: '2.1초', p95: '4.2초', p99: '5.8초', status: 'warn' as const },
|
||||||
|
{ target: 'AI 추론 API (단건)', slo: '2.0초', p50: '0.6초', p95: '1.4초', p99: '1.9초', status: 'good' as const },
|
||||||
|
{ target: '연계 API (read)', slo: '500ms', p50: '120ms', p95: '380ms', p99: '510ms', status: 'warn' as const },
|
||||||
|
{ target: '함정 모바일 Agent API', slo: '1.0초', p50: '0.3초', p95: '0.8초', p99: '1.2초', status: 'good' as const },
|
||||||
|
{ target: 'AI 탐지 알림 End-to-End', slo: '3.0초', p50: '1.1초', p95: '2.3초', p99: '2.8초', status: 'good' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-02 동시접속·TPS ──────────────────
|
||||||
|
const CAPACITY_METRICS = [
|
||||||
|
{ metric: '현재 동시접속', current: 342, target: '600 (정상 피크)', max: '900 (작전 피크)', utilization: 57, intent: 'info' as const },
|
||||||
|
{ metric: '현재 TPS', current: 185, target: '400 TPS', max: '600 TPS', utilization: 46, intent: 'info' as const },
|
||||||
|
{ metric: '활성 세션', current: 287, target: '500', max: '750', utilization: 57, intent: 'info' as const },
|
||||||
|
{ metric: 'WebSocket 연결', current: 142, target: '300', max: '500', utilization: 47, intent: 'info' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-03 배치 처리 현황 ──────────────────
|
||||||
|
const BATCH_JOBS = [
|
||||||
|
{ name: 'AIS 국내 정제·적재', schedule: '매 5분', volume: '~5 GB/일', sla: '5분', avg: '2분 18초', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: 'S&P 글로벌 AIS 집계·격자', schedule: '00:00 야간', volume: '~500 GB/일 (압축 후)', sla: '3시간', avg: '2시간 12분', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '위성영상 타일링·인덱싱', schedule: '수신 직후', volume: '건당 2~10 GB', sla: '2시간/건', avg: '1시간 24분', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '피처 스토어 갱신', schedule: '매시 정각', volume: '~200 MB', sla: '1시간', avg: '8분 42초', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: 'AI 모델 재학습 (주간)', schedule: '일요일 02:00', volume: '학습셋 전체', sla: '8시간', avg: '6시간 18분', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '통계·리포트 집계', schedule: '매시 정각', volume: '~50 MB', sla: '30분', avg: '6분 12초', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '해양기상·환경 수집', schedule: '매시 정각', volume: '~500 MB', sla: '10분', avg: '3분 48초', lastRun: '지연', status: 'warn' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-04 AI 모델 성능 ──────────────────
|
||||||
|
const AI_MODELS = [
|
||||||
|
{ model: '불법조업 위험도 예측', accuracy: 92.4, precision: 89.1, recall: 87.6, f1: 88.3, rocAuc: 0.948, target: '≥ 85% 정확도', status: 'good' as const },
|
||||||
|
{ model: '순찰 경로 추천 (단일)', accuracy: 94.1, precision: 91.2, recall: 90.5, f1: 90.8, rocAuc: 0.961, target: '≥ 90% 정확도', status: 'good' as const },
|
||||||
|
{ model: '다함정 협력 경로 최적화', accuracy: 91.8, precision: 88.4, recall: 87.9, f1: 88.1, rocAuc: 0.936, target: '≥ 85% 정확도', status: 'good' as const },
|
||||||
|
{ model: '불법 어선 (Dark Vessel) 탐지', accuracy: 96.2, precision: 94.8, recall: 92.3, f1: 93.5, rocAuc: 0.978, target: '≥ 92% 정확도', status: 'good' as const },
|
||||||
|
{ model: '불법 어망·어구 탐지', accuracy: 88.7, precision: 85.2, recall: 83.6, f1: 84.4, rocAuc: 0.912, target: '≥ 85% 정확도', status: 'warn' as const },
|
||||||
|
{ model: 'AIS 조작 패턴 감지', accuracy: 93.5, precision: 91.7, recall: 89.4, f1: 90.5, rocAuc: 0.954, target: '≥ 90% 정확도', status: 'good' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-05 가용성·장애복구 ──────────────────
|
||||||
|
const AVAILABILITY_METRICS = [
|
||||||
|
{ component: '애플리케이션 서버 (K8s)', uptime: '99.98%', rto: '≤ 30초', rpo: '0 (stateless)', lastIncident: '없음', status: 'good' as const },
|
||||||
|
{ component: 'DB (PostgreSQL HA)', uptime: '99.95%', rto: '≤ 60초', rpo: '≤ 5초', lastIncident: '2026-03-28', status: 'good' as const },
|
||||||
|
{ component: 'TimescaleDB (Hot)', uptime: '99.92%', rto: '≤ 120초', rpo: '≤ 15초', lastIncident: '2026-04-02', status: 'good' as const },
|
||||||
|
{ component: '벡터 DB (RAG)', uptime: '99.87%', rto: '≤ 180초', rpo: '≤ 30초', lastIncident: '2026-04-08', status: 'warn' as const },
|
||||||
|
{ component: 'NAS 스토리지', uptime: '99.99%', rto: '≤ 60초', rpo: '0 (이중화)', lastIncident: '없음', status: 'good' as const },
|
||||||
|
{ component: '통합게이트웨이', uptime: '99.89%', rto: '≤ 60초', rpo: '≤ 10초', lastIncident: '2026-04-05', status: 'good' as const },
|
||||||
|
{ component: 'S&P Global AIS API', uptime: '99.41%', rto: 'Fallback 즉시', rpo: '국내 신호 대체', lastIncident: '2026-04-12', status: 'warn' as const },
|
||||||
|
{ component: 'LLM Q&A 서버 (H200)', uptime: '99.76%', rto: '≤ 120초', rpo: '0 (stateless)', lastIncident: '2026-04-09', status: 'good' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-06 확장성·자원 사용률 ──────────────────
|
||||||
|
const RESOURCE_USAGE = [
|
||||||
|
{ resource: '워커 노드 CPU', current: 48, threshold: 70, max: 80, scalePolicy: 'HPA 자동 확장', unit: '%' },
|
||||||
|
{ resource: '워커 노드 메모리', current: 52, threshold: 75, max: 85, scalePolicy: 'HPA 자동 확장', unit: '%' },
|
||||||
|
{ resource: 'AI 서버 GPU (RTX pro 6000)', current: 61, threshold: 80, max: 90, scalePolicy: '추론 큐잉', unit: '%' },
|
||||||
|
{ resource: 'LLM 서버 GPU (H200)', current: 44, threshold: 75, max: 85, scalePolicy: '요청 병합·배치', unit: '%' },
|
||||||
|
{ resource: 'DB 연결 풀', current: 128, threshold: 300, max: 400, scalePolicy: 'PgBouncer 풀 확대', unit: '개' },
|
||||||
|
{ resource: 'NAS 사용량', current: 28, threshold: 75, max: 90, scalePolicy: '콜드 티어 이관', unit: '% (100TB)' },
|
||||||
|
{ resource: 'Kafka 컨슈머 Lag', current: 142, threshold: 5000, max: 10000, scalePolicy: '파티션 증설', unit: 'msg' },
|
||||||
|
{ resource: 'Redis 캐시 메모리', current: 38, threshold: 70, max: 85, scalePolicy: 'Eviction + 클러스터 확장', unit: '%' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 성능 영향 최소화 전략 (S&P 글로벌 대응) ──────────────────
|
||||||
|
const IMPACT_REDUCTION = [
|
||||||
|
{ strategy: '이중 수집 파이프라인 물리 분리', target: '국내 vs 글로벌 격리', effect: '글로벌 장애 → 국내 무영향', per: 'PER-01·05' },
|
||||||
|
{ strategy: '경계 조기 필터링', target: '지리·선박 클래스 필터', effect: '원본 50~80% 감축', per: 'PER-03' },
|
||||||
|
{ strategy: '스트림·백프레셔 (Kafka)', target: 'Lag 임계 초과 시 다운샘플링', effect: '온라인 무영향', per: 'PER-01·03' },
|
||||||
|
{ strategy: '티어드 스토리지 (Hot/Warm/Cold)', target: '1~7일 / 30일 / 이후', effect: '쿼리 비용 최소화', per: 'PER-03·06' },
|
||||||
|
{ strategy: '공간 사전 집계 (H3 격자)', target: 'Materialized View', effect: '대시보드 Redis만 조회', per: 'PER-01' },
|
||||||
|
{ strategy: 'Circuit Breaker (S&P)', target: '실패율 50% 차단', effect: '국내 신호 Fallback', per: 'PER-05' },
|
||||||
|
{ strategy: 'K8s PriorityClass 격리', target: '온라인 vs 배치', effect: '상황실 SLO 절대 보장', per: 'PER-01·03' },
|
||||||
|
{ strategy: 'HPA 자동 확장', target: 'CPU/메모리 70% 임계', effect: '피크 자동 대응', per: 'PER-02·06' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 로컬 status 문자열을 카탈로그 PerformanceStatus로 매핑
|
||||||
|
const toStatus = (s: 'good' | 'warn' | 'critical' | 'success'): PerformanceStatus => {
|
||||||
|
if (s === 'good' || s === 'success') return 'good';
|
||||||
|
if (s === 'warn') return 'warning';
|
||||||
|
return 'critical';
|
||||||
|
};
|
||||||
|
const statusIntent = (s: 'good' | 'warn' | 'critical' | 'success') =>
|
||||||
|
getPerformanceStatusIntent(toStatus(s));
|
||||||
|
const barColor = (ratio: number): string =>
|
||||||
|
getPerformanceStatusHex(utilizationStatus(ratio));
|
||||||
|
|
||||||
|
export function PerformanceMonitoring() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
// 향후 Phase 3 에서 EXPORT 버튼 추가 시 disabled={!canExport} 로 연결
|
||||||
|
void hasPermission('admin:performance-monitoring', 'EXPORT');
|
||||||
|
|
||||||
|
const TABS: Array<{ key: Tab; icon: typeof BarChart3; label: string }> = [
|
||||||
|
{ key: 'overview', icon: BarChart3, label: '성능 현황' },
|
||||||
|
{ key: 'response', icon: Gauge, label: '응답성 (PER-01)' },
|
||||||
|
{ key: 'capacity', icon: Users, label: '처리용량 (PER-02·03)' },
|
||||||
|
{ key: 'aiModel', icon: Brain, label: 'AI 모델 (PER-04)' },
|
||||||
|
{ key: 'availability', icon: Shield, label: '가용성·확장성 (PER-05·06)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Activity}
|
||||||
|
iconColor="text-cyan-600 dark:text-cyan-400"
|
||||||
|
title="성능 모니터링"
|
||||||
|
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabBar variant="underline">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<TabButton key={t.key} variant="underline" active={tab === t.key}
|
||||||
|
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* ── ① 성능 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{PERF_KPI.map(k => {
|
||||||
|
const hex = getPerformanceStatusHex(k.status);
|
||||||
|
return (
|
||||||
|
<div key={k.label} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: hex, borderLeftWidth: 3 }}>
|
||||||
|
<k.icon className="w-5 h-5" style={{ color: hex }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: hex }}>
|
||||||
|
{k.value}<span className="text-[10px] ml-1 text-hint">{k.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 그룹별 SLO */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">사용자 그룹별 SLO (총 2,900명 + 추정)</span>
|
||||||
|
<Badge intent="info" size="xs">본청 200 · 상황실 100 확정</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">그룹</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">총 인원</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">동시접속 추정</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">SLA</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">우선순위</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">특성</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{USER_GROUPS.map(g => (
|
||||||
|
<tr key={g.group} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{g.group}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{g.users.toLocaleString()}명</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{g.concurrent}</td>
|
||||||
|
<td className="py-2 px-2 text-label">{g.sla}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={g.priority} size="xs">{g.priority === 'critical' ? '최상' : g.priority === 'high' ? '높음' : '일반'}</Badge></td>
|
||||||
|
<td className="py-2 px-2 text-hint">{g.note}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 성능 영향 최소화 전략 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">성능 영향 최소화 전략 (글로벌 AIS 대응)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{IMPACT_REDUCTION.map((s, i) => (
|
||||||
|
<div key={s.strategy} className="flex items-start gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-600 dark:text-amber-400">{i + 1}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-[11px] text-heading font-medium">{s.strategy}</span>
|
||||||
|
<Badge intent="info" size="xs">{s.per}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mb-0.5">대상: {s.target}</div>
|
||||||
|
<div className="text-[9px] text-green-600 dark:text-green-400">효과: {s.effect}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 응답성 (PER-01) ── */}
|
||||||
|
{tab === 'response' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gauge className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-01 서비스 응답성 — SLO vs 실측 (p50/p95/p99)</span>
|
||||||
|
</div>
|
||||||
|
<Badge intent="success" size="sm">TER-03 검증 통과</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">대상</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">SLO 목표</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">p50</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">p95</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">p99</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{RESPONSE_SLO.map(r => (
|
||||||
|
<tr key={r.target} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{r.target}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400 font-medium">{r.slo}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-hint">{r.p50}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{r.p95}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{r.p99}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(r.status)} size="xs">{r.status === 'good' ? '정상' : '주의'}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">상황실 전용 SLO (24/7 100명)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ item: '메인 대시보드 초기 로드', target: '≤ 2초', current: '1.8초', met: true },
|
||||||
|
{ item: '위험도 지도 실시간 갱신', target: '≤ 1초', current: '0.7초', met: true },
|
||||||
|
{ item: 'AI 탐지 알림 수신 → 표출', target: '≤ 3초 E2E', current: '2.3초', met: true },
|
||||||
|
{ item: '단속 계획·경로 조회', target: '≤ 1.5초', current: '1.1초', met: true },
|
||||||
|
{ item: '장애 시 세션 유지', target: '< 30초 복구', current: '18초', met: true },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.item} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-heading font-medium">{s.item}</div>
|
||||||
|
<div className="text-[9px] text-hint">목표: {s.target}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-green-600 dark:text-green-400 font-bold">{s.current}</span>
|
||||||
|
{s.met ? <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">측정 방법론</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-[11px]">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">샘플링:</strong> <span className="text-label">1초 간격 p50/p95/p99 집계</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">도구:</strong> <span className="text-label">OpenTelemetry + Prometheus + Grafana</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">APM:</strong> <span className="text-label">분산 추적 + Trace ID 요청 단위 관통</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">API 재시도:</strong> <span className="text-label">3회 · Exponential Backoff · 타임아웃 3초</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">경보:</strong> <span className="text-label">SLO 위반 지속 5분 → PagerDuty</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">원인 분석:</strong> <span className="text-label">RED/USE 방법론 + 로그·메트릭·추적 상관 분석</span></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 처리용량 (PER-02·03) ── */}
|
||||||
|
{tab === 'capacity' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 동시접속·TPS */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-02 동시접속·처리용량 (정상 피크 600 / 작전 피크 900)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{CAPACITY_METRICS.map(c => (
|
||||||
|
<div key={c.metric} className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">{c.metric}</div>
|
||||||
|
<div className="text-xl font-bold text-heading mb-1">{c.current.toLocaleString()}</div>
|
||||||
|
<div className="text-[9px] text-label mb-2">목표 {c.target} / 최대 {c.max}</div>
|
||||||
|
<div className="h-1.5 bg-surface-raised rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${c.utilization}%`, backgroundColor: barColor(c.utilization / 100) }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">{c.utilization}% 사용 중</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 배치 작업 현황 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-03 배치 · 대용량 처리 현황</span>
|
||||||
|
<Badge intent="success" size="xs">SLA 준수 6/7</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">배치 작업</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">스케줄</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">처리 볼륨</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">SLA</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">평균 소요</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">최근 실행</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{BATCH_JOBS.map(j => (
|
||||||
|
<tr key={j.name} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{j.name}</td>
|
||||||
|
<td className="py-2 px-2 text-hint">{j.schedule}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{j.volume}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{j.sla}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{j.avg}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(j.status)} size="xs">{j.lastRun}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 처리 볼륨 산정 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HardDrive className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">데이터 처리 볼륨 (국내 + S&P 글로벌)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-[11px]">
|
||||||
|
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">일 수집 (원본)</div>
|
||||||
|
<div className="text-xl font-bold text-heading">1.6 ~ 3.2 TB</div>
|
||||||
|
<div className="text-[9px] text-label mt-1">AIS(국내) + S&P A/B + 위성 + 환경</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">일 적재 (필터·압축 후)</div>
|
||||||
|
<div className="text-xl font-bold text-heading">330 ~ 900 GB</div>
|
||||||
|
<div className="text-[9px] text-green-600 dark:text-green-400 mt-1">경계 필터링 50~80% 감축</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">3년 누적 (티어드)</div>
|
||||||
|
<div className="text-xl font-bold text-heading">~360 TB ~ 1 PB</div>
|
||||||
|
<div className="text-[9px] text-amber-600 dark:text-amber-400 mt-1">NAS 100TB → 객체스토리지 이관</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ AI 모델 성능 (PER-04) ── */}
|
||||||
|
{tab === 'aiModel' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-04 AI 모델 성능 지표</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge intent="success" size="xs">6개 모델 운영 중</Badge>
|
||||||
|
<Badge intent="warning" size="xs">어망·어구 모델 개선 필요</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">모델</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">정확도</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">정밀도</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">재현율</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">F1</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">ROC-AUC</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">목표</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{AI_MODELS.map(m => (
|
||||||
|
<tr key={m.model} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{m.model}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{m.accuracy}%</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{m.precision}%</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{m.recall}%</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{m.f1}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{m.rocAuc}</td>
|
||||||
|
<td className="py-2 px-2 text-hint text-[10px]">{m.target}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(m.status)} size="xs">{m.status === 'good' ? '통과' : '주의'}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">모델 성능 저하 대응</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-[11px]">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">학습/검증/테스트 분할:</strong> <span className="text-label">70/15/15 비율, K-Fold 5</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">드리프트 탐지:</strong> <span className="text-label">입력 분포 KL divergence 주간 모니터링</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">성능 저하 임계:</strong> <span className="text-label">F1 3%p 하락 시 자동 재학습 트리거</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">설명가능성:</strong> <span className="text-label">Feature Importance + SHAP 값 제공</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">A/B 테스트:</strong> <span className="text-label">Shadow → Canary 5% → 50% → 100% 단계 배포</span></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Cpu className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">추론 성능 (GPU 활용)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[11px] text-label">AI 서버 (RTX pro 6000 Blackwell ×2)</span>
|
||||||
|
<span className="text-[11px] text-heading font-bold">61%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-amber-500 rounded-full" style={{ width: '61%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">추론 단건 평균 1.4초 · 큐 대기 <200ms</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[11px] text-label">LLM 서버 (H200 ×2)</span>
|
||||||
|
<span className="text-[11px] text-heading font-bold">44%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-green-500 rounded-full" style={{ width: '44%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">Q&A 스트리밍 첫 토큰 평균 380ms</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-border/40">
|
||||||
|
<div className="text-[10px] text-hint mb-1">동시 추론 요청 처리</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge intent="info" size="xs">최대 100 요청/초</Badge>
|
||||||
|
<Badge intent="success" size="xs">큐잉 지연 <1%</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 가용성·확장성 (PER-05·06) ── */}
|
||||||
|
{tab === 'availability' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 가용성 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-05 가용성 및 장애복구 (목표 ≥ 99.9%)</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">구성요소</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">가동률</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">RTO</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">RPO</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">최근 장애</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{AVAILABILITY_METRICS.map(a => (
|
||||||
|
<tr key={a.component} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{a.component}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{a.uptime}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{a.rto}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{a.rpo}</td>
|
||||||
|
<td className="py-2 px-2 text-hint">{a.lastIncident}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(a.status)} size="xs">{a.status === 'good' ? '정상' : '주의'}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 확장성 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Server className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-06 확장성 및 자원 사용률</span>
|
||||||
|
<Badge intent="info" size="xs">2배(6,000명) 확장 목표</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{RESOURCE_USAGE.map(r => {
|
||||||
|
const ratio = r.current / r.max;
|
||||||
|
return (
|
||||||
|
<div key={r.resource} className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] text-label font-medium">{r.resource}</span>
|
||||||
|
<span className="text-[11px] text-heading font-bold">{r.current}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ' ' + r.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full overflow-hidden mb-2">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${(ratio) * 100}%`, backgroundColor: barColor(ratio) }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[9px]">
|
||||||
|
<span className="text-hint">경보 {r.threshold}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''} · 한계 {r.max}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''}</span>
|
||||||
|
<Badge intent="muted" size="xs">{r.scalePolicy}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 요약 지표 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Wifi className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">연간 가동률 목표</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-cyan-600 dark:text-cyan-400">99.9%</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">월간 다운타임 ≤ 43분</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">RTO 평균</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">≤ 60초</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">자동 페일오버 · Self-healing</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Database className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">RPO 평균</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">≤ 10초</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">실시간 복제 + 백업 이중화</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">Scale-out 여유</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">×2</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">6,000명까지 선형 확장</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { Button } from '@shared/components/ui/button';
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
import {
|
import {
|
||||||
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
|
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
|
||||||
type RoleWithPermissions, type PermTreeNode, type PermEntry,
|
type RoleWithPermissions, type PermTreeNode, type PermEntry,
|
||||||
@ -19,6 +20,7 @@ import { useSettingsStore } from '@stores/settingsStore';
|
|||||||
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||||||
import { ColorPicker } from '@shared/components/common/ColorPicker';
|
import { ColorPicker } from '@shared/components/common/ColorPicker';
|
||||||
import { updateRole as apiUpdateRole } from '@/services/adminApi';
|
import { updateRole as apiUpdateRole } from '@/services/adminApi';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 트리 기반 권한 관리 패널 (wing 패턴).
|
* 트리 기반 권한 관리 패널 (wing 패턴).
|
||||||
@ -45,6 +47,7 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
|
|||||||
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
|
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
|
||||||
|
|
||||||
export function PermissionsPanel() {
|
export function PermissionsPanel() {
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
|
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
|
||||||
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
|
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
|
||||||
@ -230,7 +233,7 @@ export function PermissionsPanel() {
|
|||||||
|
|
||||||
await updateRolePermissions(selectedRole.roleSn, changes);
|
await updateRolePermissions(selectedRole.roleSn, changes);
|
||||||
await load(); // 새로 가져와서 동기화
|
await load(); // 새로 가져와서 동기화
|
||||||
alert(`권한 ${changes.length}건 갱신되었습니다.`);
|
alert(`${tc('success.permissionUpdated')} (${changes.length})`);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : 'unknown');
|
setError(e instanceof Error ? e.message : 'unknown');
|
||||||
} finally {
|
} finally {
|
||||||
@ -247,7 +250,7 @@ export function PermissionsPanel() {
|
|||||||
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
|
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
|
||||||
await load();
|
await load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
alert(tc('error.createFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -257,23 +260,23 @@ export function PermissionsPanel() {
|
|||||||
await load();
|
await load();
|
||||||
setEditingColor(null);
|
setEditingColor(null);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRole = async () => {
|
const handleDeleteRole = async () => {
|
||||||
if (!selectedRole) return;
|
if (!selectedRole) return;
|
||||||
if (selectedRole.builtinYn === 'Y') {
|
if (selectedRole.builtinYn === 'Y') {
|
||||||
alert('내장 역할은 삭제할 수 없습니다.');
|
alert(tc('message.builtinRoleCannotDelete'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
|
if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
|
||||||
try {
|
try {
|
||||||
await deleteRole(selectedRole.roleSn);
|
await deleteRole(selectedRole.roleSn);
|
||||||
setSelectedRoleSn(null);
|
setSelectedRoleSn(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
alert(tc('error.deleteFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -358,14 +361,17 @@ export function PermissionsPanel() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button type="button" onClick={load}
|
<Button
|
||||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
variant="ghost"
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
size="sm"
|
||||||
</button>
|
onClick={load}
|
||||||
|
aria-label={tc('aria.refresh')}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-heading">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||||
|
|
||||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
@ -378,28 +384,44 @@ export function PermissionsPanel() {
|
|||||||
<div className="text-xs text-label font-bold">역할</div>
|
<div className="text-xs text-label font-bold">역할</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{canCreateRole && (
|
{canCreateRole && (
|
||||||
<button type="button" onClick={() => setShowCreate(!showCreate)}
|
<Button
|
||||||
className="p-1 text-hint hover:text-green-400" title="신규 역할">
|
variant="ghost"
|
||||||
<Plus className="w-3.5 h-3.5" />
|
size="sm"
|
||||||
</button>
|
onClick={() => setShowCreate(!showCreate)}
|
||||||
|
aria-label="신규 역할"
|
||||||
|
title="신규 역할"
|
||||||
|
icon={<Plus className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
||||||
<button type="button" onClick={handleDeleteRole}
|
<Button
|
||||||
className="p-1 text-hint hover:text-red-400" title="역할 삭제">
|
variant="ghost"
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
size="sm"
|
||||||
</button>
|
onClick={handleDeleteRole}
|
||||||
|
aria-label="역할 삭제"
|
||||||
|
title="역할 삭제"
|
||||||
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
||||||
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
<Input
|
||||||
|
aria-label={tc('aria.roleCode')}
|
||||||
|
size="sm"
|
||||||
|
value={newRoleCd}
|
||||||
|
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||||
placeholder="ROLE_CD (대문자)"
|
placeholder="ROLE_CD (대문자)"
|
||||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
/>
|
||||||
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
<Input
|
||||||
placeholder="역할 이름"
|
aria-label={tc('aria.roleName')}
|
||||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
size="sm"
|
||||||
|
value={newRoleNm}
|
||||||
|
onChange={(e) => setNewRoleNm(e.target.value)}
|
||||||
|
placeholder={tc('aria.roleName')}
|
||||||
|
/>
|
||||||
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
||||||
<div className="flex gap-1 pt-1">
|
<div className="flex gap-1 pt-1">
|
||||||
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
|
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
|
||||||
@ -442,7 +464,7 @@ export function PermissionsPanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
||||||
className="text-[8px] text-hint hover:text-blue-400"
|
className="text-[8px] text-hint hover:text-label"
|
||||||
title="색상 변경"
|
title="색상 변경"
|
||||||
>
|
>
|
||||||
●
|
●
|
||||||
@ -479,9 +501,9 @@ export function PermissionsPanel() {
|
|||||||
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-hint mt-0.5">
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
셀 의미: <span className="text-blue-400">✓ 명시 허용</span> /
|
셀 의미: <span className="text-label">✓ 명시 허용</span> /
|
||||||
<span className="text-blue-300/80 ml-1">✓ 상속 허용</span> /
|
<span className="text-blue-300/80 ml-1">✓ 상속 허용</span> /
|
||||||
<span className="text-red-400 ml-1">— 명시 거부</span> /
|
<span className="text-heading ml-1">— 명시 거부</span> /
|
||||||
<span className="text-gray-500 ml-1">× 강제 거부</span> /
|
<span className="text-gray-500 ml-1">× 강제 거부</span> /
|
||||||
<span className="text-hint ml-1">· 미지정</span>
|
<span className="text-hint ml-1">· 미지정</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import { Button } from '@shared/components/ui/button';
|
|||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import type { BadgeIntent } from '@lib/theme/variants';
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
import {
|
import {
|
||||||
Settings, Database, Search, ChevronDown, ChevronRight,
|
Settings, Database, Search,
|
||||||
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
||||||
Filter, RefreshCw, BookOpen, Layers, Hash, Info,
|
Filter, RefreshCw, Hash, Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
||||||
@ -77,6 +77,7 @@ const SYSTEM_SETTINGS = {
|
|||||||
|
|
||||||
export function SystemConfig() {
|
export function SystemConfig() {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
const [tab, setTab] = useState<CodeTab>('areas');
|
const [tab, setTab] = useState<CodeTab>('areas');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [majorFilter, setMajorFilter] = useState('');
|
const [majorFilter, setMajorFilter] = useState('');
|
||||||
@ -149,7 +150,7 @@ export function SystemConfig() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Database}
|
icon={Database}
|
||||||
iconColor="text-cyan-400"
|
iconColor="text-label"
|
||||||
title={t('systemConfig.title')}
|
title={t('systemConfig.title')}
|
||||||
description={t('systemConfig.desc')}
|
description={t('systemConfig.desc')}
|
||||||
demo
|
demo
|
||||||
@ -168,11 +169,11 @@ export function SystemConfig() {
|
|||||||
{/* KPI 카드 */}
|
{/* KPI 카드 */}
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
{[
|
{[
|
||||||
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-label', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
||||||
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
|
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-label', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
|
||||||
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-purple-400', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
|
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-heading', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
|
||||||
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-label', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
||||||
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-label', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<Card key={kpi.label} className="bg-surface-raised border-border">
|
<Card key={kpi.label} className="bg-surface-raised border-border">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
@ -218,7 +219,7 @@ export function SystemConfig() {
|
|||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
||||||
<input
|
<input
|
||||||
aria-label="코드 검색"
|
aria-label={tc('aria.searchCode')}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
|
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
|
||||||
placeholder={
|
placeholder={
|
||||||
@ -233,7 +234,7 @@ export function SystemConfig() {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||||
<select
|
<select
|
||||||
aria-label="대분류 필터"
|
aria-label={tc('aria.categoryFilter')}
|
||||||
value={majorFilter}
|
value={majorFilter}
|
||||||
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
|
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
|
||||||
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
|
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
|
||||||
@ -270,7 +271,7 @@ export function SystemConfig() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(pagedData as AreaCode[]).map((a) => (
|
{(pagedData as AreaCode[]).map((a) => (
|
||||||
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
||||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
|
<td className="px-4 py-2 text-label font-mono font-medium">{a.code}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
|
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
|
||||||
@ -313,7 +314,7 @@ export function SystemConfig() {
|
|||||||
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
|
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
|
||||||
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
|
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
<td className="px-4 py-2 text-label font-mono font-medium">{s.code}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Badge intent="muted" size="sm">{s.major}</Badge>
|
<Badge intent="muted" size="sm">{s.major}</Badge>
|
||||||
</td>
|
</td>
|
||||||
@ -323,12 +324,12 @@ export function SystemConfig() {
|
|||||||
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
|
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{s.active
|
{s.active
|
||||||
? <span className="text-green-400 text-[9px]">Y</span>
|
? <span className="text-label text-[9px]">Y</span>
|
||||||
: <span className="text-hint text-[9px]">N</span>
|
: <span className="text-hint text-[9px]">N</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{s.fishing && <span className="text-yellow-400">★</span>}
|
{s.fishing && <span className="text-label">★</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -357,7 +358,7 @@ export function SystemConfig() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(pagedData as FisheryCode[]).map((f) => (
|
{(pagedData as FisheryCode[]).map((f) => (
|
||||||
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
||||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
|
<td className="px-4 py-2 text-label font-mono font-medium">{f.code}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
|
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
|
||||||
@ -399,7 +400,7 @@ export function SystemConfig() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(pagedData as VesselTypeCode[]).map((v) => (
|
{(pagedData as VesselTypeCode[]).map((v) => (
|
||||||
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
||||||
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
|
<td className="px-3 py-2 text-label font-mono font-medium">{v.code}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
|
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
|
||||||
@ -409,13 +410,7 @@ export function SystemConfig() {
|
|||||||
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
|
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
|
||||||
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
|
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`text-[9px] font-mono ${
|
<span className="text-[9px] font-mono text-label">{v.source}</span>
|
||||||
v.source === 'AIS' ? 'text-cyan-400'
|
|
||||||
: v.source === 'GIC' ? 'text-green-400'
|
|
||||||
: v.source === 'RRA' ? 'text-blue-400'
|
|
||||||
: v.source === 'PMS' ? 'text-orange-400'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
}`}>{v.source}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
|
||||||
@ -431,23 +426,25 @@ export function SystemConfig() {
|
|||||||
{/* 페이지네이션 (코드 탭에서만) */}
|
{/* 페이지네이션 (코드 탭에서만) */}
|
||||||
{tab !== 'settings' && totalPages > 1 && (
|
{tab !== 'settings' && totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</button>
|
</Button>
|
||||||
<span className="text-[11px] text-hint">
|
<span className="text-[11px] text-hint">
|
||||||
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
||||||
</span>
|
</span>
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||||
disabled={page >= totalPages - 1}
|
disabled={page >= totalPages - 1}
|
||||||
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -466,7 +463,7 @@ export function SystemConfig() {
|
|||||||
<Card key={section} className="bg-surface-raised border-border">
|
<Card key={section} className="bg-surface-raised border-border">
|
||||||
<CardHeader className="px-4 pt-3 pb-2">
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
<meta.icon className="w-3.5 h-3.5 text-cyan-400" />
|
<meta.icon className="w-3.5 h-3.5 text-label" />
|
||||||
{meta.title}
|
{meta.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -474,9 +471,7 @@ export function SystemConfig() {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
||||||
<span className="text-hint">{item.label}</span>
|
<span className="text-hint">{item.label}</span>
|
||||||
<span className={`font-medium ${
|
<span className="font-medium text-label">{item.value}</span>
|
||||||
item.value === '활성' ? 'text-green-400' : 'text-label'
|
|
||||||
}`}>{item.value}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { X, Check, Loader2 } from 'lucide-react';
|
import { X, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
|
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
|
||||||
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
|
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
|
||||||
|
|
||||||
@ -11,6 +13,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -44,7 +47,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@ -60,7 +63,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
|||||||
{user.userAcnt} ({user.userNm}) - 다중 역할 가능 (OR 합집합)
|
{user.userAcnt} ({user.userNm}) - 다중 역할 가능 (OR 합집합)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading">
|
<button type="button" aria-label={tc('aria.closeDialog')} onClick={onClose} className="text-hint hover:text-heading">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -99,15 +102,18 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||||
<button type="button" onClick={onClose}
|
<Button variant="secondary" size="sm" onClick={onClose}>
|
||||||
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
|
|
||||||
취소
|
취소
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={handleSave} disabled={saving}
|
<Button
|
||||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
|
variant="primary"
|
||||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
저장
|
저장
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,3 +3,8 @@ export { SystemConfig } from './SystemConfig';
|
|||||||
export { NoticeManagement } from './NoticeManagement';
|
export { NoticeManagement } from './NoticeManagement';
|
||||||
export { AdminPanel } from './AdminPanel';
|
export { AdminPanel } from './AdminPanel';
|
||||||
export { DataHub } from './DataHub';
|
export { DataHub } from './DataHub';
|
||||||
|
export { AISecurityPage } from './AISecurityPage';
|
||||||
|
export { AIAgentSecurityPage } from './AIAgentSecurityPage';
|
||||||
|
export { DataRetentionPolicy } from './DataRetentionPolicy';
|
||||||
|
export { DataModelVerification } from './DataModelVerification';
|
||||||
|
export { PerformanceMonitoring } from './PerformanceMonitoring';
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
@ -44,6 +46,7 @@ const INITIAL_MESSAGES: Message[] = [
|
|||||||
|
|
||||||
export function AIAssistant() {
|
export function AIAssistant() {
|
||||||
const { t } = useTranslation('ai');
|
const { t } = useTranslation('ai');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [selectedConv, setSelectedConv] = useState('1');
|
const [selectedConv, setSelectedConv] = useState('1');
|
||||||
@ -79,7 +82,7 @@ export function AIAssistant() {
|
|||||||
<PageContainer className="h-full flex flex-col">
|
<PageContainer className="h-full flex flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
iconColor="text-green-400"
|
iconColor="text-green-600 dark:text-green-400"
|
||||||
title={t('assistant.title')}
|
title={t('assistant.title')}
|
||||||
description={t('assistant.desc')}
|
description={t('assistant.desc')}
|
||||||
/>
|
/>
|
||||||
@ -91,7 +94,7 @@ export function AIAssistant() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{SAMPLE_CONVERSATIONS.map(c => (
|
{SAMPLE_CONVERSATIONS.map(c => (
|
||||||
<div key={c.id} onClick={() => setSelectedConv(c.id)}
|
<div key={c.id} onClick={() => setSelectedConv(c.id)}
|
||||||
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
|
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-600 dark:text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
|
||||||
<div className="truncate">{c.title}</div>
|
<div className="truncate">{c.title}</div>
|
||||||
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
|
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -111,7 +114,7 @@ export function AIAssistant() {
|
|||||||
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && (
|
||||||
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
|
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
|
||||||
<Bot className="w-4 h-4 text-green-400" />
|
<Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
|
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
|
||||||
@ -123,7 +126,7 @@ export function AIAssistant() {
|
|||||||
{msg.refs && msg.refs.length > 0 && (
|
{msg.refs && msg.refs.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
|
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
|
||||||
{msg.refs.map(r => (
|
{msg.refs.map(r => (
|
||||||
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5">
|
<Badge key={r} className="bg-green-500/10 text-green-600 dark:text-green-400 border-0 text-[8px] flex items-center gap-0.5">
|
||||||
<FileText className="w-2.5 h-2.5" />{r}
|
<FileText className="w-2.5 h-2.5" />{r}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@ -132,7 +135,7 @@ export function AIAssistant() {
|
|||||||
</div>
|
</div>
|
||||||
{msg.role === 'user' && (
|
{msg.role === 'user' && (
|
||||||
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
|
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
|
||||||
<User className="w-4 h-4 text-blue-400" />
|
<User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -140,17 +143,22 @@ export function AIAssistant() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 입력창 */}
|
{/* 입력창 */}
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
<input
|
<Input
|
||||||
aria-label="AI 어시스턴트 질의"
|
aria-label="AI 어시스턴트 질의"
|
||||||
|
size="md"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||||
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
|
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
|
||||||
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSend}
|
||||||
|
aria-label={tc('aria.send')}
|
||||||
|
icon={<Send className="w-4 h-4" />}
|
||||||
/>
|
/>
|
||||||
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import {
|
import {
|
||||||
@ -57,7 +58,7 @@ const MODELS: ModelVersion[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const modelColumns: DataColumn<ModelVersion>[] = [
|
const modelColumns: DataColumn<ModelVersion>[] = [
|
||||||
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> },
|
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-bold">{v as string}</span> },
|
||||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const s = v as string;
|
const s = v as string;
|
||||||
@ -68,7 +69,7 @@ const modelColumns: DataColumn<ModelVersion>[] = [
|
|||||||
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||||||
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||||||
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
|
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
|
||||||
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; },
|
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-600 dark:text-green-400' : n < 15 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}>{n}%</span>; },
|
||||||
},
|
},
|
||||||
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
|
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
|
||||||
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||||
@ -175,7 +176,7 @@ const GEAR_CODES: GearCode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const gearColumns: DataColumn<GearCode>[] = [
|
const gearColumns: DataColumn<GearCode>[] = [
|
||||||
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||||
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
@ -197,6 +198,149 @@ const GEAR_PROFILES = [
|
|||||||
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
|
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── DAR-03 5종 어구 구조 비교 (FAO ISSCFG) ──────────
|
||||||
|
|
||||||
|
interface DAR03GearSummary {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
faoCode: string;
|
||||||
|
mesh: string;
|
||||||
|
iuuRisk: '매우 높음' | '높음' | '중간' | '낮음~중간';
|
||||||
|
aisType: string;
|
||||||
|
gCodes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAR03_GEAR_SUMMARY: DAR03GearSummary[] = [
|
||||||
|
{ no: '①', name: '저층 트롤', faoCode: 'OTB/TBB', mesh: '≥60mm', iuuRisk: '높음', aisType: '어선 AIS', gCodes: 'G-01, G-03' },
|
||||||
|
{ no: '②', name: '쌍끌이 트롤', faoCode: 'PTM', mesh: '≥56mm', iuuRisk: '매우 높음', aisType: '어선 AIS 2척', gCodes: 'G-02, G-06' },
|
||||||
|
{ no: '③', name: '스토우넷', faoCode: 'FYK', mesh: '≥55mm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04, G-05' },
|
||||||
|
{ no: '④', name: '자망', faoCode: 'GNS/GND', mesh: '55~144mm', iuuRisk: '낮음~중간', aisType: '어구 AIS 부표', gCodes: 'G-03, G-05' },
|
||||||
|
{ no: '⑤', name: '통발·함정', faoCode: 'FPO', mesh: '탈출구 Ø≥8cm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAR03_IUU_INTENT: Record<DAR03GearSummary['iuuRisk'], BadgeIntent> = {
|
||||||
|
'매우 높음': 'critical',
|
||||||
|
'높음': 'high',
|
||||||
|
'중간': 'warning',
|
||||||
|
'낮음~중간': 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DAR03GearDetail {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
nameEn: string;
|
||||||
|
image: string;
|
||||||
|
specs: { k: string; v: string }[];
|
||||||
|
gCodes: { code: string; desc: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAR03_GEAR_DETAILS: DAR03GearDetail[] = [
|
||||||
|
{
|
||||||
|
no: '①', name: '저층 트롤', nameEn: 'Bottom Trawl (OTB/TBB)', image: '/dar03/bottom-trawl.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'OTB / TBB' },
|
||||||
|
{ k: '최소 망목', v: '≥ 60mm (마름모형)' },
|
||||||
|
{ k: '주요 어종', v: '참조기 · 갈치' },
|
||||||
|
{ k: '조업 속력', v: '2.5~4.5 knot' },
|
||||||
|
{ k: '항적 패턴', v: 'U형 회전 · 직선 왕복' },
|
||||||
|
{ k: 'AIS', v: '어선 AIS (어구 AIS 없음)' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-01', desc: '허가 해역 외 트롤 → GIS 교차' },
|
||||||
|
{ code: 'G-03', desc: '미등록 어구 → label=1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '②', name: '쌍끌이 중층 트롤', nameEn: 'Pair Midwater Trawl (PTM)', image: '/dar03/pair-trawl.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'PTM' },
|
||||||
|
{ k: '최소 망목', v: '≥ 56mm' },
|
||||||
|
{ k: '주요 어종', v: '전갱이 · 고등어 · 참조기' },
|
||||||
|
{ k: '선박 간격', v: '300~500m 유지' },
|
||||||
|
{ k: '조업 속력', v: '2~4 knot (2척 동기화)' },
|
||||||
|
{ k: 'AIS', v: '2척 어선 AIS 동기화' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-02', desc: '금어기 내 공조 조업 탐지' },
|
||||||
|
{ code: 'G-06', desc: '2척 동기화 2시간+ → 공조' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '③', name: '스토우넷 (안강망)', nameEn: 'Stow Net (FYK)', image: '/dar03/stow-net.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'FYK' },
|
||||||
|
{ k: '최소 망목', v: '≥ 55mm (캔버스형)' },
|
||||||
|
{ k: '주요 어종', v: '참조기 · 갈치 · 실치' },
|
||||||
|
{ k: '설치 방식', v: '말뚝·닻으로 고정' },
|
||||||
|
{ k: 'AIS', v: '어구 AIS 부표 부착 의무' },
|
||||||
|
{ k: '탐지 지표', v: '위치 이탈·출현·소실 주기' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-01', desc: '위치 편차 200m+ → 구역 외' },
|
||||||
|
{ code: 'G-04', desc: '신호 30분 내 반복 → MMSI 조작' },
|
||||||
|
{ code: 'G-05', desc: '이동 500m+ → 인위적 이동' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '④', name: '자망', nameEn: 'Gillnet (GNS/GND)', image: '/dar03/gillnet.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'GNS / GND' },
|
||||||
|
{ k: '최소 망목', v: '55~144mm (어종별 상이)' },
|
||||||
|
{ k: '참조기 기준', v: '55mm (황해)' },
|
||||||
|
{ k: '은돔 기준', v: '100mm' },
|
||||||
|
{ k: 'AIS', v: '어구 AIS 부표 부착' },
|
||||||
|
{ k: '탐지 지표', v: '미등록 여부·기간 이탈' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-02', desc: '금어기 내 신호 출현 → label=1' },
|
||||||
|
{ code: 'G-03', desc: '등록DB 미매칭 → 불법 자망' },
|
||||||
|
{ code: 'G-05', desc: '조류 보정 후 500m+ → 이동' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '⑤', name: '통발 · 함정', nameEn: 'Pot / Trap (FPO)', image: '/dar03/pot-trap.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'FPO' },
|
||||||
|
{ k: '탈출구 (꽃게)', v: 'Ø ≥ 8cm 또는 높이 33mm' },
|
||||||
|
{ k: '탈출구 (참게)', v: '측면 30mm + 말단 7cm' },
|
||||||
|
{ k: '주요 어종', v: '꽃게 · 참게 · 장어' },
|
||||||
|
{ k: '미성어 방류율', v: '95% 이상 (탈출구 적용 시)' },
|
||||||
|
{ k: 'AIS', v: '어구 AIS 부표 부착' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-01', desc: '허가 구역 외 설치 → GIS 교차' },
|
||||||
|
{ code: 'G-04', desc: '어선-어구 출현·소실 60분+ 불일치' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DAR03AisSignal {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
aisType: string;
|
||||||
|
normal: string[];
|
||||||
|
threshold: string[];
|
||||||
|
gCodes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAR03_AIS_SIGNALS: DAR03AisSignal[] = [
|
||||||
|
{ no: '①', name: '저층 트롤', aisType: '어선 AIS (Class-A)',
|
||||||
|
normal: ['2.5~4.5 knot', 'U형 항적 반복'],
|
||||||
|
threshold: ['5 knot 이상 급가속', '금지 해역 진입'], gCodes: 'G-01, G-03' },
|
||||||
|
{ no: '②', name: '쌍끌이 트롤', aisType: '어선 AIS 2척',
|
||||||
|
normal: ['2~4 knot 동기화', '500m 간격 유지'],
|
||||||
|
threshold: ['동기화 2시간 이상', '동시 AIS 차단 30분+'], gCodes: 'G-02, G-06' },
|
||||||
|
{ no: '③', name: '스토우넷', aisType: '어구 AIS (Class-B)',
|
||||||
|
normal: ['위치 완전 고정', '신호 지속 출현'],
|
||||||
|
threshold: ['위치 편차 200m+', '출현·소실 30분 이내 반복'], gCodes: 'G-01, G-04, G-05' },
|
||||||
|
{ no: '④', name: '자망', aisType: '어구 AIS (Class-B)',
|
||||||
|
normal: ['위치 반고정', '조류에 따라 완만이동'],
|
||||||
|
threshold: ['등록 DB 미매칭', '금어기 내 신호 출현'], gCodes: 'G-02, G-03' },
|
||||||
|
{ no: '⑤', name: '통발', aisType: '어구 AIS (Class-B)',
|
||||||
|
normal: ['위치 완전 고정', '신호 지속'],
|
||||||
|
threshold: ['어선 접근·이탈 불일치 60분+', '구역 외 위치'], gCodes: 'G-01, G-04' },
|
||||||
|
];
|
||||||
|
|
||||||
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
|
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
|
||||||
|
|
||||||
interface DetectionEngine {
|
interface DetectionEngine {
|
||||||
@ -253,14 +397,14 @@ export function AIModelManagement() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Brain}
|
icon={Brain}
|
||||||
iconColor="text-purple-400"
|
iconColor="text-purple-600 dark:text-purple-400"
|
||||||
title={t('modelManagement.title')}
|
title={t('modelManagement.title')}
|
||||||
description={t('modelManagement.desc')}
|
description={t('modelManagement.desc')}
|
||||||
demo
|
demo
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
<span className="text-[10px] text-green-400 font-bold">운영 모델: {currentModel.version}</span>
|
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">운영 모델: {currentModel.version}</span>
|
||||||
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
|
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -269,12 +413,12 @@ export function AIModelManagement() {
|
|||||||
{/* KPI */}
|
{/* KPI */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
|
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-500/10' },
|
||||||
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||||
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-600 dark:text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||||
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-500/10' },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
@ -311,13 +455,13 @@ export function AIModelManagement() {
|
|||||||
{/* 업데이트 알림 */}
|
{/* 업데이트 알림 */}
|
||||||
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
|
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
|
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-blue-300 font-bold">새로운 모델 v2.4.0 테스트 완료</div>
|
<div className="text-sm text-blue-300 font-bold">새로운 모델 v2.4.0 테스트 완료</div>
|
||||||
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
<Button variant="primary" size="sm" className="shrink-0">운영 배포</Button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||||||
</div>
|
</div>
|
||||||
@ -352,7 +496,7 @@ export function AIModelManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-[9px] text-hint">가중치</div>
|
<div className="text-[9px] text-hint">가중치</div>
|
||||||
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
|
<div className="text-[12px] font-bold text-cyan-600 dark:text-cyan-400">{rule.weight}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -362,7 +506,7 @@ export function AIModelManagement() {
|
|||||||
{/* 가중치 합계 */}
|
{/* 가중치 합계 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" />위험도 가중치</div>
|
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />위험도 가중치</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{rules.filter((r) => r.enabled).map((r, i) => (
|
{rules.filter((r) => r.enabled).map((r, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
@ -421,7 +565,7 @@ export function AIModelManagement() {
|
|||||||
{/* 파이프라인 스테이지 */}
|
{/* 파이프라인 스테이지 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{PIPELINE_STAGES.map((stage, i) => {
|
{PIPELINE_STAGES.map((stage, i) => {
|
||||||
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
|
const stColor = stage.status === '정상' ? 'text-green-600 dark:text-green-400' : stage.status === '진행중' ? 'text-blue-600 dark:text-blue-400' : 'text-hint';
|
||||||
return (
|
return (
|
||||||
<div key={stage.stage} className="flex-1 flex items-start gap-2">
|
<div key={stage.stage} className="flex-1 flex items-start gap-2">
|
||||||
<Card className="flex-1 bg-surface-raised border-border">
|
<Card className="flex-1 bg-surface-raised border-border">
|
||||||
@ -552,7 +696,7 @@ export function AIModelManagement() {
|
|||||||
<div key={kpi.label}>
|
<div key={kpi.label}>
|
||||||
<div className="flex justify-between text-[10px] mb-1">
|
<div className="flex justify-between text-[10px] mb-1">
|
||||||
<span className="text-muted-foreground">{kpi.label}</span>
|
<span className="text-muted-foreground">{kpi.label}</span>
|
||||||
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
|
<span className={achieved ? 'text-green-600 dark:text-green-400 font-bold' : 'text-red-600 dark:text-red-400 font-bold'}>
|
||||||
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
|
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -601,6 +745,166 @@ export function AIModelManagement() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── DAR-03 5종 어구 구조 비교 ── */}
|
||||||
|
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
|
||||||
|
<Info className="w-5 h-5 text-indigo-400 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[12px] font-bold text-indigo-300">DAR-03 · 5종 어구 구조 비교 (FAO ISSCFG)</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
|
불법 어망·어구 탐지 참고자료 — FAO 국제 어구 분류 기준 · Wang et al.(2022) 논문 기반 · G-01~G-06 탐지 코드 연계
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="purple" size="sm">참고자료</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5종 어구 특성 비교 요약 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Anchor className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
5종 어구 특성 비교 요약
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">어구</th>
|
||||||
|
<th className="text-center py-2">FAO 코드</th>
|
||||||
|
<th className="text-center py-2">최소 망목</th>
|
||||||
|
<th className="text-center py-2">IUU 위험도</th>
|
||||||
|
<th className="text-center py-2">AIS 부착</th>
|
||||||
|
<th className="text-left py-2 px-2">주요 G코드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DAR03_GEAR_SUMMARY.map((g) => (
|
||||||
|
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-2">{g.no}</span>
|
||||||
|
<span className="text-heading font-medium">{g.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground font-mono">{g.mesh}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
|
||||||
|
<td className="py-2 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 어구별 구조 도식 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
어구별 구조 도식 비교
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-[9px] text-hint italic">
|
||||||
|
※ FAO 어구 분류 기준 및 Wang et al.(2022) 논문 기반 개념도. 임계값은 사업 착수 후 해양경찰청 실무 데이터 분석으로 최종 확정.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{DAR03_GEAR_DETAILS.map((g) => (
|
||||||
|
<Card key={g.no} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[12px] font-bold text-heading">{g.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{g.nameEn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-2 mb-3 flex items-center justify-center">
|
||||||
|
<img src={g.image} alt={g.nameEn} className="w-full h-auto max-h-48 object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
{g.specs.map((s) => (
|
||||||
|
<div key={s.k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground">{s.k}</span>
|
||||||
|
<span className="text-heading font-medium">{s.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-2">
|
||||||
|
<div className="text-[9px] text-hint mb-1.5 font-medium">G코드 연계</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{g.gCodes.map((gc) => (
|
||||||
|
<div key={gc.code} className="flex items-start gap-2 text-[9px]">
|
||||||
|
<Badge intent="cyan" size="xs">{gc.code}</Badge>
|
||||||
|
<span className="text-muted-foreground flex-1">{gc.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AIS 신호 특성 및 이상 판정 기준 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Radio className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
어구별 AIS 신호 특성 및 이상 판정 기준
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">어구</th>
|
||||||
|
<th className="text-left py-2">AIS 유형</th>
|
||||||
|
<th className="text-left py-2">정상 신호 특성</th>
|
||||||
|
<th className="text-left py-2">이상 탐지 임계값</th>
|
||||||
|
<th className="text-left py-2 px-2">G코드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DAR03_AIS_SIGNALS.map((s) => (
|
||||||
|
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
|
||||||
|
<td className="py-2.5 px-2">
|
||||||
|
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-1">{s.no}</span>
|
||||||
|
<span className="text-heading font-medium">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-label">{s.aisType}</td>
|
||||||
|
<td className="py-2.5">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{s.normal.map((n) => (
|
||||||
|
<li key={n} className="text-muted-foreground flex items-start gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{n}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{s.threshold.map((th) => (
|
||||||
|
<li key={th} className="text-muted-foreground flex items-start gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5" />
|
||||||
|
<span>{th}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -618,8 +922,8 @@ export function AIModelManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex gap-3 shrink-0 text-center">
|
<div className="ml-auto flex gap-3 shrink-0 text-center">
|
||||||
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint">허가 선박</div></div>
|
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint">허가 선박</div></div>
|
||||||
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint">탐지 엔진</div></div>
|
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">7</div><div className="text-[9px] text-hint">탐지 엔진</div></div>
|
||||||
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint">업종 분류</div></div>
|
<div><div className="text-lg font-bold text-green-600 dark:text-green-400">5</div><div className="text-[9px] text-hint">업종 분류</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -671,7 +975,7 @@ export function AIModelManagement() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
<Ship className="w-4 h-4 text-cyan-400" />대상 선박 현황 (906척, 6개 업종)
|
<Ship className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />대상 선박 현황 (906척, 6개 업종)
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-[10px]">
|
<table className="w-full text-[10px]">
|
||||||
<thead>
|
<thead>
|
||||||
@ -688,7 +992,7 @@ export function AIModelManagement() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{TARGET_VESSELS.map((v) => (
|
{TARGET_VESSELS.map((v) => (
|
||||||
<tr key={v.code} className="border-b border-border">
|
<tr key={v.code} className="border-b border-border">
|
||||||
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
|
<td className="py-1.5 text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v.code}</td>
|
||||||
<td className="py-1.5 text-label">{v.name}</td>
|
<td className="py-1.5 text-label">{v.name}</td>
|
||||||
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
|
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
|
||||||
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
|
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
|
||||||
@ -711,7 +1015,7 @@ export function AIModelManagement() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />알람 심각도 체계
|
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />알람 심각도 체계
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{ALARM_SEVERITY.map((a) => (
|
{ALARM_SEVERITY.map((a) => (
|
||||||
@ -761,8 +1065,8 @@ export function AIModelManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 shrink-0 text-center">
|
<div className="flex gap-4 shrink-0 text-center">
|
||||||
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API 엔드포인트</div></div>
|
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API 엔드포인트</div></div>
|
||||||
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint">저장 단위</div></div>
|
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">3</div><div className="text-[9px] text-hint">저장 단위</div></div>
|
||||||
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint">가용률</div></div>
|
<div><div className="text-lg font-bold text-blue-600 dark:text-blue-400">99.7%</div><div className="text-[9px] text-hint">가용률</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -811,7 +1115,7 @@ export function AIModelManagement() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
<Code className="w-4 h-4 text-cyan-400" />
|
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
RESTful API 엔드포인트
|
RESTful API 엔드포인트
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-[10px] table-fixed">
|
<table className="w-full text-[10px] table-fixed">
|
||||||
@ -852,7 +1156,7 @@ export function AIModelManagement() {
|
|||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
|
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
|
<td className="py-1.5 font-mono text-cyan-600 dark:text-cyan-400">{api.endpoint}</td>
|
||||||
<td className="py-1.5 text-hint">{api.unit}</td>
|
<td className="py-1.5 text-hint">{api.unit}</td>
|
||||||
<td className="py-1.5 text-label">{api.desc}</td>
|
<td className="py-1.5 text-label">{api.desc}</td>
|
||||||
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
|
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
|
||||||
@ -872,7 +1176,7 @@ export function AIModelManagement() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
<Code className="w-4 h-4 text-cyan-400" />
|
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||||
API 호출 예시
|
API 호출 예시
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -880,7 +1184,7 @@ export function AIModelManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] text-muted-foreground">격자별 위험도 조회 (파라미터: 좌표 범위, 시간)</span>
|
<span className="text-[10px] text-muted-foreground">격자별 위험도 조회 (파라미터: 좌표 범위, 시간)</span>
|
||||||
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
|
<button type="button" aria-label={tcCommon('aria.copyExampleUrl')} onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
|
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
|
||||||
{`GET /api/v1/predictions/grid
|
{`GET /api/v1/predictions/grid
|
||||||
@ -928,7 +1232,7 @@ export function AIModelManagement() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
<ExternalLink className="w-4 h-4 text-purple-400" />
|
<ExternalLink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
후속 서비스 연계 매핑
|
후속 서비스 연계 매핑
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -952,7 +1256,7 @@ export function AIModelManagement() {
|
|||||||
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{s.apis.map((a) => (
|
{s.apis.map((a) => (
|
||||||
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
|
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-600 dark:text-cyan-400">{a}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -969,13 +1273,13 @@ export function AIModelManagement() {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: '총 호출', value: '142,856', color: 'text-heading' },
|
{ label: '총 호출', value: '142,856', color: 'text-heading' },
|
||||||
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
|
{ label: 'grid 조회', value: '68,420', color: 'text-blue-600 dark:text-blue-400' },
|
||||||
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
|
{ label: 'zone 조회', value: '32,115', color: 'text-green-600 dark:text-green-400' },
|
||||||
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
|
{ label: 'time 조회', value: '18,903', color: 'text-yellow-600 dark:text-yellow-400' },
|
||||||
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
|
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-600 dark:text-orange-400' },
|
||||||
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
|
{ label: 'alarms', value: '8,208', color: 'text-red-600 dark:text-red-400' },
|
||||||
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
|
{ label: '평균 응답', value: '23ms', color: 'text-cyan-600 dark:text-cyan-400' },
|
||||||
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
|
{ label: '오류율', value: '0.03%', color: 'text-green-600 dark:text-green-400' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
|
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
|
||||||
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>
|
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>
|
||||||
|
|||||||
456
frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx
Normal file
456
frontend/src/features/ai-operations/LGCNSMLOpsPage.tsx
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
|
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||||||
|
import { getMlopsJobStatusIntent, getMlopsJobStatusLabel } from '@shared/constants/mlopsJobStatuses';
|
||||||
|
import { getModelStatusIntent, getModelStatusLabel } from '@shared/constants/modelDeploymentStatuses';
|
||||||
|
import {
|
||||||
|
Brain, Database, GitBranch, Activity, Server, Shield,
|
||||||
|
Settings, Layers, BarChart3, Code, Play,
|
||||||
|
Zap, FlaskConical, CheckCircle,
|
||||||
|
Terminal, RefreshCw, Box,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* LGCNS MLOps 플랫폼 통합 페이지
|
||||||
|
*
|
||||||
|
* LGCNS DAP(Data AI Platform) 기반 MLOps 파이프라인 관리:
|
||||||
|
* ① 프로젝트 관리 ② 분석환경 생성 및 관리 ③ 모델 관리 ④ Job 실행 관리
|
||||||
|
* ⑤ 공통서비스 ⑥ 모니터링 ⑦ Repository
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'project' | 'environment' | 'model' | 'job' | 'common' | 'monitoring' | 'repository';
|
||||||
|
|
||||||
|
const TABS: { key: Tab; icon: React.ElementType; label: string }[] = [
|
||||||
|
{ key: 'project', icon: Layers, label: '프로젝트 관리' },
|
||||||
|
{ key: 'environment', icon: Terminal, label: '분석환경 관리' },
|
||||||
|
{ key: 'model', icon: Brain, label: '모델 관리' },
|
||||||
|
{ key: 'job', icon: Play, label: 'Job 실행 관리' },
|
||||||
|
{ key: 'common', icon: Settings, label: '공통서비스' },
|
||||||
|
{ key: 'monitoring', icon: Activity, label: '모니터링' },
|
||||||
|
{ key: 'repository', icon: Database, label: 'Repository' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOB_PROGRESS_COLOR: Record<string, string> = {
|
||||||
|
running: 'bg-blue-500',
|
||||||
|
done: 'bg-green-500',
|
||||||
|
fail: 'bg-red-500',
|
||||||
|
pending: 'bg-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 프로젝트 관리 ──────────────────
|
||||||
|
const PROJECTS = [
|
||||||
|
{ id: 'PRJ-001', name: '불법조업 위험도 예측', owner: '분석팀', status: '활성', models: 5, experiments: 12, updated: '2026-04-10' },
|
||||||
|
{ id: 'PRJ-002', name: '경비함정 경로추천', owner: '운항팀', status: '활성', models: 3, experiments: 8, updated: '2026-04-09' },
|
||||||
|
{ id: 'PRJ-003', name: '다크베셀 탐지', owner: '분석팀', status: '활성', models: 2, experiments: 6, updated: '2026-04-08' },
|
||||||
|
{ id: 'PRJ-004', name: '환적 네트워크 분석', owner: '수사팀', status: '대기', models: 1, experiments: 3, updated: '2026-04-05' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 분석환경 ──────────────────
|
||||||
|
const ENVIRONMENTS = [
|
||||||
|
{ name: 'Jupyter Notebook', icon: Code, type: 'IDE', gpu: 'Blackwell x1', status: '실행중', user: '김분석', created: '04-10' },
|
||||||
|
{ name: 'RStudio Server', icon: BarChart3, type: 'IDE', gpu: '-', status: '중지', user: '이연구', created: '04-08' },
|
||||||
|
{ name: 'VS Code Server', icon: Terminal, type: 'IDE', gpu: 'H200 x1', status: '실행중', user: '박개발', created: '04-09' },
|
||||||
|
{ name: 'TensorBoard', icon: Activity, type: '모니터링', gpu: '-', status: '실행중', user: '김분석', created: '04-10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKFLOWS = [
|
||||||
|
{ id: 'WF-012', name: 'AIS 전처리 → LSTM 학습', steps: 5, status: 'running', progress: 60, duration: '2h 15m' },
|
||||||
|
{ id: 'WF-011', name: '어구분류 피처엔지니어링', steps: 3, status: 'done', progress: 100, duration: '45m' },
|
||||||
|
{ id: 'WF-010', name: 'GNN 환적탐지 학습', steps: 4, status: 'done', progress: 100, duration: '3h 20m' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 모델 관리 ──────────────────
|
||||||
|
const MODELS = [
|
||||||
|
{ name: '불법조업 위험도 v2.1', framework: 'PyTorch', status: 'DEPLOYED', accuracy: 93.2, version: 'v2.1.0', kpi: 'F1=92.3%', endpoint: '/v1/infer/risk' },
|
||||||
|
{ name: '경비함정 경로추천 v1.5', framework: 'TensorFlow', status: 'DEPLOYED', accuracy: 89.7, version: 'v1.5.2', kpi: 'F1=88.4%', endpoint: '/v1/infer/patrol' },
|
||||||
|
{ name: 'Transformer 궤적 v0.9', framework: 'PyTorch', status: 'APPROVED', accuracy: 91.2, version: 'v0.9.0', kpi: 'F1=90.5%', endpoint: '-' },
|
||||||
|
{ name: 'GNN 환적탐지 v0.3', framework: 'DGL', status: 'TESTING', accuracy: 82.3, version: 'v0.3.0', kpi: 'F1=80.1%', endpoint: '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARAMETERS = [
|
||||||
|
{ name: 'learning_rate', type: 'float', default: '0.001', range: '1e-5 ~ 0.1' },
|
||||||
|
{ name: 'batch_size', type: 'int', default: '64', range: '16 ~ 256' },
|
||||||
|
{ name: 'epochs', type: 'int', default: '50', range: '10 ~ 200' },
|
||||||
|
{ name: 'dropout', type: 'float', default: '0.2', range: '0.0 ~ 0.5' },
|
||||||
|
{ name: 'hidden_dim', type: 'int', default: '256', range: '64 ~ 1024' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Job 실행 관리 ──────────────────
|
||||||
|
const JOBS = [
|
||||||
|
{ id: 'JOB-088', name: 'LSTM 위험도 학습', type: '학습', resource: 'Blackwell x2', status: 'running', progress: 72, started: '04-10 08:00', elapsed: '3h 28m' },
|
||||||
|
{ id: 'JOB-087', name: 'AIS 피처 추출', type: '전처리', resource: 'CPU 16core', status: 'done', progress: 100, started: '04-10 06:00', elapsed: '1h 45m' },
|
||||||
|
{ id: 'JOB-086', name: 'GNN 하이퍼파라미터 탐색', type: 'HPS', resource: 'H200 x2', status: 'running', progress: 45, started: '04-10 07:30', elapsed: '2h 10m' },
|
||||||
|
{ id: 'JOB-085', name: '위험도 모델 배포', type: '배포', resource: 'Blackwell x1', status: 'done', progress: 100, started: '04-09 14:00', elapsed: '15m' },
|
||||||
|
{ id: 'JOB-084', name: 'SAR 이미지 전처리', type: '전처리', resource: 'CPU 32core', status: 'fail', progress: 34, started: '04-09 10:00', elapsed: '0h 50m' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PIPELINE_STAGES = [
|
||||||
|
{ name: '데이터 수집', status: 'done' },
|
||||||
|
{ name: '전처리', status: 'done' },
|
||||||
|
{ name: '피처 엔지니어링', status: 'done' },
|
||||||
|
{ name: '모델 학습', status: 'running' },
|
||||||
|
{ name: '모델 평가', status: 'pending' },
|
||||||
|
{ name: '모델 배포', status: 'pending' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 공통서비스 ──────────────────
|
||||||
|
const COMMON_SERVICES = [
|
||||||
|
{ name: 'Feature Store', icon: Database, desc: '피처 저장소', status: '정상', version: 'v3.2', detail: '20개 피처 · 2.4TB' },
|
||||||
|
{ name: 'Model Registry', icon: GitBranch, desc: '모델 레지스트리', status: '정상', version: 'v2.1', detail: '12개 모델 등록' },
|
||||||
|
{ name: 'Data Catalog', icon: Layers, desc: '데이터 카탈로그', status: '정상', version: 'v1.5', detail: '48 테이블 · 1.2M rows' },
|
||||||
|
{ name: 'Experiment Tracker', icon: FlaskConical, desc: '실험 추적', status: '정상', version: 'v4.0', detail: '42개 실험 기록' },
|
||||||
|
{ name: 'API Gateway', icon: Zap, desc: 'API 게이트웨이', status: '정상', version: 'v3.0', detail: '221 req/s' },
|
||||||
|
{ name: 'Security Manager', icon: Shield, desc: '보안 관리', status: '정상', version: 'v2.0', detail: 'RBAC + JWT' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 모니터링 ──────────────────
|
||||||
|
const GPU_RESOURCES = [
|
||||||
|
{ name: 'Blackwell #1', usage: 78, mem: '38/48GB', temp: '62°C', job: 'JOB-088' },
|
||||||
|
{ name: 'Blackwell #2', usage: 52, mem: '25/48GB', temp: '55°C', job: 'JOB-088' },
|
||||||
|
{ name: 'H200 #1', usage: 85, mem: '68/80GB', temp: '71°C', job: 'JOB-086' },
|
||||||
|
{ name: 'H200 #2', usage: 45, mem: '36/80GB', temp: '48°C', job: '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_METRICS = [
|
||||||
|
{ label: '실행중 Job', value: '3', icon: Play, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: '대기중 Job', value: '2', icon: RefreshCw, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||||
|
{ label: 'GPU 사용률', value: '65%', icon: Activity, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||||
|
{ label: '배포 모델', value: '2', icon: Brain, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
|
{ label: 'API 호출/s', value: '221', icon: Zap, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROJECT_STATS = [
|
||||||
|
{ label: '활성 프로젝트', value: 3, icon: Layers, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: '총 모델', value: 11, icon: Brain, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
|
{ label: '총 실험', value: 29, icon: FlaskConical, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||||
|
{ label: '참여 인원', value: 8, icon: Server, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LGCNSMLOpsPage() {
|
||||||
|
const { t, i18n } = useTranslation('ai');
|
||||||
|
const lang = i18n.language as 'ko' | 'en';
|
||||||
|
const [tab, setTab] = useState<Tab>('project');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Box}
|
||||||
|
iconColor="text-cyan-400"
|
||||||
|
title={t('lgcnsMlops.title')}
|
||||||
|
description={t('lgcnsMlops.desc')}
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<TabBar variant="underline">
|
||||||
|
{TABS.map(tt => (
|
||||||
|
<TabButton key={tt.key} active={tab === tt.key} icon={<tt.icon className="w-3.5 h-3.5" />} onClick={() => setTab(tt.key)}>
|
||||||
|
{tt.label}
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* ── ① 프로젝트 관리 ── */}
|
||||||
|
{tab === 'project' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">프로젝트 목록</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2">프로젝트명</th><th className="text-left py-2">담당</th>
|
||||||
|
<th className="text-center py-2">상태</th><th className="text-center py-2">모델</th><th className="text-center py-2">실험</th><th className="text-right py-2 px-2">수정일</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{PROJECTS.map(p => (
|
||||||
|
<tr key={p.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-muted-foreground">{p.id}</td>
|
||||||
|
<td className="py-2 text-heading font-medium">{p.name}</td>
|
||||||
|
<td className="py-2 text-muted-foreground">{p.owner}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getStatusIntent(p.status)} size="sm">{p.status}</Badge></td>
|
||||||
|
<td className="py-2 text-center text-heading">{p.models}</td>
|
||||||
|
<td className="py-2 text-center text-heading">{p.experiments}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-muted-foreground">{p.updated}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{PROJECT_STATS.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className={`p-1.5 rounded-lg ${k.bg}`}>
|
||||||
|
<k.icon className={`w-3.5 h-3.5 ${k.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 분석환경 관리 ── */}
|
||||||
|
{tab === 'environment' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">분석환경 목록</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ENVIRONMENTS.map(e => (
|
||||||
|
<div key={e.name} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<e.icon className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[11px] text-heading font-medium w-36">{e.name}</span>
|
||||||
|
<Badge intent="muted" size="sm">{e.type}</Badge>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-24">GPU: {e.gpu}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-1">사용자: {e.user}</span>
|
||||||
|
<Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">워크플로우</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{WORKFLOWS.map(w => (
|
||||||
|
<div key={w.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<span className="text-[10px] text-muted-foreground w-16">{w.id}</span>
|
||||||
|
<span className="text-[11px] text-heading font-medium flex-1">{w.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Steps: {w.steps}</span>
|
||||||
|
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${JOB_PROGRESS_COLOR[w.status] ?? 'bg-muted'}`} style={{ width: `${w.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{w.progress}%</span>
|
||||||
|
<Badge intent={getMlopsJobStatusIntent(w.status)} size="sm">
|
||||||
|
{getMlopsJobStatusLabel(w.status, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 모델 관리 ── */}
|
||||||
|
{tab === 'model' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">등록 모델</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">모델명</th><th className="text-left py-2">프레임워크</th><th className="text-center py-2">상태</th>
|
||||||
|
<th className="text-center py-2">정확도</th><th className="text-center py-2">KPI</th><th className="text-left py-2">버전</th><th className="text-left py-2 px-2">Endpoint</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{MODELS.map(m => (
|
||||||
|
<tr key={m.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-medium">{m.name}</td>
|
||||||
|
<td className="py-2 text-muted-foreground">{m.framework}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<Badge intent={getModelStatusIntent(m.status)} size="sm">
|
||||||
|
{getModelStatusLabel(m.status, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-heading font-bold">{m.accuracy}%</td>
|
||||||
|
<td className="py-2 text-center text-green-400">{m.kpi}</td>
|
||||||
|
<td className="py-2 text-muted-foreground">{m.version}</td>
|
||||||
|
<td className="py-2 px-2 text-muted-foreground font-mono text-[9px]">{m.endpoint}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">Parameter 관리</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">파라미터</th><th className="text-left py-2">타입</th><th className="text-center py-2">기본값</th><th className="text-left py-2 px-2">범위</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{PARAMETERS.map(p => (
|
||||||
|
<tr key={p.name} className="border-b border-border/50">
|
||||||
|
<td className="py-1.5 px-2 text-heading font-mono">{p.name}</td>
|
||||||
|
<td className="py-1.5 text-muted-foreground">{p.type}</td>
|
||||||
|
<td className="py-1.5 text-center text-heading">{p.default}</td>
|
||||||
|
<td className="py-1.5 px-2 text-muted-foreground">{p.range}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ Job 실행 관리 ── */}
|
||||||
|
{tab === 'job' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">Job Pipeline</div>
|
||||||
|
<div className="flex items-center gap-1 mb-4 px-2">
|
||||||
|
{PIPELINE_STAGES.map((s, i) => (
|
||||||
|
<div key={s.name} className="flex items-center gap-1 flex-1">
|
||||||
|
<div className={`flex-1 py-2 px-3 rounded-lg text-center text-[10px] font-medium ${
|
||||||
|
s.status === 'done' ? 'bg-green-500/15 text-green-400 border border-green-500/30' :
|
||||||
|
s.status === 'running' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 animate-pulse' :
|
||||||
|
'bg-surface-overlay text-hint border border-border'
|
||||||
|
}`}>
|
||||||
|
{s.status === 'done' && <CheckCircle className="w-3 h-3 inline mr-1" />}
|
||||||
|
{s.status === 'running' && <RefreshCw className="w-3 h-3 inline mr-1 animate-spin" />}
|
||||||
|
{s.name}
|
||||||
|
</div>
|
||||||
|
{i < PIPELINE_STAGES.length - 1 && <span className="text-hint text-[10px]">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">Job 목록</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="text-hint border-b border-border">
|
||||||
|
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2">Job명</th><th className="text-center py-2">유형</th>
|
||||||
|
<th className="text-left py-2">리소스</th><th className="text-center py-2">진행률</th><th className="text-center py-2">상태</th><th className="text-right py-2 px-2">소요시간</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{JOBS.map(j => (
|
||||||
|
<tr key={j.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-muted-foreground">{j.id}</td>
|
||||||
|
<td className="py-2 text-heading font-medium">{j.name}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent="muted" size="sm">{j.type}</Badge></td>
|
||||||
|
<td className="py-2 text-muted-foreground text-[9px]">{j.resource}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="flex items-center gap-2 justify-center">
|
||||||
|
<div className="w-16 h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${JOB_PROGRESS_COLOR[j.status] ?? 'bg-muted'}`} style={{ width: `${j.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-heading">{j.progress}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<Badge intent={getMlopsJobStatusIntent(j.status)} size="sm">
|
||||||
|
{getMlopsJobStatusLabel(j.status, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right text-muted-foreground">{j.elapsed}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 공통서비스 ── */}
|
||||||
|
{tab === 'common' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{COMMON_SERVICES.map(s => (
|
||||||
|
<Card key={s.name}><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-500/10">
|
||||||
|
<s.icon className="w-5 h-5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold text-heading">{s.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{s.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent={getStatusIntent(s.status)} size="sm" className="ml-auto">{s.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-[10px]">
|
||||||
|
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-hint">버전</span><span className="text-label">{s.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-hint">상세</span><span className="text-label">{s.detail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑥ 모니터링 ── */}
|
||||||
|
{tab === 'monitoring' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{SYSTEM_METRICS.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className={`p-1.5 rounded-lg ${k.bg}`}>
|
||||||
|
<k.icon className={`w-3.5 h-3.5 ${k.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">GPU 리소스 현황</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{GPU_RESOURCES.map(g => (
|
||||||
|
<div key={g.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<span className="text-[10px] text-heading font-medium w-24">{g.name}</span>
|
||||||
|
<div className="flex-1 h-2 bg-switch-background rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${g.usage > 80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-heading font-bold w-8">{g.usage}%</span>
|
||||||
|
<span className="text-[9px] text-hint">{g.mem}</span>
|
||||||
|
<span className="text-[9px] text-hint">{g.temp}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{g.job}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">서비스 상태</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ name: 'DAP API Gateway', status: 'ok', rps: 221 },
|
||||||
|
{ name: 'Model Serving', status: 'ok', rps: 186 },
|
||||||
|
{ name: 'Feature Store', status: 'ok', rps: 45 },
|
||||||
|
{ name: 'Experiment Tracker', status: 'ok', rps: 32 },
|
||||||
|
{ name: 'Job Scheduler', status: 'ok', rps: 15 },
|
||||||
|
{ name: 'PostgreSQL', status: 'ok', rps: 890 },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${s.status === 'ok' ? 'bg-green-500 shadow-[0_0_4px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_4px_#eab308]'}`} />
|
||||||
|
<span className="text-[10px] text-heading font-medium flex-1">{s.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{s.rps} req/s</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑦ Repository ── */}
|
||||||
|
{tab === 'repository' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">소스코드/글로벌 Repository</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[
|
||||||
|
['kcg-ai-monitoring', 'frontend + backend + prediction 모노레포'],
|
||||||
|
['kcg-ml-models', '모델 아카이브 (버전별 weight)'],
|
||||||
|
['kcg-data-pipeline', 'ETL 스크립트 + Airflow DAG'],
|
||||||
|
['kcg-feature-store', '피처 정의 + 변환 로직'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
<span className="text-heading font-mono">{k}</span><span className="text-hint">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">데이터/분석 Repository</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[
|
||||||
|
['AIS 원본 데이터', 'SNPDB · 5분 주기 증분 · 1.2TB'],
|
||||||
|
['학습 데이터셋', '1,456,200건 · 04-03 갱신'],
|
||||||
|
['벡터 DB (Milvus)', '1.2M 문서 · 3.6M 벡터'],
|
||||||
|
['모델 Artifact', 'S3 · 24개 버전 · 12.8GB'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
<span className="text-heading">{k}</span><span className="text-hint">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
@ -107,6 +108,7 @@ const WORKERS = [
|
|||||||
|
|
||||||
export function MLOpsPage() {
|
export function MLOpsPage() {
|
||||||
const { t } = useTranslation('ai');
|
const { t } = useTranslation('ai');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
const [tab, setTab] = useState<Tab>('dashboard');
|
const [tab, setTab] = useState<Tab>('dashboard');
|
||||||
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
|
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
|
||||||
const [selectedTmpl, setSelectedTmpl] = useState(0);
|
const [selectedTmpl, setSelectedTmpl] = useState(0);
|
||||||
@ -116,7 +118,7 @@ export function MLOpsPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Cpu}
|
icon={Cpu}
|
||||||
iconColor="text-purple-400"
|
iconColor="text-purple-600 dark:text-purple-400"
|
||||||
title={t('mlops.title')}
|
title={t('mlops.title')}
|
||||||
description={t('mlops.desc')}
|
description={t('mlops.desc')}
|
||||||
demo
|
demo
|
||||||
@ -134,7 +136,7 @@ export function MLOpsPage() {
|
|||||||
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
|
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
|
||||||
]).map(t => (
|
]).map(t => (
|
||||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -159,7 +161,7 @@ export function MLOpsPage() {
|
|||||||
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
||||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
||||||
<span className="text-[10px] text-hint">{m.ver}</span>
|
<span className="text-[10px] text-hint">{m.ver}</span>
|
||||||
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
|
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">F1 {m.f1}%</span>
|
||||||
</div>
|
</div>
|
||||||
))}</div>
|
))}</div>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
@ -187,7 +189,7 @@ export function MLOpsPage() {
|
|||||||
{TEMPLATES.map((t, i) => (
|
{TEMPLATES.map((t, i) => (
|
||||||
<div key={t.name} onClick={() => setSelectedTmpl(i)}
|
<div key={t.name} onClick={() => setSelectedTmpl(i)}
|
||||||
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
|
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
|
||||||
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-400" />
|
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-600 dark:text-blue-400" />
|
||||||
<div className="text-[10px] font-bold text-heading">{t.name}</div>
|
<div className="text-[10px] font-bold text-heading">{t.name}</div>
|
||||||
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
|
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -197,7 +199,7 @@ export function MLOpsPage() {
|
|||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="text-[12px] font-bold text-heading">실험 목록</div>
|
<div className="text-[12px] font-bold text-heading">실험 목록</div>
|
||||||
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>새 실험</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{EXPERIMENTS.map(e => (
|
{EXPERIMENTS.map(e => (
|
||||||
@ -208,7 +210,7 @@ export function MLOpsPage() {
|
|||||||
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||||||
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||||||
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||||||
{e.f1 > 0 && <span className="text-[10px] text-cyan-400 font-bold">F1 {e.f1}</span>}
|
{e.f1 > 0 && <span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">F1 {e.f1}</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -261,7 +263,7 @@ export function MLOpsPage() {
|
|||||||
<tbody>{DEPLOYS.map((d, i) => (
|
<tbody>{DEPLOYS.map((d, i) => (
|
||||||
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
||||||
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
|
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
|
||||||
<td className="px-3 py-2 text-cyan-400 font-mono">{d.ver}</td>
|
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{d.ver}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
|
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
|
||||||
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
|
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
|
||||||
<td className="px-3 py-2 text-label">{d.latency}</td>
|
<td className="px-3 py-2 text-label">{d.latency}</td>
|
||||||
@ -288,7 +290,7 @@ export function MLOpsPage() {
|
|||||||
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
|
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
|
||||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
|
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
|
||||||
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
<Button variant="primary" size="sm" icon={<Rocket className="w-3 h-3" />}>배포</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -313,15 +315,15 @@ export function MLOpsPage() {
|
|||||||
"version": "v2.1.0"
|
"version": "v2.1.0"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
<Button variant="primary" size="sm" icon={<Zap className="w-3 h-3" />}>실행</Button>
|
||||||
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||||||
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
|
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
|
||||||
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
|
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
|
||||||
<span className="text-muted-foreground">상태 <span className="text-green-400 font-bold">200 OK</span></span>
|
<span className="text-muted-foreground">상태 <span className="text-green-600 dark:text-green-400 font-bold">200 OK</span></span>
|
||||||
<span className="text-muted-foreground">지연 <span className="text-green-400 font-bold">23ms</span></span>
|
<span className="text-muted-foreground">지연 <span className="text-green-600 dark:text-green-400 font-bold">23ms</span></span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
|
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
|
||||||
"risk_score": 87.5,
|
"risk_score": 87.5,
|
||||||
@ -353,7 +355,7 @@ export function MLOpsPage() {
|
|||||||
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
|
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
|
||||||
]).map(t => (
|
]).map(t => (
|
||||||
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
|
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
|
||||||
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
|
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -367,7 +369,7 @@ export function MLOpsPage() {
|
|||||||
{LLM_MODELS.map((m, i) => (
|
{LLM_MODELS.map((m, i) => (
|
||||||
<div key={m.name} onClick={() => setSelectedLLM(i)}
|
<div key={m.name} onClick={() => setSelectedLLM(i)}
|
||||||
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
||||||
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-400" />
|
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-600 dark:text-purple-400" />
|
||||||
<div className="text-[10px] font-bold text-heading">{m.name}</div>
|
<div className="text-[10px] font-bold text-heading">{m.name}</div>
|
||||||
<div className="text-[8px] text-hint">{m.sub}</div>
|
<div className="text-[8px] text-hint">{m.sub}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -381,7 +383,7 @@ export function MLOpsPage() {
|
|||||||
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Play className="w-3 h-3" />}>학습 시작</Button>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
</div>
|
</div>
|
||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
@ -417,10 +419,10 @@ export function MLOpsPage() {
|
|||||||
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
|
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Search className="w-3 h-3" />}>검색 시작</Button>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
||||||
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-600 dark:text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
||||||
<table className="w-full text-[10px]">
|
<table className="w-full text-[10px]">
|
||||||
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
|
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
|
||||||
<tbody>{HPS_TRIALS.map(t => (
|
<tbody>{HPS_TRIALS.map(t => (
|
||||||
@ -505,7 +507,7 @@ export function MLOpsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
|
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
|
||||||
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
|
<Button variant="primary" size="md" aria-label={tc('aria.send')} icon={<Send className="w-4 h-4" />} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export { AIModelManagement } from './AIModelManagement';
|
export { AIModelManagement } from './AIModelManagement';
|
||||||
export { MLOpsPage } from './MLOpsPage';
|
export { MLOpsPage } from './MLOpsPage';
|
||||||
|
export { LGCNSMLOpsPage } from './LGCNSMLOpsPage';
|
||||||
export { AIAssistant } from './AIAssistant';
|
export { AIAssistant } from './AIAssistant';
|
||||||
export { LLMOpsPage } from './LLMOpsPage';
|
export { LLMOpsPage } from './LLMOpsPage';
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
|
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { useAuth } from '@/app/auth/AuthContext';
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
import { LoginError } from '@/services/authApi';
|
import { LoginError } from '@/services/authApi';
|
||||||
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
|
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
|
||||||
@ -105,7 +106,7 @@ export function LoginPage() {
|
|||||||
{/* 로고 영역 */}
|
{/* 로고 영역 */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
|
||||||
<Shield className="w-8 h-8 text-blue-400" />
|
<Shield className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
|
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
|
||||||
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
|
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
|
||||||
@ -122,7 +123,7 @@ export function LoginPage() {
|
|||||||
disabled={m.disabled}
|
disabled={m.disabled}
|
||||||
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
|
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
|
||||||
authMethod === m.key
|
authMethod === m.key
|
||||||
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
|
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||||
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||||
title={m.disabled ? '향후 도입 예정' : ''}
|
title={m.disabled ? '향후 도입 예정' : ''}
|
||||||
@ -188,16 +189,18 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
className="w-full font-bold"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
@ -205,7 +208,7 @@ export function LoginPage() {
|
|||||||
{t('button.authenticating')}
|
{t('button.authenticating')}
|
||||||
</>
|
</>
|
||||||
) : t('button.login')}
|
) : t('button.login')}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
|
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
|
||||||
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
|
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
|
||||||
@ -215,7 +218,7 @@ export function LoginPage() {
|
|||||||
{/* GPKI 인증 (Phase 9 도입 예정) */}
|
{/* GPKI 인증 (Phase 9 도입 예정) */}
|
||||||
{authMethod === 'gpki' && (
|
{authMethod === 'gpki' && (
|
||||||
<div className="space-y-4 text-center py-12">
|
<div className="space-y-4 text-center py-12">
|
||||||
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
|
<Fingerprint className="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
|
||||||
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
|
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
|
||||||
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
||||||
</div>
|
</div>
|
||||||
@ -224,7 +227,7 @@ export function LoginPage() {
|
|||||||
{/* SSO 연동 (Phase 9 도입 예정) */}
|
{/* SSO 연동 (Phase 9 도입 예정) */}
|
||||||
{authMethod === 'sso' && (
|
{authMethod === 'sso' && (
|
||||||
<div className="space-y-4 text-center py-12">
|
<div className="space-y-4 text-center py-12">
|
||||||
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
<KeyRound className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
|
||||||
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
|
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
|
||||||
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
|
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
|
import { BaseMap, createStaticLayers, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { HeatPoint, MarkerData } from '@lib/map';
|
import type { HeatPoint, MarkerData } from '@lib/map';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Ship, Anchor, Eye, Navigation,
|
AlertTriangle, Ship, Anchor, Eye, Navigation,
|
||||||
@ -55,7 +55,7 @@ function RiskBar({ value, size = 'default' }: { value: number; size?: 'default'
|
|||||||
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
|
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
|
||||||
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
|
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
|
||||||
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
|
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||||
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400';
|
const textColor = pct > 70 ? 'text-red-600 dark:text-red-400' : pct > 50 ? 'text-orange-600 dark:text-orange-400' : pct > 30 ? 'text-yellow-600 dark:text-yellow-400' : 'text-blue-600 dark:text-blue-400';
|
||||||
const barW = size === 'sm' ? 'w-16' : 'w-24';
|
const barW = size === 'sm' ? 'w-16' : 'w-24';
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -78,7 +78,7 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
|
|||||||
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
|
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
|
||||||
<Icon className="w-4 h-4" style={{ color }} />
|
<Icon className="w-4 h-4" style={{ color }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}>
|
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
|
||||||
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||||
{Math.abs(diff)}
|
{Math.abs(diff)}
|
||||||
</div>
|
</div>
|
||||||
@ -187,7 +187,7 @@ function SeaAreaMap() {
|
|||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
const buildLayers = useCallback(() => [
|
||||||
...STATIC_LAYERS,
|
...createStaticLayers(),
|
||||||
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
|
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
|
||||||
createMarkerLayer('threat-markers', THREAT_MARKERS),
|
createMarkerLayer('threat-markers', THREAT_MARKERS),
|
||||||
], []);
|
], []);
|
||||||
@ -207,16 +207,16 @@ function SeaAreaMap() {
|
|||||||
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
|
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
|
||||||
<div className="text-[8px] text-muted-foreground font-bold mb-1">위협 등급</div>
|
<div className="text-[8px] text-muted-foreground font-bold mb-1">위협 등급</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[7px] text-blue-400">낮음</span>
|
<span className="text-[7px] text-blue-600 dark:text-blue-400">낮음</span>
|
||||||
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
|
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
|
||||||
<span className="text-[7px] text-red-400">높음</span>
|
<span className="text-[7px] text-red-600 dark:text-red-400">높음</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* LIVE 인디케이터 */}
|
{/* LIVE 인디케이터 */}
|
||||||
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
|
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||||
<Radar className="w-3 h-3 text-blue-500" />
|
<Radar className="w-3 h-3 text-blue-500" />
|
||||||
<span className="text-[9px] text-blue-400 font-medium">실시간 해역 위협도</span>
|
<span className="text-[9px] text-blue-600 dark:text-blue-400 font-medium">실시간 해역 위협도</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -468,8 +468,8 @@ export function Dashboard() {
|
|||||||
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
|
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
|
||||||
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
|
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
|
||||||
}}>{area.risk}</span>
|
}}>{area.risk}</span>
|
||||||
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
|
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-600 dark:text-red-400" />}
|
||||||
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />}
|
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-600 dark:text-green-400" />}
|
||||||
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]">—</span>}
|
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]">—</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -544,7 +544,7 @@ export function Dashboard() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-0">
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
<Waves className="w-3.5 h-3.5 text-blue-400" />
|
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
해상 기상 현황
|
해상 기상 현황
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -557,19 +557,19 @@ export function Dashboard() {
|
|||||||
<div className="text-[8px] text-hint">돌풍 {WEATHER_DATA.wind.gust}m/s</div>
|
<div className="text-[8px] text-hint">돌풍 {WEATHER_DATA.wind.gust}m/s</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" />
|
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 mx-auto mb-1" />
|
||||||
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
|
||||||
<div className="text-[8px] text-hint">파고</div>
|
<div className="text-[8px] text-hint">파고</div>
|
||||||
<div className="text-[8px] text-hint">주기 {WEATHER_DATA.wave.period}s</div>
|
<div className="text-[8px] text-hint">주기 {WEATHER_DATA.wave.period}s</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" />
|
<Thermometer className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 mx-auto mb-1" />
|
||||||
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
|
||||||
<div className="text-[8px] text-hint">기온</div>
|
<div className="text-[8px] text-hint">기온</div>
|
||||||
<div className="text-[8px] text-hint">수온 {WEATHER_DATA.temp.water}°C</div>
|
<div className="text-[8px] text-hint">수온 {WEATHER_DATA.temp.water}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" />
|
<Eye className="w-3.5 h-3.5 text-green-600 dark:text-green-400 mx-auto mb-1" />
|
||||||
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
|
||||||
<div className="text-[8px] text-hint">시정</div>
|
<div className="text-[8px] text-hint">시정</div>
|
||||||
<div className="text-[8px] text-hint">해상{WEATHER_DATA.seaState}급</div>
|
<div className="text-[8px] text-hint">해상{WEATHER_DATA.seaState}급</div>
|
||||||
|
|||||||
@ -1,35 +1,41 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
import { PageContainer } from '@shared/components/layout';
|
import { PageContainer } from '@shared/components/layout';
|
||||||
import {
|
import {
|
||||||
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
Search, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||||
Eye, AlertTriangle, Radio, RotateCcw,
|
Eye, AlertTriangle, Radio, RotateCcw,
|
||||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
||||||
|
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||||
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
||||||
import { useSettingsStore } from '@stores/settingsStore';
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { GearIdentification } from './GearIdentification';
|
import { GearIdentification } from './GearIdentification';
|
||||||
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
||||||
|
import { VesselMiniMap } from './components/VesselMiniMap';
|
||||||
|
import { VesselAnomalyPanel } from './components/VesselAnomalyPanel';
|
||||||
|
import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly';
|
||||||
import { PieChart as EcPieChart } from '@lib/charts';
|
import { PieChart as EcPieChart } from '@lib/charts';
|
||||||
|
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||||
import {
|
import {
|
||||||
fetchVesselAnalysis,
|
getAnalysisStats,
|
||||||
filterDarkVessels,
|
getAnalysisVessels,
|
||||||
filterTransshipSuspects,
|
getAnalysisHistory,
|
||||||
type VesselAnalysisItem,
|
type AnalysisStats,
|
||||||
type VesselAnalysisStats,
|
type VesselAnalysis,
|
||||||
} from '@/services/vesselAnalysisApi';
|
} from '@/services/analysisApi';
|
||||||
|
import { toVesselItem } from '@/services/analysisAdapter';
|
||||||
|
|
||||||
// ─── 중국 MMSI prefix ─────────────
|
// ─── 중국 MMSI prefix ─────────────
|
||||||
const CHINA_MMSI_PREFIX = '412';
|
const CHINA_MMSI_PREFIX = '412';
|
||||||
|
|
||||||
function isChinaVessel(mmsi: string): boolean {
|
|
||||||
return mmsi.startsWith(CHINA_MMSI_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 특이운항 선박 리스트 타입 ────────────────
|
// ─── 특이운항 선박 리스트 타입 ────────────────
|
||||||
type VesselStatus = '의심' | '양호' | '경고';
|
type VesselStatus = '의심' | '양호' | '경고';
|
||||||
interface VesselItem {
|
interface VesselItem {
|
||||||
@ -51,16 +57,29 @@ function deriveVesselStatus(score: number): VesselStatus {
|
|||||||
return '양호';
|
return '양호';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
function mapToVesselItem(
|
||||||
|
item: VesselAnalysisItem,
|
||||||
|
idx: number,
|
||||||
|
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||||
|
lang: 'ko' | 'en',
|
||||||
|
): VesselItem {
|
||||||
const score = item.algorithms.riskScore.score;
|
const score = item.algorithms.riskScore.score;
|
||||||
|
const vt = item.classification.vesselType;
|
||||||
|
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
|
||||||
|
// 이름: fleet_vessels 매핑으로 vessel_type 이 채워진 경우 한글 유형 라벨, 아니면 '중국어선'
|
||||||
|
const name = hasType ? getVesselTypeLabel(vt, t, lang) : '중국어선';
|
||||||
|
// 타입 뱃지: fishingPct 기반 Fishing / 그 외는 vessel_type 라벨
|
||||||
|
const type = item.classification.fishingPct > 0.5
|
||||||
|
? 'Fishing'
|
||||||
|
: hasType ? getVesselTypeLabel(vt, t, lang) : getVesselTypeLabel('UNKNOWN', t, lang);
|
||||||
return {
|
return {
|
||||||
id: String(idx + 1),
|
id: String(idx + 1),
|
||||||
mmsi: item.mmsi,
|
mmsi: item.mmsi,
|
||||||
callSign: '-',
|
callSign: '-',
|
||||||
channel: '',
|
channel: '',
|
||||||
source: 'AIS',
|
source: 'AIS',
|
||||||
name: item.classification.vesselType || item.mmsi,
|
name,
|
||||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
|
type,
|
||||||
country: 'China',
|
country: 'China',
|
||||||
status: deriveVesselStatus(score),
|
status: deriveVesselStatus(score),
|
||||||
riskPct: score,
|
riskPct: score,
|
||||||
@ -202,10 +221,14 @@ export function ChinaFishing() {
|
|||||||
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
||||||
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||||
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
||||||
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
|
const [history, setHistory] = useState<VesselAnalysis[]>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyError, setHistoryError] = useState('');
|
||||||
|
|
||||||
// API state
|
// API state
|
||||||
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
|
const [topVessels, setTopVessels] = useState<VesselAnalysisItem[]>([]);
|
||||||
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
|
const [apiStats, setApiStats] = useState<AnalysisStats | null>(null);
|
||||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||||
const [apiLoading, setApiLoading] = useState(false);
|
const [apiLoading, setApiLoading] = useState(false);
|
||||||
const [apiError, setApiError] = useState('');
|
const [apiError, setApiError] = useState('');
|
||||||
@ -214,10 +237,18 @@ export function ChinaFishing() {
|
|||||||
setApiLoading(true);
|
setApiLoading(true);
|
||||||
setApiError('');
|
setApiError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetchVesselAnalysis();
|
const [stats, topPage] = await Promise.all([
|
||||||
setServiceAvailable(res.serviceAvailable);
|
getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }),
|
||||||
setAllItems(res.items);
|
getAnalysisVessels({
|
||||||
setApiStats(res.stats);
|
hours: 1,
|
||||||
|
mmsiPrefix: CHINA_MMSI_PREFIX,
|
||||||
|
minRiskScore: 40,
|
||||||
|
size: 20,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setApiStats(stats);
|
||||||
|
setTopVessels(topPage.content.map(toVesselItem));
|
||||||
|
setServiceAvailable(true);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||||
setServiceAvailable(false);
|
setServiceAvailable(false);
|
||||||
@ -228,55 +259,77 @@ export function ChinaFishing() {
|
|||||||
|
|
||||||
useEffect(() => { loadApi(); }, [loadApi]);
|
useEffect(() => { loadApi(); }, [loadApi]);
|
||||||
|
|
||||||
// 중국어선 필터
|
// 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터)
|
||||||
const chinaVessels = useMemo(
|
useEffect(() => {
|
||||||
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
|
if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; }
|
||||||
[allItems],
|
let cancelled = false;
|
||||||
|
setHistoryLoading(true); setHistoryError('');
|
||||||
|
getAnalysisHistory(selectedMmsi, 24)
|
||||||
|
.then((rows) => { if (!cancelled) setHistory(rows); })
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setHistory([]);
|
||||||
|
setHistoryError(e instanceof Error ? e.message : '이력 조회 실패');
|
||||||
|
})
|
||||||
|
.finally(() => { if (!cancelled) setHistoryLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [selectedMmsi]);
|
||||||
|
|
||||||
|
const anomalySegments = useMemo(
|
||||||
|
() => groupAnomaliesToSegments(extractAnomalies(history)),
|
||||||
|
[history],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]);
|
// ─ 파생 계산 (서버 집계 우선) ─
|
||||||
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]);
|
// Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT.
|
||||||
|
// topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용).
|
||||||
|
const countersRow1 = useMemo(() => {
|
||||||
|
if (!apiStats) return [];
|
||||||
|
return [
|
||||||
|
{ label: '통합', count: apiStats.total, color: '#6b7280' },
|
||||||
|
{ label: 'AIS', count: apiStats.total, color: '#3b82f6' },
|
||||||
|
{ label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' },
|
||||||
|
{ label: '어업선', count: apiStats.fishingCount, color: '#10b981' },
|
||||||
|
];
|
||||||
|
}, [apiStats]);
|
||||||
|
|
||||||
// 센서 카운터 (API 기반)
|
const countersRow2 = useMemo(() => {
|
||||||
const countersRow1 = useMemo(() => [
|
if (!apiStats) return [];
|
||||||
{ label: '통합', count: allItems.length, color: '#6b7280' },
|
return [
|
||||||
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
|
{ label: '중국어선', count: apiStats.total, color: '#f97316' },
|
||||||
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
|
{ label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' },
|
||||||
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
|
{ label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' },
|
||||||
], [allItems]);
|
{ label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' },
|
||||||
|
];
|
||||||
|
}, [apiStats]);
|
||||||
|
|
||||||
const countersRow2 = useMemo(() => [
|
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
||||||
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
|
|
||||||
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
|
|
||||||
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
|
|
||||||
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
|
|
||||||
], [chinaVessels, chinaDark, chinaTransship]);
|
|
||||||
|
|
||||||
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
|
|
||||||
const vesselList: VesselItem[] = useMemo(
|
const vesselList: VesselItem[] = useMemo(
|
||||||
() => chinaVessels
|
() => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
|
||||||
.filter((i) => i.algorithms.riskScore.score >= 40)
|
[topVessels, tcCommon, lang],
|
||||||
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
|
|
||||||
.slice(0, 20)
|
|
||||||
.map((item, idx) => mapToVesselItem(item, idx)),
|
|
||||||
[chinaVessels],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 위험도별 분포 (도넛 차트용)
|
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
||||||
const riskDistribution = useMemo(() => {
|
const riskDistribution = useMemo(() => {
|
||||||
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
|
if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||||
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
|
return {
|
||||||
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
|
critical: apiStats.criticalCount,
|
||||||
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
|
high: apiStats.highCount,
|
||||||
return { critical, high, medium, low, total: chinaVessels.length };
|
medium: apiStats.mediumCount,
|
||||||
}, [chinaVessels]);
|
low: apiStats.lowCount,
|
||||||
|
total: apiStats.total,
|
||||||
|
};
|
||||||
|
}, [apiStats]);
|
||||||
|
|
||||||
// 안전도 지수 계산
|
// 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일)
|
||||||
const safetyIndex = useMemo(() => {
|
const safetyIndex = useMemo(() => {
|
||||||
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
|
const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0;
|
||||||
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
|
if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 };
|
||||||
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
|
return {
|
||||||
}, [chinaVessels]);
|
risk: Number((avgRisk / 10).toFixed(2)),
|
||||||
|
safety: Number(((100 - avgRisk) / 10).toFixed(2)),
|
||||||
|
};
|
||||||
|
}, [apiStats]);
|
||||||
|
|
||||||
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
||||||
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
||||||
@ -290,22 +343,19 @@ export function ChinaFishing() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer size="sm">
|
<PageContainer size="sm">
|
||||||
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
|
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
|
||||||
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
|
<TabBar variant="segmented">
|
||||||
{modeTabs.map((tab) => (
|
{modeTabs.map((tab) => (
|
||||||
<button type="button"
|
<TabButton
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
variant="segmented"
|
||||||
|
active={mode === tab.key}
|
||||||
onClick={() => setMode(tab.key)}
|
onClick={() => setMode(tab.key)}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
|
icon={<tab.icon className="w-3.5 h-3.5" />}
|
||||||
mode === tab.key
|
|
||||||
? 'bg-blue-600 text-on-vivid'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<tab.icon className="w-3.5 h-3.5" />
|
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</TabButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TabBar>
|
||||||
|
|
||||||
{/* 환적 탐지 모드 */}
|
{/* 환적 탐지 모드 */}
|
||||||
{mode === 'transfer' && <TransferView />}
|
{mode === 'transfer' && <TransferView />}
|
||||||
@ -319,11 +369,11 @@ export function ChinaFishing() {
|
|||||||
{!serviceAvailable && (
|
{!serviceAvailable && (
|
||||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
<span>iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다</span>
|
<span>분석 API 호출 실패 - 잠시 후 다시 시도해주세요</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{apiError && <div className="text-xs text-red-400">에러: {apiError}</div>}
|
{apiError && <div className="text-xs text-red-600 dark:text-red-400">{tcCommon('error.errorPrefix', { msg: apiError })}</div>}
|
||||||
|
|
||||||
{apiLoading && (
|
{apiLoading && (
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
@ -331,7 +381,7 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* iran 백엔드 실시간 분석 결과 */}
|
{/* 중국 선박 실시간 분석 결과 */}
|
||||||
<RealAllVessels />
|
<RealAllVessels />
|
||||||
|
|
||||||
{/* ── 상단 바: 기준일 + 검색 ── */}
|
{/* ── 상단 바: 기준일 + 검색 ── */}
|
||||||
@ -340,16 +390,21 @@ export function ChinaFishing() {
|
|||||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="text-[11px] text-label">기준 : {formatDateTime(new Date())}</span>
|
<span className="text-[11px] text-label">기준 : {formatDateTime(new Date())}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
|
<Button
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
variant="secondary"
|
||||||
</button>
|
size="sm"
|
||||||
|
onClick={loadApi}
|
||||||
|
aria-label={tcCommon('aria.refresh')}
|
||||||
|
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
|
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
|
||||||
<Search className="w-3.5 h-3.5 text-hint mr-2" />
|
<Search className="w-3.5 h-3.5 text-hint mr-2" />
|
||||||
<input aria-label="해역 또는 해구 번호 검색"
|
<input
|
||||||
|
aria-label={tcCommon('aria.searchAreaOrZone')}
|
||||||
placeholder="해역 또는 해구 번호 검색"
|
placeholder="해역 또는 해구 번호 검색"
|
||||||
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
|
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
|
<Search className="w-4 h-4 text-blue-600 dark:text-blue-500 cursor-pointer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -371,7 +426,7 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
||||||
<span>해역 전체 통항량</span>
|
<span>해역 전체 통항량</span>
|
||||||
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span>
|
<span className="text-lg font-extrabold text-heading">{(apiStats?.total ?? 0).toLocaleString()}</span>
|
||||||
<span className="text-hint">(척)</span>
|
<span className="text-hint">(척)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -407,13 +462,13 @@ export function ChinaFishing() {
|
|||||||
<div className="flex items-center justify-around mt-4">
|
<div className="flex items-center justify-around mt-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||||
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
<span className="text-orange-600 dark:text-orange-400 font-medium">종합</span> 위험지수
|
||||||
</div>
|
</div>
|
||||||
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
|
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||||
종합 <span className="text-blue-400 font-medium">안전지수</span>
|
종합 <span className="text-blue-600 dark:text-blue-400 font-medium">안전지수</span>
|
||||||
</div>
|
</div>
|
||||||
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
|
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
|
||||||
</div>
|
</div>
|
||||||
@ -422,38 +477,51 @@ export function ChinaFishing() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 관심영역 안전도 */}
|
{/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
|
||||||
<div className="col-span-4">
|
<div className="col-span-4">
|
||||||
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
||||||
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
aria-label={tcCommon('aria.areaOfInterestSelect')}
|
||||||
|
>
|
||||||
<option>영역 A</option>
|
<option>영역 A</option>
|
||||||
<option>영역 B</option>
|
<option>영역 B</option>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[9px] text-hint mb-3">설정한 관심 영역을 선택후 조회를 눌러주세요.</p>
|
<p className="text-[9px] text-hint mb-3">설정한 관심 영역을 선택후 조회를 눌러주세요.</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<Eye className="w-3.5 h-3.5 text-blue-400" />
|
<Eye className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
<span className="text-muted-foreground">특이운항</span>
|
<span className="text-muted-foreground">특이운항</span>
|
||||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
<span className="text-green-600 dark:text-green-400 font-bold ml-auto">정상</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
<AlertTriangle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||||
<span className="text-muted-foreground">불법조업</span>
|
<span className="text-muted-foreground">불법조업</span>
|
||||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
<span className="text-green-600 dark:text-green-400 font-bold ml-auto">정상</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<Radio className="w-3.5 h-3.5 text-purple-400" />
|
<Radio className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
||||||
<span className="text-muted-foreground">비허가</span>
|
<span className="text-muted-foreground">비허가</span>
|
||||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
<span className="text-green-600 dark:text-green-400 font-bold ml-auto">정상</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
|
<CircleGauge
|
||||||
|
value={
|
||||||
|
apiStats && apiStats.total > 0
|
||||||
|
? Number(((1 - apiStats.criticalCount / Math.max(apiStats.total, 1)) * 100).toFixed(1))
|
||||||
|
: 100
|
||||||
|
}
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -467,22 +535,27 @@ export function ChinaFishing() {
|
|||||||
<div className="col-span-5">
|
<div className="col-span-5">
|
||||||
<Card className="bg-surface-raised border-slate-700/30">
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* 탭 헤더 */}
|
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
|
||||||
<div className="flex border-b border-slate-700/30">
|
<TabBar variant="underline" className="border-slate-700/30">
|
||||||
{vesselTabs.map((tab) => (
|
{vesselTabs.map((tab) => {
|
||||||
<button type="button"
|
const disabled = tab !== '특이운항';
|
||||||
|
return (
|
||||||
|
<TabButton
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setVesselTab(tab)}
|
variant="underline"
|
||||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
active={vesselTab === tab}
|
||||||
vesselTab === tab
|
onClick={() => !disabled && setVesselTab(tab)}
|
||||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
disabled={disabled}
|
||||||
: 'text-hint hover:text-label'
|
className="flex-1 justify-center"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
</button>
|
{disabled && (
|
||||||
))}
|
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||||
</div>
|
)}
|
||||||
|
</TabButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
{/* 선박 목록 */}
|
{/* 선박 목록 */}
|
||||||
<div className="max-h-[420px] overflow-y-auto">
|
<div className="max-h-[420px] overflow-y-auto">
|
||||||
@ -491,10 +564,15 @@ export function ChinaFishing() {
|
|||||||
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{vesselList.map((v) => (
|
{vesselList.map((v) => {
|
||||||
|
const selected = v.mmsi === selectedMmsi;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={v.id}
|
key={v.id}
|
||||||
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
|
onClick={() => setSelectedMmsi(selected ? null : v.mmsi)}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 transition-colors cursor-pointer group ${
|
||||||
|
selected ? 'bg-blue-500/10 hover:bg-blue-500/15' : 'hover:bg-surface-overlay'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<StatusRing status={v.status} riskPct={v.riskPct} />
|
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@ -512,7 +590,8 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -524,22 +603,21 @@ export function ChinaFishing() {
|
|||||||
{/* 통계 차트 */}
|
{/* 통계 차트 */}
|
||||||
<Card className="bg-surface-raised border-slate-700/30">
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* 탭 */}
|
{/* 탭 — 월별 집계 API 미연동 */}
|
||||||
<div className="flex border-b border-slate-700/30">
|
<TabBar variant="underline" className="border-slate-700/30">
|
||||||
{statsTabs.map((tab) => (
|
{statsTabs.map((tab) => (
|
||||||
<button type="button"
|
<TabButton
|
||||||
key={tab}
|
key={tab}
|
||||||
|
variant="underline"
|
||||||
|
active={statsTab === tab}
|
||||||
onClick={() => setStatsTab(tab)}
|
onClick={() => setStatsTab(tab)}
|
||||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
className="flex-1 justify-center"
|
||||||
statsTab === tab
|
|
||||||
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
|
|
||||||
: 'text-hint hover:text-label'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
</button>
|
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||||
|
</TabButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TabBar>
|
||||||
|
|
||||||
<div className="p-4 flex gap-4">
|
<div className="p-4 flex gap-4">
|
||||||
{/* 월별 통계 - API 미지원, 준비중 안내 */}
|
{/* 월별 통계 - API 미지원, 준비중 안내 */}
|
||||||
@ -584,9 +662,9 @@ export function ChinaFishing() {
|
|||||||
|
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="px-4 pb-3 flex justify-end">
|
<div className="px-4 pb-3 flex justify-end">
|
||||||
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
|
<Button variant="secondary" size="sm">
|
||||||
다운로드
|
다운로드
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -594,12 +672,15 @@ export function ChinaFishing() {
|
|||||||
{/* 하단 카드 3개 */}
|
{/* 하단 카드 3개 */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
|
||||||
{/* 최근 위성영상 분석 */}
|
{/* 최근 위성영상 분석 (VIIRS 수집 파이프라인 미구축 → 데모) */}
|
||||||
<Card className="bg-surface-raised border-slate-700/30">
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
||||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline">자세히 보기</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 text-[10px]">
|
<div className="space-y-1.5 text-[10px]">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -618,16 +699,19 @@ export function ChinaFishing() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 기상 예보 */}
|
{/* 기상 예보 (기상청 API 미연동 → 데모) */}
|
||||||
<Card className="bg-surface-raised border-slate-700/30">
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
||||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline">자세히 보기</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
|
<Cloud className="w-8 h-8 text-yellow-600 dark:text-yellow-400 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[9px] text-muted-foreground">전남서부남해앞바다</div>
|
<div className="text-[9px] text-muted-foreground">전남서부남해앞바다</div>
|
||||||
@ -641,12 +725,15 @@ export function ChinaFishing() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* VTS연계 현황 */}
|
{/* VTS연계 현황 (VTS 시스템 연계 미구축 → 데모) */}
|
||||||
<Card className="bg-surface-raised border-slate-700/30">
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
||||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline">자세히 보기</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{VTS_ITEMS.map((vts) => (
|
{VTS_ITEMS.map((vts) => (
|
||||||
@ -654,22 +741,28 @@ export function ChinaFishing() {
|
|||||||
key={vts.name}
|
key={vts.name}
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
|
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
|
||||||
vts.active
|
vts.active
|
||||||
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
|
? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border border-orange-500/20'
|
||||||
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
|
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-500' : 'bg-muted'}`} />
|
||||||
{vts.name}
|
{vts.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-2">
|
<div className="flex justify-between mt-2">
|
||||||
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
|
<Button
|
||||||
<ChevronLeft className="w-4 h-4" />
|
variant="ghost"
|
||||||
</button>
|
size="sm"
|
||||||
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
|
aria-label={tcCommon('aria.previous')}
|
||||||
<ChevronRight className="w-4 h-4" />
|
icon={<ChevronLeft className="w-4 h-4" />}
|
||||||
</button>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={tcCommon('aria.next')}
|
||||||
|
icon={<ChevronRight className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -677,6 +770,28 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── 선택 시: 궤적 미니맵 + 특이운항 판별 구간 상세 (최근 24h 분석 이력 기반) ── */}
|
||||||
|
{selectedMmsi && (
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
<div className="col-span-5">
|
||||||
|
<VesselMiniMap
|
||||||
|
mmsi={selectedMmsi}
|
||||||
|
vesselName={vesselList.find((v) => v.mmsi === selectedMmsi)?.name}
|
||||||
|
segments={anomalySegments}
|
||||||
|
onClose={() => setSelectedMmsi(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-7">
|
||||||
|
<VesselAnomalyPanel
|
||||||
|
segments={anomalySegments}
|
||||||
|
loading={historyLoading}
|
||||||
|
error={historyError}
|
||||||
|
totalHistoryCount={history.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</>}
|
</>}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,12 +7,14 @@ import { Select } from '@shared/components/ui/select';
|
|||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
|
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
|
||||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { MarkerData } from '@lib/map';
|
import type { MarkerData } from '@lib/map';
|
||||||
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
|
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
|
||||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { getRiskIntent } from '@shared/constants/statusIntent';
|
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||||
|
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
|
||||||
import { useSettingsStore } from '@stores/settingsStore';
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
import { DarkDetailPanel } from './components/DarkDetailPanel';
|
||||||
|
|
||||||
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
|
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
|
||||||
|
|
||||||
@ -51,6 +53,10 @@ function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
|
|||||||
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
|
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
|
||||||
const patterns = (feat.dark_patterns as string[]) ?? [];
|
const patterns = (feat.dark_patterns as string[]) ?? [];
|
||||||
|
|
||||||
|
// 위치: lat/lon이 없으면 features.gap_start_lat/lon 사용
|
||||||
|
const lat = (v.lat && v.lat !== 0) ? v.lat : (feat.gap_start_lat as number) ?? 0;
|
||||||
|
const lon = (v.lon && v.lon !== 0) ? v.lon : (feat.gap_start_lon as number) ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `DV-${String(idx + 1).padStart(3, '0')}`,
|
id: `DV-${String(idx + 1).padStart(3, '0')}`,
|
||||||
mmsi: v.mmsi,
|
mmsi: v.mmsi,
|
||||||
@ -62,8 +68,8 @@ function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
|
|||||||
risk: v.riskScore ?? 0,
|
risk: v.riskScore ?? 0,
|
||||||
gap: v.gapDurationMin ?? 0,
|
gap: v.gapDurationMin ?? 0,
|
||||||
lastAIS: formatDateTime(v.analyzedAt),
|
lastAIS: formatDateTime(v.analyzedAt),
|
||||||
lat: v.lat ?? 0,
|
lat,
|
||||||
lng: v.lon ?? 0,
|
lng: lon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +80,7 @@ export function DarkVesselDetection() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [tierFilter, setTierFilter] = useState<string>('');
|
const [tierFilter, setTierFilter] = useState<string>('');
|
||||||
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
|
|
||||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
||||||
{ key: 'id', label: 'ID', width: '70px',
|
{ key: 'id', label: 'ID', width: '70px',
|
||||||
@ -81,20 +88,23 @@ export function DarkVesselDetection() {
|
|||||||
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
|
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const tier = v as string;
|
const tier = v as string;
|
||||||
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
|
return <Badge intent={getRiskIntent(tier === 'WATCH' ? 40 : getAlertLevelTierScore(tier))} size="sm">{tier}</Badge>;
|
||||||
} },
|
} },
|
||||||
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
|
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const n = v as number;
|
const n = v as number;
|
||||||
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
|
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
|
||||||
|
: n >= 50 ? 'text-orange-600 dark:text-orange-400'
|
||||||
|
: 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return <span className={`font-bold font-mono ${c}`}>{n}</span>;
|
||||||
} },
|
} },
|
||||||
{ key: 'name', label: '선박 유형', sortable: true,
|
{ key: 'name', label: '선박 유형', sortable: true,
|
||||||
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span> },
|
||||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const mmsi = v as string;
|
const mmsi = v as string;
|
||||||
return (
|
return (
|
||||||
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
||||||
{mmsi}
|
{mmsi}
|
||||||
</button>
|
</button>
|
||||||
@ -109,7 +119,10 @@ export function DarkVesselDetection() {
|
|||||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const n = v as number;
|
const n = v as number;
|
||||||
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
|
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
|
||||||
|
: n >= 50 ? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
return <span className={`font-bold ${c}`}>{n}</span>;
|
||||||
} },
|
} },
|
||||||
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
|
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
|
||||||
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
|
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
|
||||||
@ -121,6 +134,12 @@ export function DarkVesselDetection() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// 선택된 선박의 원본 VesselAnalysis 조회
|
||||||
|
const selectedVessel = useMemo(
|
||||||
|
() => selectedMmsi ? rawData.find(v => v.mmsi === selectedMmsi) ?? null : null,
|
||||||
|
[rawData, selectedMmsi],
|
||||||
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@ -169,30 +188,56 @@ export function DarkVesselDetection() {
|
|||||||
|
|
||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
const buildLayers = useCallback(() => {
|
||||||
...STATIC_LAYERS,
|
const validData = DATA.filter((d) => d.lat !== 0 && d.lng !== 0);
|
||||||
|
const layers = [
|
||||||
|
...createStaticLayers(),
|
||||||
|
// 전체 선박 마커 (tier별 색상)
|
||||||
|
createMarkerLayer(
|
||||||
|
'dv-markers',
|
||||||
|
validData.map((d) => ({
|
||||||
|
lat: d.lat, lng: d.lng,
|
||||||
|
color: TIER_HEX[d.darkTier] || '#6b7280',
|
||||||
|
radius: d.darkScore >= 70 ? 1000 : 600,
|
||||||
|
label: `${d.id}`,
|
||||||
|
} as MarkerData)),
|
||||||
|
),
|
||||||
|
// CRITICAL 위험 반경
|
||||||
createRadiusLayer(
|
createRadiusLayer(
|
||||||
'dv-radius',
|
'dv-radius',
|
||||||
DATA.filter((d) => d.darkScore >= 70).map((d) => ({
|
validData.filter((d) => d.darkScore >= 70).map((d) => ({
|
||||||
lat: d.lat, lng: d.lng, radius: 10000,
|
lat: d.lat, lng: d.lng, radius: 10000,
|
||||||
color: TIER_HEX[d.darkTier] || '#ef4444',
|
color: TIER_HEX[d.darkTier] || '#ef4444',
|
||||||
})),
|
})),
|
||||||
0.08,
|
0.08,
|
||||||
),
|
),
|
||||||
createMarkerLayer(
|
];
|
||||||
'dv-markers',
|
|
||||||
DATA.filter((d) => d.lat !== 0).map((d) => ({
|
|
||||||
lat: d.lat, lng: d.lng,
|
|
||||||
color: TIER_HEX[d.darkTier] || '#6b7280',
|
|
||||||
radius: d.darkScore >= 70 ? 1200 : 800,
|
|
||||||
label: `${d.id} ${d.name}`,
|
|
||||||
} as MarkerData)),
|
|
||||||
),
|
|
||||||
], [DATA]);
|
|
||||||
|
|
||||||
useMapLayers(mapRef, buildLayers, [DATA]);
|
// 클릭 선택 선박 하이라이트 (흰색 원 + 큰 마커)
|
||||||
|
if (selectedMmsi) {
|
||||||
|
const target = validData.find(d => d.mmsi === selectedMmsi);
|
||||||
|
if (target) {
|
||||||
|
layers.push(
|
||||||
|
createRadiusLayer(
|
||||||
|
'dv-highlight',
|
||||||
|
[{ lat: target.lat, lng: target.lng, radius: 15000, color: '#ffffff' }],
|
||||||
|
0.15,
|
||||||
|
),
|
||||||
|
createMarkerLayer(
|
||||||
|
'dv-highlight-marker',
|
||||||
|
[{ lat: target.lat, lng: target.lng, color: '#ffffff', radius: 2000, label: `${target.mmsi}` } as MarkerData],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers;
|
||||||
|
}, [DATA, selectedMmsi]);
|
||||||
|
|
||||||
|
useMapLayers(mapRef, buildLayers, [DATA, selectedMmsi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={EyeOff}
|
icon={EyeOff}
|
||||||
@ -213,7 +258,7 @@ export function DarkVesselDetection() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
@ -224,10 +269,10 @@ export function DarkVesselDetection() {
|
|||||||
{/* KPI — tier 기반 */}
|
{/* KPI — tier 기반 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
|
{ l: '전체', v: tierCounts.total, c: 'text-red-600 dark:text-red-400', filter: '' },
|
||||||
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
|
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-600 dark:text-red-400', filter: 'CRITICAL' },
|
||||||
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
|
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-600 dark:text-orange-400', filter: 'HIGH' },
|
||||||
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
|
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-600 dark:text-yellow-400', filter: 'WATCH' },
|
||||||
].map((k) => (
|
].map((k) => (
|
||||||
<div key={k.l}
|
<div key={k.l}
|
||||||
onClick={() => setTierFilter(k.filter)}
|
onClick={() => setTierFilter(k.filter)}
|
||||||
@ -244,7 +289,8 @@ export function DarkVesselDetection() {
|
|||||||
<DataTable data={DATA} columns={cols} pageSize={10}
|
<DataTable data={DATA} columns={cols} pageSize={10}
|
||||||
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
|
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
|
||||||
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
|
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
|
||||||
exportFilename="Dark_Vessel_탐지" />
|
exportFilename="Dark_Vessel_탐지"
|
||||||
|
onRowClick={(row) => setSelectedMmsi(row.mmsi)} />
|
||||||
|
|
||||||
{/* 탐지 위치 지도 */}
|
{/* 탐지 위치 지도 */}
|
||||||
<Card>
|
<Card>
|
||||||
@ -264,11 +310,17 @@ export function DarkVesselDetection() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}척</span>
|
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">{tierCounts.CRITICAL}척</span>
|
||||||
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
|
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* 판정 상세 사이드 패널 */}
|
||||||
|
{selectedVessel && (
|
||||||
|
<DarkDetailPanel vessel={selectedVessel} onClose={() => setSelectedMmsi(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AlertOctagon, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import { Textarea } from '@shared/components/ui/textarea';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
import {
|
||||||
|
GEAR_COLLISION_STATUS_ORDER,
|
||||||
|
getGearCollisionStatusIntent,
|
||||||
|
getGearCollisionStatusLabel,
|
||||||
|
} from '@shared/constants/gearCollisionStatuses';
|
||||||
|
import {
|
||||||
|
getGearCollisionStats,
|
||||||
|
listGearCollisions,
|
||||||
|
resolveGearCollision,
|
||||||
|
type GearCollision,
|
||||||
|
type GearCollisionResolveAction,
|
||||||
|
type GearCollisionStats,
|
||||||
|
} from '@/services/gearCollisionApi';
|
||||||
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 페이지.
|
||||||
|
*
|
||||||
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 에 공존 송출되는 경우를 목록화하고
|
||||||
|
* 운영자가 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE 로 분류할 수 있게 한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
||||||
|
const DEFAULT_HOURS = 48;
|
||||||
|
|
||||||
|
export function GearCollisionDetection() {
|
||||||
|
const { t } = useTranslation('detection');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<GearCollision[]>([]);
|
||||||
|
const [stats, setStats] = useState<GearCollisionStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<string>('');
|
||||||
|
const [nameFilter, setNameFilter] = useState<string>('');
|
||||||
|
const [selected, setSelected] = useState<GearCollision | null>(null);
|
||||||
|
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
|
||||||
|
const [resolveNote, setResolveNote] = useState('');
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [page, summary] = await Promise.all([
|
||||||
|
listGearCollisions({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
severity: severityFilter || undefined,
|
||||||
|
name: nameFilter || undefined,
|
||||||
|
hours: DEFAULT_HOURS,
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
|
getGearCollisionStats(DEFAULT_HOURS),
|
||||||
|
]);
|
||||||
|
setRows(page.content);
|
||||||
|
setStats(summary);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [statusFilter, severityFilter, nameFilter, t]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// 선택된 row 와 현재 목록의 동기화
|
||||||
|
const syncedSelected = useMemo(
|
||||||
|
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
|
||||||
|
[rows, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
|
||||||
|
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
|
||||||
|
render: (_, row) => (
|
||||||
|
<span className="font-mono text-[10px] text-label">
|
||||||
|
{row.mmsiLo} ↔ {row.mmsiHi}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
|
||||||
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
|
||||||
|
width: '90px', align: 'center', sortable: true,
|
||||||
|
render: (v) => <span className="font-mono text-label">{v as number}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
|
||||||
|
width: '110px', align: 'right', sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
||||||
|
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'severity', label: t('gearCollision.columns.severity'),
|
||||||
|
width: '90px', align: 'center', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||||
|
{getAlertLevelLabel(v as string, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status', label: t('gearCollision.columns.status'),
|
||||||
|
width: '110px', align: 'center', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
|
||||||
|
{getGearCollisionStatusLabel(v as string, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
|
||||||
|
width: '130px', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [t, tc, lang]);
|
||||||
|
|
||||||
|
const handleResolve = useCallback(async () => {
|
||||||
|
if (!syncedSelected) return;
|
||||||
|
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
|
||||||
|
if (!ok) return;
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const updated = await resolveGearCollision(syncedSelected.id, {
|
||||||
|
action: resolveAction,
|
||||||
|
note: resolveNote || undefined,
|
||||||
|
});
|
||||||
|
setSelected(updated);
|
||||||
|
setResolveNote('');
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
|
||||||
|
|
||||||
|
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={AlertOctagon}
|
||||||
|
iconColor="text-orange-600 dark:text-orange-400"
|
||||||
|
title={t('gearCollision.title')}
|
||||||
|
description={t('gearCollision.desc')}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
{t('gearCollision.list.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title={t('gearCollision.stats.title')}>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.open')}
|
||||||
|
value={statusCount('OPEN')}
|
||||||
|
intent="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.reviewed')}
|
||||||
|
value={statusCount('REVIEWED')}
|
||||||
|
intent="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.confirmed')}
|
||||||
|
value={statusCount('CONFIRMED_ILLEGAL')}
|
||||||
|
intent="critical"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.falsePositive')}
|
||||||
|
value={statusCount('FALSE_POSITIVE')}
|
||||||
|
intent="muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('gearCollision.list.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||||
|
<Select
|
||||||
|
aria-label={t('gearCollision.filters.status')}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('gearCollision.filters.allStatus')}</option>
|
||||||
|
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
|
||||||
|
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
aria-label={t('gearCollision.filters.severity')}
|
||||||
|
value={severityFilter}
|
||||||
|
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('gearCollision.filters.allSeverity')}</option>
|
||||||
|
{SEVERITY_OPTIONS.map((sv) => (
|
||||||
|
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
aria-label={t('gearCollision.filters.name')}
|
||||||
|
placeholder={t('gearCollision.filters.name')}
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Badge intent="info" size="sm">
|
||||||
|
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<p className="text-hint text-xs py-4 text-center">
|
||||||
|
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={rows as (GearCollision & Record<string, unknown>)[]}
|
||||||
|
columns={cols}
|
||||||
|
pageSize={20}
|
||||||
|
showSearch={false}
|
||||||
|
showExport={false}
|
||||||
|
showPrint={false}
|
||||||
|
onRowClick={(row) => setSelected(row as GearCollision)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{syncedSelected && (
|
||||||
|
<Section title={t('gearCollision.detail.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.mmsiPair')}
|
||||||
|
value={`${syncedSelected.mmsiLo} ↔ ${syncedSelected.mmsiHi}`}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.parentName')}
|
||||||
|
value={syncedSelected.parentName ?? '-'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.firstSeenAt')}
|
||||||
|
value={formatDateTime(syncedSelected.firstSeenAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.lastSeenAt')}
|
||||||
|
value={formatDateTime(syncedSelected.lastSeenAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.coexistenceCount')}
|
||||||
|
value={String(syncedSelected.coexistenceCount)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.swapCount')}
|
||||||
|
value={String(syncedSelected.swapCount)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.maxDistance')}
|
||||||
|
value={
|
||||||
|
syncedSelected.maxDistanceKm != null
|
||||||
|
? Number(syncedSelected.maxDistanceKm).toFixed(2)
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-label">
|
||||||
|
{t('gearCollision.columns.severity')}:
|
||||||
|
</span>
|
||||||
|
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
|
||||||
|
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-label ml-3">
|
||||||
|
{t('gearCollision.columns.status')}:
|
||||||
|
</span>
|
||||||
|
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
|
||||||
|
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{syncedSelected.resolutionNote && (
|
||||||
|
<p className="text-xs text-hint border-l-2 border-border pl-2">
|
||||||
|
{syncedSelected.resolutionNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="gc-resolve-action"
|
||||||
|
className="block text-xs text-label"
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.title')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="gc-resolve-action"
|
||||||
|
aria-label={t('gearCollision.resolve.title')}
|
||||||
|
value={resolveAction}
|
||||||
|
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
|
||||||
|
>
|
||||||
|
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
|
||||||
|
<option value="CONFIRMED_ILLEGAL">
|
||||||
|
{t('gearCollision.resolve.confirmedIllegal')}
|
||||||
|
</option>
|
||||||
|
<option value="FALSE_POSITIVE">
|
||||||
|
{t('gearCollision.resolve.falsePositive')}
|
||||||
|
</option>
|
||||||
|
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
aria-label={t('gearCollision.resolve.note')}
|
||||||
|
placeholder={t('gearCollision.resolve.notePlaceholder')}
|
||||||
|
value={resolveNote}
|
||||||
|
onChange={(e) => setResolveNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSelected(null); setResolveNote(''); }}
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 컴포넌트 ─────────────
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, intent }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-hint">{label}</span>
|
||||||
|
{intent ? (
|
||||||
|
<Badge intent={intent} size="md">
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-heading">{value}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||||
|
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GearCollisionDetection;
|
||||||
@ -2,22 +2,65 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Checkbox } from '@shared/components/ui/checkbox';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
|
import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
|
||||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
import type { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import type { MarkerData } from '@lib/map';
|
import {
|
||||||
|
BaseMap, createStaticLayers,
|
||||||
|
createGeoJsonLayer, createGearPolygonLayer,
|
||||||
|
createShipIconLayer, createGearIconLayer,
|
||||||
|
type MapHandle,
|
||||||
|
type ShipIconData, type GearIconData,
|
||||||
|
} from '@lib/map';
|
||||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||||
import { formatDate } from '@shared/utils/dateFormat';
|
import { formatDate } from '@shared/utils/dateFormat';
|
||||||
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
|
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
|
||||||
import { getAlertLevelHex } from '@shared/constants/alertLevels';
|
import { getAlertLevelHex } from '@shared/constants/alertLevels';
|
||||||
import { useSettingsStore } from '@stores/settingsStore';
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes';
|
||||||
|
import { getGearViolationIntent } from '@shared/constants/gearViolationCodes';
|
||||||
|
import { getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
||||||
|
import { GearDetailPanel } from './components/GearDetailPanel';
|
||||||
|
import { GearReplayController } from './components/GearReplayController';
|
||||||
|
import { useGearReplayStore } from '@stores/gearReplayStore';
|
||||||
|
import { useGearReplayLayers } from '@/hooks/useGearReplayLayers';
|
||||||
|
import fishingZonesGeoJson from '@lib/map/data/fishing-zones-wgs84.json';
|
||||||
|
|
||||||
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
||||||
|
|
||||||
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; };
|
type Gear = {
|
||||||
|
id: string;
|
||||||
|
groupKey: string;
|
||||||
|
type: string; // 어구 그룹 유형 (구역 내/외)
|
||||||
|
owner: string; // 모선 MMSI 또는 그룹 라벨
|
||||||
|
zone: string; // 수역 코드
|
||||||
|
status: string;
|
||||||
|
permit: string;
|
||||||
|
installed: string;
|
||||||
|
lastSignal: string;
|
||||||
|
risk: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
parentStatus: string;
|
||||||
|
parentMmsi: string;
|
||||||
|
confidence: string;
|
||||||
|
memberCount: number;
|
||||||
|
members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>;
|
||||||
|
// 폴리곤 원본
|
||||||
|
polygon: unknown;
|
||||||
|
// G코드 위반 정보
|
||||||
|
gCodes: string[];
|
||||||
|
gearViolationScore: number;
|
||||||
|
gearViolationEvidence: Record<string, Record<string, unknown>>;
|
||||||
|
pairTrawlDetected: boolean;
|
||||||
|
pairTrawlPairMmsi: string;
|
||||||
|
allowedGears: string[];
|
||||||
|
topScore: number; // 최대 후보 일치율 (0~1)
|
||||||
|
};
|
||||||
|
|
||||||
// 한글 위험도 → AlertLevel hex 매핑
|
|
||||||
const RISK_HEX: Record<string, string> = {
|
const RISK_HEX: Record<string, string> = {
|
||||||
'고위험': getAlertLevelHex('CRITICAL'),
|
'고위험': getAlertLevelHex('CRITICAL'),
|
||||||
'중위험': getAlertLevelHex('MEDIUM'),
|
'중위험': getAlertLevelHex('MEDIUM'),
|
||||||
@ -37,14 +80,31 @@ function deriveStatus(g: GearGroupItem): string {
|
|||||||
return '확인 중';
|
return '확인 중';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
/** 그룹 유형에서 수역 코드 추론 (backend가 zoneCode를 미제공하므로 groupType 기반) */
|
||||||
|
function deriveZone(g: GearGroupItem): string {
|
||||||
|
if (g.groupType === 'GEAR_OUT_ZONE') return 'EEZ_OR_BEYOND';
|
||||||
|
// GEAR_IN_ZONE: 위치 기반 추론 — 위도/경도로 대략적 수역 판별
|
||||||
|
const lat = g.centerLat;
|
||||||
|
const lon = g.centerLon;
|
||||||
|
if (lat > 37.0 && lon > 129.0) return 'ZONE_I'; // 동해
|
||||||
|
if (lat < 34.0 && lon > 127.0) return 'ZONE_II'; // 남해
|
||||||
|
if (lat < 35.5 && lon < 127.0) return 'ZONE_III'; // 서남해
|
||||||
|
if (lat >= 35.5 && lon < 126.5) return 'ZONE_IV'; // 서해
|
||||||
|
return 'ZONE_III'; // 기본값
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGroupToGear(g: GearGroupItem, idx: number, t: (k: string, opts?: { defaultValue?: string }) => string, lang: 'ko' | 'en'): Gear {
|
||||||
const risk = deriveRisk(g);
|
const risk = deriveRisk(g);
|
||||||
const status = deriveStatus(g);
|
const status = deriveStatus(g);
|
||||||
|
const zone = deriveZone(g);
|
||||||
|
// 그룹명: 항상 groupLabel/groupKey 사용 (모선 후보 MMSI는 별도 칼럼)
|
||||||
|
const owner = g.groupLabel || g.groupKey;
|
||||||
return {
|
return {
|
||||||
id: `G-${String(idx + 1).padStart(3, '0')}`,
|
id: `G-${String(idx + 1).padStart(3, '0')}`,
|
||||||
type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'),
|
groupKey: g.groupKey,
|
||||||
owner: g.members[0]?.name || g.members[0]?.mmsi || '-',
|
type: getGearGroupTypeLabel(g.groupType, t, lang),
|
||||||
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
|
owner,
|
||||||
|
zone,
|
||||||
status,
|
status,
|
||||||
permit: 'NONE',
|
permit: 'NONE',
|
||||||
installed: formatDate(g.snapshotTime),
|
installed: formatDate(g.snapshotTime),
|
||||||
@ -52,9 +112,58 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
|||||||
risk,
|
risk,
|
||||||
lat: g.centerLat,
|
lat: g.centerLat,
|
||||||
lng: g.centerLon,
|
lng: g.centerLon,
|
||||||
|
parentStatus: g.resolution?.status ?? '-',
|
||||||
|
parentMmsi: g.resolution?.selectedParentMmsi ?? '-',
|
||||||
|
confidence: (g.candidateCount ?? 0) > 0 ? `${g.candidateCount}건` : '-',
|
||||||
|
memberCount: g.memberCount ?? 0,
|
||||||
|
members: g.members ?? [],
|
||||||
|
polygon: g.polygon,
|
||||||
|
gCodes: [],
|
||||||
|
gearViolationScore: 0,
|
||||||
|
gearViolationEvidence: {},
|
||||||
|
pairTrawlDetected: false,
|
||||||
|
pairTrawlPairMmsi: '',
|
||||||
|
allowedGears: getZoneAllowedGears(zone),
|
||||||
|
topScore: Math.max(g.liveTopScore ?? 0, g.resolution?.topScore ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 필터 그룹 내 체크박스 목록 */
|
||||||
|
function FilterCheckGroup({ label, selected, onChange, options }: {
|
||||||
|
label: string;
|
||||||
|
selected: Set<string>;
|
||||||
|
onChange: (v: Set<string>) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
const toggle = (v: string) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(v)) next.delete(v); else next.add(v);
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] text-hint font-medium">{label} {selected.size > 0 && <span className="text-primary">({selected.size})</span>}</div>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
{options.map(o => (
|
||||||
|
<Checkbox
|
||||||
|
key={o.value}
|
||||||
|
checked={selected.has(o.value)}
|
||||||
|
onChange={() => toggle(o.value)}
|
||||||
|
label={o.label}
|
||||||
|
className="w-3 h-3"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplayOverlay() {
|
||||||
|
const groupKey = useGearReplayStore(s => s.groupKey);
|
||||||
|
if (!groupKey) return null;
|
||||||
|
return <GearReplayController onClose={() => useGearReplayStore.getState().reset()} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function GearDetection() {
|
export function GearDetection() {
|
||||||
const { t } = useTranslation('detection');
|
const { t } = useTranslation('detection');
|
||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
@ -62,32 +171,110 @@ export function GearDetection() {
|
|||||||
|
|
||||||
const cols: DataColumn<Gear>[] = useMemo(() => [
|
const cols: DataColumn<Gear>[] = useMemo(() => [
|
||||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
{ key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium text-[11px]">{v as string}</span> },
|
||||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
{ key: 'owner', label: '어구 그룹', sortable: true,
|
||||||
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
|
render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
||||||
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
|
{ key: 'memberCount', label: '멤버', width: '50px', align: 'center',
|
||||||
|
render: v => <span className="font-mono text-[10px] text-label">{v as number}척</span> },
|
||||||
|
{ key: 'zone', label: '설치 해역', width: '130px', sortable: true,
|
||||||
|
render: (v: unknown) => (
|
||||||
|
<Badge intent={getZoneCodeIntent(v as string)} size="sm">
|
||||||
|
{getZoneCodeLabel(v as string, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
) },
|
||||||
|
{ key: 'permit', label: '허가', width: '70px', align: 'center',
|
||||||
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
|
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
|
||||||
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
|
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
|
||||||
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
|
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
{ key: 'gCodes', label: 'G코드', width: '100px',
|
||||||
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
render: (_: unknown, row: Gear) => row.gCodes.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-0.5">
|
||||||
|
{row.gCodes.map(code => (
|
||||||
|
<Badge key={code} intent={getGearViolationIntent(code)} size="sm">{code}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <span className="text-hint text-[10px]">-</span> },
|
||||||
|
{ key: 'risk', label: '위험도', width: '65px', align: 'center', sortable: true,
|
||||||
|
render: v => {
|
||||||
|
const r = v as string;
|
||||||
|
const c = r === '고위험' ? 'text-red-600 dark:text-red-400'
|
||||||
|
: r === '중위험' ? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
return <span className={`text-[10px] font-bold ${c}`}>{r}</span>;
|
||||||
|
} },
|
||||||
|
{ key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true,
|
||||||
|
render: v => {
|
||||||
|
const s = v as string;
|
||||||
|
const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : 'muted';
|
||||||
|
const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s;
|
||||||
|
return <Badge intent={intent} size="sm">{label}</Badge>;
|
||||||
|
} },
|
||||||
|
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
|
||||||
|
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
|
||||||
|
{ key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true,
|
||||||
|
render: (v: unknown) => {
|
||||||
|
const s = v as number;
|
||||||
|
if (s <= 0) return <span className="text-hint text-[10px]">-</span>;
|
||||||
|
const pct = Math.round(s * 100);
|
||||||
|
const c = pct >= 72 ? 'text-green-600 dark:text-green-400' : pct >= 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-hint';
|
||||||
|
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
|
||||||
|
} },
|
||||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||||
], [tc, lang]);
|
], [t, tc, lang]);
|
||||||
|
|
||||||
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
||||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── 필터 상태 (다중 선택, localStorage 영속화) ──
|
||||||
|
const [filterOpen, setFilterOpen] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('kcg-gear-filter-open') ?? 'false'); } catch { return false; }
|
||||||
|
});
|
||||||
|
const [filterZone, setFilterZone] = useState<Set<string>>(() => {
|
||||||
|
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fz') ?? '[]')); } catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const [filterStatus, setFilterStatus] = useState<Set<string>>(() => {
|
||||||
|
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fs') ?? '[]')); } catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const [filterRisk, setFilterRisk] = useState<Set<string>>(() => {
|
||||||
|
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fr') ?? '[]')); } catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const [filterParentStatus, setFilterParentStatus] = useState<Set<string>>(() => {
|
||||||
|
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fps') ?? '[]')); } catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const [filterPermit, setFilterPermit] = useState<Set<string>>(() => {
|
||||||
|
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fp') ?? '[]')); } catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const [filterMemberMin, setFilterMemberMin] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('kcg-gear-fmn') ?? '2'); } catch { return 2; }
|
||||||
|
});
|
||||||
|
const [filterMemberMax, setFilterMemberMax] = useState(() => {
|
||||||
|
try { const v = localStorage.getItem('kcg-gear-fmx'); return v ? JSON.parse(v) : Infinity; } catch { return Infinity; }
|
||||||
|
});
|
||||||
|
const checkFilterCount = filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size;
|
||||||
|
|
||||||
|
// localStorage 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('kcg-gear-filter-open', JSON.stringify(filterOpen));
|
||||||
|
localStorage.setItem('kcg-gear-fz', JSON.stringify([...filterZone]));
|
||||||
|
localStorage.setItem('kcg-gear-fs', JSON.stringify([...filterStatus]));
|
||||||
|
localStorage.setItem('kcg-gear-fr', JSON.stringify([...filterRisk]));
|
||||||
|
localStorage.setItem('kcg-gear-fps', JSON.stringify([...filterParentStatus]));
|
||||||
|
localStorage.setItem('kcg-gear-fp', JSON.stringify([...filterPermit]));
|
||||||
|
localStorage.setItem('kcg-gear-fmn', JSON.stringify(filterMemberMin));
|
||||||
|
if (filterMemberMax !== Infinity) localStorage.setItem('kcg-gear-fmx', JSON.stringify(filterMemberMax));
|
||||||
|
else localStorage.removeItem('kcg-gear-fmx');
|
||||||
|
}, [filterOpen, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax]);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetchGroups();
|
const res = await fetchGroups('GEAR');
|
||||||
setServiceAvailable(res.serviceAvailable);
|
setServiceAvailable(res.serviceAvailable);
|
||||||
setGroups(res.items.filter(
|
setGroups(res.items);
|
||||||
(i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE',
|
|
||||||
));
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||||
setServiceAvailable(false);
|
setServiceAvailable(false);
|
||||||
@ -99,39 +286,175 @@ export function GearDetection() {
|
|||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
const DATA: Gear[] = useMemo(
|
const DATA: Gear[] = useMemo(
|
||||||
() => groups.map((g, i) => mapGroupToGear(g, i)),
|
() => groups.map((g, i) => mapGroupToGear(g, i, t, lang)),
|
||||||
[groups],
|
[groups, t, lang],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 필터 옵션 (고유값 추출)
|
||||||
|
const filterOptions = useMemo(() => ({
|
||||||
|
zones: [...new Set(DATA.map(d => d.zone))].sort(),
|
||||||
|
statuses: [...new Set(DATA.map(d => d.status))],
|
||||||
|
risks: [...new Set(DATA.map(d => d.risk))],
|
||||||
|
parentStatuses: [...new Set(DATA.map(d => d.parentStatus))],
|
||||||
|
permits: [...new Set(DATA.map(d => d.permit))],
|
||||||
|
maxMember: DATA.reduce((max, d) => Math.max(max, d.memberCount), 0),
|
||||||
|
}), [DATA]);
|
||||||
|
|
||||||
|
const hasActiveFilter = checkFilterCount > 0 || (filterMemberMin > 2 || (filterMemberMax !== Infinity && filterMemberMax < filterOptions.maxMember));
|
||||||
|
|
||||||
|
// 데이터 로드 후 멤버 슬라이더 최대값 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterOptions.maxMember > 0 && filterMemberMax === Infinity) {
|
||||||
|
setFilterMemberMax(filterOptions.maxMember);
|
||||||
|
}
|
||||||
|
}, [filterOptions.maxMember, filterMemberMax]);
|
||||||
|
|
||||||
|
// ── 필터 적용 ──
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
const effMax = filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax;
|
||||||
|
return DATA.filter(d =>
|
||||||
|
(filterZone.size === 0 || filterZone.has(d.zone)) &&
|
||||||
|
(filterStatus.size === 0 || filterStatus.has(d.status)) &&
|
||||||
|
(filterRisk.size === 0 || filterRisk.has(d.risk)) &&
|
||||||
|
(filterParentStatus.size === 0 || filterParentStatus.has(d.parentStatus)) &&
|
||||||
|
(filterPermit.size === 0 || filterPermit.has(d.permit)) &&
|
||||||
|
d.memberCount >= filterMemberMin && d.memberCount <= effMax,
|
||||||
|
);
|
||||||
|
}, [DATA, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax, filterOptions.maxMember]);
|
||||||
|
|
||||||
|
const selectedGear = useMemo(
|
||||||
|
() => DATA.find(g => g.id === selectedId) ?? null,
|
||||||
|
[DATA, selectedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
|
||||||
...STATIC_LAYERS,
|
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
|
||||||
createRadiusLayer(
|
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
|
||||||
'gear-radius',
|
get current() { return mapRef.current?.overlay ?? null; },
|
||||||
DATA.filter(g => g.risk === '고위험').map(g => ({
|
}), []);
|
||||||
lat: g.lat,
|
|
||||||
lng: g.lng,
|
|
||||||
radius: 6000,
|
|
||||||
color: RISK_HEX[g.risk] || "#64748b",
|
|
||||||
})),
|
|
||||||
0.1,
|
|
||||||
),
|
|
||||||
createMarkerLayer(
|
|
||||||
'gear-markers',
|
|
||||||
DATA.map(g => ({
|
|
||||||
lat: g.lat,
|
|
||||||
lng: g.lng,
|
|
||||||
color: RISK_HEX[g.risk] || "#64748b",
|
|
||||||
radius: g.risk === '고위험' ? 1200 : 800,
|
|
||||||
label: `${g.id} ${g.type}`,
|
|
||||||
} as MarkerData)),
|
|
||||||
),
|
|
||||||
], [DATA]);
|
|
||||||
|
|
||||||
useMapLayers(mapRef, buildLayers, [DATA]);
|
const replayGroupKey = useGearReplayStore(s => s.groupKey);
|
||||||
|
const isReplayActive = !!replayGroupKey;
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const layers: any[] = [
|
||||||
|
// 1. 정적 레이어 (EEZ + NLL)
|
||||||
|
...createStaticLayers(),
|
||||||
|
|
||||||
|
// 2. 특정해역 I~IV 폴리곤 (항상 표시)
|
||||||
|
createGeoJsonLayer('fishing-zones', fishingZonesGeoJson, '#6366f1', {
|
||||||
|
fillOpacity: 15,
|
||||||
|
lineWidth: 2,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isReplayActive) {
|
||||||
|
// ── 리플레이 모드: 정적 레이어만 (리플레이 훅이 나머지 직접 관리) ──
|
||||||
|
// 비선택 어구 그룹 중심 (흐린 마름모) — 위치 참조용
|
||||||
|
layers.push(createGearIconLayer(
|
||||||
|
'gear-dim',
|
||||||
|
DATA.filter(g => g.groupKey !== replayGroupKey).map(g => ({
|
||||||
|
lat: g.lat, lon: g.lng, color: '#475569', size: 10,
|
||||||
|
} as GearIconData)),
|
||||||
|
));
|
||||||
|
} else if (selectedId) {
|
||||||
|
// ── 선택 모드 (리플레이 비활성) ──
|
||||||
|
const sel = DATA.find(g => g.id === selectedId);
|
||||||
|
|
||||||
|
// 선택된 어구 그룹 폴리곤 강조
|
||||||
|
if (sel?.polygon) {
|
||||||
|
layers.push(createGearPolygonLayer('gear-polygon-selected', [{
|
||||||
|
polygon: sel.polygon,
|
||||||
|
color: '#f59e0b',
|
||||||
|
label: `${sel.id} ${sel.type}`,
|
||||||
|
risk: sel.risk,
|
||||||
|
}]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멤버 아이콘: 선박(PARENT)=삼각형+COG, 어구(GEAR)=마름모
|
||||||
|
if (sel && sel.members.length > 0) {
|
||||||
|
const ships = sel.members.filter(m => m.lat != null && m.lon != null && (m.isParent || m.role === 'PARENT'));
|
||||||
|
const gears = sel.members.filter(m => m.lat != null && m.lon != null && !m.isParent && m.role !== 'PARENT');
|
||||||
|
|
||||||
|
if (ships.length > 0) {
|
||||||
|
layers.push(createShipIconLayer('sel-ships', ships.map(m => ({
|
||||||
|
lat: m.lat!, lon: m.lon!,
|
||||||
|
cog: (m as Record<string, unknown>).cog as number | undefined,
|
||||||
|
color: '#06b6d4', size: 28,
|
||||||
|
label: `${m.mmsi} ${m.name ?? ''}`,
|
||||||
|
isParent: true,
|
||||||
|
} as ShipIconData))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gears.length > 0) {
|
||||||
|
layers.push(createGearIconLayer('sel-gears', gears.map(m => ({
|
||||||
|
lat: m.lat!, lon: m.lon!,
|
||||||
|
color: '#f59e0b', size: 18,
|
||||||
|
label: `${m.mmsi} ${m.name ?? ''}`,
|
||||||
|
} as GearIconData))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비선택 어구 그룹 중심 (흐린 마름모)
|
||||||
|
layers.push(createGearIconLayer(
|
||||||
|
'gear-dim',
|
||||||
|
DATA.filter(g => g.id !== selectedId).map(g => ({
|
||||||
|
lat: g.lat, lon: g.lng, color: '#475569', size: 10,
|
||||||
|
} as GearIconData)),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// ── 기본 모드: 모든 어구 폴리곤 + 아이콘 ──
|
||||||
|
layers.push(createGearPolygonLayer(
|
||||||
|
'gear-polygons',
|
||||||
|
DATA.filter(g => g.polygon != null).map(g => ({
|
||||||
|
polygon: g.polygon,
|
||||||
|
color: RISK_HEX[g.risk] || '#64748b',
|
||||||
|
label: `${g.id} ${g.type}`,
|
||||||
|
risk: g.risk,
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 어구 그룹 중심 마름모 아이콘
|
||||||
|
layers.push(createGearIconLayer(
|
||||||
|
'gear-center-icons',
|
||||||
|
DATA.map(g => ({
|
||||||
|
lat: g.lat, lon: g.lng,
|
||||||
|
color: RISK_HEX[g.risk] || '#64748b',
|
||||||
|
size: g.risk === '고위험' ? 20 : 14,
|
||||||
|
label: `${g.id} ${g.owner}`,
|
||||||
|
} as GearIconData)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers;
|
||||||
|
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
|
||||||
|
|
||||||
|
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
|
||||||
|
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReplayActive) return; // replay hook이 overlay 독점
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
mapRef.current?.overlay?.setProps({ layers: buildLayers() });
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [buildLayers, isReplayActive]);
|
||||||
|
|
||||||
|
useGearReplayLayers(overlayRef, buildLayers);
|
||||||
|
|
||||||
|
// 수역별 통계
|
||||||
|
const zoneStats = useMemo(() => {
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
filteredData.forEach(d => { stats[d.zone] = (stats[d.zone] || 0) + 1; });
|
||||||
|
return stats;
|
||||||
|
}, [filteredData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<GearDetailPanel gear={selectedGear} onClose={() => setSelectedId(null)} />
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Anchor}
|
icon={Anchor}
|
||||||
@ -143,13 +466,11 @@ export function GearDetection() {
|
|||||||
{!serviceAvailable && (
|
{!serviceAvailable && (
|
||||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
<span>iran 분석 서비스 미연결 - 실시간 어구 데이터를 불러올 수 없습니다</span>
|
<span>AI 분석 엔진 미연결 - 실시간 어구 데이터를 불러올 수 없습니다</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||||
<div className="text-xs text-red-400">에러: {error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
@ -157,45 +478,175 @@ export function GearDetection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* 요약 배지 */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
{[
|
{[
|
||||||
{ l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' },
|
{ l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
|
||||||
{ l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
|
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-600 dark:text-red-400' },
|
||||||
{ l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
|
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-600 dark:text-yellow-400' },
|
||||||
{ l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' },
|
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-600 dark:text-green-400' },
|
||||||
].map(k => (
|
].map(k => (
|
||||||
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
<div key={k.l} className="flex-1 min-w-[100px] flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
|
{/* 수역별 분포 */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{Object.entries(zoneStats).sort(([,a],[,b]) => b - a).map(([zone, cnt]) => (
|
||||||
|
<Badge key={zone} intent={getZoneCodeIntent(zone)} size="sm">
|
||||||
|
{getZoneCodeLabel(zone, t, lang)} {cnt}건
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 토글 버튼 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label={tc('aria.filterToggle')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${
|
||||||
|
hasActiveFilter
|
||||||
|
? 'bg-primary/10 border-primary/40 text-heading'
|
||||||
|
: 'bg-surface-raised border-border text-label hover:border-primary/50'
|
||||||
|
}`}>
|
||||||
|
<Filter className="w-3.5 h-3.5" />
|
||||||
|
세부 필터
|
||||||
|
{hasActiveFilter && (
|
||||||
|
<span className="bg-primary text-on-vivid rounded-full px-1.5 py-0.5 text-[9px] font-bold leading-none">
|
||||||
|
{filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{hasActiveFilter && (
|
||||||
|
<>
|
||||||
|
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}건</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={tc('aria.filterReset')}
|
||||||
|
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
|
||||||
|
icon={<X className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 패널 (접기/펼치기) */}
|
||||||
|
{filterOpen && (
|
||||||
|
<div className="bg-surface-raised border border-border rounded-lg p-3 space-y-3">
|
||||||
|
<FilterCheckGroup label="설치 해역" selected={filterZone} onChange={setFilterZone}
|
||||||
|
options={filterOptions.zones.map(z => ({ value: z, label: getZoneCodeLabel(z, t, lang) }))} />
|
||||||
|
<FilterCheckGroup label="판정" selected={filterStatus} onChange={setFilterStatus}
|
||||||
|
options={filterOptions.statuses.map(s => ({ value: s, label: s }))} />
|
||||||
|
<FilterCheckGroup label="위험도" selected={filterRisk} onChange={setFilterRisk}
|
||||||
|
options={filterOptions.risks.map(r => ({ value: r, label: r }))} />
|
||||||
|
<FilterCheckGroup label="모선 상태" selected={filterParentStatus} onChange={setFilterParentStatus}
|
||||||
|
options={filterOptions.parentStatuses.map(s => ({
|
||||||
|
value: s,
|
||||||
|
label: s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격'
|
||||||
|
: s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s,
|
||||||
|
}))} />
|
||||||
|
<FilterCheckGroup label="허가" selected={filterPermit} onChange={setFilterPermit}
|
||||||
|
options={filterOptions.permits.map(p => ({ value: p, label: getPermitStatusLabel(p, tc, lang) }))} />
|
||||||
|
|
||||||
|
{/* 멤버 수 범위 슬라이더 */}
|
||||||
|
{filterOptions.maxMember > 2 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] text-hint font-medium">
|
||||||
|
멤버 수 <span className="text-label font-bold">{filterMemberMin}~{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}척</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
|
||||||
|
<input type="range" min={2} max={filterOptions.maxMember}
|
||||||
|
value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))}
|
||||||
|
aria-label={tc('aria.memberCountMin')}
|
||||||
|
className="flex-1 h-1 accent-primary cursor-pointer" />
|
||||||
|
<input type="range" min={2} max={filterOptions.maxMember}
|
||||||
|
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
|
||||||
|
onChange={e => setFilterMemberMax(Number(e.target.value))}
|
||||||
|
aria-label={tc('aria.memberCountMax')}
|
||||||
|
className="flex-1 h-1 accent-primary cursor-pointer" />
|
||||||
|
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 패널 내 초기화 */}
|
||||||
|
<div className="pt-2 border-t border-border flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}건 표시</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={tc('aria.filterReset')}
|
||||||
|
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
|
||||||
|
icon={<X className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
전체 초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={cols}
|
||||||
|
pageSize={10}
|
||||||
|
searchPlaceholder="그룹유형, 모선, 해역 검색..."
|
||||||
|
searchKeys={['type', 'owner', 'zone', 'groupKey']}
|
||||||
|
exportFilename="어구탐지"
|
||||||
|
onRowClick={(row: Gear) => {
|
||||||
|
const newId = row.id === selectedId ? null : row.id;
|
||||||
|
setSelectedId(newId);
|
||||||
|
// 선택 시 지도 중심 이동
|
||||||
|
if (newId) {
|
||||||
|
const gear = DATA.find(g => g.id === newId);
|
||||||
|
if (gear && mapRef.current?.map) {
|
||||||
|
mapRef.current.map.flyTo({
|
||||||
|
center: [gear.lng, gear.lat],
|
||||||
|
zoom: 10,
|
||||||
|
duration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 어구 탐지 위치 지도 */}
|
{/* 어구 탐지 위치 지도 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0 relative">
|
<CardContent className="p-0 relative">
|
||||||
<BaseMap ref={mapRef} center={[36.5, 127.0]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
|
<BaseMap ref={mapRef} center={[34.5, 126.0]} zoom={7} height={500} className="rounded-lg overflow-hidden" />
|
||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">어구 위험도</div>
|
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">범례</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground">고위험 (불법 의심/확정)</span></div>
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground">고위험 어구 그룹</span></div>
|
||||||
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground">중위험 (확인 중)</span></div>
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground">중위험 (확인 중)</span></div>
|
||||||
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground">안전 (정상)</span></div>
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground">안전 (정상)</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border space-y-1">
|
||||||
|
<div className="text-[8px] text-muted-foreground font-bold">특정해역</div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-purple-500/30 border border-purple-500/60" /><span className="text-[8px] text-muted-foreground">해역 I (동해)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-blue-500/30 border border-blue-500/60" /><span className="text-[8px] text-muted-foreground">해역 II (남해)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-cyan-500/30 border border-cyan-500/60" /><span className="text-[8px] text-muted-foreground">해역 III (서남해)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-amber-500/30 border border-amber-500/60" /><span className="text-[8px] text-muted-foreground">해역 IV (서해)</span></div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
||||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
<Anchor className="w-3.5 h-3.5 text-orange-400" />
|
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||||
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}건</span>
|
<span className="text-[10px] text-orange-600 dark:text-orange-400 font-bold">{DATA.length}건</span>
|
||||||
<span className="text-[9px] text-hint">어구 탐지 위치</span>
|
<span className="text-[9px] text-hint">어구 그룹</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 리플레이 컨트롤러 (활성 시 표시) */}
|
||||||
|
<ReplayOverlay />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Search, Anchor, Ship, Eye, AlertTriangle, CheckCircle, XCircle,
|
Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
|
||||||
ChevronRight, ChevronDown, Info, Shield, Radar, Target, Waves,
|
ChevronRight, Info, Shield, Radar, Target, Waves,
|
||||||
ArrowRight, Flag, Zap, HelpCircle
|
ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
|
||||||
|
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
|
||||||
|
|
||||||
// ─── 판별 기준 데이터 ─────────────────
|
// ─── 판별 기준 데이터 ─────────────────
|
||||||
|
|
||||||
@ -569,7 +574,7 @@ function GearComparisonTable() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-0">
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
<Info className="w-3.5 h-3.5 text-blue-400" />
|
<Info className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
한·중 어구 비교 레퍼런스 (GB/T 5147-2003 기반)
|
한·중 어구 비교 레퍼런스 (GB/T 5147-2003 기반)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -589,18 +594,18 @@ function GearComparisonTable() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[9px] text-red-400 font-medium mb-1">중국어선 특징</div>
|
<div className="text-[9px] text-red-600 dark:text-red-400 font-medium mb-1">중국어선 특징</div>
|
||||||
{row.chinaFeatures.map((f, i) => (
|
{row.chinaFeatures.map((f, i) => (
|
||||||
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
||||||
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f}
|
<span className="text-red-600 dark:text-red-500 mt-0.5 shrink-0">-</span>{f}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[9px] text-blue-400 font-medium mb-1">한국어선 특징</div>
|
<div className="text-[9px] text-blue-600 dark:text-blue-400 font-medium mb-1">한국어선 특징</div>
|
||||||
{row.koreaFeatures.map((f, i) => (
|
{row.koreaFeatures.map((f, i) => (
|
||||||
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
||||||
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f}
|
<span className="text-blue-600 dark:text-blue-500 mt-0.5 shrink-0">-</span>{f}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -619,11 +624,25 @@ function GearComparisonTable() {
|
|||||||
|
|
||||||
// ─── 메인 페이지 ──────────────────────
|
// ─── 메인 페이지 ──────────────────────
|
||||||
|
|
||||||
|
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
|
||||||
|
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
|
||||||
|
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
|
||||||
|
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
|
||||||
|
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
|
||||||
|
C40: 'unknown', FC: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZONE_CODE_SEA_AREA: Record<string, string> = {
|
||||||
|
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
|
||||||
|
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
|
||||||
|
};
|
||||||
|
|
||||||
export function GearIdentification() {
|
export function GearIdentification() {
|
||||||
const { t } = useTranslation('detection');
|
const { t } = useTranslation('detection');
|
||||||
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
|
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
|
||||||
const [result, setResult] = useState<IdentificationResult | null>(null);
|
const [result, setResult] = useState<IdentificationResult | null>(null);
|
||||||
const [showReference, setShowReference] = useState(false);
|
const [showReference, setShowReference] = useState(false);
|
||||||
|
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
|
||||||
|
|
||||||
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
|
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
|
||||||
setInput((prev) => ({ ...prev, [key]: value }));
|
setInput((prev) => ({ ...prev, [key]: value }));
|
||||||
@ -636,6 +655,58 @@ export function GearIdentification() {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setInput(DEFAULT_INPUT);
|
setInput(DEFAULT_INPUT);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setAutoSelected(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
|
||||||
|
const applyAutoDetection = (v: GearDetection) => {
|
||||||
|
const code = (v.gearCode || '').toUpperCase();
|
||||||
|
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
|
||||||
|
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
|
||||||
|
|
||||||
|
setInput({
|
||||||
|
...DEFAULT_INPUT,
|
||||||
|
gearCategory: category,
|
||||||
|
permitCode: code,
|
||||||
|
mmsiPrefix: v.mmsi.slice(0, 3),
|
||||||
|
seaArea,
|
||||||
|
discoveryDate: v.analyzedAt.slice(0, 10),
|
||||||
|
});
|
||||||
|
setAutoSelected(v);
|
||||||
|
|
||||||
|
// 자동탐지 근거를 결과 패널에 프리셋
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
|
||||||
|
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
|
||||||
|
if (v.permitStatus) {
|
||||||
|
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
|
||||||
|
}
|
||||||
|
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
|
||||||
|
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
|
||||||
|
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
|
||||||
|
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
|
||||||
|
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
|
||||||
|
|
||||||
|
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
origin: 'china',
|
||||||
|
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
|
||||||
|
gearType: category,
|
||||||
|
gearSubType: code,
|
||||||
|
gbCode: '',
|
||||||
|
koreaName: '',
|
||||||
|
reasons,
|
||||||
|
warnings,
|
||||||
|
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
|
||||||
|
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
|
||||||
|
: '추가 정보 입력 후 판별 실행',
|
||||||
|
alertLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 입력 폼 영역으로 스크롤
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -644,7 +715,7 @@ export function GearIdentification() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<Search className="w-5 h-5 text-cyan-500" />
|
<Search className="w-5 h-5 text-cyan-600 dark:text-cyan-500" />
|
||||||
{t('gearId.title')}
|
{t('gearId.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[10px] text-hint mt-0.5">
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
@ -652,19 +723,47 @@ export function GearIdentification() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowReference(!showReference)}
|
onClick={() => setShowReference(!showReference)}
|
||||||
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
|
icon={<Info className="w-3 h-3" />}
|
||||||
>
|
>
|
||||||
<Info className="w-3 h-3" />
|
|
||||||
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
|
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 레퍼런스 테이블 (토글) */}
|
{/* 레퍼런스 테이블 (토글) */}
|
||||||
{showReference && <GearComparisonTable />}
|
{showReference && <GearComparisonTable />}
|
||||||
|
|
||||||
|
{/* 자동탐지 선택 힌트 */}
|
||||||
|
{autoSelected && (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge intent="info" size="sm">자동탐지 연계</Badge>
|
||||||
|
<span className="text-hint">MMSI</span>
|
||||||
|
<span className="font-mono text-cyan-600 dark:text-cyan-400">{autoSelected.mmsi}</span>
|
||||||
|
<span className="text-hint">·</span>
|
||||||
|
<span className="text-hint">어구</span>
|
||||||
|
<span className="font-mono text-label">{autoSelected.gearCode}</span>
|
||||||
|
<span className="text-hint">·</span>
|
||||||
|
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
|
||||||
|
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-hint ml-2">하단 자동탐지 목록에서 클릭한 선박 정보가 아래 폼에 프리필되었습니다. 추가 정보 입력 후 판별 실행하세요.</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
|
||||||
|
className="shrink-0 text-[10px]"
|
||||||
|
>
|
||||||
|
해제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-12 gap-4">
|
<div className="grid grid-cols-12 gap-4">
|
||||||
{/* ── 좌측: 입력 폼 ── */}
|
{/* ── 좌측: 입력 폼 ── */}
|
||||||
<div className="col-span-5 space-y-3">
|
<div className="col-span-5 space-y-3">
|
||||||
@ -783,19 +882,22 @@ export function GearIdentification() {
|
|||||||
|
|
||||||
{/* 판별 버튼 */}
|
{/* 판별 버튼 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
onClick={runIdentification}
|
onClick={runIdentification}
|
||||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
icon={<Zap className="w-4 h-4" />}
|
||||||
|
className="flex-1 font-bold"
|
||||||
>
|
>
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
어구 국적 판별 실행
|
어구 국적 판별 실행
|
||||||
</button>
|
</Button>
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
onClick={resetForm}
|
onClick={resetForm}
|
||||||
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
|
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -855,7 +957,7 @@ export function GearIdentification() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-0">
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500" />
|
||||||
판별 근거 ({result.reasons.length}건)
|
판별 근거 ({result.reasons.length}건)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -863,7 +965,7 @@ export function GearIdentification() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{result.reasons.map((reason, i) => (
|
{result.reasons.map((reason, i) => (
|
||||||
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
|
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
|
||||||
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" />
|
<ChevronRight className="w-3 h-3 text-green-600 dark:text-green-500 mt-0.5 shrink-0" />
|
||||||
<span className="text-[11px] text-label">{reason}</span>
|
<span className="text-[11px] text-label">{reason}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -875,7 +977,7 @@ export function GearIdentification() {
|
|||||||
{result.warnings.length > 0 && (
|
{result.warnings.length > 0 && (
|
||||||
<Card className="bg-surface-raised border-orange-500/20">
|
<Card className="bg-surface-raised border-orange-500/20">
|
||||||
<CardHeader className="px-4 pt-3 pb-0">
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5">
|
<CardTitle className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1.5">
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
경고 / 위반 사항 ({result.warnings.length}건)
|
경고 / 위반 사항 ({result.warnings.length}건)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@ -884,8 +986,8 @@ export function GearIdentification() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{result.warnings.map((warning, i) => (
|
{result.warnings.map((warning, i) => (
|
||||||
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
|
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
|
||||||
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
|
<XCircle className="w-3 h-3 text-orange-600 dark:text-orange-500 mt-0.5 shrink-0" />
|
||||||
<span className="text-[11px] text-orange-300">{warning}</span>
|
<span className="text-[11px] text-orange-700 dark:text-orange-300">{warning}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -897,13 +999,13 @@ export function GearIdentification() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-0">
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
<Shield className="w-3.5 h-3.5 text-purple-500" />
|
<Shield className="w-3.5 h-3.5 text-purple-600 dark:text-purple-500" />
|
||||||
AI 탐지 Rule (해당 어구)
|
AI 탐지 Rule (해당 어구)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 pt-2">
|
<CardContent className="px-4 pb-4 pt-2">
|
||||||
{result.gearType === 'trawl' && (
|
{result.gearType === 'trawl' && (
|
||||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
{`# 트롤 탐지 조건 (Trawl Detection Rule)
|
{`# 트롤 탐지 조건 (Trawl Detection Rule)
|
||||||
if speed in range(2.0, 5.0) # knots
|
if speed in range(2.0, 5.0) # knots
|
||||||
and trajectory == 'parallel_sweep' # 반복 평행선
|
and trajectory == 'parallel_sweep' # 반복 평행선
|
||||||
@ -918,7 +1020,7 @@ and speed_sync > 0.92 # 2선 속도 동기화`}
|
|||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{result.gearType === 'gillnet' && (
|
{result.gearType === 'gillnet' && (
|
||||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
{`# 자망 탐지 조건 (Gillnet Detection Rule)
|
{`# 자망 탐지 조건 (Gillnet Detection Rule)
|
||||||
if speed < 2.0 # knots
|
if speed < 2.0 # knots
|
||||||
and stop_duration > 30 # min
|
and stop_duration > 30 # min
|
||||||
@ -933,7 +1035,7 @@ and sar_vessel_detect == True # SAR 위치 확인
|
|||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{result.gearType === 'purseSeine' && (
|
{result.gearType === 'purseSeine' && (
|
||||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
|
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
|
||||||
if trajectory == 'circular' # 원형 궤적
|
if trajectory == 'circular' # 원형 궤적
|
||||||
and speed_change > 5.0 # kt (고→저 급변)
|
and speed_change > 5.0 # kt (고→저 급변)
|
||||||
@ -949,7 +1051,7 @@ and vessel_spacing < 1000 # m
|
|||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{result.gearType === 'setNet' && (
|
{result.gearType === 'setNet' && (
|
||||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-600 dark:text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
{`# 정치망 — EEZ 내 중국어선 미허가 어구
|
{`# 정치망 — EEZ 내 중국어선 미허가 어구
|
||||||
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
|
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
|
||||||
#
|
#
|
||||||
@ -975,7 +1077,7 @@ and vessel_spacing < 1000 # m
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-0">
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
<Waves className="w-3.5 h-3.5 text-cyan-500" />
|
<Waves className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-500" />
|
||||||
다중 센서 교차 검증 파이프라인
|
다중 센서 교차 검증 파이프라인
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -1002,6 +1104,158 @@ and vessel_spacing < 1000 # m
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 자동탐지 결과 (prediction 기반) */}
|
||||||
|
<AutoGearDetectionSection
|
||||||
|
onSelect={applyAutoDetection}
|
||||||
|
selectedId={autoSelected?.id ?? null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 자동탐지 결과 섹션 ─────────────────
|
||||||
|
|
||||||
|
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
|
||||||
|
CLOSED_SEASON_FISHING: '금어기 조업',
|
||||||
|
UNREGISTERED_GEAR: '미등록 어구',
|
||||||
|
GEAR_MISMATCH: '어구 불일치',
|
||||||
|
MULTIPLE_VIOLATION: '복합 위반',
|
||||||
|
NORMAL: '정상',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
|
||||||
|
CLOSED_SEASON_FISHING: 'critical',
|
||||||
|
UNREGISTERED_GEAR: 'warning',
|
||||||
|
GEAR_MISMATCH: 'warning',
|
||||||
|
MULTIPLE_VIOLATION: 'critical',
|
||||||
|
NORMAL: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERMIT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
PERMITTED: '허가',
|
||||||
|
UNPERMITTED: '미허가',
|
||||||
|
UNKNOWN: '확인불가',
|
||||||
|
};
|
||||||
|
|
||||||
|
function AutoGearDetectionSection({
|
||||||
|
onSelect,
|
||||||
|
selectedId,
|
||||||
|
}: {
|
||||||
|
onSelect: (v: GearDetection) => void;
|
||||||
|
selectedId: number | null;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('common');
|
||||||
|
const lang = (i18n.language as 'ko' | 'en') || 'ko';
|
||||||
|
const [items, setItems] = useState<GearDetection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
|
||||||
|
setItems(page.content);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : '조회 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||||
|
<Radar className="w-4 h-4 text-cyan-600 dark:text-cyan-500" />
|
||||||
|
최근 자동탐지 결과 (prediction, 최근 1시간 중국 선박)
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
|
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL · 행 클릭 시 상단 입력 폼에 프리필
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={load}
|
||||||
|
aria-label={t('aria.refresh')}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
|
||||||
|
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-surface-overlay text-hint">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left">MMSI</th>
|
||||||
|
<th className="px-2 py-1.5 text-left">선박유형</th>
|
||||||
|
<th className="px-2 py-1.5 text-center">어구코드</th>
|
||||||
|
<th className="px-2 py-1.5 text-center">판정</th>
|
||||||
|
<th className="px-2 py-1.5 text-center">허가</th>
|
||||||
|
<th className="px-2 py-1.5 text-left">해역</th>
|
||||||
|
<th className="px-2 py-1.5 text-center">위험도</th>
|
||||||
|
<th className="px-2 py-1.5 text-right">점수</th>
|
||||||
|
<th className="px-2 py-1.5 text-left">위반</th>
|
||||||
|
<th className="px-2 py-1.5 text-left">갱신</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint">자동탐지된 어구 위반 결과가 없습니다.</td></tr>
|
||||||
|
)}
|
||||||
|
{items.map((v) => {
|
||||||
|
const selected = v.id === selectedId;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => onSelect(v)}
|
||||||
|
className={`border-t border-border cursor-pointer transition-colors ${
|
||||||
|
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
|
||||||
|
}`}
|
||||||
|
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
|
||||||
|
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
|
||||||
|
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
|
||||||
|
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
|
||||||
|
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||||
|
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
{v.riskLevel ? (
|
||||||
|
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
|
||||||
|
) : <span className="text-hint">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
|
||||||
|
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
|
||||||
|
{(v.violationCategories ?? []).join(', ') || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||||
|
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
391
frontend/src/features/detection/IllegalFishingPattern.tsx
Normal file
391
frontend/src/features/detection/IllegalFishingPattern.tsx
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ban, RefreshCw, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
import {
|
||||||
|
ILLEGAL_FISHING_CATEGORIES,
|
||||||
|
listIllegalFishingEvents,
|
||||||
|
type IllegalFishingCategory,
|
||||||
|
type IllegalFishingPatternPage,
|
||||||
|
} from '@/services/illegalFishingPatternApi';
|
||||||
|
import type { PredictionEvent } from '@/services/event';
|
||||||
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 불법 조업 이벤트 — event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종을
|
||||||
|
* 묶어 한 화면에서 조회하는 대시보드.
|
||||||
|
*
|
||||||
|
* GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이 공조
|
||||||
|
* EEZ_INTRUSION : 영해 침범 / 접속수역 고위험
|
||||||
|
* ZONE_DEPARTURE : 특정수역 진입 (risk ≥ 40)
|
||||||
|
*
|
||||||
|
* 운영자 액션(확인/상태변경/단속 등록)은 /event-list 이벤트 목록에서 수행.
|
||||||
|
* 이 페이지는 **READ 전용** 대시보드.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||||
|
const DEFAULT_SIZE = 200;
|
||||||
|
|
||||||
|
export function IllegalFishingPattern() {
|
||||||
|
const { t } = useTranslation('detection');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||||
|
|
||||||
|
const [page, setPage] = useState<IllegalFishingPatternPage | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<IllegalFishingCategory | ''>('');
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||||
|
const [mmsiFilter, setMmsiFilter] = useState('');
|
||||||
|
const [selected, setSelected] = useState<PredictionEvent | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await listIllegalFishingEvents({
|
||||||
|
category: categoryFilter || undefined,
|
||||||
|
level: levelFilter || undefined,
|
||||||
|
vesselMmsi: mmsiFilter || undefined,
|
||||||
|
size: DEFAULT_SIZE,
|
||||||
|
});
|
||||||
|
setPage(result);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [categoryFilter, levelFilter, mmsiFilter, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const rows = page?.content ?? [];
|
||||||
|
const levelCount = (code: string) => page?.byLevel?.[code] ?? 0;
|
||||||
|
const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0;
|
||||||
|
|
||||||
|
const cols: DataColumn<PredictionEvent & Record<string, unknown>>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'occurredAt',
|
||||||
|
label: t('illegalPattern.columns.occurredAt'),
|
||||||
|
width: '140px',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'level',
|
||||||
|
label: t('illegalPattern.columns.level'),
|
||||||
|
width: '90px',
|
||||||
|
align: 'center',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||||
|
{getAlertLevelLabel(v as string, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
label: t('illegalPattern.columns.category'),
|
||||||
|
width: '130px',
|
||||||
|
align: 'center',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent="info" size="sm">
|
||||||
|
{t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: t('illegalPattern.columns.title'),
|
||||||
|
minWidth: '260px',
|
||||||
|
render: (v) => <span className="text-label">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vesselMmsi',
|
||||||
|
label: t('illegalPattern.columns.mmsi'),
|
||||||
|
width: '110px',
|
||||||
|
render: (v, row) => (
|
||||||
|
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
||||||
|
{(v as string) || '-'}
|
||||||
|
{row.vesselName ? <span className="text-hint ml-1">({row.vesselName})</span> : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zoneCode',
|
||||||
|
label: t('illegalPattern.columns.zone'),
|
||||||
|
width: '130px',
|
||||||
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: t('illegalPattern.columns.status'),
|
||||||
|
width: '90px',
|
||||||
|
align: 'center',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={(v as string) === 'NEW' ? 'warning' : 'muted'} size="sm">
|
||||||
|
{t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, tc, lang],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Ban}
|
||||||
|
iconColor="text-red-600 dark:text-red-400"
|
||||||
|
title={t('illegalPattern.title')}
|
||||||
|
description={t('illegalPattern.desc')}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
{t('illegalPattern.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title={t('illegalPattern.stats.title')}>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
<StatCard label={t('illegalPattern.stats.total')} value={page?.content.length ?? 0} />
|
||||||
|
<StatCard
|
||||||
|
label={getAlertLevelLabel('CRITICAL', tc, lang)}
|
||||||
|
value={levelCount('CRITICAL')}
|
||||||
|
intent="critical"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={getAlertLevelLabel('HIGH', tc, lang)}
|
||||||
|
value={levelCount('HIGH')}
|
||||||
|
intent="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={getAlertLevelLabel('MEDIUM', tc, lang)}
|
||||||
|
value={levelCount('MEDIUM')}
|
||||||
|
intent="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={getAlertLevelLabel('LOW', tc, lang)}
|
||||||
|
value={levelCount('LOW')}
|
||||||
|
intent="muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('illegalPattern.byCategory.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
{ILLEGAL_FISHING_CATEGORIES.map((code) => (
|
||||||
|
<Card key={code} variant="default">
|
||||||
|
<CardContent className="py-2 px-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-heading">
|
||||||
|
{t(`illegalPattern.category.${code}`)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint">
|
||||||
|
{t(`illegalPattern.categoryDesc.${code}`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-heading">{categoryCount(code)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('illegalPattern.list.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||||
|
<Select
|
||||||
|
aria-label={t('illegalPattern.filters.category')}
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value as IllegalFishingCategory | '')}
|
||||||
|
>
|
||||||
|
<option value="">{t('illegalPattern.filters.allCategory')}</option>
|
||||||
|
{ILLEGAL_FISHING_CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`illegalPattern.category.${c}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
aria-label={t('illegalPattern.filters.level')}
|
||||||
|
value={levelFilter}
|
||||||
|
onChange={(e) => setLevelFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('illegalPattern.filters.allLevel')}</option>
|
||||||
|
{LEVEL_OPTIONS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{getAlertLevelLabel(l, tc, lang)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
aria-label={t('illegalPattern.filters.mmsi')}
|
||||||
|
placeholder={t('illegalPattern.filters.mmsi')}
|
||||||
|
value={mmsiFilter}
|
||||||
|
onChange={(e) => setMmsiFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Badge intent="info" size="sm">
|
||||||
|
{t('illegalPattern.filters.limit')} · {DEFAULT_SIZE}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<p className="text-hint text-xs py-4 text-center">{t('illegalPattern.list.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={rows as (PredictionEvent & Record<string, unknown>)[]}
|
||||||
|
columns={cols}
|
||||||
|
pageSize={20}
|
||||||
|
showSearch={false}
|
||||||
|
showExport={false}
|
||||||
|
showPrint={false}
|
||||||
|
onRowClick={(row) => setSelected(row as PredictionEvent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<Section title={t('illegalPattern.detail.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<DetailRow
|
||||||
|
label={t('illegalPattern.columns.occurredAt')}
|
||||||
|
value={formatDateTime(selected.occurredAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow label={t('illegalPattern.columns.category')} value={selected.category} />
|
||||||
|
<DetailRow label={t('illegalPattern.columns.level')} value={selected.level} />
|
||||||
|
<DetailRow label={t('illegalPattern.columns.title')} value={selected.title} />
|
||||||
|
<DetailRow
|
||||||
|
label={t('illegalPattern.columns.mmsi')}
|
||||||
|
value={selected.vesselMmsi ?? '-'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('illegalPattern.detail.vesselName')}
|
||||||
|
value={selected.vesselName ?? '-'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('illegalPattern.columns.zone')}
|
||||||
|
value={selected.zoneCode ?? '-'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('illegalPattern.detail.location')}
|
||||||
|
value={
|
||||||
|
selected.lat != null && selected.lon != null
|
||||||
|
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow label={t('illegalPattern.columns.status')} value={selected.status} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selected.detail && (
|
||||||
|
<p className="text-xs text-label border-l-2 border-border pl-2">
|
||||||
|
{selected.detail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selected.features && Object.keys(selected.features).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-hint mb-1">
|
||||||
|
{t('illegalPattern.detail.features')}
|
||||||
|
</div>
|
||||||
|
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
|
||||||
|
{JSON.stringify(selected.features, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
||||||
|
{t('illegalPattern.detail.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ExternalLink className="w-3.5 h-3.5" />}
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `/event-list?category=${selected.category}&mmsi=${selected.vesselMmsi ?? ''}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('illegalPattern.detail.openEventList')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 컴포넌트 ─────────────
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, intent }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-hint">{label}</span>
|
||||||
|
{intent ? (
|
||||||
|
<Badge intent={intent} size="md">
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-heading">{value}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||||
|
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IllegalFishingPattern;
|
||||||
@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import { Loader2, RefreshCw, MapPin } from 'lucide-react';
|
import { Loader2, RefreshCw, MapPin } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||||
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
||||||
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||||
@ -9,9 +12,9 @@ import { useSettingsStore } from '@stores/settingsStore';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iran 백엔드의 실시간 어구/선단 그룹을 표시.
|
* prediction 분석 엔진이 산출한 실시간 어구/선단 그룹을 표시.
|
||||||
* - GET /api/vessel-analysis/groups
|
* - GET /api/vessel-analysis/groups
|
||||||
* - 자체 DB의 ParentResolution이 합성되어 있음
|
* - 자체 DB의 ParentResolution 운영자 결정이 합성되어 있음
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function RealGearGroups() {
|
export function RealGearGroups() {
|
||||||
@ -54,7 +57,7 @@ export function RealGearGroups() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||||
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹 (iran 백엔드)
|
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹
|
||||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-hint mt-0.5">
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
@ -62,29 +65,37 @@ export function RealGearGroups() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
|
<Select
|
||||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
size="sm"
|
||||||
|
aria-label={tc('aria.groupTypeFilter')}
|
||||||
|
value={filterType}
|
||||||
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="FLEET">FLEET</option>
|
<option value="FLEET">FLEET</option>
|
||||||
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
|
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
|
||||||
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
|
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
|
||||||
</select>
|
</Select>
|
||||||
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
<Button
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
variant="ghost"
|
||||||
</button>
|
size="sm"
|
||||||
|
onClick={load}
|
||||||
|
aria-label={tc('aria.refresh')}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 */}
|
{/* 통계 */}
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
|
<StatBox label="총 그룹" value={stats.total} intent="muted" />
|
||||||
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
|
<StatBox label="FLEET" value={stats.fleet} intent="info" />
|
||||||
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
|
<StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
|
||||||
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
|
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
|
||||||
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
|
<StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||||
{loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
|
{loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
@ -142,11 +153,22 @@ export function RealGearGroups() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
|
||||||
|
critical: 'text-red-600 dark:text-red-400',
|
||||||
|
high: 'text-orange-600 dark:text-orange-400',
|
||||||
|
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
info: 'text-blue-600 dark:text-blue-400',
|
||||||
|
success: 'text-green-600 dark:text-green-400',
|
||||||
|
muted: 'text-heading',
|
||||||
|
purple: 'text-purple-600 dark:text-purple-400',
|
||||||
|
cyan: 'text-cyan-600 dark:text-cyan-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number; intent?: BadgeIntent }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
||||||
<div className="text-[9px] text-hint">{label}</div>
|
<div className="text-[9px] text-hint">{label}</div>
|
||||||
<div className={`text-lg font-bold ${color}`}>{value}</div>
|
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,39 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||||
|
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||||
|
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||||
import {
|
import {
|
||||||
fetchVesselAnalysis,
|
getAnalysisVessels,
|
||||||
type VesselAnalysisItem,
|
getDarkVessels,
|
||||||
type VesselAnalysisStats,
|
getTransshipSuspects,
|
||||||
} from '@/services/vesselAnalysisApi';
|
} from '@/services/analysisApi';
|
||||||
|
import { toVesselItem } from '@/services/analysisAdapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iran 백엔드의 실시간 vessel analysis 결과를 표시.
|
* vessel_analysis_results 기반 실시간 선박 분석 테이블.
|
||||||
* - mode: 'dark' (Dark Vessel만) / 'spoofing' (스푸핑 의심) / 'transship' (전재) / 'all'
|
* - mode: 'dark' (Dark Vessel만) / 'spoofing' (스푸핑 의심) / 'transship' (전재) / 'all'
|
||||||
* - 위험도 통계 + 필터링된 선박 테이블
|
* - 위험도 통계 카드 + 상위 위험도순 선박 테이블
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'dark' | 'spoofing' | 'transship' | 'all';
|
mode: 'dark' | 'spoofing' | 'transship' | 'all';
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
/** 'all' / 'spoofing' mode 에서 MMSI prefix 필터 (예: '412' — 중국 선박 한정) */
|
||||||
|
mmsiPrefix?: string;
|
||||||
|
/** 'all' / 'spoofing' mode 에서 서버 측 최소 riskScore 필터 */
|
||||||
|
minRiskScore?: number;
|
||||||
|
/** 서버 조회 건수 (dark/transship 기본 200) */
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
|
|
||||||
|
|
||||||
const ZONE_LABELS: Record<string, string> = {
|
const ZONE_LABELS: Record<string, string> = {
|
||||||
TERRITORIAL_SEA: '영해',
|
TERRITORIAL_SEA: '영해',
|
||||||
CONTIGUOUS_ZONE: '접속수역',
|
CONTIGUOUS_ZONE: '접속수역',
|
||||||
@ -33,9 +44,17 @@ const ZONE_LABELS: Record<string, string> = {
|
|||||||
ZONE_IV: '특정해역 IV',
|
ZONE_IV: '특정해역 IV',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
const ENDPOINT_LABEL: Record<Props['mode'], string> = {
|
||||||
|
all: 'GET /api/analysis/vessels',
|
||||||
|
dark: 'GET /api/analysis/dark',
|
||||||
|
transship: 'GET /api/analysis/transship',
|
||||||
|
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore, size = 200 }: Props) {
|
||||||
|
const { t, i18n } = useTranslation('common');
|
||||||
|
const lang = (i18n.language as 'ko' | 'en') || 'ko';
|
||||||
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
||||||
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
|
|
||||||
const [available, setAvailable] = useState(true);
|
const [available, setAvailable] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -44,24 +63,27 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetchVesselAnalysis();
|
const page = mode === 'dark'
|
||||||
setItems(res.items);
|
? await getDarkVessels({ hours: 1, size })
|
||||||
setStats(res.stats);
|
: mode === 'transship'
|
||||||
setAvailable(res.serviceAvailable);
|
? await getTransshipSuspects({ hours: 1, size })
|
||||||
|
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
|
||||||
|
setItems(page.content.map(toVesselItem));
|
||||||
|
setAvailable(true);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : 'unknown');
|
setError(e instanceof Error ? e.message : 'unknown');
|
||||||
|
setAvailable(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [mode, mmsiPrefix, minRiskScore, size]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = items;
|
let result = items;
|
||||||
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark);
|
// spoofing 은 /analysis/vessels 결과를 클라에서 임계값 필터
|
||||||
else if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
|
if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
|
||||||
else if (mode === 'transship') result = result.filter((i) => i.algorithms.transship.isSuspect);
|
|
||||||
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
|
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
|
||||||
return result;
|
return result;
|
||||||
}, [items, mode, zoneFilter]);
|
}, [items, mode, zoneFilter]);
|
||||||
@ -71,6 +93,20 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
[filtered],
|
[filtered],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 통계 카드: mode 로 필터된 items 기반으로 집계해야 상단 숫자와 하단 리스트가 정합
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const modeFilteredItems = mode === 'spoofing'
|
||||||
|
? items.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3)
|
||||||
|
: items;
|
||||||
|
return {
|
||||||
|
total: modeFilteredItems.length,
|
||||||
|
criticalCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length,
|
||||||
|
highCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'HIGH').length,
|
||||||
|
mediumCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length,
|
||||||
|
darkCount: modeFilteredItems.filter((i) => i.algorithms.darkVessel.isDark).length,
|
||||||
|
};
|
||||||
|
}, [items, mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 space-y-3">
|
<CardContent className="p-4 space-y-3">
|
||||||
@ -81,37 +117,42 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-hint mt-0.5">
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
{ENDPOINT_LABEL[mode]} · prediction 5분 주기 분석 결과
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
<Select
|
||||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
size="sm"
|
||||||
|
aria-label={t('aria.regionFilter')}
|
||||||
|
value={zoneFilter}
|
||||||
|
onChange={(e) => setZoneFilter(e.target.value)}
|
||||||
|
>
|
||||||
<option value="">전체 해역</option>
|
<option value="">전체 해역</option>
|
||||||
<option value="TERRITORIAL_SEA">영해</option>
|
<option value="TERRITORIAL_SEA">영해</option>
|
||||||
<option value="CONTIGUOUS_ZONE">접속수역</option>
|
<option value="CONTIGUOUS_ZONE">접속수역</option>
|
||||||
<option value="EEZ_OR_BEYOND">EEZ 외</option>
|
<option value="EEZ_OR_BEYOND">EEZ 외</option>
|
||||||
</select>
|
</Select>
|
||||||
<button type="button" onClick={load}
|
<Button
|
||||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
variant="ghost"
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
size="sm"
|
||||||
</button>
|
onClick={load}
|
||||||
|
aria-label={t('aria.refresh')}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
|
||||||
{stats && (
|
|
||||||
<div className="grid grid-cols-6 gap-2">
|
<div className="grid grid-cols-6 gap-2">
|
||||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
<StatBox label="전체" value={stats.total} intent="muted" />
|
||||||
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" />
|
<StatBox label="CRITICAL" value={stats.criticalCount} intent="critical" />
|
||||||
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
|
<StatBox label="HIGH" value={stats.highCount} intent="high" />
|
||||||
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
<StatBox label="MEDIUM" value={stats.mediumCount} intent="warning" />
|
||||||
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
|
<StatBox label="Dark" value={stats.darkCount} intent="purple" />
|
||||||
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
<StatBox label="필터링" value={filtered.length} intent="cyan" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
|
||||||
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
@ -137,10 +178,12 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
)}
|
)}
|
||||||
{sortedByRisk.slice(0, 100).map((v) => (
|
{sortedByRisk.slice(0, 100).map((v) => (
|
||||||
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
|
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
|
||||||
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
|
||||||
<td className="px-2 py-1.5 text-heading font-medium">
|
<td className="px-2 py-1.5 text-heading font-medium">
|
||||||
{v.classification.vesselType}
|
{getVesselTypeLabel(v.classification.vesselType, t, lang)}
|
||||||
|
{v.classification.confidence > 0 && (
|
||||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<td className="px-2 py-1.5 text-center">
|
||||||
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
||||||
@ -160,7 +203,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-right">
|
<td className="px-2 py-1.5 text-right">
|
||||||
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
|
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
|
||||||
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
|
<span className="text-orange-600 dark:text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
|
||||||
) : <span className="text-hint">-</span>}
|
) : <span className="text-hint">-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<td className="px-2 py-1.5 text-center">
|
||||||
@ -187,11 +230,22 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
|
||||||
|
critical: 'text-red-600 dark:text-red-400',
|
||||||
|
high: 'text-orange-600 dark:text-orange-400',
|
||||||
|
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
info: 'text-blue-600 dark:text-blue-400',
|
||||||
|
success: 'text-green-600 dark:text-green-400',
|
||||||
|
muted: 'text-heading',
|
||||||
|
purple: 'text-purple-600 dark:text-purple-400',
|
||||||
|
cyan: 'text-cyan-600 dark:text-cyan-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number | undefined; intent?: BadgeIntent }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
||||||
<div className="text-[9px] text-hint">{label}</div>
|
<div className="text-[9px] text-hint">{label}</div>
|
||||||
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
|
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{(value ?? 0).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -200,4 +254,12 @@ function StatBox({ label, value, color }: { label: string; value: number; color:
|
|||||||
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
|
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
|
||||||
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
|
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
|
||||||
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
|
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
|
||||||
export const RealAllVessels = () => <RealVesselAnalysis mode="all" title="전체 분석 결과 (실시간)" icon={<Radar className="w-4 h-4 text-blue-400" />} />;
|
// 중국 선박 감시 페이지 전용 — MMSI prefix 412 고정
|
||||||
|
export const RealAllVessels = () => (
|
||||||
|
<RealVesselAnalysis
|
||||||
|
mode="all"
|
||||||
|
title="중국 선박 전체 분석 결과 (실시간)"
|
||||||
|
icon={<Radar className="w-4 h-4 text-blue-400" />}
|
||||||
|
mmsiPrefix="412"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|||||||
405
frontend/src/features/detection/TransshipmentDetection.tsx
Normal file
405
frontend/src/features/detection/TransshipmentDetection.tsx
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowLeftRight, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
|
||||||
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환적(Transshipment) 의심 선박 탐지 페이지.
|
||||||
|
*
|
||||||
|
* prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
|
||||||
|
* (transship_suspect=true) 를 전체 목록으로 조회·집계·상세 확인.
|
||||||
|
*
|
||||||
|
* features.transship_tier (CRITICAL/HIGH/MEDIUM) 와 transship_score 로 심각도 판단.
|
||||||
|
* 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준, 이 페이지는
|
||||||
|
* 전체 목록·통계 수준의 운영 대시보드.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const;
|
||||||
|
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||||
|
const DEFAULT_HOURS = 6;
|
||||||
|
const DEFAULT_SIZE = 200;
|
||||||
|
|
||||||
|
type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string;
|
||||||
|
|
||||||
|
interface TransshipFeatures {
|
||||||
|
transship_tier?: TransshipTier;
|
||||||
|
transship_score?: number;
|
||||||
|
dark_tier?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTier(row: VesselAnalysis): string {
|
||||||
|
const f = (row.features ?? {}) as TransshipFeatures;
|
||||||
|
return f.transship_tier ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScore(row: VesselAnalysis): number | null {
|
||||||
|
const f = (row.features ?? {}) as TransshipFeatures;
|
||||||
|
return typeof f.transship_score === 'number' ? f.transship_score : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransshipmentDetection() {
|
||||||
|
const { t } = useTranslation('detection');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<VesselAnalysis[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
|
||||||
|
const [levelFilter, setLevelFilter] = useState('');
|
||||||
|
const [mmsiFilter, setMmsiFilter] = useState('');
|
||||||
|
const [selected, setSelected] = useState<VesselAnalysis | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE });
|
||||||
|
setRows(resp.content);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [hours, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (levelFilter && r.riskLevel !== levelFilter) return false;
|
||||||
|
if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [rows, levelFilter, mmsiFilter]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const byLevel: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
||||||
|
const byTier: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 };
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.riskLevel && byLevel[r.riskLevel] !== undefined) {
|
||||||
|
byLevel[r.riskLevel]++;
|
||||||
|
}
|
||||||
|
const tier = readTier(r);
|
||||||
|
if (byTier[tier] !== undefined) {
|
||||||
|
byTier[tier]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byLevel, byTier };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const cols: DataColumn<VesselAnalysis & Record<string, unknown>>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'analyzedAt',
|
||||||
|
label: t('transshipment.columns.analyzedAt'),
|
||||||
|
width: '140px',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mmsi',
|
||||||
|
label: t('transshipment.columns.mmsi'),
|
||||||
|
width: '120px',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
||||||
|
{v as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transshipPairMmsi',
|
||||||
|
label: t('transshipment.columns.pairMmsi'),
|
||||||
|
width: '120px',
|
||||||
|
render: (v) => (
|
||||||
|
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transshipDurationMin',
|
||||||
|
label: t('transshipment.columns.durationMin'),
|
||||||
|
width: '90px',
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
||||||
|
return <span className="font-mono text-label">{n.toFixed(0)}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'features',
|
||||||
|
label: t('transshipment.columns.tier'),
|
||||||
|
width: '90px',
|
||||||
|
align: 'center',
|
||||||
|
render: (_, row) => {
|
||||||
|
const tier = readTier(row);
|
||||||
|
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'riskScore',
|
||||||
|
label: t('transshipment.columns.riskScore'),
|
||||||
|
width: '80px',
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'riskLevel',
|
||||||
|
label: t('transshipment.columns.riskLevel'),
|
||||||
|
width: '90px',
|
||||||
|
align: 'center',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||||
|
{getAlertLevelLabel(v as string, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zoneCode',
|
||||||
|
label: t('transshipment.columns.zone'),
|
||||||
|
width: '130px',
|
||||||
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, tc, lang],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={ArrowLeftRight}
|
||||||
|
iconColor="text-purple-600 dark:text-purple-400"
|
||||||
|
title={t('transshipment.title')}
|
||||||
|
description={t('transshipment.desc')}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
{t('transshipment.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title={t('transshipment.stats.title')}>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
|
||||||
|
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
|
||||||
|
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
|
||||||
|
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
|
||||||
|
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('transshipment.list.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||||
|
<Select
|
||||||
|
aria-label={t('transshipment.filters.hours')}
|
||||||
|
value={String(hours)}
|
||||||
|
onChange={(e) => setHours(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{HOUR_OPTIONS.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{t('transshipment.filters.hoursValue', { h })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
aria-label={t('transshipment.filters.level')}
|
||||||
|
value={levelFilter}
|
||||||
|
onChange={(e) => setLevelFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('transshipment.filters.allLevel')}</option>
|
||||||
|
{LEVEL_OPTIONS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{getAlertLevelLabel(l, tc, lang)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
aria-label={t('transshipment.filters.mmsi')}
|
||||||
|
placeholder={t('transshipment.filters.mmsi')}
|
||||||
|
value={mmsiFilter}
|
||||||
|
onChange={(e) => setMmsiFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Badge intent="info" size="sm">
|
||||||
|
{filteredRows.length} / {rows.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredRows.length === 0 && !loading ? (
|
||||||
|
<p className="text-hint text-xs py-4 text-center">
|
||||||
|
{t('transshipment.list.empty', { hours })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
|
||||||
|
columns={cols}
|
||||||
|
pageSize={20}
|
||||||
|
showSearch={false}
|
||||||
|
showExport={false}
|
||||||
|
showPrint={false}
|
||||||
|
onRowClick={(row) => setSelected(row as VesselAnalysis)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<Section title={t('transshipment.detail.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<DetailRow
|
||||||
|
label={t('transshipment.columns.analyzedAt')}
|
||||||
|
value={formatDateTime(selected.analyzedAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
|
||||||
|
<DetailRow
|
||||||
|
label={t('transshipment.columns.pairMmsi')}
|
||||||
|
value={selected.transshipPairMmsi ?? '-'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('transshipment.columns.durationMin')}
|
||||||
|
value={`${selected.transshipDurationMin ?? 0}분`}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('transshipment.columns.riskScore')}
|
||||||
|
value={String(selected.riskScore ?? 0)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('transshipment.columns.zone')}
|
||||||
|
value={selected.zoneCode ?? '-'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('transshipment.detail.location')}
|
||||||
|
value={
|
||||||
|
selected.lat != null && selected.lon != null
|
||||||
|
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-label">
|
||||||
|
{t('transshipment.columns.tier')}:
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const tier = readTier(selected);
|
||||||
|
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
||||||
|
return (
|
||||||
|
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
|
||||||
|
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className="text-xs text-label ml-3">
|
||||||
|
{t('transshipment.detail.transshipScore')}:
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono text-heading">
|
||||||
|
{readScore(selected)?.toFixed(1) ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selected.features && Object.keys(selected.features).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-hint mb-1">
|
||||||
|
{t('transshipment.detail.features')}
|
||||||
|
</div>
|
||||||
|
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
|
||||||
|
{JSON.stringify(selected.features, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
||||||
|
{t('transshipment.detail.close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 컴포넌트 ─────────────
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, intent }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-hint text-center">{label}</span>
|
||||||
|
{intent ? (
|
||||||
|
<Badge intent={intent} size="md">
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-heading">{value}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||||
|
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransshipmentDetection;
|
||||||
193
frontend/src/features/detection/components/DarkDetailPanel.tsx
Normal file
193
frontend/src/features/detection/components/DarkDetailPanel.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* DarkDetailPanel — Dark Vessel 판정 상세 사이드 패널
|
||||||
|
*
|
||||||
|
* 테이블 행 클릭 시 우측에 슬라이드 표시.
|
||||||
|
* 점수 산출 내역, 선박 정보, GAP 상세, 과거 이력을 종합 표시.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
|
||||||
|
import { buildScoreBreakdown } from '@shared/constants/darkVesselPatterns';
|
||||||
|
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||||
|
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
|
||||||
|
import { X, Ship, MapPin, Clock, AlertTriangle, TrendingUp, ExternalLink, ShieldAlert } from 'lucide-react';
|
||||||
|
import { BarChart as EcBarChart } from '@lib/charts';
|
||||||
|
|
||||||
|
interface DarkDetailPanelProps {
|
||||||
|
vessel: VesselAnalysis | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const [history, setHistory] = useState<VesselAnalysis[]>([]);
|
||||||
|
|
||||||
|
const features = vessel?.features ?? {};
|
||||||
|
const darkTier = (features.dark_tier as string) ?? 'NONE';
|
||||||
|
const darkScore = (features.dark_suspicion_score as number) ?? 0;
|
||||||
|
const darkPatterns = (features.dark_patterns as string[]) ?? [];
|
||||||
|
const darkHistory7d = (features.dark_history_7d as number) ?? 0;
|
||||||
|
const darkHistory24h = (features.dark_history_24h as number) ?? 0;
|
||||||
|
const gapStartLat = features.gap_start_lat as number | undefined;
|
||||||
|
const gapStartLon = features.gap_start_lon as number | undefined;
|
||||||
|
const gapStartSog = features.gap_start_sog as number | undefined;
|
||||||
|
const gapStartState = features.gap_start_state as string | undefined;
|
||||||
|
|
||||||
|
// 점수 산출 내역
|
||||||
|
const breakdown = useMemo(() => buildScoreBreakdown(darkPatterns), [darkPatterns]);
|
||||||
|
|
||||||
|
// 7일 이력 조회
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
if (!vessel?.mmsi) return;
|
||||||
|
try {
|
||||||
|
const res = await getAnalysisHistory(vessel.mmsi, 168); // 7일
|
||||||
|
setHistory(res);
|
||||||
|
} catch { setHistory([]); }
|
||||||
|
}, [vessel?.mmsi]);
|
||||||
|
|
||||||
|
useEffect(() => { loadHistory(); }, [loadHistory]);
|
||||||
|
|
||||||
|
// 일별 dark 건수 집계 (차트용)
|
||||||
|
const dailyDarkData = useMemo(() => {
|
||||||
|
const dayMap: Record<string, number> = {};
|
||||||
|
for (const h of history) {
|
||||||
|
if (!h.isDark) continue;
|
||||||
|
const day = (h.analyzedAt ?? '').slice(0, 10);
|
||||||
|
if (day) dayMap[day] = (dayMap[day] || 0) + 1;
|
||||||
|
}
|
||||||
|
return Object.entries(dayMap)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([day, count]) => ({ name: day.slice(5), value: count }));
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
if (!vessel) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
<span className="font-bold text-heading text-sm">판정 상세</span>
|
||||||
|
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
|
||||||
|
<span className="text-xs font-mono font-bold text-heading">{darkScore}점</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
|
||||||
|
<X className="w-4 h-4 text-hint" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* 선박 기본 정보 */}
|
||||||
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||||
|
<span className="text-label font-medium">선박 정보</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||||
|
<span className="text-hint">MMSI</span>
|
||||||
|
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline text-right font-mono"
|
||||||
|
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
|
||||||
|
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
|
||||||
|
</button>
|
||||||
|
<span className="text-hint">선종</span>
|
||||||
|
<span className="text-label text-right">{vessel.vesselType || 'UNKNOWN'}</span>
|
||||||
|
<span className="text-hint">국적</span>
|
||||||
|
<span className="text-label text-right">{vessel.mmsi?.startsWith('412') ? 'CN (중국)' : vessel.mmsi?.slice(0, 3)}</span>
|
||||||
|
<span className="text-hint">해역</span>
|
||||||
|
<span className="text-label text-right">{vessel.zoneCode || '-'}</span>
|
||||||
|
<span className="text-hint">활동상태</span>
|
||||||
|
<span className="text-label text-right">{vessel.activityState || '-'}</span>
|
||||||
|
<span className="text-hint">위험도</span>
|
||||||
|
<span className="text-right">
|
||||||
|
<Badge intent={getRiskIntent(vessel.riskScore ?? 0)} size="sm">
|
||||||
|
{vessel.riskLevel} ({vessel.riskScore})
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점수 산출 내역 */}
|
||||||
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||||
|
<span className="text-label font-medium">점수 산출 내역</span>
|
||||||
|
<span className="text-hint text-[10px]">({breakdown.items.length}개 패턴 적용)</span>
|
||||||
|
</div>
|
||||||
|
<ScoreBreakdown items={breakdown.items} totalScore={darkScore} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GAP 상세 */}
|
||||||
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
<span className="text-label font-medium">GAP 상세</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||||
|
<span className="text-hint">GAP 길이</span>
|
||||||
|
<span className="text-label text-right font-mono">
|
||||||
|
{vessel.gapDurationMin ? `${vessel.gapDurationMin}분 (${(vessel.gapDurationMin / 60).toFixed(1)}h)` : '-'}
|
||||||
|
</span>
|
||||||
|
<span className="text-hint">시작 위치</span>
|
||||||
|
<span className="text-label text-right font-mono text-[10px]">
|
||||||
|
{gapStartLat != null ? `${gapStartLat.toFixed(4)}°N ${gapStartLon?.toFixed(4)}°E` : '-'}
|
||||||
|
</span>
|
||||||
|
<span className="text-hint">시작 SOG</span>
|
||||||
|
<span className="text-label text-right font-mono">
|
||||||
|
{gapStartSog != null ? `${gapStartSog.toFixed(1)}kn` : '-'}
|
||||||
|
</span>
|
||||||
|
<span className="text-hint">시작 상태</span>
|
||||||
|
<span className="text-label text-right">{gapStartState || '-'}</span>
|
||||||
|
<span className="text-hint">분석시각</span>
|
||||||
|
<span className="text-label text-right text-[10px]">{formatDateTime(vessel.analyzedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 과거 이력 */}
|
||||||
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-label font-medium">과거 이력 (7일)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||||
|
<span className="text-hint">7일 dark 일수</span>
|
||||||
|
<span className="text-label text-right font-bold">{darkHistory7d}일</span>
|
||||||
|
<span className="text-hint">24시간 dark</span>
|
||||||
|
<span className="text-label text-right">{darkHistory24h}일</span>
|
||||||
|
</div>
|
||||||
|
{dailyDarkData.length > 0 && (
|
||||||
|
<div className="h-24 mt-2">
|
||||||
|
<EcBarChart
|
||||||
|
data={dailyDarkData}
|
||||||
|
xKey="name"
|
||||||
|
series={[{ key: 'value', name: 'Dark 건수', color: '#ef4444' }]}
|
||||||
|
height={96}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dailyDarkData.length === 0 && (
|
||||||
|
<div className="text-hint text-[10px] text-center py-2">이력 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="flex-1"
|
||||||
|
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
|
||||||
|
<Ship className="w-3.5 h-3.5 mr-1" /> 선박 상세
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" className="flex-1"
|
||||||
|
onClick={() => { /* TODO: 단속 대상 등록 API 연동 */ }}>
|
||||||
|
<Clock className="w-3.5 h-3.5 mr-1" /> 단속 대상 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user