diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 815779f..7d716c9 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -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) { diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 785a872..3aa1a4b 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -58,6 +58,7 @@ export function EventList() { loading, error, load, + silentRefresh, loadStats, } = useEventStore(); const [actionLoading, setActionLoading] = useState(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, diff --git a/frontend/src/features/parent-inference/ParentReview.tsx b/frontend/src/features/parent-inference/ParentReview.tsx index 72c73f5..b8df06c 100644 --- a/frontend/src/features/parent-inference/ParentReview.tsx +++ b/frontend/src/features/parent-inference/ParentReview.tsx @@ -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'; diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index f3dbc55..6685d58 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -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", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 9e68ca4..9539842 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -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": "승인 대기", diff --git a/frontend/src/stores/eventStore.ts b/frontend/src/stores/eventStore.ts index 8c85589..206b2e0 100644 --- a/frontend/src/stores/eventStore.ts +++ b/frontend/src/stores/eventStore.ts @@ -29,6 +29,8 @@ interface EventStore { loaded: boolean; /** API 호출 */ load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise; + /** 화면 깜박임 없는 백그라운드 갱신 (loading 상태 변경 없음) */ + silentRefresh: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise; loadStats: () => Promise; filterByLevel: (level: string | null) => LegacyEventRecord[]; } @@ -68,6 +70,23 @@ export const useEventStore = create((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();