침입수 분석 전역 프로그래스바 표시와 상태 polling 연동 마감

This commit is contained in:
kiminchan
2026-07-13 11:26:15 +09:00
parent 009a38f3ca
commit 87ba3e8fa2
7 changed files with 551 additions and 197 deletions
@@ -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;
}
+8 -12
View File
@@ -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`를 적용해 캐시된 상태 응답이 재사용되지 않게 했다.