침입수 분석 전역 프로그래스바 표시와 상태 polling 연동 마감
This commit is contained in:
@@ -321,6 +321,11 @@ export default function InftwtResultPopupPage() {
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
|
||||
@@ -99,6 +99,11 @@ const IrSetting = () => {
|
||||
buildStatusUrl: (analysisSn) => `v1/inftwtAnls/${analysisSn}/status`,
|
||||
successMessage: '침입수 분석이 완료되었습니다.',
|
||||
failureMessage: '침입수 분석이 실패했습니다. 로그를 확인해주세요.',
|
||||
analysisType: 'INFTWT',
|
||||
analysisName: '침입수 분석',
|
||||
initialProgressPercent: 10,
|
||||
initialProgressStep: 'REQUEST_ACCEPTED',
|
||||
initialProgressMessage: '요청 접수 완료',
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
|
||||
type AnalysisStatusResponse = {
|
||||
scsYn?: string | null;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
type UseAnalysisCompletionWatcherParams = {
|
||||
buildStatusUrl: (analysisSn: number) => string;
|
||||
successMessage: string;
|
||||
failureMessage: string;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
type PendingAnalysisWatch = {
|
||||
analysisSn: number;
|
||||
failureMessage: string;
|
||||
key: string;
|
||||
statusUrl: string;
|
||||
successMessage: string;
|
||||
};
|
||||
|
||||
const DEFAULT_POLLING_INTERVAL_MS = 3000;
|
||||
const PENDING_ANALYSIS_WATCHES_STORAGE_KEY = "SN_PENDING_ANALYSIS_WATCHES";
|
||||
|
||||
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);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} 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);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
const createWatchKey = (statusUrl: string, analysisSn: number) => `${statusUrl}::${analysisSn}`;
|
||||
|
||||
export function useAnalysisCompletionWatcher({
|
||||
buildStatusUrl,
|
||||
successMessage,
|
||||
failureMessage,
|
||||
}: UseAnalysisCompletionWatcherParams) {
|
||||
const startWatching = (analysisSn: number) => {
|
||||
if (typeof window === "undefined" || !Number.isFinite(analysisSn) || analysisSn <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusUrl = buildStatusUrl(analysisSn);
|
||||
const nextItem: PendingAnalysisWatch = {
|
||||
analysisSn,
|
||||
failureMessage,
|
||||
key: createWatchKey(statusUrl, analysisSn),
|
||||
statusUrl,
|
||||
successMessage,
|
||||
};
|
||||
|
||||
const nextItems = [
|
||||
...loadPendingAnalysisWatches().filter((item) => item.key !== nextItem.key),
|
||||
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() {
|
||||
useEffect(() => {
|
||||
let intervalId: number | null = null;
|
||||
let isPolling = false;
|
||||
|
||||
const pollPendingAnalysis = async () => {
|
||||
if (isPolling || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentItems = loadPendingAnalysisWatches();
|
||||
if (currentItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
for (const item of currentItems) {
|
||||
const response = (await getData(item.statusUrl)) as AnalysisStatusResponse | null;
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scsYn = String(response.scsYn ?? "").trim().toUpperCase();
|
||||
const message = String(response.message ?? "").trim();
|
||||
|
||||
if (scsYn !== "Y" && scsYn !== "N") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestItems = loadPendingAnalysisWatches();
|
||||
const exists = latestItems.some((latestItem) => latestItem.key === item.key);
|
||||
if (!exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
savePendingAnalysisWatches(latestItems.filter((latestItem) => latestItem.key !== item.key));
|
||||
alert(message || (scsYn === "Y" ? item.successMessage : item.failureMessage));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("분석 완료 상태 조회 중 오류가 발생했습니다.", error);
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorageOrVisibility = () => {
|
||||
void pollPendingAnalysis();
|
||||
};
|
||||
|
||||
intervalId = window.setInterval(() => {
|
||||
void pollPendingAnalysis();
|
||||
}, DEFAULT_POLLING_INTERVAL_MS);
|
||||
|
||||
window.addEventListener("storage", handleStorageOrVisibility);
|
||||
document.addEventListener("visibilitychange", handleStorageOrVisibility);
|
||||
void pollPendingAnalysis();
|
||||
|
||||
return () => {
|
||||
if (intervalId !== null) {
|
||||
window.clearInterval(intervalId);
|
||||
}
|
||||
window.removeEventListener("storage", handleStorageOrVisibility);
|
||||
document.removeEventListener("visibilitychange", handleStorageOrVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
|
||||
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 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 (item.analysisType !== INFTWT_ANALYSIS_TYPE) {
|
||||
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-white/95 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-[#dbeafe]">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="truncate text-[0.95rem] text-slate-600">{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 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);
|
||||
};
|
||||
}, [shouldHideOverlay]);
|
||||
|
||||
if (shouldHideOverlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeOverlayItem ? <AnalysisProgressOverlay item={activeOverlayItem} /> : null;
|
||||
}
|
||||
@@ -13,14 +13,13 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import React, { useEffect} from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import { GlobalAnalysisCompletionWatcher } from './ii/useAnalysisCompletionWatcher';
|
||||
import useStore from '../store/useStore';
|
||||
|
||||
export default function WithBaseLayout({
|
||||
@@ -49,15 +48,12 @@ export default function WithBaseLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalAnalysisCompletionWatcher />
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
</>
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -142,3 +142,63 @@
|
||||
- 버튼 클릭 시 분석 화면을 닫지 않고, 별도 `/map` 창과 `침입수 분석결과` popup을 함께 열어 기존 지도형 결과 흐름으로 바로 진입시킨다.
|
||||
- popup row select는 기존 `window.opener.postMessage`를 유지하되, opener가 분석 화면일 수 있으므로 storage 이벤트 기반 보조 동기화를 함께 둔다.
|
||||
- `map` 화면은 `analysisPopupKey` query를 받아 `분석도구` 활성 메뉴 상태를 `침입수 분석결과` 기준으로 맞춘다.
|
||||
|
||||
## 15. 2026-07-10 전역 progress watcher / overlay 설계
|
||||
|
||||
- 현재 FE는 `ii/useAnalysisCompletionWatcher.ts`에서 완료 alert용 polling만 담당하므로, 같은 파일 또는 인접 컴포넌트로 역할을 아래처럼 확장한다.
|
||||
- 분석 요청 등록
|
||||
- 상태 polling 갱신
|
||||
- 완료 alert 처리
|
||||
- 전역 progress overlay 렌더용 상태 제공
|
||||
- 실제 전역 mount는 `FE_APP_ROOT/src/app/providers/app-provider.tsx`에만 둔다.
|
||||
- 구현 단계에서는 `FE_APP_ROOT/app/(with-base-layout)/layout.tsx`의 중복 `GlobalAnalysisCompletionWatcher` mount를 제거해 polling 2중 실행을 막는다.
|
||||
|
||||
### 15.1 수정 대상 파일 보정
|
||||
|
||||
| 구분 | 파일 경로 | 수정 예정 | 메모 |
|
||||
| --- | --- | --- | --- |
|
||||
| 전역 provider | `FE_APP_ROOT/src/app/providers/app-provider.tsx` | 예정 | watcher / progress overlay 단일 mount |
|
||||
| 레이아웃 정리 | `FE_APP_ROOT/app/(with-base-layout)/layout.tsx` | 예정 | 중복 watcher 제거 또는 전역 mount 정리 |
|
||||
| 상태 watcher | `FE_APP_ROOT/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts` | 예정 | storage schema, progress polling, 완료/실패 종료 처리 확장 |
|
||||
| 침입수 실행 화면 | `FE_APP_ROOT/app/(with-base-layout)/ii/irsetting/page.tsx` | 예정 | 접수 후 progress 감시 등록, full-screen 로딩 즉시 해제 유지 |
|
||||
| 전역 overlay | `FE_APP_ROOT/app/(with-base-layout)/ii/**` 또는 `FE_APP_ROOT/src/app/providers/**` | 예정 | 좌측 하단 progress UI 신규 컴포넌트 |
|
||||
|
||||
### 15.2 FE 상태 모델
|
||||
|
||||
- 기존 `SN_PENDING_ANALYSIS_WATCHES` storage key는 유지하되 item schema를 확장한다.
|
||||
- 1차 침입수 분석용 item 초안
|
||||
- `analysisType: "INFTWT"`
|
||||
- `analysisSn`
|
||||
- `analysisName`
|
||||
- `statusUrl`
|
||||
- `progressPercent`
|
||||
- `progressStep`
|
||||
- `progressMessage`
|
||||
- `updatedAt`
|
||||
- `successMessage`
|
||||
- `failureMessage`
|
||||
- 추후 RDII 복제를 위해 `analysisType` 필드는 미리 포함한다.
|
||||
|
||||
### 15.3 사용자 표시 규칙
|
||||
|
||||
- 접수 직후 alert 문구는 기존 사용자 요구대로 `분석 데이터가 성공적으로 제출되었습니다.`를 유지한다.
|
||||
- 중앙 로딩 overlay는 접수 응답 직후 바로 닫는다.
|
||||
- 전역 progress overlay는 좌측 하단에 고정하고 아래만 표시한다.
|
||||
- 분석명
|
||||
- 진행률 바
|
||||
- 현재 단계 1줄 문구
|
||||
- 완료 후에는 overlay 안에서 별도 성공 메시지를 띄우지 않고 기존 alert만 사용한다.
|
||||
|
||||
### 15.4 polling / 렌더링 규칙
|
||||
|
||||
- polling 주기는 현재 3초를 유지하되, status 응답이 바뀔 때마다 storage를 갱신해 overlay가 같은 데이터를 읽게 한다.
|
||||
- `scsYn`이 `Y` 또는 `N`이면 해당 watch item을 제거하고 alert를 띄운다.
|
||||
- 진행 중(`P`)이면 overlay만 갱신한다.
|
||||
- `/map`, `ii/result`, `ii/irsetting`, 기타 `with-base-layout` 페이지에서 같은 storage를 읽으므로 페이지 이동과 무관하게 overlay가 유지된다.
|
||||
|
||||
### 15.5 검증 포인트 보강
|
||||
|
||||
- `침입수분석 실행 -> 접수 alert -> 중앙 로딩 종료 -> 좌측 하단 progress 표시`
|
||||
- `ii/irsetting -> ii/result -> /map` 이동 중 overlay 유지
|
||||
- 완료 시점 alert 수신 후 overlay 제거
|
||||
- 새로고침 또는 새 창 진입 시 storage에 남아 있는 진행 건을 다시 복구할지 여부는 구현 단계에서 확인하되, 1차는 같은 브라우저 세션 내 유지에 집중한다.
|
||||
|
||||
@@ -136,4 +136,38 @@
|
||||
- `ii/irsetting/page.tsx`는 더 이상 `savewithfile` 즉시 응답을 `분석 완료`로 간주하지 않는다.
|
||||
- 제출 성공 시 문구를 `분석 요청이 접수되었습니다. 완료 시 안내됩니다.`로 바꾸고, 응답으로 받은 `inftwtAnlsSn` 기준으로 상태 감시를 시작한다.
|
||||
- 신규 hook `ii/useAnalysisCompletionWatcher.ts`를 추가해 `GET /api/v1/inftwtAnls/{inftwtAnlsSn}/status` polling과 `Y/N` 종료 alert를 공통 처리한다.
|
||||
- 상태 추적 번호가 없으면 즉시 사용자에게 후속 결과목록 확인 안내를 띄운다.
|
||||
- 상태 추적 번호가 없으면 즉시 사용자에게 후속 결과목록 확인 안내를 띄운다.
|
||||
- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일이 아닌 기존 선행 오류만 남았다.
|
||||
|
||||
## 21. 2026-07-10 침입수 분석 전역 progress overlay 구현
|
||||
|
||||
- `ii/useAnalysisCompletionWatcher.tsx`로 파일을 옮기고, 기존 완료 alert watcher를 `localStorage` 기반 전역 progress watcher + 좌측 하단 overlay 렌더 구조로 확장했다.
|
||||
- `SN_PENDING_ANALYSIS_WATCHES` item에는 `analysisType`, `analysisName`, `progressPercent`, `progressStep`, `progressMessage`, `updatedAt`를 함께 저장해 페이지 이동 뒤에도 같은 진행 상태를 다시 그릴 수 있게 했다.
|
||||
- 전역 watcher mount는 `src/app/providers/app-provider.tsx` 하나로 유지하고, `with-base-layout/layout.tsx`의 중복 mount는 제거해 같은 창에서 polling이 두 번 돌지 않도록 정리했다.
|
||||
- `ii/irsetting/page.tsx`는 `analysisType=INFTWT`, 초기 `10% / REQUEST_ACCEPTED / 요청 접수 완료` 상태로 감시를 시작하면서도, 접수 alert 문구는 사용자 요구대로 `분석 데이터가 성공적으로 제출되었습니다.`를 그대로 유지한다.
|
||||
- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일 신규 오류는 제거되었으며 남은 실패는 기존 선행 오류만 확인됐다.
|
||||
|
||||
## 22. 2026-07-13 프로그래스바 최종 표시 방식 정리
|
||||
|
||||
- 팝업창에서는 progress overlay를 숨기고 메인 화면에서만 보이도록 최종 정리했다.
|
||||
- `status` polling 응답은 공용 wrapper `{ status, message, data }`를 해제해 실제 `data.progressPercent`를 읽도록 보강했다.
|
||||
- 최종 화면 표시는 보간이 아니라 단계값 고정형으로 정리했다.
|
||||
- `10%` 요청 접수 완료
|
||||
- `20%` 상세 데이터 저장 시작
|
||||
- `30%` 상세 데이터 저장 완료
|
||||
- `40%` SWMM 분석 실행 중
|
||||
- `80%` 결과 저장 중
|
||||
- `100%` 완료
|
||||
- 이 정리로 사용자는 오래 걸리는 두 구간을 `40%`, `80%`부터 보게 되어, 막판 `99%` 고정처럼 보이는 체감을 줄일 수 있다.
|
||||
|
||||
## 23. 2026-07-13 프로그래스바 구현 마감 정리
|
||||
|
||||
- 전역 progress overlay는 메인 화면에서만 보이고 `/popup/` 경로에서는 표시하지 않도록 마감했다.
|
||||
- 최종 표시 방식은 보간 없이 고정 단계값 기준으로 정리했다.
|
||||
- `10%` 요청 접수 완료
|
||||
- `20%` 상세 데이터 저장 시작
|
||||
- `30%` 상세 데이터 저장 완료
|
||||
- `40%` SWMM 분석 실행 중
|
||||
- `80%` 결과 저장 중
|
||||
- `100%` 완료
|
||||
- `status` polling은 공용 wrapper `{ status, message, data }`를 해제해 실제 `data.progressPercent`를 읽도록 보강했고, `_ts` query와 `cache: no-store`를 적용해 캐시된 상태 응답이 재사용되지 않게 했다.
|
||||
|
||||
Reference in New Issue
Block a user