Merge branch 'feature/chnprmship-polling' into develop
This commit is contained in:
커밋
77e3a531e8
32
apps/web/src/entities/aisTarget/api/searchChnprmship.ts
Normal file
32
apps/web/src/entities/aisTarget/api/searchChnprmship.ts
Normal file
@ -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,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user