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:
jungwoo choi
2026-02-13 16:46:49 +09:00
parent 44ad36e2ab
commit 81b9104aea
21 changed files with 3238 additions and 56 deletions

View File

@ -35,3 +35,17 @@
@apply bg-background text-foreground;
}
}
/* 크롤링 진행 바 애니메이션 */
@keyframes crawl-progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.animate-crawl-progress {
animation: crawl-progress 1.5s ease-in-out infinite;
}

View File

@ -0,0 +1,283 @@
"use client";
import { use, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useSiteInspectionResult, useInspectionResult } from "@/lib/queries";
import { PageTree } from "@/components/site-inspection/PageTree";
import { AggregateScorePanel } from "@/components/site-inspection/AggregateScorePanel";
import { OverallScoreGauge } from "@/components/dashboard/OverallScoreGauge";
import { CategoryScoreCard } from "@/components/dashboard/CategoryScoreCard";
import { IssueSummaryBar } from "@/components/dashboard/IssueSummaryBar";
import { InspectionMeta } from "@/components/dashboard/InspectionMeta";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ErrorState } from "@/components/common/ErrorState";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CATEGORY_LABELS, CATEGORY_KEYS } from "@/lib/constants";
import { ApiError } from "@/lib/api";
import { Clock, Menu, X } from "lucide-react";
import type { CategoryKey } from "@/types/inspection";
export default function SiteInspectionResultPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [selectedPageUrl, setSelectedPageUrl] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const {
data: siteResult,
isLoading,
isError,
error,
refetch,
} = useSiteInspectionResult(id);
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<LoadingSpinner message="사이트 검사 결과를 불러오는 중..." />
</div>
);
}
if (isError || !siteResult) {
return (
<div className="container mx-auto px-4 py-8">
<ErrorState
message={
error instanceof ApiError
? error.detail
: "사이트 검사 결과를 불러올 수 없습니다"
}
onRetry={() => refetch()}
/>
</div>
);
}
// 선택된 페이지의 inspection_id 찾기
const selectedPage = selectedPageUrl
? siteResult.discovered_pages.find((p) => p.url === selectedPageUrl)
: null;
const handleSelectPage = (url: string | null) => {
setSelectedPageUrl(url);
// 모바일: 사이드바 닫기
setIsSidebarOpen(false);
};
return (
<div className="h-[calc(100vh-64px)]">
{/* 모바일 사이드바 토글 */}
<div className="lg:hidden flex items-center gap-2 px-4 py-2 border-b">
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
{isSidebarOpen ? (
<X className="h-4 w-4" />
) : (
<Menu className="h-4 w-4" />
)}
</Button>
{selectedPageUrl && (
<span className="text-sm text-muted-foreground truncate">
{selectedPageUrl}
</span>
)}
</div>
<div className="flex h-full relative">
{/* 왼쪽 사이드바: 페이지 트리 */}
<aside
className={`
w-72 border-r bg-card flex-shrink-0 h-full
lg:relative lg:translate-x-0 lg:block
absolute z-20 top-0 left-0
transition-transform duration-200
${isSidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}
>
<PageTree
pages={siteResult.discovered_pages}
selectedUrl={selectedPageUrl}
onSelectPage={handleSelectPage}
aggregateScores={siteResult.aggregate_scores}
/>
</aside>
{/* 모바일 오버레이 */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/30 z-10 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* 오른쪽 패널: 결과 표시 */}
<main className="flex-1 overflow-y-auto p-6">
{selectedPageUrl === null ? (
// 전체 집계 보기
siteResult.aggregate_scores ? (
<AggregateScorePanel
aggregateScores={siteResult.aggregate_scores}
rootUrl={siteResult.root_url}
/>
) : (
<div className="flex flex-col items-center py-12 text-muted-foreground">
<Clock className="h-8 w-8 mb-3" />
<p> </p>
</div>
)
) : selectedPage?.inspection_id ? (
// 개별 페이지 결과 (기존 대시보드 재사용)
<PageDashboard
inspectionId={selectedPage.inspection_id}
pageUrl={selectedPageUrl}
/>
) : (
// 검사 대기 중인 페이지
<div className="flex flex-col items-center py-12 text-muted-foreground">
<Clock className="h-8 w-8 mb-3" />
<p className="text-lg font-medium"> </p>
<p className="text-sm mt-1">
</p>
</div>
)}
</main>
</div>
</div>
);
}
/**
* 개별 페이지 대시보드 컴포넌트.
* 기존 inspection 결과 컴포넌트들을 재사용하여 특정 페이지의 검사 결과를 표시한다.
*/
function PageDashboard({
inspectionId,
pageUrl,
}: {
inspectionId: string;
pageUrl: string;
}) {
const router = useRouter();
const {
data: result,
isLoading,
isError,
error,
refetch,
} = useInspectionResult(inspectionId);
const handleCategoryClick = useCallback(
(category: CategoryKey) => {
router.push(`/inspections/${inspectionId}/issues?category=${category}`);
},
[inspectionId, router]
);
const handleViewIssues = useCallback(() => {
router.push(`/inspections/${inspectionId}/issues`);
}, [inspectionId, router]);
if (isLoading) {
return <LoadingSpinner message="페이지 검사 결과를 불러오는 중..." />;
}
if (isError || !result) {
return (
<ErrorState
message={
error instanceof ApiError
? error.detail
: "검사 결과를 불러올 수 없습니다"
}
onRetry={() => refetch()}
/>
);
}
return (
<div>
{/* 페이지 URL 표시 */}
<div className="mb-4">
<h2 className="text-lg font-semibold"> </h2>
<a
href={pageUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{pageUrl}
</a>
</div>
{/* 종합 점수 */}
<Card className="mb-6">
<CardHeader className="text-center">
<CardTitle className="text-xl"> </CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<OverallScoreGauge
score={result.overall_score}
grade={result.grade}
/>
</CardContent>
</Card>
{/* 카테고리별 점수 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{CATEGORY_KEYS.map((key) => {
const cat = result.categories[key];
return (
<CategoryScoreCard
key={key}
categoryName={CATEGORY_LABELS[key]}
score={cat.score}
grade={cat.grade}
issueCount={cat.total_issues}
onClick={() => handleCategoryClick(key)}
/>
);
})}
</div>
{/* 검사 메타 정보 */}
<div className="mb-6">
<InspectionMeta
url={result.url}
createdAt={result.created_at}
durationSeconds={result.duration_seconds}
/>
</div>
{/* 이슈 상세 링크 */}
<div className="mb-6">
<Button onClick={handleViewIssues} variant="default">
</Button>
</div>
{/* 이슈 요약 바 */}
<Card>
<CardContent className="py-4">
<IssueSummaryBar
critical={result.summary.critical}
major={result.summary.major}
minor={result.summary.minor}
info={result.summary.info}
total={result.summary.total_issues}
/>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,21 @@
"use client";
import { use } from "react";
import { SiteCrawlProgress } from "@/components/site-inspection/SiteCrawlProgress";
export default function SiteInspectionProgressPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6 text-center">
</h1>
<SiteCrawlProgress siteInspectionId={id} />
</div>
);
}