Files
frontend/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx
T

468 lines
13 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { getRawJson } from "@/components/lib/api";
import { useAlertStore } from "@/shared/lib";
type AnalysisStatusResponse = {
scsYn?: string | null;
message?: string | null;
progressPercent?: number | null;
progressStep?: string | null;
progressMessage?: string | null;
updatedAt?: string | null;
};
type AnalysisStatusEnvelope = {
status?: number;
message?: string | null;
data?: AnalysisStatusResponse | null;
};
const unwrapAnalysisStatusResponse = (
response: AnalysisStatusResponse | AnalysisStatusEnvelope | null,
): AnalysisStatusResponse | null => {
if (!response || typeof response !== "object") {
return null;
}
const candidate = response as AnalysisStatusEnvelope;
if (candidate.data && typeof candidate.data === "object") {
return candidate.data;
}
return response as AnalysisStatusResponse;
};
type UseAnalysisCompletionWatcherParams = {
buildStatusUrl: (analysisSn: number) => string;
successMessage: string;
failureMessage: string;
analysisType?: string;
analysisName?: string;
intervalMs?: number;
initialProgressPercent?: number;
initialProgressStep?: string;
initialProgressMessage?: string;
};
type PendingAnalysisWatch = {
analysisSn: number;
analysisType: string;
analysisName: string;
failureMessage: string;
key: string;
progressMessage?: string;
progressPercent?: number;
progressStep?: string;
statusUrl: string;
successMessage: string;
updatedAt?: string | null;
};
const DEFAULT_ANALYSIS_TYPE = "GENERIC";
const DEFAULT_ANALYSIS_NAME = "분석 진행";
const DEFAULT_POLLING_INTERVAL_MS = 3000;
const DEFAULT_PROGRESS_MESSAGE = "진행 상태를 확인하는 중입니다.";
const INFTWT_ANALYSIS_TYPE = "INFTWT";
const RDII_ANALYSIS_TYPE = "RDII";
const PROGRESS_OVERLAY_ANALYSIS_TYPES = new Set([
INFTWT_ANALYSIS_TYPE,
RDII_ANALYSIS_TYPE,
]);
const PENDING_ANALYSIS_WATCHES_STORAGE_KEY = "SN_PENDING_ANALYSIS_WATCHES";
const PENDING_ANALYSIS_WATCHES_CHANGED_EVENT =
"sn-pending-analysis-watches-changed";
const clampProgressPercent = (value?: number | null) => {
if (!Number.isFinite(value)) {
return undefined;
}
return Math.max(0, Math.min(100, Number(value)));
};
const createWatchKey = (statusUrl: string, analysisSn: number) =>
`${statusUrl}::${analysisSn}`;
const dispatchPendingAnalysisChanged = () => {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(new Event(PENDING_ANALYSIS_WATCHES_CHANGED_EVENT));
};
const normalizePendingAnalysisWatch = (
raw: unknown,
): PendingAnalysisWatch | null => {
if (!raw || typeof raw !== "object") {
return null;
}
const candidate = raw as Record<string, unknown>;
const analysisSn = Number(candidate.analysisSn ?? 0);
const statusUrl = String(candidate.statusUrl ?? "").trim();
if (!Number.isFinite(analysisSn) || analysisSn <= 0 || !statusUrl) {
return null;
}
const progressPercent = clampProgressPercent(
typeof candidate.progressPercent === "number"
? candidate.progressPercent
: Number(candidate.progressPercent),
);
return {
analysisSn,
analysisType:
String(candidate.analysisType ?? DEFAULT_ANALYSIS_TYPE).trim() ||
DEFAULT_ANALYSIS_TYPE,
analysisName:
String(candidate.analysisName ?? DEFAULT_ANALYSIS_NAME).trim() ||
DEFAULT_ANALYSIS_NAME,
failureMessage:
String(candidate.failureMessage ?? "분석이 실패했습니다.").trim() ||
"분석이 실패했습니다.",
key:
String(candidate.key ?? createWatchKey(statusUrl, analysisSn)).trim() ||
createWatchKey(statusUrl, analysisSn),
progressMessage:
String(candidate.progressMessage ?? "").trim() || undefined,
progressPercent,
progressStep: String(candidate.progressStep ?? "").trim() || undefined,
statusUrl,
successMessage:
String(candidate.successMessage ?? "분석이 완료되었습니다.").trim() ||
"분석이 완료되었습니다.",
updatedAt:
candidate.updatedAt == null
? undefined
: String(candidate.updatedAt).trim() || undefined,
};
};
const loadPendingAnalysisWatches = (): PendingAnalysisWatch[] => {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(
PENDING_ANALYSIS_WATCHES_STORAGE_KEY,
);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map(normalizePendingAnalysisWatch)
.filter((item): item is PendingAnalysisWatch => item !== null);
} catch (error) {
console.error("분석 완료 감시 목록을 읽는 중 오류가 발생했습니다.", error);
return [];
}
};
const savePendingAnalysisWatches = (items: PendingAnalysisWatch[]) => {
if (typeof window === "undefined") {
return;
}
if (items.length === 0) {
window.localStorage.removeItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY);
dispatchPendingAnalysisChanged();
return;
}
window.localStorage.setItem(
PENDING_ANALYSIS_WATCHES_STORAGE_KEY,
JSON.stringify(items),
);
dispatchPendingAnalysisChanged();
};
const replacePendingAnalysisWatch = (
items: PendingAnalysisWatch[],
nextItem: PendingAnalysisWatch,
): PendingAnalysisWatch[] => {
const filteredItems = items.filter((item) => item.key !== nextItem.key);
return [...filteredItems, nextItem];
};
const toProgressText = (item: PendingAnalysisWatch) => {
return (
item.progressMessage?.trim() ||
item.progressStep?.trim() ||
DEFAULT_PROGRESS_MESSAGE
);
};
const toOverlayItem = (items: PendingAnalysisWatch[]) => {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item) {
continue;
}
if (!PROGRESS_OVERLAY_ANALYSIS_TYPES.has(item.analysisType)) {
continue;
}
if (typeof item.progressPercent !== "number") {
continue;
}
return item;
}
return null;
};
const AnalysisProgressOverlay = ({ item }: { item: PendingAnalysisWatch }) => {
const progressPercent = clampProgressPercent(item.progressPercent) ?? 0;
const progressText = toProgressText(item);
return (
<div className="pointer-events-none fixed bottom-4 left-4 z-[90] w-[190px] max-w-[calc(100vw-2rem)] rounded-lg border border-primary bg-background p-3 shadow-[0_10px_24px_rgba(37,139,232,0.18)] backdrop-blur-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="truncate text-[1.05rem] font-semibold text-primary">
{item.analysisName}
</p>
<span className="text-[1rem] font-semibold text-primary">
{progressPercent}%
</span>
</div>
<div className="mb-2 h-[6px] overflow-hidden rounded-full bg-primary-transparent">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="truncate text-[0.95rem] text-label">{progressText}</p>
</div>
);
};
export function useAnalysisCompletionWatcher({
buildStatusUrl,
successMessage,
failureMessage,
analysisType = DEFAULT_ANALYSIS_TYPE,
analysisName = DEFAULT_ANALYSIS_NAME,
initialProgressPercent,
initialProgressStep,
initialProgressMessage,
}: UseAnalysisCompletionWatcherParams) {
const startWatching = (analysisSn: number) => {
if (
typeof window === "undefined" ||
!Number.isFinite(analysisSn) ||
analysisSn <= 0
) {
return;
}
const statusUrl = buildStatusUrl(analysisSn);
const nextItem: PendingAnalysisWatch = {
analysisSn,
analysisType,
analysisName,
failureMessage,
key: createWatchKey(statusUrl, analysisSn),
progressMessage: initialProgressMessage,
progressPercent: clampProgressPercent(initialProgressPercent),
progressStep: initialProgressStep,
statusUrl,
successMessage,
updatedAt: undefined,
};
const nextItems = replacePendingAnalysisWatch(
loadPendingAnalysisWatches(),
nextItem,
);
savePendingAnalysisWatches(nextItems);
};
const stopWatching = (analysisSn?: number) => {
if (typeof window === "undefined") {
return;
}
if (!analysisSn) {
savePendingAnalysisWatches([]);
return;
}
const nextItems = loadPendingAnalysisWatches().filter(
(item) => item.analysisSn !== analysisSn,
);
savePendingAnalysisWatches(nextItems);
};
return {
startWatching,
stopWatching,
};
}
export function GlobalAnalysisCompletionWatcher() {
const alert = useAlertStore((state) => state.setMessage);
const pathname = usePathname();
// 전역 overlay와 완료 alert가 같은 storage 소스를 바라보도록 local state로 동기화한다.
const [pendingItems, setPendingItems] = useState<PendingAnalysisWatch[]>([]);
const activeOverlayItem = useMemo(
() => toOverlayItem(pendingItems),
[pendingItems],
);
const shouldHideOverlay = pathname?.includes("/popup/") ?? false;
useEffect(() => {
if (shouldHideOverlay) {
setPendingItems([]);
return;
}
let intervalId: number | null = null;
let isPolling = false;
const syncPendingItems = () => {
setPendingItems(loadPendingAnalysisWatches());
};
const pollPendingAnalysis = async () => {
if (isPolling || typeof window === "undefined") {
return;
}
if (document.visibilityState !== "visible") {
syncPendingItems();
return;
}
const currentItems = loadPendingAnalysisWatches();
syncPendingItems();
if (currentItems.length === 0) {
return;
}
isPolling = true;
try {
for (const item of currentItems) {
const pollUrl = `${item.statusUrl}${item.statusUrl.includes("?") ? "&" : "?"}_ts=${Date.now()}`;
const rawResponse = (await getRawJson(pollUrl, {
cache: "no-store",
})) as AnalysisStatusResponse | AnalysisStatusEnvelope | null;
const response = unwrapAnalysisStatusResponse(rawResponse);
if (!response) {
continue;
}
const scsYn = String(response.scsYn ?? "")
.trim()
.toUpperCase();
const message = String(response.message ?? "").trim();
const latestItems = loadPendingAnalysisWatches();
const latestItem = latestItems.find(
(latestWatch) => latestWatch.key === item.key,
);
if (!latestItem) {
continue;
}
const updatedItem: PendingAnalysisWatch = {
...latestItem,
progressMessage:
String(
response.progressMessage ?? latestItem.progressMessage ?? "",
).trim() || latestItem.progressMessage,
progressPercent:
clampProgressPercent(
typeof response.progressPercent === "number"
? response.progressPercent
: Number(response.progressPercent),
) ?? latestItem.progressPercent,
progressStep:
String(
response.progressStep ?? latestItem.progressStep ?? "",
).trim() || latestItem.progressStep,
updatedAt:
String(response.updatedAt ?? latestItem.updatedAt ?? "").trim() ||
latestItem.updatedAt,
};
if (scsYn !== "Y" && scsYn !== "N") {
savePendingAnalysisWatches(
replacePendingAnalysisWatch(latestItems, updatedItem),
);
continue;
}
savePendingAnalysisWatches(
latestItems.filter((latestWatch) => latestWatch.key !== item.key),
);
alert(
message ||
(scsYn === "Y" ? item.successMessage : item.failureMessage),
);
}
} catch (error) {
console.error("분석 완료 상태 조회 중 오류가 발생했습니다.", error);
} finally {
isPolling = false;
syncPendingItems();
}
};
const handlePendingAnalysisChanged = () => {
syncPendingItems();
void pollPendingAnalysis();
};
intervalId = window.setInterval(() => {
void pollPendingAnalysis();
}, DEFAULT_POLLING_INTERVAL_MS);
window.addEventListener("storage", handlePendingAnalysisChanged);
window.addEventListener(
PENDING_ANALYSIS_WATCHES_CHANGED_EVENT,
handlePendingAnalysisChanged,
);
document.addEventListener("visibilitychange", handlePendingAnalysisChanged);
syncPendingItems();
void pollPendingAnalysis();
return () => {
if (intervalId !== null) {
window.clearInterval(intervalId);
}
window.removeEventListener("storage", handlePendingAnalysisChanged);
window.removeEventListener(
PENDING_ANALYSIS_WATCHES_CHANGED_EVENT,
handlePendingAnalysisChanged,
);
document.removeEventListener(
"visibilitychange",
handlePendingAnalysisChanged,
);
};
}, [alert, shouldHideOverlay]);
if (shouldHideOverlay) {
return null;
}
return activeOverlayItem ? (
<AnalysisProgressOverlay item={activeOverlayItem} />
) : null;
}