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>
);
}

View File

@ -1,38 +1,74 @@
"use client";
import { useState, type FormEvent } from "react";
import { useState, useRef, useEffect, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Search, Loader2 } from "lucide-react";
import { Search, Loader2, Globe, ChevronDown } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useInspectionStore } from "@/stores/useInspectionStore";
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
import { cn } from "@/lib/utils";
/** 최대 페이지 수 옵션 */
const MAX_PAGES_OPTIONS = [10, 20, 50] as const;
/** 크롤링 깊이 옵션 */
const MAX_DEPTH_OPTIONS = [1, 2, 3] as const;
export function UrlInputForm() {
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSiteLoading, setIsSiteLoading] = useState(false);
const [showSiteOptions, setShowSiteOptions] = useState(false);
const [maxPages, setMaxPages] = useState<number>(20);
const [maxDepth, setMaxDepth] = useState<number>(2);
const router = useRouter();
const { setInspection } = useInspectionStore();
const { setSiteInspection } = useSiteInspectionStore();
const siteOptionsRef = useRef<HTMLDivElement>(null);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
siteOptionsRef.current &&
!siteOptionsRef.current.contains(event.target as Node)
) {
setShowSiteOptions(false);
}
}
if (showSiteOptions) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [showSiteOptions]);
/** URL 검증 공통 로직 */
const validateUrl = (): string | null => {
const trimmedUrl = url.trim();
if (!trimmedUrl) {
setError("URL을 입력해주세요");
return null;
}
if (!isValidUrl(trimmedUrl)) {
setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)");
return null;
}
return trimmedUrl;
};
/** 단일 페이지 검사 */
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const trimmedUrl = url.trim();
// 클라이언트 사이드 URL 검증
if (!trimmedUrl) {
setError("URL을 입력해주세요");
return;
}
if (!isValidUrl(trimmedUrl)) {
setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)");
return;
}
const trimmedUrl = validateUrl();
if (!trimmedUrl) return;
setIsLoading(true);
@ -51,43 +87,196 @@ export function UrlInputForm() {
}
};
/** 사이트 전체 검사 */
const handleSiteInspection = async () => {
setError(null);
const trimmedUrl = validateUrl();
if (!trimmedUrl) return;
setIsSiteLoading(true);
setShowSiteOptions(false);
try {
const response = await api.startSiteInspection(
trimmedUrl,
maxPages,
maxDepth
);
setSiteInspection(response.site_inspection_id, trimmedUrl);
router.push(
`/site-inspections/${response.site_inspection_id}/progress`
);
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);
} else {
setError(
"사이트 검사 시작 중 오류가 발생했습니다. 다시 시도해주세요."
);
}
} finally {
setIsSiteLoading(false);
}
};
const isDisabled = isLoading || isSiteLoading;
return (
<Card className="w-full max-w-2xl mx-auto">
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (error) setError(null);
}}
placeholder="https://example.com"
className="pl-10 h-12 text-base"
disabled={isLoading}
aria-label="검사할 URL 입력"
aria-invalid={!!error}
aria-describedby={error ? "url-error" : undefined}
/>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{/* URL 입력 필드 */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (error) setError(null);
}}
placeholder="https://example.com"
className="pl-10 h-12 text-base"
disabled={isDisabled}
aria-label="검사할 URL 입력"
aria-invalid={!!error}
aria-describedby={error ? "url-error" : undefined}
/>
</div>
{/* 버튼 그룹 */}
<div className="flex gap-2">
{/* 단일 페이지 검사 버튼 */}
<Button
type="submit"
size="lg"
className="h-12 px-6 text-base"
disabled={isDisabled}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
"검사 시작"
)}
</Button>
{/* 사이트 전체 검사 버튼 */}
<div className="relative" ref={siteOptionsRef}>
<Button
type="button"
size="lg"
variant="secondary"
className="h-12 px-4 text-base"
disabled={isDisabled}
onClick={() => setShowSiteOptions(!showSiteOptions)}
aria-expanded={showSiteOptions}
aria-haspopup="true"
>
{isSiteLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Globe className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<ChevronDown className="h-3 w-3" />
</>
)}
</Button>
{/* 사이트 검사 옵션 드롭다운 */}
{showSiteOptions && (
<div className="absolute right-0 top-full mt-2 w-72 z-50 rounded-lg border bg-card shadow-lg p-4">
<h4 className="text-sm font-semibold mb-3">
</h4>
{/* 최대 페이지 수 */}
<div className="mb-3">
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-2">
{MAX_PAGES_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={
maxPages === option ? "default" : "outline"
}
size="sm"
className={cn(
"flex-1",
maxPages === option && "pointer-events-none"
)}
onClick={() => setMaxPages(option)}
>
{option}
</Button>
))}
</div>
</div>
{/* 크롤링 깊이 */}
<div className="mb-4">
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-2">
{MAX_DEPTH_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={
maxDepth === option ? "default" : "outline"
}
size="sm"
className={cn(
"flex-1",
maxDepth === option && "pointer-events-none"
)}
onClick={() => setMaxDepth(option)}
>
{option}
</Button>
))}
</div>
</div>
{/* 사이트 검사 시작 버튼 */}
<Button
type="button"
className="w-full"
onClick={handleSiteInspection}
disabled={isSiteLoading}
>
{isSiteLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Globe className="h-4 w-4" />
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
<Button
type="submit"
size="lg"
className="h-12 px-6 text-base"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
"검사 시작"
)}
</Button>
</form>
{/* 에러 메시지 */}
{error && (
<p
id="url-error"

View File

@ -0,0 +1,126 @@
"use client";
import { OverallScoreGauge } from "@/components/dashboard/OverallScoreGauge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
getScoreTailwindColor,
CATEGORY_LABELS,
} from "@/lib/constants";
import type { AggregateScores } from "@/types/site-inspection";
import type { Grade, CategoryKey } from "@/types/inspection";
interface AggregateScorePanelProps {
aggregateScores: AggregateScores;
rootUrl: string;
}
/** 카테고리 점수 항목 (집계용) */
interface AggregateCategoryItem {
key: CategoryKey;
label: string;
score: number;
}
/** 사이트 전체 집계 점수 패널 */
export function AggregateScorePanel({
aggregateScores,
rootUrl,
}: AggregateScorePanelProps) {
const categoryItems: AggregateCategoryItem[] = [
{
key: "html_css",
label: CATEGORY_LABELS.html_css,
score: aggregateScores.html_css,
},
{
key: "accessibility",
label: CATEGORY_LABELS.accessibility,
score: aggregateScores.accessibility,
},
{
key: "seo",
label: CATEGORY_LABELS.seo,
score: aggregateScores.seo,
},
{
key: "performance_security",
label: CATEGORY_LABELS.performance_security,
score: aggregateScores.performance_security,
},
];
return (
<div>
{/* 상단 URL 및 검사 요약 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-1"> </h2>
<p className="text-sm text-muted-foreground">
<a
href={rootUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary"
>
{rootUrl}
</a>
</p>
<p className="text-sm text-muted-foreground mt-1">
: {aggregateScores.pages_inspected}/
{aggregateScores.pages_total}
</p>
</div>
{/* 종합 점수 게이지 */}
<Card className="mb-6">
<CardHeader className="text-center">
<CardTitle className="text-xl"> </CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<OverallScoreGauge
score={aggregateScores.overall_score}
grade={aggregateScores.grade as Grade}
/>
</CardContent>
</Card>
{/* 카테고리별 평균 점수 카드 (4개) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{categoryItems.map((item) => (
<Card key={item.key}>
<CardContent className="pt-5 pb-4 px-5 text-center">
<h3 className="text-sm font-medium text-muted-foreground mb-2">
{item.label}
</h3>
<div
className={cn(
"text-3xl font-bold mb-1",
getScoreTailwindColor(item.score)
)}
>
{item.score}
</div>
<p className="text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
))}
</div>
{/* 총 이슈 수 */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
</span>
<span className="text-lg font-bold">
{aggregateScores.total_issues}
</span>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { useMemo } from "react";
import { Globe, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { getScoreTailwindColor } from "@/lib/constants";
import { PageTreeNode } from "./PageTreeNode";
import type { DiscoveredPage, AggregateScores } from "@/types/site-inspection";
interface PageTreeProps {
/** 발견된 페이지 목록 (flat array) */
pages: DiscoveredPage[];
/** 현재 선택된 페이지 URL (null = 전체 보기) */
selectedUrl: string | null;
/** 페이지 선택 핸들러 (null을 전달하면 전체 집계 보기) */
onSelectPage: (url: string | null) => void;
/** 집계 점수 (전체 노드에 표시) */
aggregateScores: AggregateScores | null;
}
/**
* 페이지 트리 사이드바 컴포넌트.
* flat 배열을 parent_url 기준으로 트리 구조로 변환하여 렌더링한다.
*/
export function PageTree({
pages,
selectedUrl,
onSelectPage,
aggregateScores,
}: PageTreeProps) {
/**
* flat 배열에서 parent_url → children 맵 구성.
* root 노드(parent_url === null)를 최상위 자식으로 처리.
*/
const { rootPages, childrenMap, allPagesMap } = useMemo(() => {
const childrenMap = new Map<string, DiscoveredPage[]>();
const allPagesMap = new Map<string, DiscoveredPage>();
const rootPages: DiscoveredPage[] = [];
for (const page of pages) {
allPagesMap.set(page.url, page);
}
for (const page of pages) {
if (page.parent_url === null) {
rootPages.push(page);
} else {
const siblings = childrenMap.get(page.parent_url) || [];
siblings.push(page);
childrenMap.set(page.parent_url, siblings);
}
}
return { rootPages, childrenMap, allPagesMap };
}, [pages]);
const isAggregateSelected = selectedUrl === null;
return (
<div
className="flex flex-col h-full"
role="tree"
aria-label="페이지 트리"
>
{/* 헤더 */}
<div className="px-3 py-2 border-b">
<h3 className="text-sm font-semibold text-muted-foreground">
</h3>
</div>
{/* 트리 본문 */}
<div className="flex-1 overflow-y-auto py-1">
{/* 사이트 전체 (집계) 노드 */}
<div
className={cn(
"flex items-center gap-1.5 py-2 px-3 rounded-md cursor-pointer text-sm",
"hover:bg-accent/50 transition-colors",
isAggregateSelected && "bg-accent text-accent-foreground"
)}
onClick={() => onSelectPage(null)}
role="treeitem"
aria-selected={isAggregateSelected}
>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
<Globe className="h-4 w-4 flex-shrink-0 text-primary" />
<span className="font-medium truncate flex-1"> </span>
{aggregateScores && (
<span
className={cn(
"flex-shrink-0 text-xs font-bold",
getScoreTailwindColor(aggregateScores.overall_score)
)}
>
{aggregateScores.overall_score} {aggregateScores.grade}
</span>
)}
</div>
{/* 페이지 노드 트리 (재귀) */}
{rootPages.map((page) => (
<PageTreeNode
key={page.url}
page={page}
childrenPages={childrenMap}
allPages={allPagesMap}
selectedUrl={selectedUrl}
onSelectPage={onSelectPage}
level={1}
/>
))}
{/* 빈 상태 */}
{pages.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
{/* 하단 요약 */}
{pages.length > 0 && (
<div className="px-3 py-2 border-t text-xs text-muted-foreground">
{pages.length}
{aggregateScores && (
<span>
{" "}/ {aggregateScores.pages_inspected}/
{aggregateScores.pages_total}
</span>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import {
ChevronRight,
ChevronDown,
FileText,
Check,
X,
Circle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getScoreTailwindColor } from "@/lib/constants";
import type { DiscoveredPage } from "@/types/site-inspection";
interface PageTreeNodeProps {
/** 해당 노드의 페이지 데이터 */
page: DiscoveredPage;
/** 자식 페이지 목록 (트리 빌드 결과) */
childrenPages: Map<string, DiscoveredPage[]>;
/** 모든 페이지 (URL -> DiscoveredPage 조회용) */
allPages: Map<string, DiscoveredPage>;
/** 현재 선택된 페이지 URL */
selectedUrl: string | null;
/** 페이지 선택 핸들러 */
onSelectPage: (url: string) => void;
/** 들여쓰기 레벨 */
level: number;
}
/**
* URL에서 도메인을 제거하고 경로만 반환.
* 예: "https://example.com/about" -> "/about"
*/
function getPathFromUrl(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname + parsed.search;
return path || "/";
} catch {
return url;
}
}
/** 페이지 트리 노드 (재귀 컴포넌트) */
export function PageTreeNode({
page,
childrenPages,
allPages,
selectedUrl,
onSelectPage,
level,
}: PageTreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(level < 2);
const children = childrenPages.get(page.url) || [];
const hasChildren = children.length > 0;
const isSelected = selectedUrl === page.url;
const displayPath = getPathFromUrl(page.url);
return (
<div>
{/* 노드 행 */}
<div
className={cn(
"flex items-center gap-1.5 py-1.5 px-2 rounded-md cursor-pointer text-sm",
"hover:bg-accent/50 transition-colors",
isSelected && "bg-accent text-accent-foreground"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelectPage(page.url)}
role="treeitem"
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
>
{/* 확장/축소 토글 */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="flex-shrink-0 p-0.5 hover:bg-accent rounded"
aria-label={isExpanded ? "접기" : "펼치기"}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
) : (
<span className="w-[18px] flex-shrink-0" />
)}
{/* 상태 아이콘 */}
<StatusIcon status={page.status} />
{/* 페이지 아이콘 */}
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{/* URL 경로 */}
<span
className="truncate flex-1 min-w-0"
title={page.url}
>
{displayPath}
</span>
{/* 점수 배지 (완료 시) */}
{page.status === "completed" && page.overall_score !== null && (
<span
className={cn(
"flex-shrink-0 text-xs font-bold ml-1",
getScoreTailwindColor(page.overall_score)
)}
>
{page.overall_score}
</span>
)}
</div>
{/* 자식 노드 (재귀) */}
{hasChildren && isExpanded && (
<div role="group">
{children.map((childPage) => (
<PageTreeNode
key={childPage.url}
page={childPage}
childrenPages={childrenPages}
allPages={allPages}
selectedUrl={selectedUrl}
onSelectPage={onSelectPage}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}
/** 상태 아이콘 컴포넌트 */
function StatusIcon({ status }: { status: DiscoveredPage["status"] }) {
switch (status) {
case "pending":
return (
<Circle className="h-3 w-3 flex-shrink-0 text-muted-foreground/50" />
);
case "inspecting":
return (
<Circle className="h-3 w-3 flex-shrink-0 text-blue-500 fill-blue-500 animate-pulse" />
);
case "completed":
return (
<Check className="h-3 w-3 flex-shrink-0 text-green-500" />
);
case "error":
return (
<X className="h-3 w-3 flex-shrink-0 text-red-500" />
);
default:
return (
<Circle className="h-3 w-3 flex-shrink-0 text-muted-foreground/50" />
);
}
}

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

View File

@ -0,0 +1,142 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
import { api } from "@/lib/api";
import type {
SSECrawlProgress,
SSECrawlComplete,
SSEPageStart,
SSEPageComplete,
SSEAggregateUpdate,
SSESiteComplete,
} from "@/types/site-inspection";
/**
* SSE를 통해 사이트 전체 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고
* Zustand 스토어를 업데이트한다.
*/
export function useSiteInspectionSSE(siteInspectionId: string | null) {
const {
setCrawlProgress,
setCrawlComplete,
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
} = useSiteInspectionStore();
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!siteInspectionId) return;
const streamUrl = api.getSiteStreamUrl(siteInspectionId);
const eventSource = new EventSource(streamUrl);
eventSourceRef.current = eventSource;
/** 크롤링 진행 이벤트 */
eventSource.addEventListener("crawl_progress", (e: MessageEvent) => {
try {
const data: SSECrawlProgress = JSON.parse(e.data);
setCrawlProgress(data.pages_found, data.current_url);
} catch {
// JSON 파싱 실패 무시
}
});
/** 크롤링 완료 이벤트 */
eventSource.addEventListener("crawl_complete", (e: MessageEvent) => {
try {
const data: SSECrawlComplete = JSON.parse(e.data);
setCrawlComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 개별 페이지 검사 시작 이벤트 */
eventSource.addEventListener("page_start", (e: MessageEvent) => {
try {
const data: SSEPageStart = JSON.parse(e.data);
updatePageStatus(data.page_url, "inspecting");
} catch {
// JSON 파싱 실패 무시
}
});
/** 개별 페이지 검사 완료 이벤트 */
eventSource.addEventListener("page_complete", (e: MessageEvent) => {
try {
const data: SSEPageComplete = JSON.parse(e.data);
setPageComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 집계 점수 업데이트 이벤트 */
eventSource.addEventListener("aggregate_update", (e: MessageEvent) => {
try {
const data: SSEAggregateUpdate = JSON.parse(e.data);
updateAggregateScores(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 사이트 검사 완료 이벤트 */
eventSource.addEventListener("complete", (e: MessageEvent) => {
try {
const data: SSESiteComplete = JSON.parse(e.data);
setCompleted(data.aggregate_scores);
eventSource.close();
// 결과 페이지로 자동 이동
router.push(`/site-inspections/${siteInspectionId}`);
} catch {
// JSON 파싱 실패 무시
}
});
/** 에러 이벤트 */
eventSource.addEventListener("error", (e: Event) => {
if (e instanceof MessageEvent) {
try {
const data = JSON.parse(e.data);
setError(data.message || "사이트 검사 중 오류가 발생했습니다");
} catch {
setError("사이트 검사 중 오류가 발생했습니다");
}
}
// 네트워크 에러인 경우
if (eventSource.readyState === EventSource.CLOSED) {
setError("서버와의 연결이 끊어졌습니다");
}
});
// SSE 연결 타임아웃 (10분 - 사이트 전체 검사는 시간이 더 소요됨)
const timeout = setTimeout(() => {
eventSource.close();
setError("사이트 검사 시간이 초과되었습니다 (10분)");
}, 600000);
return () => {
clearTimeout(timeout);
eventSource.close();
eventSourceRef.current = null;
};
}, [
siteInspectionId,
setCrawlProgress,
setCrawlComplete,
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
router,
]);
}

View File

@ -7,6 +7,11 @@ import type {
HistoryParams,
TrendResponse,
} from "@/types/inspection";
import type {
StartSiteInspectionResponse,
SiteInspectionResult,
InspectPageResponse,
} from "@/types/site-inspection";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL ?? "";
@ -143,6 +148,50 @@ class ApiClient {
getStreamUrl(inspectionId: string): string {
return `${this.baseUrl}/api/inspections/${inspectionId}/stream`;
}
// ─────────────────────────────────────────────────────
// 사이트 전체 검사 API
// ─────────────────────────────────────────────────────
/** 사이트 전체 검사 시작 */
async startSiteInspection(
url: string,
maxPages?: number,
maxDepth?: number
): Promise<StartSiteInspectionResponse> {
return this.request("/api/site-inspections", {
method: "POST",
body: JSON.stringify({
url,
max_pages: maxPages,
max_depth: maxDepth,
}),
});
}
/** 사이트 검사 결과 조회 */
async getSiteInspection(id: string): Promise<SiteInspectionResult> {
return this.request(`/api/site-inspections/${id}`);
}
/** 특정 페이지 수동 검사 */
async inspectPage(
siteInspectionId: string,
pageUrl: string
): Promise<InspectPageResponse> {
return this.request(
`/api/site-inspections/${siteInspectionId}/inspect-page`,
{
method: "POST",
body: JSON.stringify({ url: pageUrl }),
}
);
}
/** 사이트 검사 SSE 스트림 URL 반환 */
getSiteStreamUrl(siteInspectionId: string): string {
return `${this.baseUrl}/api/site-inspections/${siteInspectionId}/stream`;
}
}
export const api = new ApiClient(API_BASE_URL);

View File

@ -55,3 +55,13 @@ export function useRecentInspections() {
staleTime: 60 * 1000,
});
}
/** 사이트 전체 검사 결과 조회 */
export function useSiteInspectionResult(siteInspectionId: string | null) {
return useQuery({
queryKey: ["siteInspection", siteInspectionId],
queryFn: () => api.getSiteInspection(siteInspectionId!),
enabled: !!siteInspectionId,
staleTime: 5 * 60 * 1000,
});
}

View File

@ -0,0 +1,156 @@
import { create } from "zustand";
import type {
DiscoveredPage,
AggregateScores,
SiteInspectionPhase,
CrawlProgress,
SSECrawlComplete,
SSEPageComplete,
SSEAggregateUpdate,
} from "@/types/site-inspection";
interface SiteInspectionState {
siteInspectionId: string | null;
rootUrl: string | null;
status: SiteInspectionPhase;
discoveredPages: DiscoveredPage[];
aggregateScores: AggregateScores | null;
selectedPageUrl: string | null;
errorMessage: string | null;
crawlProgress: CrawlProgress | null;
// Actions
setSiteInspection: (id: string, rootUrl: string) => void;
setCrawlProgress: (pagesFound: number, currentUrl: string) => void;
setCrawlComplete: (data: SSECrawlComplete) => void;
updatePageStatus: (
pageUrl: string,
status: DiscoveredPage["status"],
extra?: {
inspection_id?: string;
overall_score?: number;
grade?: string;
}
) => void;
setPageComplete: (data: SSEPageComplete) => void;
updateAggregateScores: (data: SSEAggregateUpdate) => void;
setSelectedPage: (url: string | null) => void;
setCompleted: (aggregateScores: AggregateScores) => void;
setError: (message: string) => void;
reset: () => void;
}
const initialState = {
siteInspectionId: null,
rootUrl: null,
status: "idle" as SiteInspectionPhase,
discoveredPages: [] as DiscoveredPage[],
aggregateScores: null,
selectedPageUrl: null,
errorMessage: null,
crawlProgress: null,
};
export const useSiteInspectionStore = create<SiteInspectionState>(
(set) => ({
...initialState,
setSiteInspection: (id, rootUrl) =>
set({
...initialState,
siteInspectionId: id,
rootUrl,
status: "crawling",
}),
setCrawlProgress: (pagesFound, currentUrl) =>
set({
status: "crawling",
crawlProgress: { pagesFound, currentUrl },
}),
setCrawlComplete: (data) =>
set({
status: "inspecting",
discoveredPages: data.pages,
crawlProgress: {
pagesFound: data.total_pages,
currentUrl: "",
},
}),
updatePageStatus: (pageUrl, status, extra) =>
set((state) => ({
discoveredPages: state.discoveredPages.map((page) =>
page.url === pageUrl
? {
...page,
status,
...(extra?.inspection_id && {
inspection_id: extra.inspection_id,
}),
...(extra?.overall_score !== undefined && {
overall_score: extra.overall_score,
}),
...(extra?.grade && { grade: extra.grade }),
}
: page
),
})),
setPageComplete: (data) =>
set((state) => ({
discoveredPages: state.discoveredPages.map((page) =>
page.url === data.page_url
? {
...page,
status: "completed" as const,
inspection_id: data.inspection_id,
overall_score: data.overall_score,
grade: data.grade,
}
: page
),
})),
updateAggregateScores: (data) =>
set((state) => ({
aggregateScores: state.aggregateScores
? {
...state.aggregateScores,
pages_inspected: data.pages_inspected,
pages_total: data.pages_total,
overall_score: data.overall_score,
grade: data.grade,
}
: {
overall_score: data.overall_score,
grade: data.grade,
html_css: 0,
accessibility: 0,
seo: 0,
performance_security: 0,
total_issues: 0,
pages_inspected: data.pages_inspected,
pages_total: data.pages_total,
},
})),
setSelectedPage: (url) =>
set({ selectedPageUrl: url }),
setCompleted: (aggregateScores) =>
set({
status: "completed",
aggregateScores,
}),
setError: (message) =>
set({
status: "error",
errorMessage: message,
}),
reset: () => set({ ...initialState }),
})
);

View File

@ -0,0 +1,149 @@
import type { Grade } from "@/types/inspection";
// ───────────────────────────────────────────────────────
// 사이트 검사 도메인 타입
// ───────────────────────────────────────────────────────
/** 발견된 페이지 상태 */
export type DiscoveredPageStatus =
| "pending"
| "inspecting"
| "completed"
| "error";
/** 사이트 검사 상태 */
export type SiteInspectionStatus =
| "crawling"
| "inspecting"
| "completed"
| "error";
/** 크롤링으로 발견된 개별 페이지 */
export interface DiscoveredPage {
url: string;
depth: number;
parent_url: string | null;
inspection_id: string | null;
status: DiscoveredPageStatus;
title: string | null;
overall_score: number | null;
grade: string | null;
}
/** 사이트 전체 집계 점수 */
export interface AggregateScores {
overall_score: number;
grade: string;
html_css: number;
accessibility: number;
seo: number;
performance_security: number;
total_issues: number;
pages_inspected: number;
pages_total: number;
}
/** 사이트 검사 설정 */
export interface SiteInspectionConfig {
max_pages: number;
max_depth: number;
}
/** GET /api/site-inspections/{id} 응답 - 사이트 검사 결과 */
export interface SiteInspectionResult {
site_inspection_id: string;
root_url: string;
domain: string;
status: SiteInspectionStatus;
created_at: string;
completed_at: string | null;
config: SiteInspectionConfig;
discovered_pages: DiscoveredPage[];
aggregate_scores: AggregateScores | null;
}
/** POST /api/site-inspections 응답 */
export interface StartSiteInspectionResponse {
site_inspection_id: string;
status: string;
root_url: string;
stream_url: string;
}
/** POST /api/site-inspections/{id}/pages/{url}/inspect 응답 */
export interface InspectPageResponse {
inspection_id: string;
}
// ───────────────────────────────────────────────────────
// SSE 이벤트 타입
// ───────────────────────────────────────────────────────
/** SSE crawl_progress 이벤트 */
export interface SSECrawlProgress {
pages_found: number;
current_url: string;
}
/** SSE crawl_complete 이벤트 */
export interface SSECrawlComplete {
total_pages: number;
pages: DiscoveredPage[];
}
/** SSE page_start 이벤트 */
export interface SSEPageStart {
page_url: string;
page_index: number;
}
/** SSE page_complete 이벤트 */
export interface SSEPageComplete {
page_url: string;
inspection_id: string;
overall_score: number;
grade: string;
}
/** SSE aggregate_update 이벤트 */
export interface SSEAggregateUpdate {
pages_inspected: number;
pages_total: number;
overall_score: number;
grade: string;
}
/** SSE complete 이벤트 (사이트 검사 완료) */
export interface SSESiteComplete {
status: "completed";
aggregate_scores: AggregateScores;
}
/** SSE error 이벤트 */
export interface SSESiteError {
message: string;
}
// ───────────────────────────────────────────────────────
// 프론트엔드 내부 상태 타입
// ───────────────────────────────────────────────────────
/** 사이트 검사 진행 상태 (Zustand Store) */
export type SiteInspectionPhase =
| "idle"
| "crawling"
| "inspecting"
| "completed"
| "error";
/** 크롤링 진행 상태 */
export interface CrawlProgress {
pagesFound: number;
currentUrl: string;
}
/** 페이지 트리 노드 (UI용) */
export interface PageTreeNode {
page: DiscoveredPage;
children: PageTreeNode[];
}