Merge branch 'feature/chnprmship-polling' into develop

This commit is contained in:
htlee 2026-02-16 12:23:23 +09:00
커밋 77e3a531e8
3개의 변경된 파일96개의 추가작업 그리고 50개의 파일을 삭제

파일 보기

@ -0,0 +1,32 @@
import type { AisTargetSearchResponse } from '../model/types';
export async function searchChnprmship(
params: { minutes: number },
signal?: AbortSignal,
): Promise<AisTargetSearchResponse> {
const base = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
const u = new URL(`${base}/api/ais-target/chnprmship`, window.location.origin);
u.searchParams.set('minutes', String(params.minutes));
const res = await fetch(u, { signal, headers: { accept: 'application/json' } });
const txt = await res.text();
let json: unknown = null;
try {
json = JSON.parse(txt);
} catch {
// ignore
}
if (!res.ok) {
const msg =
json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string'
? (json as { message: string }).message
: txt.slice(0, 200) || res.statusText;
throw new Error(`chnprmship API failed: ${res.status} ${msg}`);
}
if (!json || typeof json !== 'object') throw new Error('chnprmship API returned invalid payload');
const parsed = json as AisTargetSearchResponse;
if (!parsed.success) throw new Error(parsed.message || 'chnprmship API returned success=false');
return parsed;
}

파일 보기

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets"; import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship";
import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { AisTarget } from "../../entities/aisTarget/model/types";
export type AisPollingStatus = "idle" | "loading" | "ready" | "error"; export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
@ -17,14 +18,21 @@ export type AisPollingSnapshot = {
}; };
export type AisPollingOptions = { export type AisPollingOptions = {
initialMinutes?: number; /** 초기 chnprmship API 호출 시 minutes (기본 120) */
bootstrapMinutes?: number; chnprmshipMinutes?: number;
/** 주기적 폴링 시 search API minutes (기본 2) */
incrementalMinutes?: number; incrementalMinutes?: number;
/** 폴링 주기 ms (기본 60_000) */
intervalMs?: number; intervalMs?: number;
/** 보존 기간 (기본 chnprmshipMinutes) */
retentionMinutes?: number; retentionMinutes?: number;
/** incremental 폴링 시 bbox 필터 */
bbox?: string; bbox?: string;
/** incremental 폴링 시 중심 경도 */
centerLon?: number; centerLon?: number;
/** incremental 폴링 시 중심 위도 */
centerLat?: number; centerLat?: number;
/** incremental 폴링 시 반경(m) */
radiusMeters?: number; radiusMeters?: number;
enabled?: boolean; enabled?: boolean;
}; };
@ -112,11 +120,10 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
} }
export function useAisTargetPolling(opts: AisPollingOptions = {}) { export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const initialMinutes = opts.initialMinutes ?? 60; const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120;
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes; const incrementalMinutes = opts.incrementalMinutes ?? 2;
const incrementalMinutes = opts.incrementalMinutes ?? 1;
const intervalMs = opts.intervalMs ?? 60_000; const intervalMs = opts.intervalMs ?? 60_000;
const retentionMinutes = opts.retentionMinutes ?? initialMinutes; const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes;
const enabled = opts.enabled ?? true; const enabled = opts.enabled ?? true;
const bbox = opts.bbox; const bbox = opts.bbox;
const centerLon = opts.centerLon; const centerLon = opts.centerLon;
@ -146,50 +153,60 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const controller = new AbortController(); const controller = new AbortController();
const generation = ++generationRef.current; const generation = ++generationRef.current;
async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") { function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) {
if (cancelled || generation !== generationRef.current) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
const total = storeRef.current.size;
setSnapshot({
status: "ready",
error: null,
lastFetchAt: new Date().toISOString(),
lastFetchMinutes: minutes,
lastMessage: res.message,
total,
lastUpserted: upserted,
lastInserted: inserted,
lastDeleted: deleted,
});
setRev((r) => r + 1);
}
async function runInitial(minutes: number) {
try { try {
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); setSnapshot((s) => ({ ...s, status: "loading", error: null }));
const res = await searchChnprmship({ minutes }, controller.signal);
const res = await searchAisTargets( applyResult(res, minutes);
{
minutes,
bbox,
centerLon,
centerLat,
radiusMeters,
},
controller.signal,
);
if (cancelled || generation !== generationRef.current) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
const total = storeRef.current.size;
const lastFetchAt = new Date().toISOString();
setSnapshot({
status: "ready",
error: null,
lastFetchAt,
lastFetchMinutes: minutes,
lastMessage: res.message,
total,
lastUpserted: upserted,
lastInserted: inserted,
lastDeleted: deleted,
});
setRev((r) => r + 1);
} catch (e) { } catch (e) {
if (cancelled || generation !== generationRef.current) return; if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({ setSnapshot((s) => ({
...s, ...s,
status: context === "incremental" ? s.status : "error", status: "error",
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
})); }));
} }
} }
// Reset store when polling config changes (bbox, retention, etc). async function runIncremental(minutes: number) {
try {
setSnapshot((s) => ({ ...s, error: null }));
const res = await searchAisTargets(
{ minutes, bbox, centerLon, centerLat, radiusMeters },
controller.signal,
);
applyResult(res, minutes);
} catch (e) {
if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({
...s,
error: e instanceof Error ? e.message : String(e),
}));
}
}
// Reset store when polling config changes.
storeRef.current = new Map(); storeRef.current = new Map();
setSnapshot({ setSnapshot({
status: "loading", status: "loading",
@ -204,12 +221,11 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
}); });
setRev((r) => r + 1); setRev((r) => r + 1);
void run(bootstrapMinutes, "bootstrap"); // 초기 로드: chnprmship API 1회 호출
if (bootstrapMinutes !== initialMinutes) { void runInitial(chnprmshipMinutes);
void run(initialMinutes, "initial");
}
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs); // 주기적 폴링: search API로 incremental 업데이트
const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs);
return () => { return () => {
cancelled = true; cancelled = true;
@ -217,8 +233,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
window.clearInterval(id); window.clearInterval(id);
}; };
}, [ }, [
initialMinutes, chnprmshipMinutes,
bootstrapMinutes,
incrementalMinutes, incrementalMinutes,
intervalMs, intervalMs,
retentionMinutes, retentionMinutes,

파일 보기

@ -81,11 +81,10 @@ export function DashboardPage() {
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined); const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
const { targets, snapshot } = useAisTargetPolling({ const { targets, snapshot } = useAisTargetPolling({
initialMinutes: 60, chnprmshipMinutes: 120,
bootstrapMinutes: 10,
incrementalMinutes: 2, incrementalMinutes: 2,
intervalMs: 60_000, intervalMs: 60_000,
retentionMinutes: 90, retentionMinutes: 120,
bbox: useApiBbox ? apiBbox : undefined, bbox: useApiBbox ? apiBbox : undefined,
centerLon: useApiBbox ? undefined : AIS_CENTER.lon, centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
centerLat: useApiBbox ? undefined : AIS_CENTER.lat, centerLat: useApiBbox ? undefined : AIS_CENTER.lat,