kcg-ai-monitoring/frontend/src/features/ai-operations/AIModelManagement.tsx
htlee f4d56ea891 fix(frontend): 아이콘 전용 버튼 접근 이름 누락 7곳 보완
이전 스캐너가 놓친 패턴 — 모달 닫기 X 버튼과 토글 스위치 등:

- NoticeManagement: 모달 헤더 X → '닫기'
- ReportManagement: 업로드 패널 X → '업로드 패널 닫기'
- AIModelManagement: 규칙 토글 → role=switch + aria-checked + aria-label
                     API 예시 복사 → '예시 URL 복사'
- FileUpload: 파일 제거 X → '{파일명} 제거'
- NotificationBanner: 알림 닫기 X → '알림 닫기'
- SearchInput: 입력 aria-label (placeholder), 지우기 버튼 → '검색어 지우기'

검증:
- 개선된 스캐너로 remaining=0 확인 (JSX tag 중첩 파싱)
- tsc 
2026-04-08 13:16:20 +09:00

993 lines
58 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
Brain, Settings, Zap, Activity, TrendingUp, BarChart3,
Target, Eye, AlertTriangle, CheckCircle, RefreshCw,
Play, Square, Upload, GitBranch, Layers, Shield,
Anchor, Ship, Radio, Radar, Clock, ArrowUpRight, ArrowDownRight,
FileText, ChevronRight, Info, Cpu, Database, Globe, Code, Copy, ExternalLink,
} from 'lucide-react';
import { AreaChart as EcAreaChart, BarChart as EcBarChart, PieChart as EcPieChart } from '@lib/charts';
import { getEngineSeverityIntent, getEngineSeverityLabel } from '@shared/constants/engineSeverities';
import { getStatusIntent } from '@shared/constants/statusIntent';
import type { BadgeIntent } from '@lib/theme/variants';
import { useSettingsStore } from '@stores/settingsStore';
/*
* SFR-04: AI 불법조업 예측 모델 관리
*
* ① 모델 레지스트리 — 버전 관리, 배포 이력, 성능 비교
* ② 탐지 규칙 관리 — ON/OFF, 가중치 조정
* ③ 피처 엔지니어링 — Kinematic/Geometric/Temporal/Behavioral/Contextual
* ④ 학습 파이프라인 — 데이터→전처리→학습→평가→배포
* ⑤ 성능 모니터링 — 정확도, Recall, F1, 오탐률, 리드타임 추이
* ⑥ 어구 탐지 모델 — GB/T 5147 어구 분류, 피처 프로파일
* ⑦ 7대 탐지 엔진 — 불법조업 감시 알고리즘 v4.0 (906척 허가어선)
*/
// ─── 탭 정의 ─────────────────────────
type Tab = 'registry' | 'rules' | 'features' | 'pipeline' | 'monitoring' | 'gear' | 'engines' | 'api';
// ─── ① 모델 레지스트리 ──────────────────
interface ModelVersion {
version: string;
status: '운영중' | '대기' | '테스트' | '폐기';
accuracy: number;
recall: number;
f1: number;
falseAlarm: number;
trainData: string;
deployDate: string;
note: string;
[key: string]: unknown;
}
const MODELS: ModelVersion[] = [
{ version: 'v2.4.0', status: '테스트', accuracy: 93.2, recall: 91.5, f1: 92.3, falseAlarm: 7.8, trainData: '1,456,200', deployDate: '-', note: '다크베셀 탐지 강화' },
{ version: 'v2.3.1', status: '운영중', accuracy: 90.1, recall: 88.7, f1: 89.4, falseAlarm: 9.9, trainData: '1,245,891', deployDate: '2026-03-01', note: 'MMSI 변조 탐지 개선' },
{ version: 'v2.2.0', status: '대기', accuracy: 87.5, recall: 85.2, f1: 86.3, falseAlarm: 12.5, trainData: '1,102,340', deployDate: '2026-01-15', note: '환적 탐지 추가' },
{ version: 'v2.1.0', status: '폐기', accuracy: 84.3, recall: 82.1, f1: 83.2, falseAlarm: 15.7, trainData: '980,120', deployDate: '2025-11-01', note: '초기 멀티센서 융합' },
{ version: 'v2.0.0', status: '폐기', accuracy: 81.0, recall: 78.5, f1: 79.7, falseAlarm: 19.0, trainData: '750,000', deployDate: '2025-08-15', note: '베이스라인 모델' },
];
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: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: (v) => {
const s = v as string;
return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>;
},
},
{ key: 'accuracy', label: 'Accuracy', width: '80px', align: 'right', sortable: true, render: (v) => <span className="text-heading font-bold">{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: '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>; },
},
{ 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: 'note', label: '비고', render: (v) => <span className="text-hint">{v as string}</span> },
];
// ─── ② 탐지 규칙 ──────────────────────
const defaultRules = [
{ name: 'EEZ 침범 탐지', model: 'LSTM+CNN', accuracy: 95, enabled: true, weight: 30, desc: '배타적경제수역 무단 진입 실시간 탐지' },
{ name: 'MMSI 변조 탐지', model: 'Transformer', accuracy: 92, enabled: true, weight: 25, desc: 'MMSI 번호 위조·변경 패턴 감지' },
{ name: '다크베셀 탐지', model: 'SAR+RF Fusion', accuracy: 91, enabled: true, weight: 20, desc: 'AIS 미송출 선박 위성·RF 기반 탐지' },
{ name: 'AIS 신호 소실 탐지', model: 'Anomaly Det.', accuracy: 89, enabled: true, weight: 10, desc: 'AIS 고의 비활성화 패턴 분석' },
{ name: '불법환적 탐지', model: 'GNN', accuracy: 87, enabled: true, weight: 10, desc: '어선-운반선 간 접현·환적 행위 탐지' },
{ name: '불법 조업 패턴', model: 'Ensemble', accuracy: 78, enabled: false, weight: 5, desc: '궤적 기반 불법 조업 행위 패턴 분류' },
];
// ─── ③ 피처 엔지니어링 ──────────────────
const FEATURE_CATEGORIES = [
{ cat: 'Kinematic', color: '#3b82f6', icon: Activity, features: [
{ name: 'Mean Speed', desc: '평균속도', unit: 'kt' },
{ name: 'Speed Std', desc: '속도편차', unit: 'kt' },
{ name: 'Low Speed Ratio', desc: '저속비율', unit: '%' },
{ name: 'High Speed Ratio', desc: '고속비율', unit: '%' },
]},
{ cat: 'Geometric', color: '#8b5cf6', icon: Target, features: [
{ name: 'Trajectory Curvature', desc: '궤적 곡률', unit: '' },
{ name: 'Turn Angle', desc: '회전각', unit: '°' },
{ name: 'Circularity Index', desc: '원형지수', unit: '' },
{ name: 'Path Entropy', desc: '경로 엔트로피', unit: '' },
]},
{ cat: 'Temporal', color: '#f59e0b', icon: Clock, features: [
{ name: 'Dwell Time', desc: '정지시간', unit: 'min' },
{ name: 'Operation Duration', desc: '조업지속시간', unit: 'hr' },
{ name: 'Revisit Freq', desc: '반복방문주기', unit: 'day' },
{ name: 'Night Activity', desc: '야간활동비율', unit: '%' },
]},
{ cat: 'Behavioral', color: '#ef4444', icon: Eye, features: [
{ name: 'Speed Transition', desc: '속도변화패턴', unit: '' },
{ name: 'Stop-Move Pattern', desc: '정지-이동 패턴', unit: '' },
{ name: 'Scanning Pattern', desc: '탐색패턴', unit: '' },
{ name: 'Drift Pattern', desc: '표류패턴', unit: '' },
]},
{ cat: 'Contextual', color: '#10b981', icon: Layers, features: [
{ name: 'Vessel Density', desc: '주변 선박밀도', unit: '척/nm²' },
{ name: 'Fleet Coherence', desc: '선단 동기화', unit: '' },
{ name: 'EEZ Distance', desc: 'EEZ 경계거리', unit: 'nm' },
{ name: 'Fishing Zone', desc: '조업구역 여부', unit: '' },
]},
];
// ─── ④ 파이프라인 ──────────────────────
const PIPELINE_STAGES = [
{ stage: '데이터 수집', status: '정상', items: ['AIS/VMS', 'SAR 위성', '광학 위성', 'V-PASS/E-NAVI', 'VTS/VHF', '기상·해양'], icon: Database, color: '#3b82f6' },
{ stage: '전처리', status: '정상', items: ['노이즈 제거', '결측값 보정', '시간 동기화', '공간 정합', '정규화'], icon: Settings, color: '#8b5cf6' },
{ stage: '피처 추출', status: '정상', items: ['항적 패턴', '조업행동', '경계선 거리', '환경 컨텍스트'], icon: Cpu, color: '#f59e0b' },
{ stage: 'AI 학습', status: '진행중', items: ['LSTM+CNN', 'Transformer', 'GNN', 'Ensemble'], icon: Brain, color: '#ef4444' },
{ stage: '평가', status: '대기', items: ['Confusion Matrix', '과적합 검사', 'K-Fold 검증', '드리프트 감시'], icon: BarChart3, color: '#10b981' },
{ stage: '배포', status: '대기', items: ['Online Serving', '버전 관리', 'MDA 연계', '대시보드 출력'], icon: Upload, color: '#06b6d4' },
];
// ─── ⑤ 성능 모니터링 차트 데이터 ──────────
const PERF_HISTORY = [
{ month: '10월', accuracy: 81.0, recall: 78.5, f1: 79.7, falseAlarm: 19.0 },
{ month: '11월', accuracy: 84.3, recall: 82.1, f1: 83.2, falseAlarm: 15.7 },
{ month: '12월', accuracy: 85.8, recall: 83.5, f1: 84.6, falseAlarm: 14.2 },
{ month: '1월', accuracy: 87.5, recall: 85.2, f1: 86.3, falseAlarm: 12.5 },
{ month: '2월', accuracy: 89.0, recall: 87.1, f1: 88.0, falseAlarm: 11.0 },
{ month: '3월', accuracy: 90.1, recall: 88.7, f1: 89.4, falseAlarm: 9.9 },
{ month: '4월', accuracy: 93.2, recall: 91.5, f1: 92.3, falseAlarm: 7.8 },
];
const DETECTION_BY_TYPE = [
{ name: 'EEZ 침범', value: 35, color: '#ef4444' },
{ name: '다크베셀', value: 25, color: '#f97316' },
{ name: 'MMSI 변조', value: 18, color: '#eab308' },
{ name: '불법환적', value: 12, color: '#8b5cf6' },
{ name: '조업패턴', value: 10, color: '#3b82f6' },
];
// ─── ⑥ 어구 탐지 모델 ──────────────────
interface GearCode {
code: string;
name: string;
risk: '고위험' | '중위험' | '저위험';
speed: string;
feature: string;
[key: string]: unknown;
}
const GEAR_CODES: GearCode[] = [
{ code: 'TDD', name: '단선 저층트롤', risk: '고위험', speed: '2.5-4.5kt', feature: '저속직선왕복' },
{ code: 'TDS', name: '쌍선 저층트롤', risk: '고위험', speed: '2.0-3.5kt', feature: '2선편대동기속도' },
{ code: 'WDD', name: '집어등 단선선망', risk: '고위험', speed: '0-1.5kt', feature: '야간정선원형항적' },
{ code: 'WSS', name: '집어등 쌍선선망', risk: '고위험', speed: '0-2.0kt', feature: '대향포위VHF교신' },
{ code: 'CLD', name: '단선 유자망', risk: '고위험', speed: '0.5-2.0kt', feature: '해류동조장시간저속' },
{ code: 'TZD', name: '단선 중층트롤', risk: '중위험', speed: '3.0-5.0kt', feature: '지그재그어군반응' },
{ code: 'TXD', name: '새우 트롤', risk: '중위험', speed: '1.5-3.0kt', feature: '연안접근극저속' },
{ code: 'DYD', name: '단선 연승낚시', risk: '저위험', speed: '0-3.0kt', feature: '직선투승왕복' },
];
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: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
const r = v as string;
const intent: BadgeIntent = r === '고위험' ? 'critical' : r === '중위험' ? 'warning' : 'success';
return <Badge intent={intent} size="xs">{r}</Badge>;
},
},
{ key: 'speed', label: '탐지 속도', width: '90px', align: 'center', render: (v) => <span className="text-label font-mono">{v as string}</span> },
{ key: 'feature', label: 'AI 피처 포인트', render: (v) => <span className="text-muted-foreground">{v as string}</span> },
];
const GEAR_PROFILES = [
{ type: '트롤 (Trawl)', desc: '"느리게, 꾸준히, 반복적으로"', color: '#3b82f6',
features: [{ k: 'Mean Speed', v: '2~5 kt' }, { k: 'Path Entropy', v: 'High' }, { k: 'Turn Angle', v: 'Medium' }, { k: 'Duration', v: 'Long' }] },
{ type: '자망 (Gillnet)', desc: '"멈추고, 기다리고, 다시 온다"', color: '#eab308',
features: [{ k: 'Mean Speed', v: '0~2 kt' }, { k: 'Dwell Time', v: 'Very High' }, { k: 'Low Speed', v: '>80%' }, { k: 'Revisit', v: 'High' }] },
{ type: '선망 (Purse Seine)', desc: '"빠르게 돌고 → 느려짐"', color: '#ef4444',
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
];
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
interface DetectionEngine {
id: string;
name: string;
purpose: string;
input: string;
severity: string;
cooldown: string;
detail: string;
status: '운영중' | '테스트' | '개발중';
}
const DETECTION_ENGINES: DetectionEngine[] = [
{ id: '#1', name: '허가 유효성 검증', purpose: '미등록/미허가 중국어선 식별', input: 'AIS MMSI', severity: 'CRITICAL', cooldown: '24시간', detail: 'MMSI가 906척 허가 DB에 매핑되지 않으면 즉시 경보. 신규 MMSI 감지 시 미등록 선박 분류.', status: '운영중' },
{ id: '#2', name: '휴어기 조업 탐지', purpose: '허가 조업기간 외 조업행위 탐지', input: '위치, 속도, 허가기간', severity: 'CRITICAL', cooldown: '60분', detail: '선박별 최대 2개 조업기간 부여. 엔진#7 조업판정 결과 참조하여 단순 통과와 실제 조업 구분.', status: '운영중' },
{ id: '#3', name: '수역 이탈 감시', purpose: '업종별 허가수역 외 조업/체류 탐지', input: '위치(lat,lon)', severity: 'HIGH~CRITICAL', cooldown: '30분', detail: 'Ray-casting Point-in-Polygon 알고리즘. 수역II 787개 좌표점 등 복잡 폴리곤 정밀 판정. 비허가수역 HIGH, 전수역이탈 CRITICAL.', status: '운영중' },
{ id: '#4', name: '본선-부속선 분리 탐지', purpose: '2척식 저인망 쌍의 비정상 이격 감시', input: '쌍 위치, Haversine 거리', severity: 'HIGH~CRITICAL', cooldown: '60분', detail: '311쌍(622척) 매칭. 0~3NM 정상, 3~10NM HIGH(독립조업 의심), 10NM 초과 CRITICAL(긴급분리).', status: '운영중' },
{ id: '#5', name: '어획 할당량 초과 감시', purpose: '선박별 누적 어획량과 허가 할당량 비교', input: '어획량(톤)', severity: 'MEDIUM~CRITICAL', cooldown: '24시간', detail: '80% 도달 MEDIUM, 100% 초과 CRITICAL. PS 1,500톤, GN 23~52톤, FC 0톤(어획 없음).', status: '운영중' },
{ id: '#6', name: '환적 의심 탐지', purpose: '조업선-운반선(FC) 간 해상 환적 탐지', input: '근접거리, 속도, 시간', severity: 'HIGH', cooldown: '120분', detail: '3조건 동시충족: ①0.5NM 이하 근접 ②양쪽 2.0kn 이하 저속 ③30분 이상 지속. 신뢰도 0.6~0.95.', status: '운영중' },
{ id: '#7', name: '조업 행위 판정 (보조)', purpose: 'AIS 속도/침로로 조업 여부 추정', input: '속도, 침로 패턴', severity: '-', cooldown: '-', detail: 'PT 1.5~5.0kn, GN 0.5~3.0kn, PS 1.0~6.0kn, FC 0~2.0kn. 엔진#2·#3에서 참조.', status: '운영중' },
];
const TARGET_VESSELS = [
{ code: 'PT', name: '2척식 저인망 (본선)', count: 323, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '1.5~5.0 kn' },
{ code: 'PT-S', name: '2척식 저인망 (부속)', count: 323, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '1.5~5.0 kn' },
{ code: 'GN', name: '유망 (Gill Net)', count: 200, zones: 'II, III, IV', period1: '01/01~04/15', period2: '10/16~12/31', speed: '0.5~3.0 kn' },
{ code: 'OT', name: '1척식 저인망 (Otter)', count: 13, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '2.0~5.5 kn' },
{ code: 'PS', name: '위망 (Purse Seine)', count: 16, zones: 'I, II, III, IV', period1: '02/01~06/01', period2: '09/01~12/31', speed: '1.0~6.0 kn' },
{ code: 'FC', name: '운반선 (Carrier)', count: 31, zones: 'I, II, III, IV', period1: '01/01~12/31', period2: '-', speed: '0~2.0 kn' },
];
const ALARM_SEVERITY = [
{ level: 'CRITICAL', label: '긴급', color: '#ef4444', desc: '즉시 대응. 미등록·휴어기·할당초과·전수역이탈' },
{ level: 'HIGH', label: '높음', color: '#f97316', desc: '1시간 내 확인. 수역이탈·쌍분리·AIS소실·환적' },
{ level: 'MEDIUM', label: '보통', color: '#eab308', desc: '당일 확인. 할당량 80% 경고' },
{ level: 'LOW', label: '낮음', color: '#3b82f6', desc: '참고. 경미한 속도 이상' },
{ level: 'INFO', label: '정보', color: '#6b7280', desc: '로그 기록용. 정상 조업 패턴' },
];
// ─── 메인 컴포넌트 ──────────────────────
export function AIModelManagement() {
const { t } = useTranslation('ai');
const { t: tcCommon } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [tab, setTab] = useState<Tab>('registry');
const [rules, setRules] = useState(defaultRules);
const toggleRule = (i: number) => setRules((prev) => prev.map((r, idx) => idx === i ? { ...r, enabled: !r.enabled } : r));
const currentModel = MODELS.find((m) => m.status === '운영중')!;
return (
<PageContainer>
<PageHeader
icon={Brain}
iconColor="text-purple-400"
title={t('modelManagement.title')}
description={t('modelManagement.desc')}
demo
actions={
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
</div>
}
/>
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
</div>
<span className={`text-base font-bold ${kpi.color}`}>{kpi.value}</span>
<span className="text-[9px] text-hint">{kpi.label}</span>
</div>
))}
</div>
{/* 탭 */}
<div className="flex gap-1">
{[
{ key: 'registry' as Tab, icon: GitBranch, label: '모델 레지스트리' },
{ key: 'rules' as Tab, icon: Settings, label: '탐지 규칙 관리' },
{ key: 'features' as Tab, icon: Cpu, label: '피처 엔지니어링' },
{ key: 'pipeline' as Tab, icon: Activity, label: '학습 파이프라인' },
{ key: 'monitoring' as Tab, icon: BarChart3, label: '성능 모니터링' },
{ key: 'gear' as Tab, icon: Anchor, label: '어구 탐지 모델' },
{ key: 'engines' as Tab, icon: Shield, label: '7대 탐지 엔진' },
{ key: 'api' as Tab, icon: Globe, label: '예측 결과 API' },
].map((t) => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</div>
{/* ── ① 모델 레지스트리 ── */}
{tab === 'registry' && (
<div className="space-y-3">
{/* 업데이트 알림 */}
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<div className="text-sm text-blue-300 font-bold"> v2.4.0 </div>
<div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div>
</div>
</div>
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
</div>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div>
)}
{/* ── ② 탐지 규칙 관리 ── */}
{tab === 'rules' && (
<div className="grid grid-cols-3 gap-3">
{/* 규칙 ON/OFF */}
<div className="col-span-2 space-y-2">
{rules.map((rule, i) => (
<Card key={i} className="bg-surface-raised border-border">
<CardContent className="p-3 flex items-center gap-4">
<button type="button" role="switch" aria-checked={rule.enabled ? 'true' : 'false'} aria-label={rule.name} onClick={() => toggleRule(i)}
className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 transition-all shadow-sm" style={{ left: rule.enabled ? '22px' : '2px' }} />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{rule.name}</span>
<Badge intent="purple" size="xs">{rule.model}</Badge>
</div>
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="text-right">
<div className="text-[9px] text-hint"></div>
<div className="text-[12px] font-bold text-heading">{rule.accuracy}%</div>
</div>
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${rule.accuracy}%` }} />
</div>
<div className="text-right">
<div className="text-[9px] text-hint"></div>
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* 가중치 합계 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" /> </div>
<div className="space-y-4">
{rules.filter((r) => r.enabled).map((r, i) => (
<div key={i}>
<div className="flex justify-between text-[10px] mb-1"><span className="text-muted-foreground">{r.name}</span><span className="text-heading font-bold">{r.weight}%</span></div>
<div className="relative h-2 bg-switch-background rounded-full">
<div className="h-2 bg-gradient-to-r from-slate-500 to-white rounded-full" style={{ width: `${r.weight}%` }} />
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full border-2 border-slate-400 shadow" style={{ left: `calc(${r.weight}% - 6px)` }} />
</div>
</div>
))}
<div className="flex justify-between text-[11px] pt-3 border-t border-border">
<span className="text-muted-foreground"></span>
<span className="text-heading font-bold">{rules.filter((r) => r.enabled).reduce((s, r) => s + r.weight, 0)}%</span>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* ── ③ 피처 엔지니어링 ── */}
{tab === 'features' && (
<div className="grid grid-cols-5 gap-3">
{FEATURE_CATEGORIES.map((cat) => (
<Card key={cat.cat} className="bg-surface-raised border-border">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-3">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${cat.color}15` }}>
<cat.icon className="w-4 h-4" style={{ color: cat.color }} />
</div>
<div>
<div className="text-[11px] font-bold text-heading">{cat.cat}</div>
<div className="text-[9px] text-hint">{cat.features.length} features</div>
</div>
</div>
<div className="space-y-2">
{cat.features.map((f) => (
<div key={f.name} className="flex items-center justify-between px-2 py-1.5 bg-surface-overlay rounded-lg">
<div>
<div className="text-[10px] text-label font-medium">{f.name}</div>
<div className="text-[9px] text-hint">{f.desc}</div>
</div>
{f.unit && <span className="text-[8px] text-hint shrink-0">{f.unit}</span>}
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* ── ④ 학습 파이프라인 ── */}
{tab === 'pipeline' && (
<div className="space-y-3">
{/* 파이프라인 스테이지 */}
<div className="flex gap-2">
{PIPELINE_STAGES.map((stage, i) => {
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
return (
<div key={stage.stage} className="flex-1 flex items-start gap-2">
<Card className="flex-1 bg-surface-raised border-border">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-2">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${stage.color}15` }}>
<stage.icon className="w-4 h-4" style={{ color: stage.color }} />
</div>
<div>
<div className="text-[10px] font-bold text-heading">{stage.stage}</div>
<div className={`text-[9px] font-medium ${stColor}`}>{stage.status}</div>
</div>
</div>
<div className="space-y-1">
{stage.items.map((item) => (
<div key={item} className="text-[9px] text-hint flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-muted" />
{item}
</div>
))}
</div>
</CardContent>
</Card>
{i < PIPELINE_STAGES.length - 1 && (
<ChevronRight className="w-4 h-4 text-hint mt-6 shrink-0" />
)}
</div>
);
})}
</div>
{/* 자가학습 루프 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Self-Learning Cycle </div>
<div className="flex items-center justify-between gap-4">
{[
{ step: '1. 신호 수집', desc: '단속 결과, 오탐/미탐 사례 실시간 수집', color: '#3b82f6' },
{ step: '2. 데이터 축적', desc: '정제 및 라벨링 → 학습 데이터셋 자동 갱신', color: '#8b5cf6' },
{ step: '3. 자동 재학습', desc: 'AutoML 기반 재학습 및 성능 검증', color: '#ef4444' },
{ step: '4. 모델 개선', desc: '검증 모델 배포 → 탐지 정확도 향상', color: '#10b981' },
].map((s, i) => (
<div key={i} className="flex-1 text-center">
<div className="w-10 h-10 rounded-full mx-auto mb-2 flex items-center justify-center" style={{ backgroundColor: `${s.color}15`, border: `2px solid ${s.color}40` }}>
<span className="text-[12px] font-bold" style={{ color: s.color }}>{i + 1}</span>
</div>
<div className="text-[10px] font-bold text-heading">{s.step}</div>
<div className="text-[9px] text-hint mt-0.5">{s.desc}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* ── ⑤ 성능 모니터링 ── */}
{tab === 'monitoring' && (
<div className="grid grid-cols-3 gap-3">
{/* 정확도 추이 */}
<Card className="col-span-2 bg-surface-raised border-border">
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcAreaChart
data={PERF_HISTORY}
xKey="month"
series={[
{ key: 'accuracy', name: 'Accuracy', color: '#22c55e' },
{ key: 'recall', name: 'Recall', color: '#3b82f6' },
{ key: 'f1', name: 'F1', color: '#a855f7' },
]}
height={220}
yAxisDomain={[70, 100]}
/>
</CardContent>
</Card>
{/* 탐지 유형별 비율 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcPieChart
data={DETECTION_BY_TYPE.map(d => ({ name: d.name, value: d.value, color: d.color }))}
height={160}
innerRadius={35}
outerRadius={65}
/>
<div className="space-y-1 mt-2">
{DETECTION_BY_TYPE.map((d) => (
<div key={d.name} className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-muted-foreground">{d.name}</span>
</div>
<span className="text-heading font-bold">{d.value}%</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 오탐률 추이 */}
<Card className="col-span-2 bg-surface-raised border-border">
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> (False Alarm Rate) </div>
<EcBarChart
data={PERF_HISTORY}
xKey="month"
series={[{ key: 'falseAlarm', name: '오탐률 %' }]}
height={160}
itemColors={PERF_HISTORY.map(d => d.falseAlarm < 10 ? '#22c55e' : d.falseAlarm < 15 ? '#eab308' : '#ef4444')}
/>
</CardContent>
</Card>
{/* KPI 목표 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3">KPI </div>
<div className="space-y-4">
{[
{ label: '탐지 정확도', current: 93.2, target: 90, unit: '%', color: '#22c55e' },
{ label: '오탐률', current: 7.8, target: 10, unit: '%', color: '#eab308', reverse: true },
{ label: '리드타임', current: 12, target: 15, unit: 'min', color: '#06b6d4', reverse: true },
].map((kpi) => {
const achieved = kpi.reverse ? kpi.current <= kpi.target : kpi.current >= kpi.target;
return (
<div key={kpi.label}>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">{kpi.label}</span>
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
</span>
</div>
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{
width: `${Math.min(100, kpi.reverse ? (kpi.target / Math.max(kpi.current, 1)) * 100 : (kpi.current / kpi.target) * 100)}%`,
backgroundColor: kpi.color,
}} />
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
)}
{/* ── ⑥ 어구 탐지 모델 ── */}
{tab === 'gear' && (
<div className="space-y-3">
{/* GB/T 5147 어구 코드 테이블 */}
<DataTable data={GEAR_CODES} columns={gearColumns} pageSize={10}
title="GB/T 5147 주요 어구 코드 매핑" searchPlaceholder="코드, 어구명 검색..."
searchKeys={['code', 'name', 'feature']} exportFilename="어구코드" />
{/* 어구별 피처 프로파일 */}
<div className="grid grid-cols-3 gap-3">
{GEAR_PROFILES.map((gp) => (
<Card key={gp.type} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: gp.color }} />
<div className="text-[12px] font-bold text-heading">{gp.type}</div>
</div>
<div className="text-[10px] text-hint italic mb-3">{gp.desc}</div>
<div className="space-y-2">
{gp.features.map((f) => (
<div key={f.k} className="flex justify-between text-[10px] px-2 py-1.5 bg-surface-overlay rounded-lg">
<span className="text-muted-foreground">{f.k}</span>
<span className="text-heading font-medium">{f.v}</span>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* ── ⑦ 7대 탐지 엔진 ── */}
{tab === 'engines' && (
<div className="space-y-4">
{/* 개요 배너 */}
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
<Shield className="w-8 h-8 text-indigo-400 shrink-0" />
<div>
<div className="text-sm font-bold text-indigo-300"> v4.0 7 </div>
<div className="text-[10px] text-muted-foreground mt-0.5">
대상: 중국 906 (497 ) · I~IV · AIS 7 AlarmEvent
</div>
</div>
<div className="ml-auto flex gap-3 shrink-0 text-center">
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
</div>
</div>
{/* 7대 엔진 카드 */}
<div className="space-y-2">
{DETECTION_ENGINES.map((eng) => {
return (
<Card key={eng.id} className="bg-surface-raised border-border">
<CardContent className="p-3 flex items-start gap-4">
{/* 번호 */}
<div className="w-10 h-10 rounded-lg bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center shrink-0">
<span className="text-indigo-400 font-bold text-sm">{eng.id}</span>
</div>
{/* 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[12px] font-bold text-heading">{eng.name}</span>
<Badge intent={getStatusIntent(eng.status)} size="xs">{eng.status}</Badge>
</div>
<div className="text-[10px] text-muted-foreground mb-1.5">{eng.purpose}</div>
<div className="text-[10px] text-hint leading-relaxed">{eng.detail}</div>
</div>
{/* 우측 정보 */}
<div className="flex items-center gap-4 shrink-0 text-right">
<div>
<div className="text-[9px] text-hint"></div>
<div className="text-[10px] text-label">{eng.input}</div>
</div>
<div>
<div className="text-[9px] text-hint"></div>
<Badge intent={getEngineSeverityIntent(eng.severity)} size="sm">
{getEngineSeverityLabel(eng.severity, tcCommon, lang)}
</Badge>
</div>
<div>
<div className="text-[9px] text-hint"></div>
<div className="text-[10px] text-label font-mono">{eng.cooldown}</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* 하단 2칸: 대상 선박 + 알람 등급 */}
<div className="grid grid-cols-2 gap-3">
{/* 대상 선박 업종 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Ship className="w-4 h-4 text-cyan-400" /> (906, 6 )
</div>
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-border text-hint">
<th className="py-1.5 text-left font-medium"></th>
<th className="py-1.5 text-left font-medium"></th>
<th className="py-1.5 text-right font-medium"></th>
<th className="py-1.5 text-left font-medium pl-3"></th>
<th className="py-1.5 text-left font-medium">1</th>
<th className="py-1.5 text-left font-medium">2</th>
<th className="py-1.5 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{TARGET_VESSELS.map((v) => (
<tr key={v.code} className="border-b border-border">
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
<td className="py-1.5 text-label">{v.name}</td>
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
<td className="py-1.5 text-muted-foreground font-mono">{v.period1}</td>
<td className="py-1.5 text-muted-foreground font-mono">{v.period2}</td>
<td className="py-1.5 text-muted-foreground font-mono">{v.speed}</td>
</tr>
))}
<tr className="border-t border-border">
<td className="py-1.5 text-hint font-bold" colSpan={2}></td>
<td className="py-1.5 text-heading font-bold text-right">906</td>
<td className="py-1.5 text-hint pl-3" colSpan={4}>497 </td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
{/* 알람 심각도 체계 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
</div>
<div className="space-y-2">
{ALARM_SEVERITY.map((a) => (
<div key={a.level} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-surface-overlay">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: a.color }} />
<div className="w-16 shrink-0">
<span className="text-[10px] font-bold" style={{ color: a.color }}>{a.level}</span>
</div>
<span className="text-[10px] text-label font-medium w-10">{a.label}</span>
<span className="text-[10px] text-hint flex-1">{a.desc}</span>
</div>
))}
</div>
{/* 쿨다운 요약 */}
<div className="mt-3 pt-3 border-t border-border">
<div className="text-[10px] font-bold text-muted-foreground mb-2"> ( )</div>
<div className="grid grid-cols-2 gap-1 text-[9px]">
{[
['수역이탈', '30분'], ['쌍분리', '60분'], ['휴어기', '60분'], ['환적', '120분'],
['AIS소실', '360분'], ['할당량/미등록', '24시간'],
].map(([k, v]) => (
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
<span className="text-hint">{k}</span>
<span className="text-label font-mono">{v}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* ── ⑧ 예측 결과 API ── */}
{tab === 'api' && (
<div className="space-y-4">
{/* 개요 */}
<div className="bg-emerald-950/20 border border-emerald-900/30 rounded-xl p-4 flex items-center gap-4">
<Globe className="w-8 h-8 text-emerald-400 shrink-0" />
<div className="flex-1">
<div className="text-sm font-bold text-emerald-300">RFP-04 · API </div>
<div className="text-[10px] text-muted-foreground mt-0.5">
(Grid) · (Zone) · (Time) , SFR-06( ), SFR-09( ) RESTful API로
</div>
</div>
<div className="flex gap-4 shrink-0 text-center">
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API </div></div>
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
</div>
</div>
{/* 저장 단위 3종 */}
<div className="grid grid-cols-3 gap-3">
{[
{ unit: '격자 (Grid)', icon: Layers, color: '#3b82f6', desc: '1km × 1km 격자 단위', store: 'tb_pred_grid',
fields: ['grid_id', 'lat_center / lon_center', 'risk_score (0~100)', 'illegal_prob', 'dark_vessel_count', 'predicted_at', 'model_version'],
sample: { grid_id: 'G-37120-12463', lat: 37.120, lon: 124.630, risk: 87.5, prob: 0.92, dark: 3, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
},
{ unit: '해역 (Zone)', icon: Globe, color: '#10b981', desc: '특정어업수역 I~IV + EEZ', store: 'tb_pred_zone',
fields: ['zone_id (I/II/III/IV/EEZ)', 'zone_name', 'total_risk_score', 'vessel_count', 'violation_count', 'top_threat_type', 'predicted_at'],
sample: { grid_id: 'ZONE-II', lat: null, lon: null, risk: 72.3, prob: null, dark: null, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
},
{ unit: '시간 (Time)', icon: Clock, color: '#f59e0b', desc: '1시간 / 6시간 / 24시간 집계', store: 'tb_pred_time',
fields: ['time_bucket (1h/6h/24h)', 'start_time / end_time', 'avg_risk_score', 'max_risk_score', 'total_alarms', 'detection_count', 'model_version'],
sample: { grid_id: 'T-2026040309-1H', lat: null, lon: null, risk: 65.8, prob: null, dark: null, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
},
].map((s) => (
<Card key={s.unit} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${s.color}15` }}>
<s.icon className="w-4 h-4" style={{ color: s.color }} />
</div>
<div>
<div className="text-[12px] font-bold text-heading">{s.unit}</div>
<div className="text-[9px] text-hint">{s.desc}</div>
</div>
</div>
<div className="text-[9px] text-hint mb-2 font-mono">{s.store}</div>
<div className="space-y-1">
{s.fields.map((f) => (
<div key={f} className="flex items-center gap-1.5 text-[10px]">
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: s.color }} />
<span className="text-muted-foreground font-mono">{f}</span>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
{/* API 엔드포인트 목록 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-400" />
RESTful API
</div>
<table className="w-full text-[10px] table-fixed">
<colgroup>
<col style={{ width: '7%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead>
<tr className="border-b border-border text-hint">
<th className="py-2 text-left font-medium">Method</th>
<th className="py-2 text-left font-medium">Endpoint</th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"> SFR</th>
<th className="py-2 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{[
{ method: 'GET', endpoint: '/api/v1/predictions/grid', unit: '격자', desc: '격자별 위험도 예측 결과 조회', sfr: 'SFR-06 위험도', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/grid/{id}', unit: '격자', desc: '특정 격자 상세 예측 결과', sfr: 'SFR-06', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/grid/heatmap', unit: '격자', desc: '히트맵용 전체 격자 위험도', sfr: 'SFR-06 히트맵', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/zone', unit: '해역', desc: '해역별 위험도 집계 조회', sfr: 'SFR-06, SFR-09', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/zone/{id}', unit: '해역', desc: '특정 수역 상세 (위협 유형 포함)', sfr: 'SFR-09 탐지', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/zone/{id}/vessels', unit: '해역', desc: '수역 내 의심 선박 목록', sfr: 'SFR-09', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/time', unit: '시간', desc: '시간대별 위험도 추이', sfr: 'SFR-06 추이', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/time/forecast', unit: '시간', desc: '향후 6/12/24시간 예측', sfr: 'SFR-06 예보', status: '테스트' },
{ method: 'GET', endpoint: '/api/v1/predictions/vessel/{mmsi}', unit: '선박', desc: '특정 선박 위험도 이력', sfr: 'SFR-09', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/alarms', unit: '알람', desc: '예측 기반 알람 목록', sfr: 'SFR-09 경보', status: '운영' },
{ method: 'POST', endpoint: '/api/v1/predictions/run', unit: '실행', desc: '수동 예측 실행 트리거', sfr: 'SFR-04', status: '운영' },
{ method: 'GET', endpoint: '/api/v1/predictions/status', unit: '상태', desc: '예측 엔진 상태·버전 정보', sfr: 'SFR-04', status: '운영' },
].map((api, i) => (
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
<td className="py-1.5">
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
</td>
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
<td className="py-1.5 text-hint">{api.unit}</td>
<td className="py-1.5 text-label">{api.desc}</td>
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
<td className="py-1.5 text-center">
<Badge intent={getStatusIntent(api.status)} size="xs">{api.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{/* 하단: API 호출 예시 + 연계 서비스 */}
<div className="grid grid-cols-2 gap-3">
{/* API 호출 예시 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-400" />
API
</div>
<div className="space-y-3">
{/* 격자 조회 */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
</div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid
?lat_min=36.0&lat_max=38.0
&lon_min=124.0&lon_max=126.0
&time=2026-04-03T09:00Z`}
</pre>
</div>
{/* 응답 예시 */}
<div>
<div className="text-[10px] text-muted-foreground mb-1"> (JSON)</div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-emerald-400/80 overflow-x-auto">
{`{
"model_version": "v2.3.1",
"predicted_at": "2026-04-03T09:00:00Z",
"grids": [
{
"grid_id": "G-37120-12463",
"center": { "lat": 37.120, "lon": 124.630 },
"risk_score": 87.5,
"illegal_prob": 0.92,
"dark_vessel_count": 3,
"top_threat": "EEZ_VIOLATION"
},
{
"grid_id": "G-36800-12422",
"center": { "lat": 36.800, "lon": 124.220 },
"risk_score": 45.2,
"illegal_prob": 0.38,
"dark_vessel_count": 0,
"top_threat": null
}
],
"total": 1842,
"page": 1,
"page_size": 100
}`}
</pre>
</div>
</div>
</CardContent>
</Card>
{/* 연계 서비스 매핑 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<ExternalLink className="w-4 h-4 text-purple-400" />
</div>
<div className="space-y-2">
{[
{ sfr: 'SFR-06', name: '위험도 지도 서비스', apis: ['grid/heatmap', 'zone', 'time'], color: '#ef4444',
desc: '격자 히트맵 → 위험도 레이어 표출, 해역별 집계 → 관할해역 위험 현황, 시간별 추이 → 변화 애니메이션' },
{ sfr: 'SFR-09', name: '불법어선 탐지 서비스', apis: ['zone/{id}/vessels', 'vessel/{mmsi}', 'alarms'], color: '#f97316',
desc: '수역별 의심 선박 목록 → 타겟 리스트, 선박별 위험도 이력 → 상세 분석, 알람 → 실시간 경보' },
{ sfr: 'SFR-05', name: 'MDA 상황판', apis: ['grid', 'zone', 'status'], color: '#3b82f6',
desc: '격자+해역 위험도 → 종합 상황 오버레이, 엔진 상태 → 시스템 모니터링 패널' },
{ sfr: 'SFR-10', name: 'AI 순찰경로 최적화', apis: ['grid/heatmap', 'time/forecast'], color: '#10b981',
desc: '위험도 히트맵 → 순찰 우선순위 지역, 예측 → 사전 배치 경로 생성' },
{ sfr: 'SFR-11', name: '보고서·증거 관리', apis: ['alarms', 'vessel/{mmsi}'], color: '#8b5cf6',
desc: '알람 이력 → 자동 보고서 첨부, 선박 위험도 → 증거 패키지' },
].map((s) => (
<div key={s.sfr} className="px-3 py-2.5 rounded-lg bg-surface-overlay border border-border">
<div className="flex items-center gap-2 mb-1">
<Badge size="sm" className="font-bold" style={{ backgroundColor: s.color, borderColor: s.color }}>{s.sfr}</Badge>
<span className="text-[11px] font-bold text-heading">{s.name}</span>
</div>
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
<div className="flex gap-1 flex-wrap">
{s.apis.map((a) => (
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* API 사용 통계 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">API ()</div>
<div className="flex gap-3">
{[
{ label: '총 호출', value: '142,856', color: 'text-heading' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
].map((s) => (
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>
<div className="text-[9px] text-hint">{s.label}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
</PageContainer>
);
}