동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로 fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소. Prediction - algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity - fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전 - output/event_generator.py: run_gear_identity_collision_events 추가 - scheduler.py: track_gear_identity 직후 이벤트 승격 호출 Backend (domain/analysis) - GearIdentityCollision 엔티티 + Repository(Specification+stats) - GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve) - GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve) - GearCollisionResponse / StatsResponse / ResolveRequest (record) DB - V030__gear_identity_collision.sql: gear_identity_collisions 테이블 + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한 Frontend - shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록 - services/gearCollisionApi.ts (list/stats/get/resolve) - features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable + 분류 액션 폼, design system SSOT 준수) - componentRegistry + feature index + i18n detection.json / common.json(ko/en)
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AlertOctagon, 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 { Textarea } from '@shared/components/ui/textarea';
|
|
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 {
|
|
GEAR_COLLISION_STATUS_ORDER,
|
|
getGearCollisionStatusIntent,
|
|
getGearCollisionStatusLabel,
|
|
} from '@shared/constants/gearCollisionStatuses';
|
|
import {
|
|
getGearCollisionStats,
|
|
listGearCollisions,
|
|
resolveGearCollision,
|
|
type GearCollision,
|
|
type GearCollisionResolveAction,
|
|
type GearCollisionStats,
|
|
} from '@/services/gearCollisionApi';
|
|
import { useSettingsStore } from '@stores/settingsStore';
|
|
|
|
/**
|
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 페이지.
|
|
*
|
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 에 공존 송출되는 경우를 목록화하고
|
|
* 운영자가 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE 로 분류할 수 있게 한다.
|
|
*/
|
|
|
|
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
|
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
|
const DEFAULT_HOURS = 48;
|
|
|
|
export function GearCollisionDetection() {
|
|
const { t } = useTranslation('detection');
|
|
const { t: tc } = useTranslation('common');
|
|
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
|
|
|
const [rows, setRows] = useState<GearCollision[]>([]);
|
|
const [stats, setStats] = useState<GearCollisionStats | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
const [severityFilter, setSeverityFilter] = useState<string>('');
|
|
const [nameFilter, setNameFilter] = useState<string>('');
|
|
const [selected, setSelected] = useState<GearCollision | null>(null);
|
|
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
|
|
const [resolveNote, setResolveNote] = useState('');
|
|
const [resolving, setResolving] = useState(false);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const [page, summary] = await Promise.all([
|
|
listGearCollisions({
|
|
status: statusFilter || undefined,
|
|
severity: severityFilter || undefined,
|
|
name: nameFilter || undefined,
|
|
hours: DEFAULT_HOURS,
|
|
size: 200,
|
|
}),
|
|
getGearCollisionStats(DEFAULT_HOURS),
|
|
]);
|
|
setRows(page.content);
|
|
setStats(summary);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [statusFilter, severityFilter, nameFilter, t]);
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
// 선택된 row 와 현재 목록의 동기화
|
|
const syncedSelected = useMemo(
|
|
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
|
|
[rows, selected],
|
|
);
|
|
|
|
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
|
|
{
|
|
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
|
|
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
|
|
},
|
|
{
|
|
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
|
|
render: (_, row) => (
|
|
<span className="font-mono text-[10px] text-label">
|
|
{row.mmsiLo} ↔ {row.mmsiHi}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
|
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
|
},
|
|
{
|
|
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
|
|
width: '90px', align: 'center', sortable: true,
|
|
render: (v) => <span className="font-mono text-label">{v as number}</span>,
|
|
},
|
|
{
|
|
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
|
|
width: '110px', align: 'right', sortable: true,
|
|
render: (v) => {
|
|
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
|
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
|
|
},
|
|
},
|
|
{
|
|
key: 'severity', label: t('gearCollision.columns.severity'),
|
|
width: '90px', align: 'center', sortable: true,
|
|
render: (v) => (
|
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
|
{getAlertLevelLabel(v as string, tc, lang)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'status', label: t('gearCollision.columns.status'),
|
|
width: '110px', align: 'center', sortable: true,
|
|
render: (v) => (
|
|
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
|
|
{getGearCollisionStatusLabel(v as string, t, lang)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
|
|
width: '130px', sortable: true,
|
|
render: (v) => (
|
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
|
),
|
|
},
|
|
], [t, tc, lang]);
|
|
|
|
const handleResolve = useCallback(async () => {
|
|
if (!syncedSelected) return;
|
|
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
|
|
if (!ok) return;
|
|
setResolving(true);
|
|
try {
|
|
const updated = await resolveGearCollision(syncedSelected.id, {
|
|
action: resolveAction,
|
|
note: resolveNote || undefined,
|
|
});
|
|
setSelected(updated);
|
|
setResolveNote('');
|
|
await loadData();
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
|
|
} finally {
|
|
setResolving(false);
|
|
}
|
|
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
|
|
|
|
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={AlertOctagon}
|
|
iconColor="text-orange-600 dark:text-orange-400"
|
|
title={t('gearCollision.title')}
|
|
description={t('gearCollision.desc')}
|
|
actions={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={loadData}
|
|
disabled={loading}
|
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
|
>
|
|
{t('gearCollision.list.refresh')}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{error && (
|
|
<Card variant="default">
|
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Section title={t('gearCollision.stats.title')}>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
|
|
<StatCard
|
|
label={t('gearCollision.stats.open')}
|
|
value={statusCount('OPEN')}
|
|
intent="warning"
|
|
/>
|
|
<StatCard
|
|
label={t('gearCollision.stats.reviewed')}
|
|
value={statusCount('REVIEWED')}
|
|
intent="info"
|
|
/>
|
|
<StatCard
|
|
label={t('gearCollision.stats.confirmed')}
|
|
value={statusCount('CONFIRMED_ILLEGAL')}
|
|
intent="critical"
|
|
/>
|
|
<StatCard
|
|
label={t('gearCollision.stats.falsePositive')}
|
|
value={statusCount('FALSE_POSITIVE')}
|
|
intent="muted"
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t('gearCollision.list.title')}>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
|
<Select
|
|
aria-label={t('gearCollision.filters.status')}
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
>
|
|
<option value="">{t('gearCollision.filters.allStatus')}</option>
|
|
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
|
|
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
|
|
))}
|
|
</Select>
|
|
<Select
|
|
aria-label={t('gearCollision.filters.severity')}
|
|
value={severityFilter}
|
|
onChange={(e) => setSeverityFilter(e.target.value)}
|
|
>
|
|
<option value="">{t('gearCollision.filters.allSeverity')}</option>
|
|
{SEVERITY_OPTIONS.map((sv) => (
|
|
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
|
|
))}
|
|
</Select>
|
|
<Input
|
|
aria-label={t('gearCollision.filters.name')}
|
|
placeholder={t('gearCollision.filters.name')}
|
|
value={nameFilter}
|
|
onChange={(e) => setNameFilter(e.target.value)}
|
|
/>
|
|
<div className="flex items-center justify-end">
|
|
<Badge intent="info" size="sm">
|
|
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{rows.length === 0 && !loading ? (
|
|
<p className="text-hint text-xs py-4 text-center">
|
|
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
|
|
</p>
|
|
) : (
|
|
<DataTable
|
|
data={rows as (GearCollision & Record<string, unknown>)[]}
|
|
columns={cols}
|
|
pageSize={20}
|
|
showSearch={false}
|
|
showExport={false}
|
|
showPrint={false}
|
|
onRowClick={(row) => setSelected(row as GearCollision)}
|
|
/>
|
|
)}
|
|
</Section>
|
|
|
|
{syncedSelected && (
|
|
<Section title={t('gearCollision.detail.title')}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-1.5 text-xs">
|
|
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
|
|
<DetailRow
|
|
label={t('gearCollision.columns.mmsiPair')}
|
|
value={`${syncedSelected.mmsiLo} ↔ ${syncedSelected.mmsiHi}`}
|
|
mono
|
|
/>
|
|
<DetailRow
|
|
label={t('gearCollision.columns.parentName')}
|
|
value={syncedSelected.parentName ?? '-'}
|
|
/>
|
|
<DetailRow
|
|
label={t('gearCollision.detail.firstSeenAt')}
|
|
value={formatDateTime(syncedSelected.firstSeenAt)}
|
|
/>
|
|
<DetailRow
|
|
label={t('gearCollision.detail.lastSeenAt')}
|
|
value={formatDateTime(syncedSelected.lastSeenAt)}
|
|
/>
|
|
<DetailRow
|
|
label={t('gearCollision.columns.coexistenceCount')}
|
|
value={String(syncedSelected.coexistenceCount)}
|
|
/>
|
|
<DetailRow
|
|
label={t('gearCollision.detail.swapCount')}
|
|
value={String(syncedSelected.swapCount)}
|
|
/>
|
|
<DetailRow
|
|
label={t('gearCollision.columns.maxDistance')}
|
|
value={
|
|
syncedSelected.maxDistanceKm != null
|
|
? Number(syncedSelected.maxDistanceKm).toFixed(2)
|
|
: '-'
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-label">
|
|
{t('gearCollision.columns.severity')}:
|
|
</span>
|
|
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
|
|
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
|
|
</Badge>
|
|
<span className="text-xs text-label ml-3">
|
|
{t('gearCollision.columns.status')}:
|
|
</span>
|
|
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
|
|
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
|
|
</Badge>
|
|
</div>
|
|
{syncedSelected.resolutionNote && (
|
|
<p className="text-xs text-hint border-l-2 border-border pl-2">
|
|
{syncedSelected.resolutionNote}
|
|
</p>
|
|
)}
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="gc-resolve-action"
|
|
className="block text-xs text-label"
|
|
>
|
|
{t('gearCollision.resolve.title')}
|
|
</label>
|
|
<Select
|
|
id="gc-resolve-action"
|
|
aria-label={t('gearCollision.resolve.title')}
|
|
value={resolveAction}
|
|
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
|
|
>
|
|
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
|
|
<option value="CONFIRMED_ILLEGAL">
|
|
{t('gearCollision.resolve.confirmedIllegal')}
|
|
</option>
|
|
<option value="FALSE_POSITIVE">
|
|
{t('gearCollision.resolve.falsePositive')}
|
|
</option>
|
|
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
|
|
</Select>
|
|
<Textarea
|
|
aria-label={t('gearCollision.resolve.note')}
|
|
placeholder={t('gearCollision.resolve.notePlaceholder')}
|
|
value={resolveNote}
|
|
onChange={(e) => setResolveNote(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => { setSelected(null); setResolveNote(''); }}
|
|
>
|
|
{t('gearCollision.resolve.cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleResolve}
|
|
disabled={resolving}
|
|
>
|
|
{t('gearCollision.resolve.submit')}
|
|
</Button>
|
|
</div>
|
|
</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">{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-center 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 GearCollisionDetection;
|