diff --git a/frontend/src/features/detection/IllegalFishingPattern.tsx b/frontend/src/features/detection/IllegalFishingPattern.tsx
new file mode 100644
index 0000000..9fb545b
--- /dev/null
+++ b/frontend/src/features/detection/IllegalFishingPattern.tsx
@@ -0,0 +1,391 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Ban, RefreshCw, ExternalLink } from 'lucide-react';
+
+import { PageContainer, PageHeader, Section } from '@shared/components/layout';
+import { Badge } from '@shared/components/ui/badge';
+import { Button } from '@shared/components/ui/button';
+import { Input } from '@shared/components/ui/input';
+import { Select } from '@shared/components/ui/select';
+import { Card, CardContent } from '@shared/components/ui/card';
+import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
+
+import { formatDateTime } from '@shared/utils/dateFormat';
+import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
+import {
+ ILLEGAL_FISHING_CATEGORIES,
+ listIllegalFishingEvents,
+ type IllegalFishingCategory,
+ type IllegalFishingPatternPage,
+} from '@/services/illegalFishingPatternApi';
+import type { PredictionEvent } from '@/services/event';
+import { useSettingsStore } from '@stores/settingsStore';
+
+/**
+ * 불법 조업 이벤트 — event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종을
+ * 묶어 한 화면에서 조회하는 대시보드.
+ *
+ * GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이 공조
+ * EEZ_INTRUSION : 영해 침범 / 접속수역 고위험
+ * ZONE_DEPARTURE : 특정수역 진입 (risk ≥ 40)
+ *
+ * 운영자 액션(확인/상태변경/단속 등록)은 /event-list 이벤트 목록에서 수행.
+ * 이 페이지는 **READ 전용** 대시보드.
+ */
+
+const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
+const DEFAULT_SIZE = 200;
+
+export function IllegalFishingPattern() {
+ const { t } = useTranslation('detection');
+ const { t: tc } = useTranslation('common');
+ const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
+
+ const [page, setPage] = useState
(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [categoryFilter, setCategoryFilter] = useState('');
+ const [levelFilter, setLevelFilter] = useState('');
+ const [mmsiFilter, setMmsiFilter] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const loadData = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const result = await listIllegalFishingEvents({
+ category: categoryFilter || undefined,
+ level: levelFilter || undefined,
+ vesselMmsi: mmsiFilter || undefined,
+ size: DEFAULT_SIZE,
+ });
+ setPage(result);
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed'));
+ } finally {
+ setLoading(false);
+ }
+ }, [categoryFilter, levelFilter, mmsiFilter, t]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ const rows = page?.content ?? [];
+ const levelCount = (code: string) => page?.byLevel?.[code] ?? 0;
+ const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0;
+
+ const cols: DataColumn>[] = useMemo(
+ () => [
+ {
+ key: 'occurredAt',
+ label: t('illegalPattern.columns.occurredAt'),
+ width: '140px',
+ sortable: true,
+ render: (v) => (
+ {formatDateTime(v as string)}
+ ),
+ },
+ {
+ key: 'level',
+ label: t('illegalPattern.columns.level'),
+ width: '90px',
+ align: 'center',
+ sortable: true,
+ render: (v) => (
+
+ {getAlertLevelLabel(v as string, tc, lang)}
+
+ ),
+ },
+ {
+ key: 'category',
+ label: t('illegalPattern.columns.category'),
+ width: '130px',
+ align: 'center',
+ sortable: true,
+ render: (v) => (
+
+ {t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })}
+
+ ),
+ },
+ {
+ key: 'title',
+ label: t('illegalPattern.columns.title'),
+ minWidth: '260px',
+ render: (v) => {v as string},
+ },
+ {
+ key: 'vesselMmsi',
+ label: t('illegalPattern.columns.mmsi'),
+ width: '110px',
+ render: (v, row) => (
+
+ {(v as string) || '-'}
+ {row.vesselName ? ({row.vesselName}) : null}
+
+ ),
+ },
+ {
+ key: 'zoneCode',
+ label: t('illegalPattern.columns.zone'),
+ width: '130px',
+ render: (v) => {(v as string) || '-'},
+ },
+ {
+ key: 'status',
+ label: t('illegalPattern.columns.status'),
+ width: '90px',
+ align: 'center',
+ sortable: true,
+ render: (v) => (
+
+ {t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })}
+
+ ),
+ },
+ ],
+ [t, tc, lang],
+ );
+
+ return (
+
+ }
+ >
+ {t('illegalPattern.refresh')}
+
+ }
+ />
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ {ILLEGAL_FISHING_CATEGORIES.map((code) => (
+
+
+
+
+ {t(`illegalPattern.category.${code}`)}
+
+
+ {t(`illegalPattern.categoryDesc.${code}`)}
+
+
+ {categoryCount(code)}
+
+
+ ))}
+
+
+
+
+
+
+
+
setMmsiFilter(e.target.value)}
+ />
+
+
+ {t('illegalPattern.filters.limit')} · {DEFAULT_SIZE}
+
+
+
+
+ {rows.length === 0 && !loading ? (
+ {t('illegalPattern.list.empty')}
+ ) : (
+ )[]}
+ columns={cols}
+ pageSize={20}
+ showSearch={false}
+ showExport={false}
+ showPrint={false}
+ onRowClick={(row) => setSelected(row as PredictionEvent)}
+ />
+ )}
+
+
+ {selected && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selected.detail && (
+
+ {selected.detail}
+
+ )}
+ {selected.features && Object.keys(selected.features).length > 0 && (
+
+
+ {t('illegalPattern.detail.features')}
+
+
+ {JSON.stringify(selected.features, null, 2)}
+
+
+ )}
+
+
+ }
+ onClick={() => {
+ window.location.href = `/event-list?category=${selected.category}&mmsi=${selected.vesselMmsi ?? ''}`;
+ }}
+ >
+ {t('illegalPattern.detail.openEventList')}
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── 내부 컴포넌트 ─────────────
+
+interface StatCardProps {
+ label: string;
+ value: number;
+ intent?: 'warning' | 'info' | 'critical' | 'muted';
+}
+
+function StatCard({ label, value, intent }: StatCardProps) {
+ return (
+
+
+ {label}
+ {intent ? (
+
+ {value}
+
+ ) : (
+ {value}
+ )}
+
+
+ );
+}
+
+interface DetailRowProps {
+ label: string;
+ value: string;
+ mono?: boolean;
+}
+
+function DetailRow({ label, value, mono }: DetailRowProps) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default IllegalFishingPattern;
diff --git a/frontend/src/features/detection/TransshipmentDetection.tsx b/frontend/src/features/detection/TransshipmentDetection.tsx
new file mode 100644
index 0000000..e7c605e
--- /dev/null
+++ b/frontend/src/features/detection/TransshipmentDetection.tsx
@@ -0,0 +1,405 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ArrowLeftRight, RefreshCw } from 'lucide-react';
+
+import { PageContainer, PageHeader, Section } from '@shared/components/layout';
+import { Badge } from '@shared/components/ui/badge';
+import { Button } from '@shared/components/ui/button';
+import { Input } from '@shared/components/ui/input';
+import { Select } from '@shared/components/ui/select';
+import { Card, CardContent } from '@shared/components/ui/card';
+import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
+
+import { formatDateTime } from '@shared/utils/dateFormat';
+import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
+import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
+import { useSettingsStore } from '@stores/settingsStore';
+
+/**
+ * 환적(Transshipment) 의심 선박 탐지 페이지.
+ *
+ * prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
+ * (transship_suspect=true) 를 전체 목록으로 조회·집계·상세 확인.
+ *
+ * features.transship_tier (CRITICAL/HIGH/MEDIUM) 와 transship_score 로 심각도 판단.
+ * 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준, 이 페이지는
+ * 전체 목록·통계 수준의 운영 대시보드.
+ */
+
+const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const;
+const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
+const DEFAULT_HOURS = 6;
+const DEFAULT_SIZE = 200;
+
+type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string;
+
+interface TransshipFeatures {
+ transship_tier?: TransshipTier;
+ transship_score?: number;
+ dark_tier?: string;
+ [key: string]: unknown;
+}
+
+function readTier(row: VesselAnalysis): string {
+ const f = (row.features ?? {}) as TransshipFeatures;
+ return f.transship_tier ?? '-';
+}
+
+function readScore(row: VesselAnalysis): number | null {
+ const f = (row.features ?? {}) as TransshipFeatures;
+ return typeof f.transship_score === 'number' ? f.transship_score : null;
+}
+
+export function TransshipmentDetection() {
+ const { t } = useTranslation('detection');
+ const { t: tc } = useTranslation('common');
+ const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
+
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [hours, setHours] = useState(DEFAULT_HOURS);
+ const [levelFilter, setLevelFilter] = useState('');
+ const [mmsiFilter, setMmsiFilter] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const loadData = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE });
+ setRows(resp.content);
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed'));
+ } finally {
+ setLoading(false);
+ }
+ }, [hours, t]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ const filteredRows = useMemo(() => {
+ return rows.filter((r) => {
+ if (levelFilter && r.riskLevel !== levelFilter) return false;
+ if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false;
+ return true;
+ });
+ }, [rows, levelFilter, mmsiFilter]);
+
+ const stats = useMemo(() => {
+ const byLevel: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
+ const byTier: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 };
+ for (const r of rows) {
+ if (r.riskLevel && byLevel[r.riskLevel] !== undefined) {
+ byLevel[r.riskLevel]++;
+ }
+ const tier = readTier(r);
+ if (byTier[tier] !== undefined) {
+ byTier[tier]++;
+ }
+ }
+ return { byLevel, byTier };
+ }, [rows]);
+
+ const cols: DataColumn>[] = useMemo(
+ () => [
+ {
+ key: 'analyzedAt',
+ label: t('transshipment.columns.analyzedAt'),
+ width: '140px',
+ sortable: true,
+ render: (v) => (
+ {formatDateTime(v as string)}
+ ),
+ },
+ {
+ key: 'mmsi',
+ label: t('transshipment.columns.mmsi'),
+ width: '120px',
+ sortable: true,
+ render: (v) => (
+
+ {v as string}
+
+ ),
+ },
+ {
+ key: 'transshipPairMmsi',
+ label: t('transshipment.columns.pairMmsi'),
+ width: '120px',
+ render: (v) => (
+ {(v as string) || '-'}
+ ),
+ },
+ {
+ key: 'transshipDurationMin',
+ label: t('transshipment.columns.durationMin'),
+ width: '90px',
+ align: 'right',
+ sortable: true,
+ render: (v) => {
+ const n = typeof v === 'number' ? v : Number(v ?? 0);
+ return {n.toFixed(0)};
+ },
+ },
+ {
+ key: 'features',
+ label: t('transshipment.columns.tier'),
+ width: '90px',
+ align: 'center',
+ render: (_, row) => {
+ const tier = readTier(row);
+ const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
+ return (
+
+ {isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
+
+ );
+ },
+ },
+ {
+ key: 'riskScore',
+ label: t('transshipment.columns.riskScore'),
+ width: '80px',
+ align: 'right',
+ sortable: true,
+ render: (v) => {(v as number) ?? 0},
+ },
+ {
+ key: 'riskLevel',
+ label: t('transshipment.columns.riskLevel'),
+ width: '90px',
+ align: 'center',
+ sortable: true,
+ render: (v) => (
+
+ {getAlertLevelLabel(v as string, tc, lang)}
+
+ ),
+ },
+ {
+ key: 'zoneCode',
+ label: t('transshipment.columns.zone'),
+ width: '130px',
+ render: (v) => {(v as string) || '-'},
+ },
+ ],
+ [t, tc, lang],
+ );
+
+ return (
+
+ }
+ >
+ {t('transshipment.refresh')}
+
+ }
+ />
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
setMmsiFilter(e.target.value)}
+ />
+
+
+ {filteredRows.length} / {rows.length}
+
+
+
+
+ {filteredRows.length === 0 && !loading ? (
+
+ {t('transshipment.list.empty', { hours })}
+
+ ) : (
+ )[]}
+ columns={cols}
+ pageSize={20}
+ showSearch={false}
+ showExport={false}
+ showPrint={false}
+ onRowClick={(row) => setSelected(row as VesselAnalysis)}
+ />
+ )}
+
+
+ {selected && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('transshipment.columns.tier')}:
+
+ {(() => {
+ const tier = readTier(selected);
+ const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
+ return (
+
+ {isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
+
+ );
+ })()}
+
+ {t('transshipment.detail.transshipScore')}:
+
+
+ {readScore(selected)?.toFixed(1) ?? '-'}
+
+
+ {selected.features && Object.keys(selected.features).length > 0 && (
+
+
+ {t('transshipment.detail.features')}
+
+
+ {JSON.stringify(selected.features, null, 2)}
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── 내부 컴포넌트 ─────────────
+
+interface StatCardProps {
+ label: string;
+ value: number;
+ intent?: 'warning' | 'info' | 'critical' | 'muted';
+}
+
+function StatCard({ label, value, intent }: StatCardProps) {
+ return (
+
+
+ {label}
+ {intent ? (
+
+ {value}
+
+ ) : (
+ {value}
+ )}
+
+
+ );
+}
+
+interface DetailRowProps {
+ label: string;
+ value: string;
+ mono?: boolean;
+}
+
+function DetailRow({ label, value, mono }: DetailRowProps) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default TransshipmentDetection;
diff --git a/frontend/src/features/detection/index.ts b/frontend/src/features/detection/index.ts
index 94f2b13..9f65121 100644
--- a/frontend/src/features/detection/index.ts
+++ b/frontend/src/features/detection/index.ts
@@ -3,3 +3,5 @@ export { GearDetection } from './GearDetection';
export { ChinaFishing } from './ChinaFishing';
export { GearIdentification } from './GearIdentification';
export { GearCollisionDetection } from './GearCollisionDetection';
+export { IllegalFishingPattern } from './IllegalFishingPattern';
+export { TransshipmentDetection } from './TransshipmentDetection';
diff --git a/frontend/src/features/monitoring/SystemStatusPanel.tsx b/frontend/src/features/monitoring/SystemStatusPanel.tsx
index b90222e..3c4ab09 100644
--- a/frontend/src/features/monitoring/SystemStatusPanel.tsx
+++ b/frontend/src/features/monitoring/SystemStatusPanel.tsx
@@ -101,9 +101,9 @@ export function SystemStatusPanel() {
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusIntent={stats ? 'success' : 'critical'}
details={[
- ['선박 분석', stats ? `${stats.total.toLocaleString()}건` : '-'],
- ['클러스터', stats ? `${stats.clusterCount}` : '-'],
- ['어구 그룹', stats ? `${stats.gearGroups}` : '-'],
+ ['선박 분석', stats?.total != null ? `${stats.total.toLocaleString()}건` : '-'],
+ ['클러스터', stats?.clusterCount != null ? `${stats.clusterCount}` : '-'],
+ ['어구 그룹', stats?.gearGroups != null ? `${stats.gearGroups}` : '-'],
]}
/>
@@ -124,10 +124,10 @@ export function SystemStatusPanel() {
{/* 위험도 분포 */}
{stats && (
-
-
-
-
+
+
+
+
)}
diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json
index 86600d7..ffbd530 100644
--- a/frontend/src/lib/i18n/locales/en/common.json
+++ b/frontend/src/lib/i18n/locales/en/common.json
@@ -9,6 +9,8 @@
"gearDetection": "Gear Detection",
"chinaFishing": "Chinese Vessel",
"gearCollision": "Gear Collision",
+ "illegalFishing": "Illegal Fishing",
+ "transshipment": "Transshipment",
"patrolRoute": "Patrol Route",
"fleetOptimization": "Fleet Optimize",
"enforcementHistory": "History",
diff --git a/frontend/src/lib/i18n/locales/en/detection.json b/frontend/src/lib/i18n/locales/en/detection.json
index 6a6ed67..2d5d68c 100644
--- a/frontend/src/lib/i18n/locales/en/detection.json
+++ b/frontend/src/lib/i18n/locales/en/detection.json
@@ -15,6 +15,110 @@
"title": "Gear Identification",
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
},
+ "transshipment": {
+ "title": "Transshipment Suspects",
+ "desc": "Vessels passing prediction's 5-stage transshipment filter (cross-type pair → monitoring zone → RENDEZVOUS 90min+ → score 50+ → anti-cluster burst). Severity from features.transship_tier",
+ "refresh": "Refresh",
+ "stats": {
+ "title": "Overview",
+ "total": "Total",
+ "tierCritical": "Transship CRITICAL",
+ "tierHigh": "Transship HIGH",
+ "tierMedium": "Transship MEDIUM",
+ "riskCritical": "Risk CRITICAL"
+ },
+ "list": {
+ "title": "Suspect vessels",
+ "empty": "No transshipment suspects in the last {{hours}} hours."
+ },
+ "columns": {
+ "analyzedAt": "Analyzed",
+ "mmsi": "MMSI",
+ "pairMmsi": "Pair MMSI",
+ "durationMin": "Duration (min)",
+ "tier": "Transship tier",
+ "riskScore": "Risk",
+ "riskLevel": "Risk level",
+ "zone": "Zone"
+ },
+ "filters": {
+ "hours": "Window",
+ "level": "Risk level",
+ "mmsi": "MMSI",
+ "hoursValue": "Last {{h}}h",
+ "allLevel": "All levels"
+ },
+ "detail": {
+ "title": "Transshipment detail",
+ "location": "Location",
+ "features": "Raw features",
+ "transshipScore": "Transship score",
+ "close": "Close"
+ },
+ "error": {
+ "loadFailed": "Failed to load transshipment suspects."
+ }
+ },
+ "illegalPattern": {
+ "title": "Illegal Fishing Events",
+ "desc": "Integrated view of illegal fishing–related events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)",
+ "refresh": "Refresh",
+ "stats": {
+ "title": "Severity distribution",
+ "total": "Total"
+ },
+ "byCategory": {
+ "title": "By category"
+ },
+ "category": {
+ "GEAR_ILLEGAL": "Gear Violation",
+ "EEZ_INTRUSION": "Territorial/Contiguous",
+ "ZONE_DEPARTURE": "Protected Zone Entry"
+ },
+ "categoryDesc": {
+ "GEAR_ILLEGAL": "G-01/G-05/G-06 zone-gear mismatch, fixed-gear drift, pair trawl",
+ "EEZ_INTRUSION": "Territorial sea (CRITICAL) / Contiguous zone high-risk",
+ "ZONE_DEPARTURE": "Protected zone entry with risk ≥ 40"
+ },
+ "list": {
+ "title": "Events",
+ "empty": "No illegal fishing events match the filters."
+ },
+ "columns": {
+ "occurredAt": "Occurred",
+ "level": "Level",
+ "category": "Category",
+ "title": "Title",
+ "mmsi": "MMSI",
+ "zone": "Zone",
+ "status": "Status"
+ },
+ "filters": {
+ "category": "Category",
+ "level": "Level",
+ "mmsi": "MMSI",
+ "limit": "Limit",
+ "allCategory": "All categories",
+ "allLevel": "All levels"
+ },
+ "status": {
+ "NEW": "New",
+ "ACKED": "Acked",
+ "RESOLVED": "Resolved",
+ "FALSE_ALARM": "False alarm"
+ },
+ "detail": {
+ "title": "Event detail",
+ "vesselName": "Vessel name",
+ "location": "Location",
+ "features": "Extra info",
+ "close": "Close",
+ "openEventList": "Open in Event List"
+ },
+ "error": {
+ "loadFailed": "Failed to load events."
+ }
+ },
"gearCollision": {
"title": "Gear Identity Collision",
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json
index 6d306b2..b244cf6 100644
--- a/frontend/src/lib/i18n/locales/ko/common.json
+++ b/frontend/src/lib/i18n/locales/ko/common.json
@@ -9,6 +9,8 @@
"gearDetection": "어구 탐지",
"chinaFishing": "중국어선 분석",
"gearCollision": "어구 정체성 충돌",
+ "illegalFishing": "불법 조업 이벤트",
+ "transshipment": "환적 의심 탐지",
"patrolRoute": "순찰경로 추천",
"fleetOptimization": "다함정 최적화",
"enforcementHistory": "단속 이력",
diff --git a/frontend/src/lib/i18n/locales/ko/detection.json b/frontend/src/lib/i18n/locales/ko/detection.json
index 8a4b4d7..7bf9712 100644
--- a/frontend/src/lib/i18n/locales/ko/detection.json
+++ b/frontend/src/lib/i18n/locales/ko/detection.json
@@ -15,6 +15,110 @@
"title": "어구 식별 분석",
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
},
+ "transshipment": {
+ "title": "환적 의심 탐지",
+ "desc": "prediction 5단계 필터 파이프라인(이종 쌍 → 감시영역 → RENDEZVOUS 90분+ → 점수 50+ → 밀집 방폭) 통과 선박 목록. features.transship_tier 기반 심각도 분류",
+ "refresh": "새로고침",
+ "stats": {
+ "title": "현황 요약",
+ "total": "전체",
+ "tierCritical": "환적 CRITICAL",
+ "tierHigh": "환적 HIGH",
+ "tierMedium": "환적 MEDIUM",
+ "riskCritical": "종합위험 CRITICAL"
+ },
+ "list": {
+ "title": "환적 의심 선박",
+ "empty": "최근 {{hours}}시간 내 환적 의심 선박이 없습니다."
+ },
+ "columns": {
+ "analyzedAt": "분석 시각",
+ "mmsi": "MMSI",
+ "pairMmsi": "상대 MMSI",
+ "durationMin": "지속(분)",
+ "tier": "환적 tier",
+ "riskScore": "위험도",
+ "riskLevel": "종합 위험",
+ "zone": "수역"
+ },
+ "filters": {
+ "hours": "조회 기간",
+ "level": "위험도",
+ "mmsi": "MMSI 검색",
+ "hoursValue": "최근 {{h}}시간",
+ "allLevel": "전체 위험도"
+ },
+ "detail": {
+ "title": "환적 의심 상세",
+ "location": "좌표",
+ "features": "분석 피처 원본",
+ "transshipScore": "환적 점수",
+ "close": "닫기"
+ },
+ "error": {
+ "loadFailed": "환적 의심 목록을 불러오지 못했습니다."
+ }
+ },
+ "illegalPattern": {
+ "title": "불법 조업 이벤트",
+ "desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",
+ "refresh": "새로고침",
+ "stats": {
+ "title": "심각도 분포",
+ "total": "전체"
+ },
+ "byCategory": {
+ "title": "카테고리별 건수"
+ },
+ "category": {
+ "GEAR_ILLEGAL": "어구 위반",
+ "EEZ_INTRUSION": "영해/접속수역 침범",
+ "ZONE_DEPARTURE": "특정수역 진입"
+ },
+ "categoryDesc": {
+ "GEAR_ILLEGAL": "G-01/G-05/G-06 수역·어구 불일치, 고정어구 drift, 쌍끌이 공조",
+ "EEZ_INTRUSION": "영해(CRITICAL) / 접속수역 + 고위험 위반",
+ "ZONE_DEPARTURE": "관심 수역(ZONE_I~IV) 진입 + 위험도 40+"
+ },
+ "list": {
+ "title": "이벤트 목록",
+ "empty": "조건에 맞는 불법 조업 이벤트가 없습니다."
+ },
+ "columns": {
+ "occurredAt": "발생 시각",
+ "level": "심각도",
+ "category": "카테고리",
+ "title": "제목",
+ "mmsi": "MMSI",
+ "zone": "수역",
+ "status": "상태"
+ },
+ "filters": {
+ "category": "카테고리",
+ "level": "심각도",
+ "mmsi": "MMSI 검색",
+ "limit": "최대",
+ "allCategory": "전체 카테고리",
+ "allLevel": "전체 심각도"
+ },
+ "status": {
+ "NEW": "신규",
+ "ACKED": "확인",
+ "RESOLVED": "처리완료",
+ "FALSE_ALARM": "오탐"
+ },
+ "detail": {
+ "title": "이벤트 상세",
+ "vesselName": "선박명",
+ "location": "좌표",
+ "features": "추가 정보",
+ "close": "닫기",
+ "openEventList": "이벤트 목록에서 열기"
+ },
+ "error": {
+ "loadFailed": "이벤트를 불러오지 못했습니다."
+ }
+ },
"gearCollision": {
"title": "어구 정체성 충돌 탐지",
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
diff --git a/frontend/src/services/illegalFishingPatternApi.ts b/frontend/src/services/illegalFishingPatternApi.ts
new file mode 100644
index 0000000..2cda3cb
--- /dev/null
+++ b/frontend/src/services/illegalFishingPatternApi.ts
@@ -0,0 +1,77 @@
+/**
+ * 불법 조업 이벤트 전용 서비스 — 기존 /api/events 를 category 다중 조회로 래핑.
+ *
+ * category 는 event_generator 의 rule 에서 오는 단일 값이지만, UI 관점에서 "불법 조업"
+ * 은 여러 카테고리의 합집합이다:
+ * - GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이
+ * - EEZ_INTRUSION : 영해 침범 / 접속수역 + 고위험
+ * - ZONE_DEPARTURE : 특정수역 진입 (관심구역 모니터링)
+ *
+ * backend 변경 없이 클라이언트에서 병렬 조회 후 머지한다.
+ */
+import { getEvents, type EventPageResponse, type PredictionEvent } from './event';
+
+export const ILLEGAL_FISHING_CATEGORIES = [
+ 'GEAR_ILLEGAL',
+ 'EEZ_INTRUSION',
+ 'ZONE_DEPARTURE',
+] as const;
+
+export type IllegalFishingCategory = (typeof ILLEGAL_FISHING_CATEGORIES)[number];
+
+export interface ListParams {
+ /** 단일 카테고리를 지정하면 해당 카테고리만, '' 이면 3개 모두 병합 조회 */
+ category?: IllegalFishingCategory | '';
+ level?: string;
+ status?: string;
+ vesselMmsi?: string;
+ size?: number;
+}
+
+export interface IllegalFishingPatternPage {
+ content: PredictionEvent[];
+ totalElements: number;
+ byCategory: Record;
+ byLevel: Record;
+}
+
+/**
+ * 병합 조회 — category 미지정 시 3개 병렬 조회 후 occurredAt desc 정렬로 머지.
+ * 각 카테고리 최대 size 건씩 수집하므로, 기본 200 * 3 = 600 건이 상한.
+ */
+export async function listIllegalFishingEvents(params?: ListParams): Promise {
+ const size = params?.size ?? 200;
+ const targetCategories: IllegalFishingCategory[] = params?.category
+ ? [params.category]
+ : [...ILLEGAL_FISHING_CATEGORIES];
+
+ const pages: EventPageResponse[] = await Promise.all(
+ targetCategories.map((category) =>
+ getEvents({
+ category,
+ level: params?.level,
+ status: params?.status,
+ vesselMmsi: params?.vesselMmsi,
+ page: 0,
+ size,
+ }),
+ ),
+ );
+
+ const allEvents: PredictionEvent[] = pages.flatMap((p) => p.content);
+ allEvents.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
+
+ const byCategory: Record = {};
+ const byLevel: Record = {};
+ for (const e of allEvents) {
+ byCategory[e.category] = (byCategory[e.category] ?? 0) + 1;
+ byLevel[e.level] = (byLevel[e.level] ?? 0) + 1;
+ }
+
+ return {
+ content: allEvents,
+ totalElements: pages.reduce((acc, p) => acc + p.totalElements, 0),
+ byCategory,
+ byLevel,
+ };
+}
diff --git a/prediction/pipeline/stage_runner.py b/prediction/pipeline/stage_runner.py
new file mode 100644
index 0000000..b402a7b
--- /dev/null
+++ b/prediction/pipeline/stage_runner.py
@@ -0,0 +1,58 @@
+"""사이클 스테이지 에러 경계 유틸.
+
+`run_analysis_cycle` 내부의 각 스테이지를 한 지점에서 감싸서
+실패 스테이지를 명시적으로 로깅하고, 부분 실패가 후속 스테이지를
+막지 않도록 한다.
+
+설계 원칙:
+- 비필수 스테이지는 예외를 흡수하고 None 을 반환 → 호출자는
+ `if result is None` 로 건너뛰기 선택 가능
+- 필수 스테이지(`required=True`)는 예외를 그대로 올려 상위
+ `run_analysis_cycle` 의 top-level try/except 가 잡도록 한다
+- `logger.exception` 사용으로 stacktrace 가 저널에 남도록 하여
+ 원격 서버(journalctl) 에서 실패 지점 특정 가능
+"""
+from __future__ import annotations
+
+import logging
+import time
+from typing import Any, Callable, TypeVar
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar('T')
+
+
+def run_stage(
+ name: str,
+ fn: Callable[..., T],
+ *args: Any,
+ required: bool = False,
+ **kwargs: Any,
+) -> T | None:
+ """스테이지 실행 + 지속시간 로깅 + 실패 격리.
+
+ Args:
+ name: 스테이지 이름 (로그 라벨). 'fleet_tracking', 'pair_detection' 등
+ fn: 실행할 호출 가능 객체
+ *args, **kwargs: fn 에 전달
+ required: True 면 실패 시 예외를 re-raise. False 면 None 반환하고 계속
+
+ Returns:
+ fn 의 반환값, 또는 실패 시 None (required=False 일 때)
+
+ Raises:
+ fn 이 던진 예외 (required=True 일 때만)
+ """
+ t0 = time.time()
+ try:
+ result = fn(*args, **kwargs)
+ elapsed = time.time() - t0
+ logger.info('stage %s ok in %.2fs', name, elapsed)
+ return result
+ except Exception as e:
+ elapsed = time.time() - t0
+ logger.exception('stage %s failed after %.2fs: %s', name, elapsed, e)
+ if required:
+ raise
+ return None
diff --git a/prediction/scheduler.py b/prediction/scheduler.py
index 08c3f22..fd9a030 100644
--- a/prediction/scheduler.py
+++ b/prediction/scheduler.py
@@ -8,6 +8,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
from config import settings
from fleet_tracker import GEAR_PATTERN
+from pipeline.stage_runner import run_stage
logger = logging.getLogger(__name__)
@@ -69,7 +70,7 @@ def _fetch_dark_history(kcg_conn, mmsi_list: list[str]) -> dict[str, dict]:
for m, n7, n24, t in cur.fetchall()
}
except Exception as e:
- logger.warning('fetch_dark_history failed: %s', e)
+ logger.exception('fetch_dark_history failed: %s', e)
return {}
@@ -170,7 +171,7 @@ def run_analysis_cycle():
collision_events['skipped_low'],
)
except Exception as e:
- logger.warning('gear collision event promotion failed: %s', e)
+ logger.exception('gear collision event promotion failed: %s', e)
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
@@ -193,7 +194,7 @@ def run_analysis_cycle():
logger.info('group polygons: %d saved, %d cleaned, %d gear groups',
saved, cleaned, len(gear_groups))
except Exception as e:
- logger.warning('group polygon generation failed: %s', e)
+ logger.exception('group polygon generation failed: %s', e)
# 4.7 어구 연관성 분석 (멀티모델 패턴 추적)
try:
@@ -226,7 +227,7 @@ def run_analysis_cycle():
inference_result['skipped'],
)
except Exception as e:
- logger.warning('gear correlation failed: %s', e)
+ logger.exception('gear correlation failed: %s', e)
# 4.9 페어 후보 탐색 (bbox 1차 + 궤적 유사도 2차 → G-06 pair_trawl 판정)
pair_results: dict[str, dict] = {}
@@ -300,7 +301,7 @@ def run_analysis_cycle():
REJECT_COUNTERS['insufficient_aligned'], REJECT_COUNTERS['no_sync_at_any_tier'],
)
except Exception as e:
- logger.warning('pair detection failed: %s', e)
+ logger.exception('pair detection failed: %s', e)
# 5. 선박별 추가 알고리즘 → AnalysisResult 생성
# dark 이력 일괄 조회 (7일 history) — 사이클당 1회
@@ -712,37 +713,36 @@ def run_analysis_cycle():
'transship_score': item['score'],
}
- # 7. 결과 저장
- upserted = kcgdb.upsert_results(results)
- kcgdb.cleanup_old(hours=48)
+ # 7. 결과 저장 (필수 — 실패 시 사이클 abort)
+ upserted = run_stage('upsert_results', kcgdb.upsert_results, results, required=True)
+ run_stage('cleanup_old', kcgdb.cleanup_old, hours=48)
- # 8. 출력 모듈 (이벤트 생성, 위반 분류, KPI 갱신, 통계 집계, 경보)
- try:
- from output.violation_classifier import run_violation_classifier
- from output.event_generator import run_event_generator
- from output.kpi_writer import run_kpi_writer
- from output.stats_aggregator import aggregate_hourly, aggregate_daily
- from output.alert_dispatcher import run_alert_dispatcher
+ # 8. 출력 모듈 — 각 단계를 독립적으로 실행해 실패 지점을 명시적으로 기록.
+ # 한 모듈이 깨져도 다른 모듈은 계속 돌아가야 한다 (예: event_generator 는 실패했어도
+ # kpi_writer / stats_aggregator / alert_dispatcher 는 이전 사이클 결과로 동작 가능).
+ from output.violation_classifier import run_violation_classifier
+ from output.event_generator import run_event_generator
+ from output.kpi_writer import run_kpi_writer
+ from output.stats_aggregator import aggregate_hourly, aggregate_daily
+ from output.alert_dispatcher import run_alert_dispatcher
- from dataclasses import asdict
- results_dicts = [asdict(r) for r in results]
- # 필드명 매핑 (AnalysisResult → 출력 모듈 기대 형식)
- for d in results_dicts:
- d['zone_code'] = d.pop('zone', None)
- d['gap_duration_min'] = d.get('gap_duration_min', 0)
- d['transship_suspect'] = d.pop('is_transship_suspect', False)
- d['fleet_is_leader'] = d.pop('is_leader', False)
- d['fleet_cluster_id'] = d.pop('cluster_id', None)
- d['speed_kn'] = None # 분석 결과에 속도 없음
- run_violation_classifier(results_dicts)
- run_event_generator(results_dicts)
- run_kpi_writer()
- aggregate_hourly()
- aggregate_daily()
- run_alert_dispatcher()
- logger.info('output modules completed')
- except Exception as e:
- logger.warning('output modules failed (non-fatal): %s', e)
+ from dataclasses import asdict
+ results_dicts = [asdict(r) for r in results]
+ # 필드명 매핑 (AnalysisResult → 출력 모듈 기대 형식)
+ for d in results_dicts:
+ d['zone_code'] = d.pop('zone', None)
+ d['gap_duration_min'] = d.get('gap_duration_min', 0)
+ d['transship_suspect'] = d.pop('is_transship_suspect', False)
+ d['fleet_is_leader'] = d.pop('is_leader', False)
+ d['fleet_cluster_id'] = d.pop('cluster_id', None)
+ d['speed_kn'] = None # 분석 결과에 속도 없음
+
+ run_stage('violation_classifier', run_violation_classifier, results_dicts)
+ run_stage('event_generator', run_event_generator, results_dicts)
+ run_stage('kpi_writer', run_kpi_writer)
+ run_stage('stats_aggregate_hourly', aggregate_hourly)
+ run_stage('stats_aggregate_daily', aggregate_daily)
+ run_stage('alert_dispatcher', run_alert_dispatcher)
# 9. Redis에 분석 컨텍스트 캐싱 (채팅용)
try:
@@ -788,7 +788,7 @@ def run_analysis_cycle():
'polygon_summary': kcgdb.fetch_polygon_summary(),
})
except Exception as e:
- logger.warning('failed to cache analysis context for chat: %s', e)
+ logger.exception('failed to cache analysis context for chat: %s', e)
elapsed = round(time.time() - start, 2)
_last_run['duration_sec'] = elapsed