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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
126
frontend/src/components/site-inspection/AggregateScorePanel.tsx
Normal file
126
frontend/src/components/site-inspection/AggregateScorePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/site-inspection/PageTree.tsx
Normal file
135
frontend/src/components/site-inspection/PageTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/site-inspection/PageTreeNode.tsx
Normal file
166
frontend/src/components/site-inspection/PageTreeNode.tsx
Normal 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" />
|
||||
);
|
||||
}
|
||||
}
|
||||
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" />;
|
||||
}
|
||||
}
|
||||
142
frontend/src/hooks/useSiteInspectionSSE.ts
Normal file
142
frontend/src/hooks/useSiteInspectionSSE.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
156
frontend/src/stores/useSiteInspectionStore.ts
Normal file
156
frontend/src/stores/useSiteInspectionStore.ts
Normal 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 }),
|
||||
})
|
||||
);
|
||||
149
frontend/src/types/site-inspection.ts
Normal file
149
frontend/src/types/site-inspection.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user