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:
@ -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;
|
||||
}
|
||||
|
||||
283
frontend/src/app/site-inspections/[id]/page.tsx
Normal file
283
frontend/src/app/site-inspections/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/app/site-inspections/[id]/progress/page.tsx
Normal file
21
frontend/src/app/site-inspections/[id]/progress/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user