diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java b/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java new file mode 100644 index 0000000..98fc2ff --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java @@ -0,0 +1,39 @@ +package gc.mda.kcg.domain.event; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 알림 조회 API. + * 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공. + */ +@RestController +@RequestMapping("/api/alerts") +@RequiredArgsConstructor +public class AlertController { + + private final PredictionAlertRepository alertRepository; + + /** + * 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능. + */ + @GetMapping + @RequirePermission(resource = "monitoring", operation = "READ") + public Object getAlerts( + @RequestParam(required = false) Long eventId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + if (eventId != null) { + return alertRepository.findByEventIdOrderBySentAtDesc(eventId); + } + return alertRepository.findAllByOrderBySentAtDesc( + PageRequest.of(page, size) + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionAlert.java b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionAlert.java new file mode 100644 index 0000000..53c0f61 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionAlert.java @@ -0,0 +1,56 @@ +package gc.mda.kcg.domain.event; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * AI 예측 알림. + * 이벤트 발생 시 발송된 알림(SMS, 푸시 등) 이력을 저장. + */ +@Entity +@Table(name = "prediction_alerts", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PredictionAlert { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id") + private Long eventId; + + @Column(name = "channel", length = 20) + private String channel; + + @Column(name = "recipient", length = 200) + private String recipient; + + @Column(name = "sent_at") + private OffsetDateTime sentAt; + + @Column(name = "delivery_status", nullable = false, length = 20) + private String deliveryStatus; + + @Column(name = "ai_confidence", precision = 5, scale = 4) + private BigDecimal aiConfidence; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map metadata; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", insertable = false, updatable = false) + private PredictionEvent event; + + @PrePersist + void prePersist() { + if (deliveryStatus == null) deliveryStatus = "SENT"; + if (sentAt == null) sentAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionAlertRepository.java b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionAlertRepository.java new file mode 100644 index 0000000..613c8d0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionAlertRepository.java @@ -0,0 +1,14 @@ +package gc.mda.kcg.domain.event; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PredictionAlertRepository extends JpaRepository { + + List findByEventIdOrderBySentAtDesc(Long eventId); + + Page findAllByOrderBySentAtDesc(Pageable pageable); +} diff --git a/frontend/src/features/dashboard/Dashboard.tsx b/frontend/src/features/dashboard/Dashboard.tsx index c4ba30d..554b976 100644 --- a/frontend/src/features/dashboard/Dashboard.tsx +++ b/frontend/src/features/dashboard/Dashboard.tsx @@ -15,7 +15,7 @@ import { AreaChart, PieChart } from '@lib/charts'; import { useKpiStore } from '@stores/kpiStore'; import { useEventStore } from '@stores/eventStore'; import { usePatrolStore } from '@stores/patrolStore'; -import { useVesselStore } from '@stores/vesselStore'; +import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi'; // ─── 작전 경보 등급 ───────────────────── type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; @@ -44,6 +44,7 @@ const KPI_UI_MAP: Record = { }; +// TODO: /api/risk-grid 연동 예정 const AREA_RISK_DATA = [ { area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' }, { area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' }, @@ -54,12 +55,14 @@ const AREA_RISK_DATA = [ { area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' }, ]; +// TODO: /api/stats/daily 연동 예정 const HOURLY_DETECTION = [ { hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 }, { hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 }, { hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 }, ]; +// TODO: /api/stats/daily 연동 예정 const VESSEL_TYPE_DATA = [ { name: 'EEZ 침범', value: 18, color: '#ef4444' }, { name: '다크베셀', value: 12, color: '#f97316' }, @@ -68,6 +71,7 @@ const VESSEL_TYPE_DATA = [ { name: '고속도주', value: 4, color: '#06b6d4' }, ]; +// TODO: /api/weather 연동 예정 const WEATHER_DATA = { wind: { speed: 12, direction: 'NW', gust: 18 }, wave: { height: 1.8, period: 6 }, @@ -175,6 +179,7 @@ function FuelGauge({ percent }: { percent: number }) { // ─── 해역 위협 미니맵 (Leaflet) ─────────────────── +// TODO: /api/risk-grid 연동 예정 const THREAT_AREAS = [ { name: '서해 NLL', lat: 37.80, lng: 124.90, risk: 95, vessels: 8 }, { name: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 91, vessels: 14 }, @@ -284,14 +289,26 @@ export function Dashboard() { const kpiStore = useKpiStore(); const eventStore = useEventStore(); - const vesselStore = useVesselStore(); const patrolStore = usePatrolStore(); + const [riskVessels, setRiskVessels] = useState([]); + useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]); useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]); - useEffect(() => { if (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]); useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]); + useEffect(() => { + fetchVesselAnalysis() + .then((res) => { + if (!res.serviceAvailable) { setRiskVessels([]); return; } + const sorted = [...res.items].sort( + (a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score, + ); + setRiskVessels(sorted.slice(0, 8)); + }) + .catch(() => setRiskVessels([])); + }, []); + const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => { const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' }; return { @@ -313,19 +330,21 @@ export function Dashboard() { area: e.area ?? '-', })), [eventStore.events]); - const TOP_RISK_VESSELS = useMemo(() => vesselStore.suspects.slice(0, 8).map((v) => ({ - id: v.id, - name: v.name, - risk: v.risk / 100, - type: v.pattern ?? v.type, - flag: v.flag === 'CN' ? '중국' : v.flag === 'KR' ? '한국' : '미상', - tonnage: v.tonnage ?? null, - speed: v.speed != null ? `${v.speed}kt` : '-', - heading: v.heading != null ? `${v.heading}°` : '-', - lastAIS: v.lastSignal ?? '-', - location: `N${v.lat.toFixed(2)} E${v.lng.toFixed(2)}`, - pattern: v.status, - })), [vesselStore.suspects]); + const TOP_RISK_VESSELS = useMemo(() => riskVessels.map((v) => { + const risk = v.algorithms.riskScore; + return { + id: v.mmsi, + name: v.mmsi, + risk: risk.score, + type: v.classification.vesselType, + riskLevel: risk.level, + zone: v.algorithms.location.zone, + isDark: v.algorithms.darkVessel.isDark, + activity: v.algorithms.activity.state, + isSpoofing: v.algorithms.gpsSpoofing.spoofingScore >= 0.3, + isTransship: v.algorithms.transship.isSuspect, + }; + }), [riskVessels]); const PATROL_SHIPS = useMemo(() => patrolStore.ships.map((s) => ({ name: s.name, @@ -583,52 +602,35 @@ export function Dashboard() { {/* 테이블 헤더 */} -
+
# - 선박명 / ID - 위반 유형 - 국적/지역 - 속력/침로 - AIS 상태 - 행동패턴 - 위치 + MMSI + 선종 + 해역 + 활동 상태 + 특이사항 위험도
{TOP_RISK_VESSELS.map((vessel, index) => (
#{index + 1} -
-
- 0.9 ? 'bg-red-500' : vessel.risk > 0.8 ? 'bg-orange-500' : 'bg-yellow-500'} /> - {vessel.name} -
- {vessel.id} +
+ 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} /> + {vessel.name}
- {vessel.type} - {vessel.flag} -
-
{vessel.speed}
-
{vessel.heading}
+ {vessel.type} + {vessel.zone} + {vessel.activity} +
+ {vessel.isDark && 다크} + {vessel.isSpoofing && GPS변조} + {vessel.isTransship && 전재} + {!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && -}
- {vessel.lastAIS} - {vessel.pattern} - {vessel.location}
))} diff --git a/frontend/src/features/field-ops/AIAlert.tsx b/frontend/src/features/field-ops/AIAlert.tsx index 0d785f2..10a9dca 100644 --- a/frontend/src/features/field-ops/AIAlert.tsx +++ b/frontend/src/features/field-ops/AIAlert.tsx @@ -1,61 +1,195 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Bell, Send, CheckCircle, XCircle, Clock, MapPin, AlertTriangle, Ship } from 'lucide-react'; -import { useEventStore } from '@stores/eventStore'; +import { Send, Loader2, AlertTriangle } from 'lucide-react'; +import { getAlerts, type PredictionAlert } from '@/services/event'; /* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */ -interface Alert { id: string; time: string; type: string; location: string; confidence: number; target: string; status: string; received: string; [key: string]: unknown; } -const cols: DataColumn[] = [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'time', label: '탐지 시각', width: '80px', sortable: true, render: v => {v as string} }, - { key: 'type', label: '탐지 유형', width: '80px', sortable: true, render: v => {v as string} }, - { key: 'location', label: '위치좌표', width: '120px', render: v => {v as string} }, - { key: 'confidence', label: '신뢰도', width: '60px', align: 'center', sortable: true, - render: v => { const n = v as number; return 90 ? 'text-red-400' : n > 80 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}%; } }, - { key: 'target', label: '수신 대상', render: v => {v as string} }, - { key: 'status', label: '발송 상태', width: '80px', align: 'center', sortable: true, - render: v => { const s = v as string; const c = s === '수신확인' ? 'bg-green-500/20 text-green-400' : s === '발송완료' ? 'bg-blue-500/20 text-blue-400' : 'bg-red-500/20 text-red-400'; return {s}; } }, - { key: 'received', label: '수신 시각', width: '80px', render: v => {v as string} }, +interface AlertRow { + id: number; + eventId: number; + time: string; + channel: string; + recipient: string; + confidence: string; + status: string; + [key: string]: unknown; +} + +const STATUS_LABEL: Record = { + SENT: '발송완료', + DELIVERED: '수신확인', + FAILED: '발송실패', +}; + +const cols: DataColumn[] = [ + { + key: 'id', + label: 'ID', + width: '70px', + render: (v) => {v as number}, + }, + { + key: 'eventId', + label: '이벤트', + width: '80px', + render: (v) => EVT-{v as number}, + }, + { + key: 'time', + label: '발송 시각', + width: '130px', + sortable: true, + render: (v) => {v as string}, + }, + { + key: 'channel', + label: '채널', + width: '80px', + sortable: true, + render: (v) => ( + {v as string} + ), + }, + { + key: 'recipient', + label: '수신 대상', + render: (v) => {v as string}, + }, + { + key: 'confidence', + label: '신뢰도', + width: '70px', + align: 'center', + sortable: true, + render: (v) => { + const s = v as string; + if (!s) return -; + const n = parseFloat(s); + const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400'; + return {(n * 100).toFixed(0)}%; + }, + }, + { + key: 'status', + label: '상태', + width: '80px', + align: 'center', + sortable: true, + render: (v) => { + const s = v as string; + const c = + s === 'DELIVERED' + ? 'bg-green-500/20 text-green-400' + : s === 'SENT' + ? 'bg-blue-500/20 text-blue-400' + : 'bg-red-500/20 text-red-400'; + return ( + {STATUS_LABEL[s] ?? s} + ); + }, + }, ]; +const PAGE_SIZE = 10; + export function AIAlert() { const { t } = useTranslation('fieldOps'); - const { alerts: storeAlerts, load } = useEventStore(); - useEffect(() => { load(); }, [load]); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [totalElements, setTotalElements] = useState(0); - const DATA: Alert[] = useMemo( + const fetchAlerts = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await getAlerts({ page: 0, size: 100 }); + setAlerts(res.content); + setTotalElements(res.totalElements); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAlerts(); + }, [fetchAlerts]); + + const data: AlertRow[] = useMemo( () => - storeAlerts.map((a) => ({ + alerts.map((a) => ({ id: a.id, - time: a.time, - type: a.type, - location: a.location, - confidence: a.confidence, - target: a.target, - status: a.status, - received: a.status === '수신확인' ? a.time.replace(/:\d{2}$/, (m) => `:${String(Number(m.slice(1)) + 3).padStart(2, '0')}`) : '-', + eventId: a.eventId, + time: a.sentAt ? new Date(a.sentAt).toLocaleString('ko-KR') : '-', + channel: a.channel ?? '-', + recipient: a.recipient ?? '-', + confidence: a.aiConfidence != null ? String(a.aiConfidence) : '', + status: a.deliveryStatus, })), - [storeAlerts], + [alerts], ); + const deliveredCount = alerts.filter((a) => a.deliveryStatus === 'DELIVERED').length; + const failedCount = alerts.filter((a) => a.deliveryStatus === 'FAILED').length; + + if (loading) { + return ( +
+ + 알림 데이터 로딩 중... +
+ ); + } + + if (error) { + return ( +
+ + 알림 조회 실패: {error} + +
+ ); + } + return (
-

{t('aiAlert.title')}

+

+ + {t('aiAlert.title')} +

{t('aiAlert.desc')}

- {[{ l: '총 발송', v: DATA.length, c: 'text-heading' }, { l: '수신확인', v: DATA.filter(d => d.status === '수신확인').length, c: 'text-green-400' }, { l: '미수신', v: DATA.filter(d => d.status === '미수신').length, c: 'text-red-400' }].map(k => ( -
- {k.v}{k.l} + {[ + { l: '총 발송', v: totalElements, c: 'text-heading' }, + { l: '수신확인', v: deliveredCount, c: 'text-green-400' }, + { l: '실패', v: failedCount, c: 'text-red-400' }, + ].map((k) => ( +
+ {k.v} + {k.l}
))}
- +
); } diff --git a/frontend/src/services/event.ts b/frontend/src/services/event.ts index e7e77ab..a43de18 100644 --- a/frontend/src/services/event.ts +++ b/frontend/src/services/event.ts @@ -100,6 +100,40 @@ export async function getEventStats(): Promise { return res.json(); } +// ─── 알림 API ──────────────────────────────────── + +export interface PredictionAlert { + id: number; + eventId: number; + channel: string; + recipient: string | null; + sentAt: string; + deliveryStatus: string; + aiConfidence: number | null; +} + +export interface AlertPageResponse { + content: PredictionAlert[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +export async function getAlerts(params?: { + eventId?: number; + page?: number; + size?: number; +}): Promise { + const query = new URLSearchParams(); + if (params?.eventId != null) query.set('eventId', String(params.eventId)); + query.set('page', String(params?.page ?? 0)); + query.set('size', String(params?.size ?? 20)); + const res = await fetch(`${API_BASE}/alerts?${query}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + // ─── 하위 호환 헬퍼 (기존 EventRecord 형식 → PredictionEvent 매핑) ── /** @deprecated PredictionEvent를 직접 사용하세요 */