feat: Phase 6-8 - iran 백엔드 실연결 + 시스템 상태 + AI 채팅 기반

Phase 6: iran 백엔드 실연결 + 화면 연동
- application.yml: app.iran-backend.base-url=https://kcg.gc-si.dev
- IranBackendClient: RestClient 확장 (Accept JSON header, getAs<T>)
- VesselAnalysisProxyController: HYBRID 합성 로직 추가
  - GET /api/vessel-analysis: stats + 7423건 분석 결과 통과
  - GET /api/vessel-analysis/groups: 476건 그룹 + 자체 DB resolution 합성
  - GET /api/vessel-analysis/groups/{key}/detail
  - GET /api/vessel-analysis/groups/{key}/correlations
  - 권한: detection / detection:gear-detection (READ)
- 프론트 services/vesselAnalysisApi.ts: 타입 + 필터 헬퍼
  (filterDarkVessels, filterSpoofingVessels, filterTransshipSuspects)
- features/detection/RealGearGroups.tsx: 어구/선단 그룹 실시간 표시
  (FLEET/GEAR_IN_ZONE/GEAR_OUT_ZONE 필터, 통계 5종, 운영자 결정 합성 표시)
- features/detection/RealVesselAnalysis.tsx: 분석 결과 모드별 렌더
  - mode='dark' / 'spoofing' / 'transship' / 'all'
  - 위험도순 정렬 + 6개 통계 카드 + 해역/Dark/Spoofing/전재 표시
- 화면 연동:
  - GearDetection → RealGearGroups 추가
  - DarkVesselDetection → RealDarkVessels + RealSpoofingVessels
  - ChinaFishing(dashboard) → RealAllVessels
  - TransferDetection → RealTransshipSuspects

Phase 7: 시스템 상태 대시보드
- features/monitoring/SystemStatusPanel.tsx
  - 3개 서비스 카드: KCG Backend / iran 백엔드 / Prediction
  - 위험도 분포 (CRITICAL/HIGH/MEDIUM/LOW) 4개 박스
  - 30초 자동 폴링
- MonitoringDashboard 최상단에 SystemStatusPanel 추가

Phase 8: AI 채팅 기반 (SSE는 Phase 9 인증 후)
- 프론트 services/chatApi.ts: sendChatMessage (graceful fallback)
- 백엔드 PredictionProxyController.chat 추가
  - POST /api/prediction/chat
  - 권한: ai-operations:ai-assistant (READ)
  - 현재 stub 응답 (iran chat 인증 토큰 필요)
- AIAssistant 페이지에 백엔드 호출 통합
  (handleSend → sendChatMessage → 응답 표시 + graceful 메시지)

검증:
- 백엔드 컴파일/기동 성공 (Started in 5.2s)
- iran 프록시: 471개 그룹, 7423건 분석 결과 정상 통과
- 프론트 빌드 통과 (502ms)
- E2E 시나리오:
  - admin 로그인 → /api/vessel-analysis/groups → 476건 + serviceAvailable=true
  - /api/prediction/chat → stub 응답 (Phase 9 안내)

설계 원칙:
- iran 백엔드 미연결 시 graceful degradation (serviceAvailable=false + 빈 데이터)
- HYBRID 합성: prediction 후보 + 자체 DB의 운영자 결정을 백엔드에서 조합
- 향후 iran 인증 토큰 통과 후 SSE 채팅 활성화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 10:22:04 +09:00
부모 febfb2cbe8
커밋 95ca1018b5
15개의 변경된 파일896개의 추가작업 그리고 22개의 파일을 삭제

파일 보기

@ -1,21 +1,21 @@
package gc.mda.kcg.domain.analysis; package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.config.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map; import java.util.Map;
/** /**
* iran 백엔드 REST 클라이언트. * iran 백엔드 REST 클라이언트.
* *
* 현재는 호출 자체는 시도하되, 연결 불가 graceful degradation: * 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
* - 503 또는 응답을 반환하여 프론트에서 UI 처리 * 호출 실패 graceful degradation: null 반환 프론트에 응답.
* *
* 향후 운영 환경에서 iran 백엔드 base-url이 정확히 설정되면 그대로 사용 가능. * 향후 prediction 이관 IranBackendClient를 PredictionDirectClient로 교체하면 .
*/ */
@Slf4j @Slf4j
@Component @Component
@ -28,7 +28,10 @@ public class IranBackendClient {
String baseUrl = appProperties.getIranBackend().getBaseUrl(); String baseUrl = appProperties.getIranBackend().getBaseUrl();
this.enabled = baseUrl != null && !baseUrl.isBlank(); this.enabled = baseUrl != null && !baseUrl.isBlank();
this.restClient = enabled this.restClient = enabled
? RestClient.builder().baseUrl(baseUrl).build() ? RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.build()
: RestClient.create(); : RestClient.create();
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl); log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
} }
@ -51,4 +54,17 @@ public class IranBackendClient {
return null; 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;
}
}
} }

파일 보기

@ -48,4 +48,20 @@ public class PredictionProxyController {
public ResponseEntity<?> trigger() { public ResponseEntity<?> trigger() {
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결")); return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
} }
/**
* AI 채팅 프록시 (POST).
* 향후 prediction 인증 통과 SSE 스트리밍으로 전환.
*/
@PostMapping("/chat")
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
return ResponseEntity.ok(Map.of(
"ok", false,
"serviceAvailable", false,
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "")
));
}
} }

파일 보기

@ -1,18 +1,24 @@
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 org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.*;
import java.util.Map;
/** /**
* iran 백엔드 분석 데이터를 프록시 제공. * iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID).
* *
* 현재 단계: iran 백엔드 미연결 응답 + serviceAvailable=false * 라우팅:
* 향후 단계: 연결 + 자체 DB의 운영자 결정과 조합 (HYBRID) * GET /api/vessel-analysis 전체 분석결과 + 통계 (단순 프록시)
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
*
* 권한: detection / detection:gear-detection (READ)
*/ */
@RestController @RestController
@RequestMapping("/api/vessel-analysis") @RequestMapping("/api/vessel-analysis")
@ -20,6 +26,7 @@ import java.util.Map;
public class VesselAnalysisProxyController { public class VesselAnalysisProxyController {
private final IranBackendClient iranClient; private final IranBackendClient iranClient;
private final ParentResolutionRepository resolutionRepository;
@GetMapping @GetMapping
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection", operation = "READ")
@ -28,22 +35,65 @@ public class VesselAnalysisProxyController {
if (data == null) { if (data == null) {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"serviceAvailable", false, "serviceAvailable", false,
"message", "iran 백엔드 미연결 (Phase 5에서 연결 예정)", "message", "iran 백엔드 미연결",
"results", List.of(), "items", List.of(),
"stats", Map.of() "stats", Map.of(),
"count", 0
)); ));
} }
return ResponseEntity.ok(data); // 통과 + 메타데이터 추가
Map<String, Object> enriched = new LinkedHashMap<>(data);
enriched.put("serviceAvailable", true);
return ResponseEntity.ok(enriched);
} }
/**
* 그룹 목록 + 자체 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"); Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
if (data == null) { if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groups", List.of())); return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"items", List.of(),
"count", 0
));
} }
return ResponseEntity.ok(data);
@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);
} }
@GetMapping("/groups/{groupKey}/detail") @GetMapping("/groups/{groupKey}/detail")
@ -55,4 +105,19 @@ public class VesselAnalysisProxyController {
} }
return ResponseEntity.ok(data); return ResponseEntity.ok(data);
} }
@GetMapping("/groups/{groupKey}/correlations")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupCorrelations(
@PathVariable String groupKey,
@RequestParam(required = false) Double minScore
) {
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations";
if (minScore != null) path += "?minScore=" + minScore;
Map<String, Object> data = iranClient.getJson(path);
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
} }

파일 보기

@ -60,7 +60,8 @@ app:
prediction: prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001} base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
iran-backend: iran-backend:
base-url: ${IRAN_BACKEND_BASE_URL:http://localhost:18080} # 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
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:

파일 보기

@ -3,6 +3,7 @@ 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 { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react'; import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react';
import { sendChatMessage } from '@/services/chatApi';
/* SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 */ /* SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 */
@ -46,13 +47,31 @@ export function AIAssistant() {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1'); const [selectedConv, setSelectedConv] = useState('1');
const handleSend = () => { const handleSend = async () => {
if (!input.trim()) return; if (!input.trim()) return;
setMessages(prev => [...prev, const userMsg = input;
{ role: 'user', content: input }, setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
{ role: 'assistant', content: '질의를 분석 중입니다. 관련 법령·사례·AI 예측 결과를 종합하여 답변을 생성합니다...', refs: [] },
]);
setInput(''); setInput('');
// 백엔드 prediction chat 프록시 호출
setMessages((prev) => [...prev, { role: 'assistant', content: '질의를 분석 중입니다...', refs: [] }]);
try {
const res = await sendChatMessage(userMsg);
const reply = res.ok
? (res.reply ?? '응답 없음')
: (res.message ?? 'Prediction 채팅 미연결');
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: reply, refs: [] };
return next;
});
} catch (e) {
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: '에러: ' + (e instanceof Error ? e.message : 'unknown'), refs: [] };
return next;
});
}
}; };
return ( return (

파일 보기

@ -7,6 +7,7 @@ import {
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon
} from 'lucide-react'; } from 'lucide-react';
import { GearIdentification } from './GearIdentification'; import { GearIdentification } from './GearIdentification';
import { RealAllVessels } from './RealVesselAnalysis';
import { BaseChart, PieChart as EcPieChart } from '@lib/charts'; import { BaseChart, PieChart as EcPieChart } from '@lib/charts';
import type { EChartsOption } from 'echarts'; import type { EChartsOption } from 'echarts';
import { useTransferStore } from '@stores/transferStore'; import { useTransferStore } from '@stores/transferStore';
@ -336,6 +337,9 @@ export function ChinaFishing() {
{/* AI 대시보드 모드 */} {/* AI 대시보드 모드 */}
{mode === 'dashboard' && <> {mode === 'dashboard' && <>
{/* iran 백엔드 실시간 분석 결과 */}
<RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */} {/* ── 상단 바: 기준일 + 검색 ── */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40"> <div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">

파일 보기

@ -7,6 +7,7 @@ import { Eye, EyeOff, AlertTriangle, Ship, Radar, Radio, Target, Shield, Tag } f
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { useVesselStore } from '@stores/vesselStore'; import { useVesselStore } from '@stores/vesselStore';
import { RealDarkVessels, RealSpoofingVessels } from './RealVesselAnalysis';
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ /* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
@ -116,6 +117,10 @@ export function DarkVesselDetection() {
</div> </div>
))} ))}
</div> </div>
{/* iran 백엔드 실시간 Dark Vessel + GPS 스푸핑 */}
<RealDarkVessels />
<RealSpoofingVessels />
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박명, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" /> <DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박명, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
{/* 탐지 위치 지도 */} {/* 탐지 위치 지도 */}

파일 보기

@ -7,6 +7,7 @@ import { Anchor, MapPin, AlertTriangle, CheckCircle, Clock, Ship, Filter } from
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { useGearStore } from '@stores/gearStore'; import { useGearStore } from '@stores/gearStore';
import { RealGearGroups } from './RealGearGroups';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */ /* SFR-10: 불법 어망·어구 탐지 및 관리 */
@ -92,6 +93,9 @@ export function GearDetection() {
</div> </div>
))} ))}
</div> </div>
{/* iran 백엔드 실시간 어구/선단 그룹 */}
<RealGearGroups />
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" /> <DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
{/* 어구 탐지 위치 지도 */} {/* 어구 탐지 위치 지도 */}

파일 보기

@ -0,0 +1,158 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, MapPin } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
/**
* iran / .
* - GET /api/vessel-analysis/groups
* - DB의 ParentResolution이
*/
const TYPE_COLORS: Record<string, string> = {
FLEET: 'bg-blue-500/20 text-blue-400',
GEAR_IN_ZONE: 'bg-orange-500/20 text-orange-400',
GEAR_OUT_ZONE: 'bg-purple-500/20 text-purple-400',
};
const STATUS_COLORS: Record<string, string> = {
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
};
export function RealGearGroups() {
const [items, setItems] = useState<GearGroupItem[]>([]);
const [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filterType, setFilterType] = useState<string>('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchGroups();
setItems(res.items);
setAvailable(res.serviceAvailable);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = filterType ? items.filter((i) => i.groupType === filterType) : items;
const stats = {
total: items.length,
fleet: items.filter((i) => i.groupType === 'FLEET').length,
gearInZone: items.filter((i) => i.groupType === 'GEAR_IN_ZONE').length,
gearOutZone: items.filter((i) => i.groupType === 'GEAR_OUT_ZONE').length,
confirmed: items.filter((i) => i.resolution?.status === 'MANUAL_CONFIRMED').length,
};
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">
<MapPin className="w-4 h-4 text-orange-400" /> / (iran )
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/vessel-analysis/groups · DB의 (resolution)
</div>
</div>
<div className="flex items-center gap-2">
<select value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""></option>
<option value="FLEET">FLEET</option>
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
</select>
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 통계 */}
<div className="grid grid-cols-5 gap-2">
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
</div>
{error && <div className="text-xs text-red-400">: {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="overflow-x-auto max-h-96">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint sticky top-0">
<tr>
<th className="px-2 py-1.5 text-left"></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-right">(NM²)</th>
<th className="px-2 py-1.5 text-left"> </th>
<th className="px-2 py-1.5 text-left"> </th>
<th className="px-2 py-1.5 text-left"> </th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={8} className="px-3 py-6 text-center text-hint"> .</td></tr>
)}
{filtered.slice(0, 100).map((g) => (
<tr key={`${g.groupKey}-${g.subClusterId}`} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-2 py-1.5">
<Badge className={`${TYPE_COLORS[g.groupType]} border-0 text-[9px]`}>{g.groupType}</Badge>
</td>
<td className="px-2 py-1.5 text-heading font-medium font-mono text-[10px]">{g.groupKey}</td>
<td className="px-2 py-1.5 text-center text-muted-foreground">{g.subClusterId}</td>
<td className="px-2 py-1.5 text-right text-cyan-400 font-bold">{g.memberCount}</td>
<td className="px-2 py-1.5 text-right text-muted-foreground">{g.areaSqNm?.toFixed(2)}</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px] font-mono">
{g.centerLat?.toFixed(3)}, {g.centerLon?.toFixed(3)}
</td>
<td className="px-2 py-1.5">
{g.resolution ? (
<Badge className={`${STATUS_COLORS[g.resolution.status] || ''} border-0 text-[9px]`}>
{g.resolution.status}
</Badge>
) : <span className="text-hint text-[10px]">-</span>}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-'}
</td>
</tr>
))}
</tbody>
</table>
{filtered.length > 100 && (
<div className="text-center text-[10px] text-hint mt-2"> 100 ( {filtered.length})</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value}</div>
</div>
);
}

파일 보기

@ -0,0 +1,207 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import {
fetchVesselAnalysis,
type VesselAnalysisItem,
type VesselAnalysisStats,
} from '@/services/vesselAnalysisApi';
/**
* iran vessel analysis .
* - mode: 'dark' (Dark Vessel만) / 'spoofing' ( ) / 'transship' () / 'all'
* - +
*/
interface Props {
mode: 'dark' | 'spoofing' | 'transship' | 'all';
title: string;
icon?: React.ReactNode;
}
const RISK_COLORS: Record<string, string> = {
CRITICAL: 'bg-red-500/20 text-red-400',
HIGH: 'bg-orange-500/20 text-orange-400',
MEDIUM: 'bg-yellow-500/20 text-yellow-400',
LOW: 'bg-blue-500/20 text-blue-400',
};
const ZONE_LABELS: Record<string, string> = {
TERRITORIAL_SEA: '영해',
CONTIGUOUS_ZONE: '접속수역',
EEZ_OR_BEYOND: 'EEZ 외',
ZONE_I: '특정해역 I',
ZONE_II: '특정해역 II',
ZONE_III: '특정해역 III',
ZONE_IV: '특정해역 IV',
};
export function RealVesselAnalysis({ mode, title, icon }: Props) {
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
const [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [zoneFilter, setZoneFilter] = useState<string>('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchVesselAnalysis();
setItems(res.items);
setStats(res.stats);
setAvailable(res.serviceAvailable);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => {
let result = items;
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark);
else 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);
return result;
}, [items, mode, zoneFilter]);
const sortedByRisk = useMemo(
() => [...filtered].sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score),
[filtered],
);
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">
{icon} {title}
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/vessel-analysis · iran
</div>
</div>
<div className="flex items-center gap-2">
<select value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""> </option>
<option value="TERRITORIAL_SEA"></option>
<option value="CONTIGUOUS_ZONE"></option>
<option value="EEZ_OR_BEYOND">EEZ </option>
</select>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 통계 카드 */}
{stats && (
<div className="grid grid-cols-6 gap-2">
<StatBox label="전체" value={stats.total} color="text-heading" />
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" />
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
</div>
)}
{error && <div className="text-xs text-red-400">: {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 max-h-96">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint sticky top-0">
<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-right"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center">Dark</th>
<th className="px-2 py-1.5 text-right">Spoofing</th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-left"></th>
</tr>
</thead>
<tbody>
{sortedByRisk.length === 0 && (
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint"> .</td></tr>
)}
{sortedByRisk.slice(0, 100).map((v) => (
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">
{v.classification.vesselType}
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
</td>
<td className="px-2 py-1.5 text-center">
<Badge className={`${RISK_COLORS[v.algorithms.riskScore.level] || ''} border-0 text-[9px]`}>
{v.algorithms.riskScore.level}
</Badge>
</td>
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.algorithms.riskScore.score}</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{ZONE_LABELS[v.algorithms.location.zone] || v.algorithms.location.zone}
<span className="text-hint ml-1">({v.algorithms.location.distToBaselineNm.toFixed(1)}NM)</span>
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">{v.algorithms.activity.state}</td>
<td className="px-2 py-1.5 text-center">
{v.algorithms.darkVessel.isDark ? (
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v.algorithms.darkVessel.gapDurationMin}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-right">
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-center">
{v.algorithms.transship.isSuspect ? (
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{v.algorithms.transship.durationMin}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.timestamp ? new Date(v.timestamp).toLocaleTimeString('ko-KR') : '-'}
</td>
</tr>
))}
</tbody>
</table>
{sortedByRisk.length > 100 && (
<div className="text-center text-[10px] text-hint mt-2">
100 ( {sortedByRisk.length}, )
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
</div>
);
}
// 편의 export: 모드별 default props
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
export const RealAllVessels = () => <RealVesselAnalysis mode="all" title="전체 분석 결과 (실시간)" icon={<Radar className="w-4 h-4 text-blue-400" />} />;

파일 보기

@ -7,6 +7,7 @@ import type { LucideIcon } from 'lucide-react';
import { AreaChart, PieChart } from '@lib/charts'; import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore'; import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore'; import { useEventStore } from '@stores/eventStore';
import { SystemStatusPanel } from './SystemStatusPanel';
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */ /* SFR-12: 모니터링 및 경보 현황판(대시보드) */
@ -64,6 +65,9 @@ export function MonitoringDashboard() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Activity className="w-5 h-5 text-green-400" />{t('monitoring.title')}</h2> <h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Activity className="w-5 h-5 text-green-400" />{t('monitoring.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('monitoring.desc')}</p> <p className="text-[10px] text-hint mt-0.5">{t('monitoring.desc')}</p>
</div> </div>
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="flex gap-2"> <div className="flex gap-2">
{KPI.map(k => ( {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 key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">

파일 보기

@ -0,0 +1,174 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, Activity, Database, Wifi } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchVesselAnalysis, type VesselAnalysisStats } from '@/services/vesselAnalysisApi';
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
interface PredictionHealth {
status?: string;
message?: string;
snpdb?: boolean;
kcgdb?: boolean;
store?: { vessels?: number; points?: number; memory_mb?: number; targets?: number; permitted?: number };
}
interface AnalysisStatus {
timestamp?: string;
duration_sec?: number;
vessel_count?: number;
upserted?: number;
error?: string | null;
status?: string;
}
/**
* ( ).
*
* :
* 1. (kcg-ai-backend)
* 2. iran + Prediction ( )
* 3. ( )
*/
export function SystemStatusPanel() {
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
const [health, setHealth] = useState<PredictionHealth | null>(null);
const [analysis, setAnalysis] = useState<AnalysisStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const [vaRes, healthRes, statusRes] = await Promise.all([
fetchVesselAnalysis().catch(() => null),
fetch(`${API_BASE}/prediction/health`, { credentials: 'include' }).then((r) => r.json()).catch(() => null),
fetch(`${API_BASE}/prediction/status`, { credentials: 'include' }).then((r) => r.json()).catch(() => null),
]);
if (vaRes) setStats(vaRes.stats);
if (healthRes) setHealth(healthRes);
if (statusRes) setAnalysis(statusRes);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
// 30초마다 자동 새로고침
const timer = setInterval(load, 30000);
return () => clearInterval(timer);
}, [load]);
return (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-heading flex items-center gap-2">
<Activity className="w-4 h-4 text-cyan-400" />
<span className="text-[9px] text-hint">(30 )</span>
</div>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
<div className="grid grid-cols-3 gap-3">
{/* KCG 백엔드 */}
<ServiceCard
icon={<Database className="w-4 h-4" />}
title="KCG AI Backend"
status="UP"
statusColor="text-green-400"
details={[
['포트', ':8080'],
['프로파일', 'local'],
['DB', 'kcgaidb'],
]}
/>
{/* iran 백엔드 */}
<ServiceCard
icon={<Wifi className="w-4 h-4" />}
title="iran 백엔드 (분석)"
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusColor={stats ? 'text-green-400' : 'text-red-400'}
details={[
['선박 분석', stats ? `${stats.total.toLocaleString()}` : '-'],
['클러스터', stats ? `${stats.clusterCount}` : '-'],
['어구 그룹', stats ? `${stats.gearGroups}` : '-'],
]}
/>
{/* Prediction */}
<ServiceCard
icon={<Activity className="w-4 h-4" />}
title="Prediction Service"
status={health?.status || 'UNKNOWN'}
statusColor={health?.status === 'ok' ? 'text-green-400' : 'text-yellow-400'}
details={[
['SNPDB', health?.snpdb === true ? 'OK' : '-'],
['KCGDB', health?.kcgdb === true ? 'OK' : '-'],
['최근 분석', analysis?.duration_sec ? `${analysis.duration_sec}` : '-'],
]}
/>
</div>
{/* 위험도 분포 */}
{stats && (
<div className="grid grid-cols-4 gap-2">
<RiskBox label="CRITICAL" value={stats.critical} color="text-red-400" />
<RiskBox label="HIGH" value={stats.high} color="text-orange-400" />
<RiskBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
<RiskBox label="LOW" value={stats.low} color="text-blue-400" />
</div>
)}
</CardContent>
</Card>
);
}
function ServiceCard({ icon, title, status, statusColor, details }: {
icon: React.ReactNode;
title: string;
status: string;
statusColor: string;
details: [string, string][];
}) {
return (
<div className="bg-surface-overlay border border-border rounded p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs text-heading font-medium">
<span className="text-cyan-400">{icon}</span>
{title}
</div>
<Badge className={`bg-transparent border ${statusColor.replace('text-', 'border-')} ${statusColor} text-[9px]`}>
{status}
</Badge>
</div>
<div className="space-y-0.5">
{details.map(([k, v]) => (
<div key={k} className="flex justify-between text-[10px]">
<span className="text-hint">{k}</span>
<span className="text-label font-mono">{v}</span>
</div>
))}
</div>
</div>
);
}
function RiskBox({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
</div>
);
}

파일 보기

@ -3,6 +3,7 @@ import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Ship, MapPin } from 'lucide-react'; import { Ship, MapPin } from 'lucide-react';
import { useTransferStore } from '@stores/transferStore'; import { useTransferStore } from '@stores/transferStore';
import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
export function TransferDetection() { export function TransferDetection() {
const { transfers, load } = useTransferStore(); const { transfers, load } = useTransferStore();
@ -30,6 +31,9 @@ export function TransferDetection() {
<p className="text-xs text-hint mt-0.5"> </p> <p className="text-xs text-hint mt-0.5"> </p>
</div> </div>
{/* iran 백엔드 실시간 전재 의심 */}
<RealTransshipSuspects />
{/* 탐지 조건 */} {/* 탐지 조건 */}
<Card className="bg-surface-overlay border-slate-700/40"> <Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-5"> <CardContent className="p-5">

파일 보기

@ -0,0 +1,67 @@
/**
* AI API.
* - prediction (/api/prediction/chat)
* - SSE ( stub) - prediction
*
* SSE :
* const ctrl = new AbortController();
* const res = await fetch(`${API_BASE}/prediction/chat`, {
* method: 'POST',
* credentials: 'include',
* headers: { 'Content-Type': 'application/json' },
* body: JSON.stringify({ message, stream: true }),
* signal: ctrl.signal,
* });
* const reader = res.body!.getReader();
* const decoder = new TextDecoder();
* while (true) {
* const { value, done } = await reader.read();
* if (done) break;
* const chunk = decoder.decode(value);
* // 'data: {...}\n\n' 파싱
* }
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ChatResponse {
ok: boolean;
reply?: string;
message?: string;
serviceAvailable?: boolean;
}
/**
* (SSE ).
* Phase 8 graceful .
*/
export async function sendChatMessage(message: string): Promise<ChatResponse> {
try {
const res = await fetch(`${API_BASE}/prediction/chat`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: false }),
});
if (!res.ok) {
return {
ok: false,
serviceAvailable: false,
message: `Prediction 채팅 서비스 미연결 (HTTP ${res.status}). 향후 인증 연동 후 활성화 예정.`,
};
}
const data = await res.json();
return { ok: true, reply: data.reply || data.content || JSON.stringify(data) };
} catch (e: unknown) {
return {
ok: false,
serviceAvailable: false,
message: 'Prediction 채팅 서비스 연결 실패: ' + (e instanceof Error ? e.message : 'unknown'),
};
}
}

파일 보기

@ -0,0 +1,130 @@
/**
* iran API.
* - () iran + HYBRID .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface VesselAnalysisStats {
total: number;
dark: number;
spoofing: number;
critical: number;
high: number;
medium: number;
low: number;
clusterCount: number;
gearGroups: number;
gearCount: number;
}
export interface VesselAnalysisItem {
mmsi: string;
timestamp: string;
classification: {
vesselType: string;
confidence: number;
fishingPct: number;
clusterId: number;
season: string;
};
algorithms: {
location: { zone: string; distToBaselineNm: number };
activity: { state: string; ucafScore: number; ucftScore: number };
darkVessel: { isDark: boolean; gapDurationMin: number };
gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number };
cluster: { clusterId: number; clusterSize: number };
fleetRole: { isLeader: boolean; role: string };
riskScore: { score: number; level: string };
transship: { isSuspect: boolean; pairMmsi: string; durationMin: number };
};
features?: Record<string, number>;
}
export interface VesselAnalysisResponse {
serviceAvailable: boolean;
count: number;
stats: VesselAnalysisStats;
items: VesselAnalysisItem[];
}
export interface GearGroupItem {
groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
groupKey: string;
groupLabel?: string;
subClusterId: number;
snapshotTime: string;
polygon: unknown; // GeoJSON geometry
centerLat: number;
centerLon: number;
areaSqNm: number;
memberCount: number;
members: { mmsi: string; name?: string; lat?: number; lon?: number }[];
color?: string;
resolution: {
status: string;
selectedParentMmsi: string | null;
approvedAt: string | null;
manualComment: string | null;
} | null;
candidateCount?: number;
}
export interface GroupsResponse {
serviceAvailable: boolean;
count: number;
items: GearGroupItem[];
}
async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
return res.json();
}
export function fetchVesselAnalysis() {
return apiGet<VesselAnalysisResponse>('/vessel-analysis');
}
export function fetchGroups() {
return apiGet<GroupsResponse>('/vessel-analysis/groups');
}
export function fetchGroupDetail(groupKey: string) {
return apiGet<unknown>(`/vessel-analysis/groups/${encodeURIComponent(groupKey)}/detail`);
}
export function fetchGroupCorrelations(groupKey: string, minScore?: number) {
const qs = minScore ? `?minScore=${minScore}` : '';
return apiGet<unknown>(`/vessel-analysis/groups/${encodeURIComponent(groupKey)}/correlations${qs}`);
}
// ─── 필터/유틸 ─────────────────────────────────
/**
* Dark Vessel만 .
*/
export function filterDarkVessels(items: VesselAnalysisItem[]): VesselAnalysisItem[] {
return items.filter((i) => i.algorithms.darkVessel.isDark);
}
/**
* GPS (score >= 0.3).
*/
export function filterSpoofingVessels(items: VesselAnalysisItem[], threshold = 0.3): VesselAnalysisItem[] {
return items.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= threshold);
}
/**
* .
*/
export function filterTransshipSuspects(items: VesselAnalysisItem[]): VesselAnalysisItem[] {
return items.filter((i) => i.algorithms.transship.isSuspect);
}
/**
* .
*/
export function filterByRiskLevel(items: VesselAnalysisItem[], levels: string[]): VesselAnalysisItem[] {
return items.filter((i) => levels.includes(i.algorithms.riskScore.level));
}