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:
부모
febfb2cbe8
커밋
95ca1018b5
@ -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="어구탐지" />
|
||||||
|
|
||||||
{/* 어구 탐지 위치 지도 */}
|
{/* 어구 탐지 위치 지도 */}
|
||||||
|
|||||||
158
frontend/src/features/detection/RealGearGroups.tsx
Normal file
158
frontend/src/features/detection/RealGearGroups.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/features/detection/RealVesselAnalysis.tsx
Normal file
207
frontend/src/features/detection/RealVesselAnalysis.tsx
Normal file
@ -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">
|
||||||
|
|||||||
174
frontend/src/features/monitoring/SystemStatusPanel.tsx
Normal file
174
frontend/src/features/monitoring/SystemStatusPanel.tsx
Normal file
@ -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">
|
||||||
|
|||||||
67
frontend/src/services/chatApi.ts
Normal file
67
frontend/src/services/chatApi.ts
Normal file
@ -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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
130
frontend/src/services/vesselAnalysisApi.ts
Normal file
130
frontend/src/services/vesselAnalysisApi.ts
Normal file
@ -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));
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user