- 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>
272 lines
7.7 KiB
TypeScript
272 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
|
|
import { useSiteInspectionSSE } from "@/hooks/useSiteInspectionSSE";
|
|
import { useSiteInspectionResult } from "@/lib/queries";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { ErrorState } from "@/components/common/ErrorState";
|
|
import {
|
|
Globe,
|
|
Search,
|
|
Check,
|
|
X,
|
|
Circle,
|
|
Loader2,
|
|
ExternalLink,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { getScoreTailwindColor } from "@/lib/constants";
|
|
import type { DiscoveredPage } from "@/types/site-inspection";
|
|
|
|
interface SiteCrawlProgressProps {
|
|
siteInspectionId: string;
|
|
}
|
|
|
|
/**
|
|
* 사이트 크롤링 검사 진행 상태 표시 컴포넌트.
|
|
* 크롤링 단계와 검사 단계를 시각적으로 표현한다.
|
|
*/
|
|
export function SiteCrawlProgress({
|
|
siteInspectionId,
|
|
}: SiteCrawlProgressProps) {
|
|
const {
|
|
status,
|
|
rootUrl,
|
|
crawlProgress,
|
|
discoveredPages,
|
|
aggregateScores,
|
|
errorMessage,
|
|
initFromApi,
|
|
} = useSiteInspectionStore();
|
|
|
|
// API에서 현재 상태 조회 (리로드 시 스토어 복원용)
|
|
const { data: apiResult } = useSiteInspectionResult(siteInspectionId);
|
|
|
|
useEffect(() => {
|
|
if (apiResult && status === "idle") {
|
|
initFromApi(apiResult);
|
|
}
|
|
}, [apiResult, status, initFromApi]);
|
|
|
|
// SSE 연결
|
|
useSiteInspectionSSE(siteInspectionId);
|
|
|
|
const handleRetry = () => {
|
|
window.location.reload();
|
|
};
|
|
|
|
// 전체 진행률 계산
|
|
const completedPages = discoveredPages.filter(
|
|
(p) => p.status === "completed"
|
|
).length;
|
|
const totalPages = discoveredPages.length;
|
|
const overallProgress =
|
|
totalPages > 0 ? Math.round((completedPages / totalPages) * 100) : 0;
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto">
|
|
{/* URL 표시 */}
|
|
{rootUrl && (
|
|
<div className="flex items-center gap-2 mb-6 text-muted-foreground">
|
|
<ExternalLink className="h-4 w-4" />
|
|
<span className="text-sm truncate">{rootUrl}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 크롤링 단계 */}
|
|
{status === "crawling" && (
|
|
<CrawlPhase
|
|
pagesFound={crawlProgress?.pagesFound ?? 0}
|
|
currentUrl={crawlProgress?.currentUrl ?? ""}
|
|
/>
|
|
)}
|
|
|
|
{/* 검사 단계 */}
|
|
{(status === "inspecting" || status === "completed") && (
|
|
<InspectionPhase
|
|
pages={discoveredPages}
|
|
completedPages={completedPages}
|
|
totalPages={totalPages}
|
|
overallProgress={overallProgress}
|
|
aggregateScores={aggregateScores}
|
|
/>
|
|
)}
|
|
|
|
{/* 에러 상태 */}
|
|
{status === "error" && (
|
|
<div className="mt-6">
|
|
<ErrorState
|
|
message={errorMessage || "사이트 검사 중 오류가 발생했습니다"}
|
|
onRetry={handleRetry}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 초기 연결 중 상태 */}
|
|
{status === "idle" && (
|
|
<div className="flex flex-col items-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="mt-3 text-sm text-muted-foreground">
|
|
서버에 연결하는 중...
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 크롤링 단계 UI */
|
|
function CrawlPhase({
|
|
pagesFound,
|
|
currentUrl,
|
|
}: {
|
|
pagesFound: number;
|
|
currentUrl: string;
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="relative">
|
|
<Globe className="h-8 w-8 text-primary" />
|
|
<Search className="h-4 w-4 text-primary absolute -bottom-1 -right-1 animate-pulse" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold">사이트 링크 수집 중...</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{pagesFound}개 페이지 발견
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 크롤링 진행 바 (무한 애니메이션) */}
|
|
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary mb-3">
|
|
<div className="h-full w-1/3 bg-primary rounded-full animate-crawl-progress" />
|
|
</div>
|
|
|
|
{/* 현재 크롤링 중인 URL */}
|
|
{currentUrl && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{currentUrl}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/** 검사 단계 UI */
|
|
function InspectionPhase({
|
|
pages,
|
|
completedPages,
|
|
totalPages,
|
|
overallProgress,
|
|
aggregateScores,
|
|
}: {
|
|
pages: DiscoveredPage[];
|
|
completedPages: number;
|
|
totalPages: number;
|
|
overallProgress: number;
|
|
aggregateScores: { overall_score: number; grade: string } | null;
|
|
}) {
|
|
return (
|
|
<div>
|
|
{/* 전체 진행률 */}
|
|
<Card className="mb-4">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="font-semibold">페이지 검사 진행</h3>
|
|
<span className="text-sm text-muted-foreground">
|
|
{completedPages}/{totalPages}
|
|
</span>
|
|
</div>
|
|
<Progress value={overallProgress} className="h-3" />
|
|
<div className="flex items-center justify-between mt-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{overallProgress}% 완료
|
|
</span>
|
|
{aggregateScores && (
|
|
<span
|
|
className={cn(
|
|
"text-xs font-bold",
|
|
getScoreTailwindColor(aggregateScores.overall_score)
|
|
)}
|
|
>
|
|
현재 평균: {aggregateScores.overall_score}점{" "}
|
|
{aggregateScores.grade}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 개별 페이지 목록 */}
|
|
<div className="space-y-1.5">
|
|
{pages.map((page) => (
|
|
<PageProgressItem key={page.url} page={page} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 개별 페이지 진행 항목 */
|
|
function PageProgressItem({ page }: { page: DiscoveredPage }) {
|
|
let displayPath: string;
|
|
try {
|
|
const parsed = new URL(page.url);
|
|
displayPath = parsed.pathname + parsed.search || "/";
|
|
} catch {
|
|
displayPath = page.url;
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30">
|
|
{/* 상태 아이콘 */}
|
|
<PageStatusIcon status={page.status} />
|
|
|
|
{/* URL 경로 */}
|
|
<span className="text-sm truncate flex-1 min-w-0" title={page.url}>
|
|
{displayPath}
|
|
</span>
|
|
|
|
{/* 점수 (완료 시) */}
|
|
{page.status === "completed" && page.overall_score !== null && (
|
|
<span
|
|
className={cn(
|
|
"text-xs font-bold flex-shrink-0",
|
|
getScoreTailwindColor(page.overall_score)
|
|
)}
|
|
>
|
|
{page.overall_score}점
|
|
</span>
|
|
)}
|
|
|
|
{/* 검사 중 표시 */}
|
|
{page.status === "inspecting" && (
|
|
<span className="text-xs text-blue-500 flex-shrink-0">검사 중</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 페이지 상태 아이콘 */
|
|
function PageStatusIcon({ status }: { status: DiscoveredPage["status"] }) {
|
|
switch (status) {
|
|
case "pending":
|
|
return <Circle className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />;
|
|
case "inspecting":
|
|
return (
|
|
<Loader2 className="h-4 w-4 flex-shrink-0 text-blue-500 animate-spin" />
|
|
);
|
|
case "completed":
|
|
return <Check className="h-4 w-4 flex-shrink-0 text-green-500" />;
|
|
case "error":
|
|
return <X className="h-4 w-4 flex-shrink-0 text-red-500" />;
|
|
default:
|
|
return <Circle className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />;
|
|
}
|
|
}
|