docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
prediction algorithms/transshipment.py 5단계 필터 파이프라인 결과를 전체 목록·
집계·상세 수준으로 조회하는 READ 전용 대시보드.
### 배경
기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준(특정 MMSI 의 환적
이력)이고, 환적 의심 선박 전체 목록을 보려면 ChinaFishing 의 탭 중 하나를 거쳐야
했다. /api/analysis/transship 엔드포인트는 이미 존재하나 전용 페이지가 없었음.
### 변경
- frontend/src/features/detection/TransshipmentDetection.tsx 신설 (405 라인)
- PageContainer + PageHeader(ArrowLeftRight) + KPI 5장
(Total / Transship tier CRITICAL/HIGH/MEDIUM / Risk CRITICAL)
- DataTable 8컬럼 (analyzedAt / mmsi / pairMmsi / duration / tier / risk / zone)
- features.transship_tier 읽어 Badge 로 심각도 표시
- 필터: hours(1/6/12/24/48) / riskLevel / mmsi 검색
- 상세 패널: 분석 피처 JSON 원본 + 좌표 + transship_score
- 기존 analysisApi.getTransshipSuspects 재사용 — backend 변경 없음
- index.ts + componentRegistry.ts 등록
- detection.json (ko/en) transshipment.* 네임스페이스 추가 (각 44키)
- common.json (ko/en) nav.transshipment 추가
- V033__menu_transshipment_detection.sql
- auth_perm_tree(detection:transshipment, nav_sort=910)
- ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ
### 권한 주의
/api/analysis/transship 의 @RequirePermission 은 현재 detection:dark-vessel.
이 메뉴 READ 만으로는 API 호출 불가. 현행 운영자 역할(OPERATOR/ANALYST/FIELD)
은 dark-vessel READ 도 보유하므로 실용 동작.
향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
detection:transshipment 로 교체하는 권한 일관화는 별도 MR (후속).
### 검증
- npx tsc --noEmit 통과
- pre-commit tsc + ESLint 통과 예정
- Flyway V033 자동 적용 (백엔드 재배포 필요)
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
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<VesselAnalysis[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
|
|
const [levelFilter, setLevelFilter] = useState('');
|
|
const [mmsiFilter, setMmsiFilter] = useState('');
|
|
const [selected, setSelected] = useState<VesselAnalysis | null>(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<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
const byTier: Record<string, number> = { 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<VesselAnalysis & Record<string, unknown>>[] = useMemo(
|
|
() => [
|
|
{
|
|
key: 'analyzedAt',
|
|
label: t('transshipment.columns.analyzedAt'),
|
|
width: '140px',
|
|
sortable: true,
|
|
render: (v) => (
|
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'mmsi',
|
|
label: t('transshipment.columns.mmsi'),
|
|
width: '120px',
|
|
sortable: true,
|
|
render: (v) => (
|
|
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
|
{v as string}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'transshipPairMmsi',
|
|
label: t('transshipment.columns.pairMmsi'),
|
|
width: '120px',
|
|
render: (v) => (
|
|
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
|
|
),
|
|
},
|
|
{
|
|
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 <span className="font-mono text-label">{n.toFixed(0)}</span>;
|
|
},
|
|
},
|
|
{
|
|
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 (
|
|
<Badge
|
|
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
|
|
size="sm"
|
|
>
|
|
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'riskScore',
|
|
label: t('transshipment.columns.riskScore'),
|
|
width: '80px',
|
|
align: 'right',
|
|
sortable: true,
|
|
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
|
|
},
|
|
{
|
|
key: 'riskLevel',
|
|
label: t('transshipment.columns.riskLevel'),
|
|
width: '90px',
|
|
align: 'center',
|
|
sortable: true,
|
|
render: (v) => (
|
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
|
{getAlertLevelLabel(v as string, tc, lang)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'zoneCode',
|
|
label: t('transshipment.columns.zone'),
|
|
width: '130px',
|
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
|
},
|
|
],
|
|
[t, tc, lang],
|
|
);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={ArrowLeftRight}
|
|
iconColor="text-purple-600 dark:text-purple-400"
|
|
title={t('transshipment.title')}
|
|
description={t('transshipment.desc')}
|
|
actions={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={loadData}
|
|
disabled={loading}
|
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
|
>
|
|
{t('transshipment.refresh')}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{error && (
|
|
<Card variant="default">
|
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Section title={t('transshipment.stats.title')}>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
|
|
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
|
|
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
|
|
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
|
|
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t('transshipment.list.title')}>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
|
<Select
|
|
aria-label={t('transshipment.filters.hours')}
|
|
value={String(hours)}
|
|
onChange={(e) => setHours(Number(e.target.value))}
|
|
>
|
|
{HOUR_OPTIONS.map((h) => (
|
|
<option key={h} value={h}>
|
|
{t('transshipment.filters.hoursValue', { h })}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Select
|
|
aria-label={t('transshipment.filters.level')}
|
|
value={levelFilter}
|
|
onChange={(e) => setLevelFilter(e.target.value)}
|
|
>
|
|
<option value="">{t('transshipment.filters.allLevel')}</option>
|
|
{LEVEL_OPTIONS.map((l) => (
|
|
<option key={l} value={l}>
|
|
{getAlertLevelLabel(l, tc, lang)}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Input
|
|
aria-label={t('transshipment.filters.mmsi')}
|
|
placeholder={t('transshipment.filters.mmsi')}
|
|
value={mmsiFilter}
|
|
onChange={(e) => setMmsiFilter(e.target.value)}
|
|
/>
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<Badge intent="info" size="sm">
|
|
{filteredRows.length} / {rows.length}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{filteredRows.length === 0 && !loading ? (
|
|
<p className="text-hint text-xs py-4 text-center">
|
|
{t('transshipment.list.empty', { hours })}
|
|
</p>
|
|
) : (
|
|
<DataTable
|
|
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
|
|
columns={cols}
|
|
pageSize={20}
|
|
showSearch={false}
|
|
showExport={false}
|
|
showPrint={false}
|
|
onRowClick={(row) => setSelected(row as VesselAnalysis)}
|
|
/>
|
|
)}
|
|
</Section>
|
|
|
|
{selected && (
|
|
<Section title={t('transshipment.detail.title')}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
|
<div className="space-y-1.5">
|
|
<DetailRow
|
|
label={t('transshipment.columns.analyzedAt')}
|
|
value={formatDateTime(selected.analyzedAt)}
|
|
/>
|
|
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
|
|
<DetailRow
|
|
label={t('transshipment.columns.pairMmsi')}
|
|
value={selected.transshipPairMmsi ?? '-'}
|
|
mono
|
|
/>
|
|
<DetailRow
|
|
label={t('transshipment.columns.durationMin')}
|
|
value={`${selected.transshipDurationMin ?? 0}분`}
|
|
/>
|
|
<DetailRow
|
|
label={t('transshipment.columns.riskScore')}
|
|
value={String(selected.riskScore ?? 0)}
|
|
/>
|
|
<DetailRow
|
|
label={t('transshipment.columns.zone')}
|
|
value={selected.zoneCode ?? '-'}
|
|
/>
|
|
<DetailRow
|
|
label={t('transshipment.detail.location')}
|
|
value={
|
|
selected.lat != null && selected.lon != null
|
|
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
|
: '-'
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-label">
|
|
{t('transshipment.columns.tier')}:
|
|
</span>
|
|
{(() => {
|
|
const tier = readTier(selected);
|
|
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
|
return (
|
|
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
|
|
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
|
</Badge>
|
|
);
|
|
})()}
|
|
<span className="text-xs text-label ml-3">
|
|
{t('transshipment.detail.transshipScore')}:
|
|
</span>
|
|
<span className="text-xs font-mono text-heading">
|
|
{readScore(selected)?.toFixed(1) ?? '-'}
|
|
</span>
|
|
</div>
|
|
{selected.features && Object.keys(selected.features).length > 0 && (
|
|
<div>
|
|
<div className="text-[10px] text-hint mb-1">
|
|
{t('transshipment.detail.features')}
|
|
</div>
|
|
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
|
|
{JSON.stringify(selected.features, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end pt-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
|
{t('transshipment.detail.close')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// ─── 내부 컴포넌트 ─────────────
|
|
|
|
interface StatCardProps {
|
|
label: string;
|
|
value: number;
|
|
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
|
}
|
|
|
|
function StatCard({ label, value, intent }: StatCardProps) {
|
|
return (
|
|
<Card variant="default">
|
|
<CardContent className="py-3 flex flex-col items-center gap-1">
|
|
<span className="text-[10px] text-hint text-center">{label}</span>
|
|
{intent ? (
|
|
<Badge intent={intent} size="md">
|
|
{value}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-lg font-bold text-heading">{value}</span>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface DetailRowProps {
|
|
label: string;
|
|
value: string;
|
|
mono?: boolean;
|
|
}
|
|
|
|
function DetailRow({ label, value, mono }: DetailRowProps) {
|
|
return (
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-hint w-24 shrink-0">{label}</span>
|
|
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TransshipmentDetection;
|