468 lines
13 KiB
TypeScript
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;
|
|
}
|