feat: 3-mode inspection with tabbed UI + batch upload

- Add batch inspection backend (multipart upload, SSE streaming, MongoDB)
- Add tabbed UI (single page / site crawling / batch upload) on home and history pages
- Add batch inspection progress, result pages with 2-panel layout
- Rename "사이트 전체" to "사이트 크롤링" across codebase
- Add python-multipart dependency for file upload
- Consolidate nginx SSE location for all inspection types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 19:15:27 +09:00
parent 9bb844c5e1
commit 8326c84be9
32 changed files with 3700 additions and 61 deletions

View File

@ -0,0 +1,123 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore";
import { api } from "@/lib/api";
import type {
SSEBatchPageStart,
SSEBatchPageComplete,
SSEBatchAggregateUpdate,
SSEBatchComplete,
} from "@/types/batch-inspection";
/**
* SSE를 통해 배치 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 검사 진행 상태를 실시간 수신하고
* Zustand 스토어를 업데이트한다.
* 크롤링 단계 없이 page_start, page_complete, aggregate_update, complete, error만 리스닝.
*/
export function useBatchInspectionSSE(batchInspectionId: string | null) {
const {
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
} = useBatchInspectionStore();
const router = useRouter();
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!batchInspectionId) return;
const streamUrl = api.getBatchStreamUrl(batchInspectionId);
const eventSource = new EventSource(streamUrl);
eventSourceRef.current = eventSource;
/** 개별 페이지 검사 시작 이벤트 */
eventSource.addEventListener("page_start", (e: MessageEvent) => {
try {
const data: SSEBatchPageStart = JSON.parse(e.data);
updatePageStatus(data.page_url, "inspecting");
} catch {
// JSON 파싱 실패 무시
}
});
/** 개별 페이지 검사 완료 이벤트 */
eventSource.addEventListener("page_complete", (e: MessageEvent) => {
try {
const data: SSEBatchPageComplete = JSON.parse(e.data);
setPageComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 집계 점수 업데이트 이벤트 */
eventSource.addEventListener("aggregate_update", (e: MessageEvent) => {
try {
const data: SSEBatchAggregateUpdate = JSON.parse(e.data);
updateAggregateScores(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 배치 검사 완료 이벤트 */
eventSource.addEventListener("complete", (e: MessageEvent) => {
try {
const data: SSEBatchComplete = JSON.parse(e.data);
setCompleted(data.aggregate_scores);
eventSource.close();
// stale 캐시 제거 → 결과 페이지에서 fresh 데이터 로드
queryClient.removeQueries({
queryKey: ["batchInspection", batchInspectionId],
});
router.push(`/batch-inspections/${batchInspectionId}`);
} catch {
// JSON 파싱 실패 무시
}
});
/** 에러 이벤트 */
eventSource.addEventListener("error", (e: Event) => {
if (e instanceof MessageEvent) {
try {
const data = JSON.parse(e.data);
setError(data.message || "배치 검사 중 오류가 발생했습니다");
} catch {
setError("배치 검사 중 오류가 발생했습니다");
}
}
// 네트워크 에러인 경우
if (eventSource.readyState === EventSource.CLOSED) {
setError("서버와의 연결이 끊어졌습니다");
}
});
// SSE 연결 타임아웃 (10분 - 배치 검사는 시간이 더 소요될 수 있음)
const timeout = setTimeout(() => {
eventSource.close();
setError("배치 검사 시간이 초과되었습니다 (10분)");
}, 600000);
return () => {
clearTimeout(timeout);
eventSource.close();
eventSourceRef.current = null;
};
}, [
batchInspectionId,
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
router,
queryClient,
]);
}

View File

@ -15,7 +15,7 @@ import type {
} from "@/types/site-inspection";
/**
* SSE를 통해 사이트 전체 검사 진행 상태를 수신하는 커스텀 훅.
* SSE를 통해 사이트 크롤링 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고
* Zustand 스토어를 업데이트한다.
*/
@ -122,7 +122,7 @@ export function useSiteInspectionSSE(siteInspectionId: string | null) {
}
});
// SSE 연결 타임아웃 (10분 - 사이트 전체 검사는 시간이 더 소요됨)
// SSE 연결 타임아웃 (10분 - 사이트 크롤링 검사는 시간이 더 소요됨)
const timeout = setTimeout(() => {
eventSource.close();
setError("사이트 검사 시간이 초과되었습니다 (10분)");