Files
web-inspector/frontend/src/components/site-inspection/SiteCrawlProgress.tsx
jungwoo choi 8326c84be9 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>
2026-02-13 19:15:27 +09:00

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" />;
}
}