feat: 사이트 전체 검사 기능 추가
도메인 하위 링크를 BFS로 자동 크롤링하여 페이지별 검사 수행. - BFS 링크 크롤러 (같은 도메인 필터링, max_pages/max_depth 설정) - 사이트 검사 오케스트레이션 (크롤링→순차 검사→집계) - SSE 실시간 진행 상태 (크롤링/검사/완료) - 페이지 트리 + 집계 결과 UI - UrlInputForm에 "사이트 전체 검사" 버튼 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
259
frontend/src/components/site-inspection/SiteCrawlProgress.tsx
Normal file
259
frontend/src/components/site-inspection/SiteCrawlProgress.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
|
||||
import { useSiteInspectionSSE } from "@/hooks/useSiteInspectionSSE";
|
||||
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,
|
||||
} = useSiteInspectionStore();
|
||||
|
||||
// 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" />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user