feat(frontend): 워크플로우 연결 Step 5 — 자동갱신 + 모선추론 연결 + i18n

자동 갱신 (30초, 깜박임 없음):
- eventStore: silentRefresh() 메서드 추가 (loading 상태 미변경, 데이터만 교체)
- EventList: 30초 인터벌로 silentRefresh + loadStats 호출
- DarkVesselDetection: 30초 인터벌로 getDarkVessels silent 갱신

모선추론 자동 연결:
- ParentReview CONFIRM → createLabelSession 자동 호출 (학습 데이터 수집 시작)
- ParentReview REJECT → excludeForGroup 자동 호출 (잘못된 후보 재추론 방지)
- 자동 연결 실패 시 리뷰 자체는 유지 (catch 무시)

i18n (ko/en):
- darkTier: CRITICAL/HIGH/WATCH/NONE 라벨
- transshipTier: CRITICAL/HIGH/WATCH 라벨
- adminSubGroup: AI 플랫폼/시스템 운영/사용자 관리/감사·보안

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-09 11:43:18 +09:00
부모 6887a2b4fc
커밋 e401c07dd3
6개의 변경된 파일99개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -136,6 +136,17 @@ export function DarkVesselDetection() {
useEffect(() => { loadData(); }, [loadData]);
// 30초 자동 갱신 (깜박임 없음 — loading 상태 변경 없이 데이터만 교체)
useEffect(() => {
const timer = setInterval(async () => {
try {
const res = await getDarkVessels({ hours: 1, size: 500 });
setRawData(res.content);
} catch { /* silent */ }
}, 30_000);
return () => clearInterval(timer);
}, []);
const DATA: Suspect[] = useMemo(() => {
let items = rawData.map((v, i) => mapToSuspect(v, i));
if (tierFilter) {

파일 보기

@ -58,6 +58,7 @@ export function EventList() {
loading,
error,
load,
silentRefresh,
loadStats,
} = useEventStore();
const [actionLoading, setActionLoading] = useState<number | null>(null);
@ -210,6 +211,16 @@ export function EventList() {
fetchData();
}, [fetchData]);
// 30초 자동 갱신 (깜박임 없음 — silentRefresh 사용)
useEffect(() => {
const params = levelFilter ? { level: levelFilter } : undefined;
const timer = setInterval(() => {
silentRefresh(params);
loadStats();
}, 30_000);
return () => clearInterval(timer);
}, [levelFilter, silentRefresh, loadStats]);
// store events -> EventRow 변환 (rawEvents에서 numeric id 참조)
const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({
id: e.id,

파일 보기

@ -9,6 +9,8 @@ import { useAuth } from '@/app/auth/AuthContext';
import {
fetchReviewList,
reviewParent,
createLabelSession,
excludeForGroup,
type ParentResolution,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
@ -70,6 +72,28 @@ export function ParentReview() {
selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined,
comment: `${action} via UI`,
});
// CONFIRM → LabelSession 자동 생성 (학습 데이터 수집 시작)
if (action === 'CONFIRM') {
const mmsi = selectedMmsi || item.selectedParentMmsi;
if (mmsi) {
await createLabelSession(item.groupKey, item.subClusterId, {
labelParentMmsi: mmsi,
}).catch(() => { /* LabelSession 실패는 무시 — 리뷰 자체는 성공 */ });
}
}
// REJECT → Exclusion 자동 등록 (잘못된 후보 재추론 방지)
if (action === 'REJECT') {
const mmsi = item.selectedParentMmsi;
if (mmsi) {
await excludeForGroup(item.groupKey, item.subClusterId, {
excludedMmsi: mmsi,
reason: '운영자 거부',
}).catch(() => { /* Exclusion 실패는 무시 */ });
}
}
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';

파일 보기

@ -132,6 +132,23 @@
"INTERMITTENT": "Intermittent",
"SPEED_ANOMALY": "Speed Anomaly"
},
"darkTier": {
"CRITICAL": "Intentional Loss (Critical)",
"HIGH": "Suspicious Loss",
"WATCH": "Under Watch",
"NONE": "Normal"
},
"transshipTier": {
"CRITICAL": "Confirmed Transship",
"HIGH": "Suspected Transship",
"WATCH": "Under Watch"
},
"adminSubGroup": {
"aiPlatform": "AI Platform",
"systemOps": "System Operations",
"userMgmt": "User Management",
"auditSecurity": "Audit & Security"
},
"userAccountStatus": {
"ACTIVE": "Active",
"PENDING": "Pending",

파일 보기

@ -132,6 +132,23 @@
"INTERMITTENT": "신호 간헐송출",
"SPEED_ANOMALY": "속도 이상"
},
"darkTier": {
"CRITICAL": "고의 소실 (위험)",
"HIGH": "의심 소실",
"WATCH": "관찰 대상",
"NONE": "정상"
},
"transshipTier": {
"CRITICAL": "환적 확실",
"HIGH": "환적 의심",
"WATCH": "관찰 대상"
},
"adminSubGroup": {
"aiPlatform": "AI 플랫폼",
"systemOps": "시스템 운영",
"userMgmt": "사용자 관리",
"auditSecurity": "감사·보안"
},
"userAccountStatus": {
"ACTIVE": "활성",
"PENDING": "승인 대기",

파일 보기

@ -29,6 +29,8 @@ interface EventStore {
loaded: boolean;
/** API 호출 */
load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
/** 화면 깜박임 없는 백그라운드 갱신 (loading 상태 변경 없음) */
silentRefresh: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
loadStats: () => Promise<void>;
filterByLevel: (level: string | null) => LegacyEventRecord[];
}
@ -68,6 +70,23 @@ export const useEventStore = create<EventStore>((set, get) => ({
}
},
silentRefresh: async (params) => {
try {
const res = await getEvents(params);
const legacy = res.content.map(toLegacyEvent);
set({
rawEvents: res.content,
events: legacy,
totalElements: res.totalElements,
totalPages: res.totalPages,
currentPage: res.number,
pageSize: res.size,
});
} catch {
// silent: 에러 무시 — 다음 갱신에서 재시도
}
},
loadStats: async () => {
try {
const stats = await getEventStats();