diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java index 446a334..cc04c3e 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java @@ -1,21 +1,21 @@ package gc.mda.kcg.domain.analysis; import gc.mda.kcg.config.AppProperties; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import java.time.Duration; import java.util.Map; /** * iran 백엔드 REST 클라이언트. * - * 현재는 호출 자체는 시도하되, 연결 불가 시 graceful degradation: - * - 503 또는 빈 응답을 반환하여 프론트에서 빈 UI 처리 + * 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합) + * 호출 실패 시 graceful degradation: null 반환 → 프론트에 빈 응답. * - * 향후 운영 환경에서 iran 백엔드 base-url이 정확히 설정되면 그대로 사용 가능. + * 향후 prediction 이관 시 IranBackendClient를 PredictionDirectClient로 교체하면 됨. */ @Slf4j @Component @@ -28,7 +28,10 @@ public class IranBackendClient { String baseUrl = appProperties.getIranBackend().getBaseUrl(); this.enabled = baseUrl != null && !baseUrl.isBlank(); this.restClient = enabled - ? RestClient.builder().baseUrl(baseUrl).build() + ? RestClient.builder() + .baseUrl(baseUrl) + .defaultHeader("Accept", "application/json") + .build() : RestClient.create(); log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl); } @@ -51,4 +54,17 @@ public class IranBackendClient { return null; } } + + /** + * 임의 타입 GET 호출. + */ + public T getAs(String path, Class 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; + } + } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java index d528687..59ab371 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java @@ -48,4 +48,20 @@ public class PredictionProxyController { public ResponseEntity trigger() { 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 body) { + // iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답 + // 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍 + return ResponseEntity.ok(Map.of( + "ok", false, + "serviceAvailable", false, + "message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "") + )); + } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java index 03c5be0..3593357 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java @@ -1,18 +1,24 @@ 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 lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.Map; +import java.util.*; /** - * 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 @RequestMapping("/api/vessel-analysis") @@ -20,6 +26,7 @@ import java.util.Map; public class VesselAnalysisProxyController { private final IranBackendClient iranClient; + private final ParentResolutionRepository resolutionRepository; @GetMapping @RequirePermission(resource = "detection", operation = "READ") @@ -28,22 +35,65 @@ public class VesselAnalysisProxyController { if (data == null) { return ResponseEntity.ok(Map.of( "serviceAvailable", false, - "message", "iran 백엔드 미연결 (Phase 5에서 연결 예정)", - "results", List.of(), - "stats", Map.of() + "message", "iran 백엔드 미연결", + "items", List.of(), + "stats", Map.of(), + "count", 0 )); } - return ResponseEntity.ok(data); + // 통과 + 메타데이터 추가 + Map enriched = new LinkedHashMap<>(data); + enriched.put("serviceAvailable", true); + return ResponseEntity.ok(enriched); } + /** + * 그룹 목록 + 자체 DB의 parentResolution 합성. + * 각 그룹에 resolution 필드 추가. + */ @GetMapping("/groups") @RequirePermission(resource = "detection:gear-detection", operation = "READ") public ResponseEntity getGroups() { Map data = iranClient.getJson("/api/vessel-analysis/groups"); 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> items = (List>) data.getOrDefault("items", List.of()); + + // 자체 DB의 모든 resolution을 group_key로 인덱싱 + Map resolutionByKey = new HashMap<>(); + for (ParentResolution r : resolutionRepository.findAll()) { + resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r); + } + + // 각 그룹에 합성 + for (Map 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 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 result = new LinkedHashMap<>(data); + result.put("items", items); + result.put("serviceAvailable", true); + return ResponseEntity.ok(result); } @GetMapping("/groups/{groupKey}/detail") @@ -55,4 +105,19 @@ public class VesselAnalysisProxyController { } 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 data = iranClient.getJson(path); + if (data == null) { + return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey)); + } + return ResponseEntity.ok(data); + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ed57eed..9fdcbd0 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -60,7 +60,8 @@ app: prediction: base-url: ${PREDICTION_BASE_URL:http://localhost:8001} 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: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174} jwt: diff --git a/frontend/src/features/ai-operations/AIAssistant.tsx b/frontend/src/features/ai-operations/AIAssistant.tsx index b87cf3a..f1d5333 100644 --- a/frontend/src/features/ai-operations/AIAssistant.tsx +++ b/frontend/src/features/ai-operations/AIAssistant.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react'; +import { sendChatMessage } from '@/services/chatApi'; /* SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 */ @@ -46,13 +47,31 @@ export function AIAssistant() { const [input, setInput] = useState(''); const [selectedConv, setSelectedConv] = useState('1'); - const handleSend = () => { + const handleSend = async () => { if (!input.trim()) return; - setMessages(prev => [...prev, - { role: 'user', content: input }, - { role: 'assistant', content: '질의를 분석 중입니다. 관련 법령·사례·AI 예측 결과를 종합하여 답변을 생성합니다...', refs: [] }, - ]); + const userMsg = input; + setMessages((prev) => [...prev, { role: 'user', content: userMsg }]); 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 ( diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 1437fcd..e3926bc 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -7,6 +7,7 @@ import { MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon } from 'lucide-react'; import { GearIdentification } from './GearIdentification'; +import { RealAllVessels } from './RealVesselAnalysis'; import { BaseChart, PieChart as EcPieChart } from '@lib/charts'; import type { EChartsOption } from 'echarts'; import { useTransferStore } from '@stores/transferStore'; @@ -336,6 +337,9 @@ export function ChinaFishing() { {/* AI 대시보드 모드 */} {mode === 'dashboard' && <> + {/* iran 백엔드 실시간 분석 결과 */} + + {/* ── 상단 바: 기준일 + 검색 ── */}
diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 9752fd3..5ef7a4e 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -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 type { MarkerData } from '@lib/map'; import { useVesselStore } from '@stores/vesselStore'; +import { RealDarkVessels, RealSpoofingVessels } from './RealVesselAnalysis'; /* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ @@ -116,6 +117,10 @@ export function DarkVesselDetection() {
))}
+ {/* iran 백엔드 실시간 Dark Vessel + GPS 스푸핑 */} + + + {/* 탐지 위치 지도 */} diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 22fdab3..2717c43 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -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 type { MarkerData } from '@lib/map'; import { useGearStore } from '@stores/gearStore'; +import { RealGearGroups } from './RealGearGroups'; /* SFR-10: 불법 어망·어구 탐지 및 관리 */ @@ -92,6 +93,9 @@ export function GearDetection() { ))} + {/* iran 백엔드 실시간 어구/선단 그룹 */} + + {/* 어구 탐지 위치 지도 */} diff --git a/frontend/src/features/detection/RealGearGroups.tsx b/frontend/src/features/detection/RealGearGroups.tsx new file mode 100644 index 0000000..af52ce1 --- /dev/null +++ b/frontend/src/features/detection/RealGearGroups.tsx @@ -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 = { + 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 = { + 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([]); + const [available, setAvailable] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [filterType, setFilterType] = useState(''); + + 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 ( + + +
+
+
+ 실시간 어구/선단 그룹 (iran 백엔드) + {!available && 미연결} +
+
+ GET /api/vessel-analysis/groups · 자체 DB의 운영자 결정(resolution) 합성됨 +
+
+
+ + +
+
+ + {/* 통계 */} +
+ + + + + +
+ + {error &&
에러: {error}
} + {loading &&
} + + {!loading && ( +
+ + + + + + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.slice(0, 100).map((g) => ( + + + + + + + + + + + ))} + +
유형그룹 키서브멤버면적(NM²)중심 좌표운영자 결정스냅샷 시각
데이터가 없습니다.
+ {g.groupType} + {g.groupKey}{g.subClusterId}{g.memberCount}{g.areaSqNm?.toFixed(2)} + {g.centerLat?.toFixed(3)}, {g.centerLon?.toFixed(3)} + + {g.resolution ? ( + + {g.resolution.status} + + ) : -} + + {g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-'} +
+ {filtered.length > 100 && ( +
상위 100건만 표시 (전체 {filtered.length}건)
+ )} +
+ )} +
+
+ ); +} + +function StatBox({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/features/detection/RealVesselAnalysis.tsx b/frontend/src/features/detection/RealVesselAnalysis.tsx new file mode 100644 index 0000000..6c8b48d --- /dev/null +++ b/frontend/src/features/detection/RealVesselAnalysis.tsx @@ -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 = { + 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 = { + 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([]); + const [stats, setStats] = useState(null); + const [available, setAvailable] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [zoneFilter, setZoneFilter] = useState(''); + + 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 ( + + +
+
+
+ {icon} {title} + {!available && 미연결} +
+
+ GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과 +
+
+
+ + +
+
+ + {/* 통계 카드 */} + {stats && ( +
+ + + + + + +
+ )} + + {error &&
에러: {error}
} + {loading &&
} + + {!loading && ( +
+ + + + + + + + + + + + + + + + + {sortedByRisk.length === 0 && ( + + )} + {sortedByRisk.slice(0, 100).map((v) => ( + + + + + + + + + + + + + ))} + +
MMSI선박 유형위험도점수해역활동DarkSpoofing전재갱신
필터된 데이터가 없습니다.
{v.mmsi} + {v.classification.vesselType} + ({(v.classification.confidence * 100).toFixed(0)}%) + + + {v.algorithms.riskScore.level} + + {v.algorithms.riskScore.score} + {ZONE_LABELS[v.algorithms.location.zone] || v.algorithms.location.zone} + ({v.algorithms.location.distToBaselineNm.toFixed(1)}NM) + {v.algorithms.activity.state} + {v.algorithms.darkVessel.isDark ? ( + {v.algorithms.darkVessel.gapDurationMin}분 + ) : -} + + {v.algorithms.gpsSpoofing.spoofingScore > 0 ? ( + {v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)} + ) : -} + + {v.algorithms.transship.isSuspect ? ( + {v.algorithms.transship.durationMin}분 + ) : -} + + {v.timestamp ? new Date(v.timestamp).toLocaleTimeString('ko-KR') : '-'} +
+ {sortedByRisk.length > 100 && ( +
+ 상위 100건만 표시 (전체 {sortedByRisk.length}건, 위험도순) +
+ )} +
+ )} +
+
+ ); +} + +function StatBox({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{label}
+
{value.toLocaleString()}
+
+ ); +} + +// 편의 export: 모드별 default props +export const RealDarkVessels = () => } />; +export const RealSpoofingVessels = () => } />; +export const RealTransshipSuspects = () => } />; +export const RealAllVessels = () => } />; diff --git a/frontend/src/features/monitoring/MonitoringDashboard.tsx b/frontend/src/features/monitoring/MonitoringDashboard.tsx index fe0f3d1..be54767 100644 --- a/frontend/src/features/monitoring/MonitoringDashboard.tsx +++ b/frontend/src/features/monitoring/MonitoringDashboard.tsx @@ -7,6 +7,7 @@ import type { LucideIcon } from 'lucide-react'; import { AreaChart, PieChart } from '@lib/charts'; import { useKpiStore } from '@stores/kpiStore'; import { useEventStore } from '@stores/eventStore'; +import { SystemStatusPanel } from './SystemStatusPanel'; /* SFR-12: 모니터링 및 경보 현황판(대시보드) */ @@ -64,6 +65,9 @@ export function MonitoringDashboard() {

{t('monitoring.title')}

{t('monitoring.desc')}

+ {/* iran 백엔드 + Prediction 시스템 상태 (실시간) */} + +
{KPI.map(k => (
diff --git a/frontend/src/features/monitoring/SystemStatusPanel.tsx b/frontend/src/features/monitoring/SystemStatusPanel.tsx new file mode 100644 index 0000000..4cf1d69 --- /dev/null +++ b/frontend/src/features/monitoring/SystemStatusPanel.tsx @@ -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(null); + const [health, setHealth] = useState(null); + const [analysis, setAnalysis] = useState(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 ( + + +
+
+ 시스템 상태 + (30초 자동 갱신) +
+ +
+ + {error &&
에러: {error}
} + +
+ {/* KCG 백엔드 */} + } + title="KCG AI Backend" + status="UP" + statusColor="text-green-400" + details={[ + ['포트', ':8080'], + ['프로파일', 'local'], + ['DB', 'kcgaidb'], + ]} + /> + + {/* iran 백엔드 */} + } + 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 */} + } + 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}초` : '-'], + ]} + /> +
+ + {/* 위험도 분포 */} + {stats && ( +
+ + + + +
+ )} +
+
+ ); +} + +function ServiceCard({ icon, title, status, statusColor, details }: { + icon: React.ReactNode; + title: string; + status: string; + statusColor: string; + details: [string, string][]; +}) { + return ( +
+
+
+ {icon} + {title} +
+ + {status} + +
+
+ {details.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ ); +} + +function RiskBox({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{label}
+
{value.toLocaleString()}
+
+ ); +} diff --git a/frontend/src/features/vessel/TransferDetection.tsx b/frontend/src/features/vessel/TransferDetection.tsx index ec7faa2..febc19a 100644 --- a/frontend/src/features/vessel/TransferDetection.tsx +++ b/frontend/src/features/vessel/TransferDetection.tsx @@ -3,6 +3,7 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { Ship, MapPin } from 'lucide-react'; import { useTransferStore } from '@stores/transferStore'; +import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis'; export function TransferDetection() { const { transfers, load } = useTransferStore(); @@ -30,6 +31,9 @@ export function TransferDetection() {

선박 간 근접 접촉 및 환적 의심 행위 분석

+ {/* iran 백엔드 실시간 전재 의심 */} + + {/* 탐지 조건 */} diff --git a/frontend/src/services/chatApi.ts b/frontend/src/services/chatApi.ts new file mode 100644 index 0000000..851496f --- /dev/null +++ b/frontend/src/services/chatApi.ts @@ -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 { + 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'), + }; + } +} diff --git a/frontend/src/services/vesselAnalysisApi.ts b/frontend/src/services/vesselAnalysisApi.ts new file mode 100644 index 0000000..9fbe645 --- /dev/null +++ b/frontend/src/services/vesselAnalysisApi.ts @@ -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; +} + +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(path: string): Promise { + 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('/vessel-analysis'); +} + +export function fetchGroups() { + return apiGet('/vessel-analysis/groups'); +} + +export function fetchGroupDetail(groupKey: string) { + return apiGet(`/vessel-analysis/groups/${encodeURIComponent(groupKey)}/detail`); +} + +export function fetchGroupCorrelations(groupKey: string, minScore?: number) { + const qs = minScore ? `?minScore=${minScore}` : ''; + return apiGet(`/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)); +}