feat: 3-mode inspection with tabbed UI + batch upload

- Add batch inspection backend (multipart upload, SSE streaming, MongoDB)
- Add tabbed UI (single page / site crawling / batch upload) on home and history pages
- Add batch inspection progress, result pages with 2-panel layout
- Rename "사이트 전체" to "사이트 크롤링" across codebase
- Add python-multipart dependency for file upload
- Consolidate nginx SSE location for all inspection types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 19:15:27 +09:00
parent 9bb844c5e1
commit 8326c84be9
32 changed files with 3700 additions and 61 deletions

View File

@ -0,0 +1,481 @@
"use client";
import { use, useState, useCallback } from "react";
import { useBatchInspectionResult, useInspectionResult, useInspectionIssues } from "@/lib/queries";
import { BatchUrlList } from "@/components/batch-inspection/BatchUrlList";
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 { FilterBar } from "@/components/issues/FilterBar";
import { IssueList } from "@/components/issues/IssueList";
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, getScoreTailwindColor } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { ApiError } from "@/lib/api";
import { Clock, Menu, X, ArrowLeft, FileText } from "lucide-react";
import type { CategoryKey } from "@/types/inspection";
export default function BatchInspectionResultPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [selectedPageUrl, setSelectedPageUrl] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const {
data: batchResult,
isLoading,
isError,
error,
refetch,
} = useBatchInspectionResult(id);
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<LoadingSpinner message="배치 검사 결과를 불러오는 중..." />
</div>
);
}
if (isError || !batchResult) {
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
? batchResult.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="flex items-center gap-2 px-4 py-2 border-b">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate">{batchResult.name}</span>
</div>
{/* 모바일 사이드바 토글 */}
<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" />
)}
URL
</Button>
{selectedPageUrl && (
<span className="text-sm text-muted-foreground truncate">
{selectedPageUrl}
</span>
)}
</div>
<div className="flex h-full relative">
{/* 왼쪽 사이드바: URL 목록 */}
<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"}
`}
>
<BatchUrlList
pages={batchResult.discovered_pages}
selectedUrl={selectedPageUrl}
onSelectPage={handleSelectPage}
aggregateScores={batchResult.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 ? (
// 전체 집계 보기
batchResult.aggregate_scores ? (
<BatchAggregatePanel
aggregateScores={batchResult.aggregate_scores}
name={batchResult.name}
/>
) : (
<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>
);
}
/**
* 배치 검사 전체 집계 패널.
* AggregateScorePanel을 배치 검사용으로 래핑한다 (rootUrl 대신 name 표시).
*/
function BatchAggregatePanel({
aggregateScores,
name,
}: {
aggregateScores: NonNullable<
import("@/types/batch-inspection").BatchInspectionResult["aggregate_scores"]
>;
name: string;
}) {
// AggregateScorePanel은 rootUrl을 받지만, 배치에서는 name을 표시하도록
// 직접 구현하여 "배치 검사 결과"로 제목 변경
const categoryItems = [
{
key: "html_css" as const,
label: CATEGORY_LABELS.html_css,
score: aggregateScores.html_css,
},
{
key: "accessibility" as const,
label: CATEGORY_LABELS.accessibility,
score: aggregateScores.accessibility,
},
{
key: "seo" as const,
label: CATEGORY_LABELS.seo,
score: aggregateScores.seo,
},
{
key: "performance_security" as const,
label: CATEGORY_LABELS.performance_security,
score: aggregateScores.performance_security,
},
];
return (
<div>
{/* 상단 배치 이름 및 검사 요약 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-1"> </h2>
<p className="text-sm text-muted-foreground">{name}</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 import("@/types/inspection").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>
);
}
/**
* 개별 페이지 대시보드 컴포넌트.
* 카테고리 클릭 시 이슈 목록을 인라인으로 표시하여 사이드바를 유지한다.
*/
function PageDashboard({
inspectionId,
pageUrl,
}: {
inspectionId: string;
pageUrl: string;
}) {
// 이슈 보기 모드 상태
const [issueView, setIssueView] = useState<{
showing: boolean;
initialCategory: string;
}>({ showing: false, initialCategory: "all" });
const {
data: result,
isLoading,
isError,
error,
refetch,
} = useInspectionResult(inspectionId);
const handleCategoryClick = useCallback(
(category: CategoryKey) => {
setIssueView({ showing: true, initialCategory: category });
},
[]
);
const handleViewIssues = useCallback(() => {
setIssueView({ showing: true, initialCategory: "all" });
}, []);
const handleBackToDashboard = useCallback(() => {
setIssueView({ showing: false, initialCategory: "all" });
}, []);
// inspectionId 변경 시 이슈 뷰 초기화
const [prevId, setPrevId] = useState(inspectionId);
if (inspectionId !== prevId) {
setPrevId(inspectionId);
setIssueView({ showing: false, initialCategory: "all" });
}
if (isLoading) {
return <LoadingSpinner message="페이지 검사 결과를 불러오는 중..." />;
}
if (isError || !result) {
return (
<ErrorState
message={
error instanceof ApiError
? error.detail
: "검사 결과를 불러올 수 없습니다"
}
onRetry={() => refetch()}
/>
);
}
// 이슈 목록 인라인 보기
if (issueView.showing) {
return (
<InlineIssueView
inspectionId={inspectionId}
initialCategory={issueView.initialCategory}
onBack={handleBackToDashboard}
/>
);
}
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>
);
}
/**
* 이슈 목록 인라인 뷰.
* 배치 검사 결과 페이지 내에서 이슈를 표시하여 사이드바를 유지한다.
*/
function InlineIssueView({
inspectionId,
initialCategory,
onBack,
}: {
inspectionId: string;
initialCategory: string;
onBack: () => void;
}) {
const [selectedCategory, setSelectedCategory] = useState(initialCategory);
const [selectedSeverity, setSelectedSeverity] = useState("all");
const { data, isLoading, isError, refetch } = useInspectionIssues(
inspectionId,
selectedCategory === "all" ? undefined : selectedCategory,
selectedSeverity === "all" ? undefined : selectedSeverity
);
return (
<div>
{/* 뒤로가기 */}
<div className="mb-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
<h2 className="text-xl font-bold mb-4"> </h2>
{/* 필터 */}
<div className="mb-6">
<FilterBar
selectedCategory={selectedCategory}
selectedSeverity={selectedSeverity}
onCategoryChange={setSelectedCategory}
onSeverityChange={setSelectedSeverity}
/>
</div>
{/* 이슈 목록 */}
{isError ? (
<ErrorState
message="이슈 목록을 불러올 수 없습니다"
onRetry={() => refetch()}
/>
) : (
<IssueList
issues={data?.issues}
isLoading={isLoading}
total={data?.total || 0}
/>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
"use client";
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useBatchInspectionResult } from "@/lib/queries";
import { BatchProgress } from "@/components/batch-inspection/BatchProgress";
export default function BatchInspectionProgressPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
// 이미 완료된 검사인 경우 결과 페이지로 리다이렉트
const { data: batchResult } = useBatchInspectionResult(id);
useEffect(() => {
if (batchResult?.status === "completed" || batchResult?.status === "error") {
router.replace(`/batch-inspections/${id}`);
}
}, [batchResult?.status, id, router]);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6 text-center">
</h1>
<BatchProgress batchInspectionId={id} />
</div>
);
}

View File

@ -1,28 +1,208 @@
"use client";
import { useState } from "react";
import { useInspectionHistory } from "@/lib/queries";
import { useRouter } from "next/navigation";
import {
InspectionTabs,
type InspectionTab,
} from "@/components/inspection/InspectionTabs";
import {
useInspectionHistory,
useSiteInspectionHistory,
useBatchInspectionHistory,
} from "@/lib/queries";
import { SearchBar } from "@/components/history/SearchBar";
import { InspectionHistoryTable } from "@/components/history/InspectionHistoryTable";
import { SiteHistoryTable } from "@/components/history/SiteHistoryTable";
import { Pagination } from "@/components/history/Pagination";
import { ErrorState } from "@/components/common/ErrorState";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { formatDateTime, getScoreTailwindColor } from "@/lib/constants";
import { Eye } from "lucide-react";
import { cn } from "@/lib/utils";
import type { BatchInspectionListItem } from "@/types/batch-inspection";
import type { Grade } from "@/types/inspection";
export default function HistoryPage() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
// ─────────────────────────────────────────────────────
// 배치 이력 테이블 (인라인)
// ─────────────────────────────────────────────────────
const { data, isLoading, isError, refetch } = useInspectionHistory(
page,
searchQuery || undefined
);
/** 배치 검사 상태 한국어 라벨 */
const BATCH_STATUS_LABELS: Record<string, string> = {
inspecting: "검사 중",
completed: "완료",
error: "오류",
};
const handleSearch = (query: string) => {
setSearchQuery(query);
setPage(1); // 검색 시 1페이지로 리셋
interface BatchHistoryTableProps {
inspections: BatchInspectionListItem[] | undefined;
isLoading: boolean;
}
function BatchHistoryTable({ inspections, isLoading }: BatchHistoryTableProps) {
const router = useRouter();
if (isLoading) {
return <LoadingSpinner message="배치 검사 이력을 불러오는 중..." />;
}
if (!inspections || inspections.length === 0) {
return (
<EmptyState
message="배치 검사 이력이 없습니다"
description="URL 목록 파일을 업로드하여 첫 번째 배치 검사를 시작해보세요"
/>
);
}
const handleRowClick = (batchInspectionId: string) => {
router.push(`/batch-inspections/${batchInspectionId}`);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
return (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center hidden sm:table-cell">
</TableHead>
<TableHead className="text-center hidden md:table-cell">
</TableHead>
<TableHead className="text-center hidden md:table-cell">
URL
</TableHead>
<TableHead className="hidden sm:table-cell"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inspections.map((item) => (
<TableRow
key={item.batch_inspection_id}
className="cursor-pointer"
onClick={() => handleRowClick(item.batch_inspection_id)}
>
<TableCell className="max-w-[200px] lg:max-w-[300px]">
<span className="truncate block text-sm font-medium">
{item.name}
</span>
<span className="text-xs text-muted-foreground sm:hidden">
{formatDateTime(item.created_at)}
</span>
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"text-xs font-medium px-2 py-1 rounded-full",
item.status === "completed"
? "bg-green-100 text-green-700"
: item.status === "error"
? "bg-red-100 text-red-700"
: "bg-yellow-100 text-yellow-700"
)}
>
{BATCH_STATUS_LABELS[item.status] || item.status}
</span>
</TableCell>
<TableCell className="text-center hidden sm:table-cell">
{item.overall_score !== null ? (
<span
className={cn(
"text-lg font-bold",
getScoreTailwindColor(item.overall_score)
)}
>
{item.overall_score}
</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center hidden md:table-cell">
{item.grade ? (
<ScoreBadge grade={item.grade as Grade} />
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center hidden md:table-cell text-sm">
{item.total_urls}
</TableCell>
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
{formatDateTime(item.created_at)}
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleRowClick(item.batch_inspection_id);
}}
aria-label="결과 보기"
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// ─────────────────────────────────────────────────────
// 메인 이력 페이지
// ─────────────────────────────────────────────────────
export default function HistoryPage() {
const [activeTab, setActiveTab] = useState<InspectionTab>("single");
// 각 탭별 독립적 페이지/검색 state
const [singlePage, setSinglePage] = useState(1);
const [singleSearch, setSingleSearch] = useState("");
const [sitePage, setSitePage] = useState(1);
const [batchPage, setBatchPage] = useState(1);
const [batchSearch, setBatchSearch] = useState("");
// 각 탭별 쿼리
const singleQuery = useInspectionHistory(
singlePage,
singleSearch || undefined
);
const siteQuery = useSiteInspectionHistory(sitePage);
const batchQuery = useBatchInspectionHistory(batchPage, batchSearch || undefined);
const handleSingleSearch = (query: string) => {
setSingleSearch(query);
setSinglePage(1);
};
const handleBatchSearch = (query: string) => {
setBatchSearch(query);
setBatchPage(1);
};
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
@ -30,35 +210,108 @@ export default function HistoryPage() {
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6"> </h1>
{/* 검색 바 */}
<div className="mb-6 max-w-md">
<SearchBar
query={searchQuery}
onSearch={handleSearch}
placeholder="URL 검색..."
/>
</div>
{/* */}
<InspectionTabs activeTab={activeTab} onTabChange={setActiveTab} />
{/* 테이블 또는 에러 */}
{isError ? (
<ErrorState
message="검사 이력을 불러올 수 없습니다"
onRetry={() => refetch()}
/>
) : (
{/* single 탭 */}
{activeTab === "single" && (
<>
<InspectionHistoryTable
inspections={data?.items}
isLoading={isLoading}
/>
{/* 페이지네이션 */}
{data && data.total_pages > 1 && (
<Pagination
currentPage={data.page}
totalPages={data.total_pages}
onPageChange={handlePageChange}
<div className="mb-6 max-w-md">
<SearchBar
query={singleSearch}
onSearch={handleSingleSearch}
placeholder="URL 검색..."
/>
</div>
{singleQuery.isError ? (
<ErrorState
message="검사 이력을 불러올 수 없습니다"
onRetry={() => singleQuery.refetch()}
/>
) : (
<>
<InspectionHistoryTable
inspections={singleQuery.data?.items}
isLoading={singleQuery.isLoading}
/>
{singleQuery.data && singleQuery.data.total_pages > 1 && (
<Pagination
currentPage={singleQuery.data.page}
totalPages={singleQuery.data.total_pages}
onPageChange={(p) => {
setSinglePage(p);
scrollToTop();
}}
/>
)}
</>
)}
</>
)}
{/* site 탭 */}
{activeTab === "site" && (
<>
{siteQuery.isError ? (
<ErrorState
message="사이트 크롤링 이력을 불러올 수 없습니다"
onRetry={() => siteQuery.refetch()}
/>
) : (
<>
<SiteHistoryTable
inspections={siteQuery.data?.items}
isLoading={siteQuery.isLoading}
/>
{siteQuery.data && siteQuery.data.total_pages > 1 && (
<Pagination
currentPage={siteQuery.data.page}
totalPages={siteQuery.data.total_pages}
onPageChange={(p) => {
setSitePage(p);
scrollToTop();
}}
/>
)}
</>
)}
</>
)}
{/* batch 탭 */}
{activeTab === "batch" && (
<>
<div className="mb-6 max-w-md">
<SearchBar
query={batchSearch}
onSearch={handleBatchSearch}
placeholder="배치 이름 검색..."
/>
</div>
{batchQuery.isError ? (
<ErrorState
message="배치 검사 이력을 불러올 수 없습니다"
onRetry={() => batchQuery.refetch()}
/>
) : (
<>
<BatchHistoryTable
inspections={batchQuery.data?.items}
isLoading={batchQuery.isLoading}
/>
{batchQuery.data && batchQuery.data.total_pages > 1 && (
<Pagination
currentPage={batchQuery.data.page}
totalPages={batchQuery.data.total_pages}
onPageChange={(p) => {
setBatchPage(p);
scrollToTop();
}}
/>
)}
</>
)}
</>
)}

View File

@ -1,10 +1,21 @@
"use client";
import { UrlInputForm } from "@/components/inspection/UrlInputForm";
import { useState } from "react";
import {
InspectionTabs,
type InspectionTab,
} from "@/components/inspection/InspectionTabs";
import { SinglePageForm } from "@/components/inspection/SinglePageForm";
import { SiteCrawlForm } from "@/components/inspection/SiteCrawlForm";
import { BatchUploadForm } from "@/components/inspection/BatchUploadForm";
import { RecentInspections } from "@/components/inspection/RecentInspections";
import { RecentSiteInspections } from "@/components/inspection/RecentSiteInspections";
import { RecentBatchInspections } from "@/components/inspection/RecentBatchInspections";
import { Search } from "lucide-react";
export default function HomePage() {
const [activeTab, setActiveTab] = useState<InspectionTab>("single");
return (
<div className="container mx-auto px-4 py-12">
{/* 히어로 섹션 */}
@ -22,11 +33,18 @@ export default function HomePage() {
</p>
</section>
{/* URL 입력 폼 */}
<UrlInputForm />
{/* */}
<InspectionTabs activeTab={activeTab} onTabChange={setActiveTab} />
{/* 최근 검사 이력 */}
<RecentInspections />
{/* 탭별 폼 */}
{activeTab === "single" && <SinglePageForm />}
{activeTab === "site" && <SiteCrawlForm />}
{activeTab === "batch" && <BatchUploadForm />}
{/* 탭별 최근 이력 */}
{activeTab === "single" && <RecentInspections />}
{activeTab === "site" && <RecentSiteInspections />}
{activeTab === "batch" && <RecentBatchInspections />}
</div>
);
}

View File

@ -25,7 +25,7 @@ export default function SiteInspectionProgressPage({
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

@ -0,0 +1,218 @@
"use client";
import { useEffect } from "react";
import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore";
import { useBatchInspectionSSE } from "@/hooks/useBatchInspectionSSE";
import { useBatchInspectionResult } from "@/lib/queries";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent } from "@/components/ui/card";
import { ErrorState } from "@/components/common/ErrorState";
import {
Check,
X,
Circle,
Loader2,
FileText,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getScoreTailwindColor } from "@/lib/constants";
import type { BatchPage } from "@/types/batch-inspection";
interface BatchProgressProps {
batchInspectionId: string;
}
/**
* 배치 검사 진행 상태 표시 컴포넌트.
* 크롤링 단계 없이 바로 검사 단계만 표시한다.
*/
export function BatchProgress({ batchInspectionId }: BatchProgressProps) {
const {
status,
name,
discoveredPages,
aggregateScores,
errorMessage,
initFromApi,
} = useBatchInspectionStore();
// API에서 현재 상태 조회 (리로드 시 스토어 복원용)
const { data: apiResult } = useBatchInspectionResult(batchInspectionId);
useEffect(() => {
if (apiResult && status === "idle") {
initFromApi(apiResult);
}
}, [apiResult, status, initFromApi]);
// SSE 연결
useBatchInspectionSSE(batchInspectionId);
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">
{/* 배치 이름 표시 */}
{name && (
<div className="flex items-center gap-2 mb-6 text-muted-foreground">
<FileText className="h-4 w-4" />
<span className="text-sm truncate">{name}</span>
</div>
)}
{/* 검사 단계 (크롤링 단계 없이 바로 검사) */}
{(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 InspectionPhase({
pages,
completedPages,
totalPages,
overallProgress,
aggregateScores,
}: {
pages: BatchPage[];
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: BatchPage }) {
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: BatchPage["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,180 @@
"use client";
import { Globe, Check, X, Circle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { getScoreTailwindColor } from "@/lib/constants";
import type { BatchPage } from "@/types/batch-inspection";
import type { AggregateScores } from "@/types/site-inspection";
interface BatchUrlListProps {
/** 배치 페이지 목록 (flat array) */
pages: BatchPage[];
/** 현재 선택된 페이지 URL (null = 전체 요약 보기) */
selectedUrl: string | null;
/** 페이지 선택 핸들러 (null을 전달하면 전체 집계 보기) */
onSelectPage: (url: string | null) => void;
/** 집계 점수 (전체 요약 노드에 표시) */
aggregateScores: AggregateScores | null;
}
/**
* 배치 검사 URL 목록 사이드바 컴포넌트.
* 트리 구조 없이 flat 리스트로 URL을 표시한다.
*/
export function BatchUrlList({
pages,
selectedUrl,
onSelectPage,
aggregateScores,
}: BatchUrlListProps) {
const isAggregateSelected = selectedUrl === null;
return (
<div
className="flex flex-col h-full"
role="listbox"
aria-label="URL 목록"
>
{/* 헤더 */}
<div className="px-3 py-2 border-b">
<h3 className="text-sm font-semibold text-muted-foreground">
URL
</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="option"
aria-selected={isAggregateSelected}
>
<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>
{/* URL 목록 */}
{pages.map((page) => (
<UrlListItem
key={page.url}
page={page}
isSelected={selectedUrl === page.url}
onSelect={() => onSelectPage(page.url)}
/>
))}
{/* 빈 상태 */}
{pages.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
URL이
</div>
)}
</div>
{/* 하단 요약 */}
{pages.length > 0 && (
<div className="px-3 py-2 border-t text-xs text-muted-foreground">
{pages.length} URL
{aggregateScores && (
<span>
{" "}/ {aggregateScores.pages_inspected}/
{aggregateScores.pages_total}
</span>
)}
</div>
)}
</div>
);
}
/** 개별 URL 목록 항목 */
function UrlListItem({
page,
isSelected,
onSelect,
}: {
page: BatchPage;
isSelected: boolean;
onSelect: () => void;
}) {
let displayPath: string;
try {
const parsed = new URL(page.url);
displayPath =
parsed.hostname + (parsed.pathname !== "/" ? parsed.pathname : "") + parsed.search;
} catch {
displayPath = page.url;
}
return (
<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",
isSelected && "bg-accent text-accent-foreground"
)}
onClick={onSelect}
role="option"
aria-selected={isSelected}
>
{/* 상태 아이콘 */}
<PageStatusIcon status={page.status} />
{/* 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",
getScoreTailwindColor(page.overall_score)
)}
>
{page.overall_score}
</span>
)}
{/* 검사 중 표시 */}
{page.status === "inspecting" && (
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 text-blue-500 animate-spin" />
)}
</div>
);
}
/** 페이지 상태 아이콘 */
function PageStatusIcon({ status }: { status: BatchPage["status"] }) {
switch (status) {
case "pending":
return <Circle className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/50" />;
case "inspecting":
return (
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 text-blue-500 animate-spin" />
);
case "completed":
return <Check className="h-3.5 w-3.5 flex-shrink-0 text-green-500" />;
case "error":
return <X className="h-3.5 w-3.5 flex-shrink-0 text-red-500" />;
default:
return <Circle className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/50" />;
}
}

View File

@ -0,0 +1,155 @@
"use client";
import { useRouter } from "next/navigation";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { formatDateTime, getScoreTailwindColor } from "@/lib/constants";
import { Eye } from "lucide-react";
import { cn } from "@/lib/utils";
import type { SiteInspectionListItem } from "@/types/site-inspection";
import type { Grade } from "@/types/inspection";
/** 사이트 검사 상태 한국어 라벨 */
const STATUS_LABELS: Record<string, string> = {
crawling: "크롤링 중",
inspecting: "검사 중",
completed: "완료",
error: "오류",
};
interface SiteHistoryTableProps {
inspections: SiteInspectionListItem[] | undefined;
isLoading: boolean;
}
/** 사이트 크롤링 이력 테이블 */
export function SiteHistoryTable({
inspections,
isLoading,
}: SiteHistoryTableProps) {
const router = useRouter();
if (isLoading) {
return <LoadingSpinner message="사이트 크롤링 이력을 불러오는 중..." />;
}
if (!inspections || inspections.length === 0) {
return (
<EmptyState
message="사이트 크롤링 이력이 없습니다"
description="사이트 URL을 입력하여 첫 번째 크롤링을 시작해보세요"
/>
);
}
const handleRowClick = (siteInspectionId: string) => {
router.push(`/site-inspections/${siteInspectionId}`);
};
return (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center hidden sm:table-cell">
</TableHead>
<TableHead className="text-center hidden md:table-cell">
</TableHead>
<TableHead className="text-center hidden md:table-cell">
</TableHead>
<TableHead className="hidden sm:table-cell"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inspections.map((item) => (
<TableRow
key={item.site_inspection_id}
className="cursor-pointer"
onClick={() => handleRowClick(item.site_inspection_id)}
>
<TableCell className="max-w-[200px] lg:max-w-[300px]">
<span className="truncate block text-sm font-medium">
{item.domain || item.root_url}
</span>
<span className="text-xs text-muted-foreground sm:hidden">
{formatDateTime(item.created_at)}
</span>
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"text-xs font-medium px-2 py-1 rounded-full",
item.status === "completed"
? "bg-green-100 text-green-700"
: item.status === "error"
? "bg-red-100 text-red-700"
: "bg-yellow-100 text-yellow-700"
)}
>
{STATUS_LABELS[item.status] || item.status}
</span>
</TableCell>
<TableCell className="text-center hidden sm:table-cell">
{item.overall_score !== null ? (
<span
className={cn(
"text-lg font-bold",
getScoreTailwindColor(item.overall_score)
)}
>
{item.overall_score}
</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center hidden md:table-cell">
{item.grade ? (
<ScoreBadge grade={item.grade as Grade} />
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center hidden md:table-cell text-sm">
{item.pages_total}
</TableCell>
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
{formatDateTime(item.created_at)}
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleRowClick(item.site_inspection_id);
}}
aria-label="결과 보기"
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,315 @@
"use client";
import { useState, useRef, useCallback, type FormEvent, type DragEvent } 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 { Upload, Loader2, FileText, X } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore";
import { cn } from "@/lib/utils";
/** 동시 검사 수 옵션 */
const CONCURRENCY_OPTIONS = [1, 2, 4, 8] as const;
/** 파일에서 URL 목록 파싱 */
function parseUrlsFromText(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
.filter((line) => isValidUrl(line));
}
/** 목록 업로드 폼 */
export function BatchUploadForm() {
const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [parsedUrls, setParsedUrls] = useState<string[]>([]);
const [concurrency, setConcurrency] = useState<number>(4);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const { setBatchInspection } = useBatchInspectionStore();
/** 파일 처리 공통 로직 */
const processFile = useCallback((selectedFile: File) => {
setError(null);
if (!selectedFile.name.endsWith(".txt")) {
setError(".txt 파일만 업로드할 수 있습니다");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const urls = parseUrlsFromText(text);
if (urls.length === 0) {
setError("파일에서 유효한 URL을 찾을 수 없습니다");
setFile(null);
setParsedUrls([]);
return;
}
setFile(selectedFile);
setParsedUrls(urls);
};
reader.onerror = () => {
setError("파일 읽기 중 오류가 발생했습니다");
};
reader.readAsText(selectedFile);
}, []);
/** 파일 선택 핸들러 */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
processFile(selectedFile);
}
};
/** 드래그 앤 드롭 핸들러 */
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
processFile(droppedFile);
}
};
/** 파일 제거 */
const handleRemoveFile = () => {
setFile(null);
setParsedUrls([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
/** 폼 제출 */
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("배치 이름을 입력해주세요");
return;
}
if (!file) {
setError("URL 목록 파일을 업로드해주세요");
return;
}
if (parsedUrls.length === 0) {
setError("파일에서 유효한 URL을 찾을 수 없습니다");
return;
}
setIsLoading(true);
try {
const response = await api.startBatchInspection(
file,
name.trim(),
concurrency
);
setBatchInspection(response.batch_inspection_id, name.trim());
router.push(`/batch-inspections/${response.batch_inspection_id}/progress`);
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);
} else {
setError("배치 검사 시작 중 오류가 발생했습니다. 다시 시도해주세요.");
}
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* 배치 이름 입력 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (error) setError(null);
}}
placeholder="예: 2월 정기 검사"
className="h-10"
disabled={isLoading}
aria-label="배치 이름 입력"
/>
</div>
{/* 파일 업로드 영역 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
URL (.txt)
</label>
<input
ref={fileInputRef}
type="file"
accept=".txt"
onChange={handleFileChange}
className="hidden"
disabled={isLoading}
/>
{!file ? (
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 p-8 rounded-lg border-2 border-dashed cursor-pointer transition-colors",
isDragOver
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
aria-label="URL 목록 파일 업로드"
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground/70">
URL, #
</p>
</div>
) : (
<div className="flex flex-col gap-3 p-4 rounded-lg border bg-muted/30">
{/* 파일 정보 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{file.name}</span>
</div>
<button
type="button"
onClick={handleRemoveFile}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="파일 제거"
disabled={isLoading}
>
<X className="h-4 w-4" />
</button>
</div>
{/* URL 미리보기 */}
<div>
<p className="text-sm font-medium text-primary mb-1.5">
{parsedUrls.length} URL
</p>
<ul className="space-y-1">
{parsedUrls.slice(0, 5).map((parsedUrl, index) => (
<li
key={index}
className="text-xs text-muted-foreground truncate"
>
{parsedUrl}
</li>
))}
{parsedUrls.length > 5 && (
<li className="text-xs text-muted-foreground/70">
... {parsedUrls.length - 5}
</li>
)}
</ul>
</div>
</div>
)}
</div>
{/* 동시 검사 수 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-2">
{CONCURRENCY_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={concurrency === option ? "default" : "outline"}
size="sm"
className={cn(
"flex-1",
concurrency === option && "pointer-events-none"
)}
onClick={() => setConcurrency(option)}
disabled={isLoading}
>
{option}
</Button>
))}
</div>
</div>
{/* 검사 시작 버튼 */}
<Button
type="submit"
size="lg"
className="h-12 text-base w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="h-4 w-4" />
</>
)}
</Button>
</form>
{error && (
<p
className="mt-2 text-sm text-destructive"
role="alert"
>
{error}
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import { Search, Globe, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
export type InspectionTab = "single" | "site" | "batch";
const TABS: { key: InspectionTab; label: string; icon: typeof Search }[] = [
{ key: "single", label: "한 페이지 검사", icon: Search },
{ key: "site", label: "사이트 크롤링", icon: Globe },
{ key: "batch", label: "목록 업로드", icon: Upload },
];
interface InspectionTabsProps {
activeTab: InspectionTab;
onTabChange: (tab: InspectionTab) => void;
}
export function InspectionTabs({ activeTab, onTabChange }: InspectionTabsProps) {
return (
<div className="flex justify-center gap-2 mb-6">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => onTabChange(key)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors",
activeTab === key
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/50 text-muted-foreground hover:bg-muted"
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { useRecentBatchInspections } from "@/lib/queries";
import { formatDate, getScoreTailwindColor } from "@/lib/constants";
import { Upload } from "lucide-react";
import type { Grade } from "@/types/inspection";
import { cn } from "@/lib/utils";
/** 최근 배치 검사 이력 (메인 페이지용) */
export function RecentBatchInspections() {
const { data, isLoading, isError } = useRecentBatchInspections();
if (isLoading) {
return <LoadingSpinner message="최근 배치 검사 이력을 불러오는 중..." />;
}
if (isError || !data) {
return null;
}
if (data.items.length === 0) {
return (
<EmptyState
message="배치 검사 이력이 없습니다"
description="URL 목록 파일을 업로드하여 첫 번째 배치 검사를 시작해보세요"
/>
);
}
return (
<div className="w-full max-w-2xl mx-auto mt-8">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="space-y-3">
{data.items.map((item) => (
<Link
key={item.batch_inspection_id}
href={`/batch-inspections/${item.batch_inspection_id}`}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="flex items-center justify-between py-4 px-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">
{item.name}
</span>
</div>
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-muted-foreground">
{formatDate(item.created_at)}
</p>
<span className="text-xs text-muted-foreground">
{item.total_urls} URL
</span>
</div>
</div>
<div className="flex items-center gap-3 ml-4">
{item.overall_score !== null && (
<>
<span
className={cn(
"text-lg font-bold",
getScoreTailwindColor(item.overall_score)
)}
>
{item.overall_score}
</span>
{item.grade && <ScoreBadge grade={item.grade as Grade} />}
</>
)}
{item.overall_score === null && (
<span className="text-sm text-muted-foreground">
{item.status === "inspecting"
? "검사 중"
: item.status === "error"
? "오류"
: "대기 중"}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { useRecentSiteInspections } from "@/lib/queries";
import { formatDate, getScoreTailwindColor } from "@/lib/constants";
import { Globe } from "lucide-react";
import type { Grade } from "@/types/inspection";
import { cn } from "@/lib/utils";
/** 최근 사이트 크롤링 이력 (메인 페이지용) */
export function RecentSiteInspections() {
const { data, isLoading, isError } = useRecentSiteInspections();
if (isLoading) {
return <LoadingSpinner message="최근 사이트 크롤링 이력을 불러오는 중..." />;
}
if (isError || !data) {
return null;
}
if (data.items.length === 0) {
return (
<EmptyState
message="사이트 크롤링 이력이 없습니다"
description="사이트 URL을 입력하여 첫 번째 크롤링을 시작해보세요"
/>
);
}
return (
<div className="w-full max-w-2xl mx-auto mt-8">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="space-y-3">
{data.items.map((item) => (
<Link
key={item.site_inspection_id}
href={`/site-inspections/${item.site_inspection_id}`}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="flex items-center justify-between py-4 px-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">
{item.domain || item.root_url}
</span>
</div>
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-muted-foreground">
{formatDate(item.created_at)}
</p>
<span className="text-xs text-muted-foreground">
{item.pages_total}
</span>
</div>
</div>
<div className="flex items-center gap-3 ml-4">
{item.overall_score !== null && (
<>
<span
className={cn(
"text-lg font-bold",
getScoreTailwindColor(item.overall_score)
)}
>
{item.overall_score}
</span>
{item.grade && <ScoreBadge grade={item.grade as Grade} />}
</>
)}
{item.overall_score === null && (
<span className="text-sm text-muted-foreground">
{item.status === "crawling"
? "크롤링 중"
: item.status === "inspecting"
? "검사 중"
: item.status === "error"
? "오류"
: "대기 중"}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { useState, 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 { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useInspectionStore } from "@/stores/useInspectionStore";
/** 한 페이지 검사 폼 */
export function SinglePageForm() {
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { setInspection } = useInspectionStore();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const trimmedUrl = url.trim();
if (!trimmedUrl) {
setError("URL을 입력해주세요");
return;
}
if (!isValidUrl(trimmedUrl)) {
setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)");
return;
}
setIsLoading(true);
try {
const response = await api.startInspection(trimmedUrl);
setInspection(response.inspection_id, trimmedUrl);
router.push(`/inspections/${response.inspection_id}/progress`);
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);
} else {
setError("검사 시작 중 오류가 발생했습니다. 다시 시도해주세요.");
}
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<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={isLoading}
aria-label="검사할 URL 입력"
aria-invalid={!!error}
aria-describedby={error ? "single-url-error" : undefined}
/>
</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>
</div>
</form>
{error && (
<p
id="single-url-error"
className="mt-2 text-sm text-destructive"
role="alert"
>
{error}
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,206 @@
"use client";
import { useState, 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 { Globe, Loader2 } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
import { cn } from "@/lib/utils";
/** 최대 페이지 수 옵션 (0 = 무제한) */
const MAX_PAGES_OPTIONS = [10, 20, 50, 0] as const;
/** 크롤링 깊이 옵션 */
const MAX_DEPTH_OPTIONS = [1, 2, 3] as const;
/** 동시 검사 수 옵션 */
const CONCURRENCY_OPTIONS = [1, 2, 4, 8] as const;
/** 사이트 크롤링 폼 */
export function SiteCrawlForm() {
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [maxPages, setMaxPages] = useState<number>(20);
const [maxDepth, setMaxDepth] = useState<number>(2);
const [concurrency, setConcurrency] = useState<number>(4);
const router = useRouter();
const { setSiteInspection } = useSiteInspectionStore();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const trimmedUrl = url.trim();
if (!trimmedUrl) {
setError("URL을 입력해주세요");
return;
}
if (!isValidUrl(trimmedUrl)) {
setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)");
return;
}
setIsLoading(true);
try {
const response = await api.startSiteInspection(
trimmedUrl,
maxPages,
maxDepth,
concurrency
);
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 {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* URL 입력 필드 */}
<div className="relative">
<Globe 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 ? "site-url-error" : undefined}
/>
</div>
{/* 옵션 영역 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 최대 페이지 수 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-1.5">
{MAX_PAGES_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={maxPages === option ? "default" : "outline"}
size="sm"
className={cn(
"flex-1 text-xs",
maxPages === option && "pointer-events-none"
)}
onClick={() => setMaxPages(option)}
disabled={isLoading}
>
{option === 0 ? "무제한" : option}
</Button>
))}
</div>
</div>
{/* 크롤링 깊이 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-1.5">
{MAX_DEPTH_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={maxDepth === option ? "default" : "outline"}
size="sm"
className={cn(
"flex-1 text-xs",
maxDepth === option && "pointer-events-none"
)}
onClick={() => setMaxDepth(option)}
disabled={isLoading}
>
{option}
</Button>
))}
</div>
</div>
{/* 동시 검사 수 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
</label>
<div className="flex gap-1.5">
{CONCURRENCY_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={concurrency === option ? "default" : "outline"}
size="sm"
className={cn(
"flex-1 text-xs",
concurrency === option && "pointer-events-none"
)}
onClick={() => setConcurrency(option)}
disabled={isLoading}
>
{option}
</Button>
))}
</div>
</div>
</div>
{/* 사이트 크롤링 시작 버튼 */}
<Button
type="submit"
size="lg"
className="h-12 text-base w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Globe className="h-4 w-4" />
</>
)}
</Button>
</form>
{error && (
<p
id="site-url-error"
className="mt-2 text-sm text-destructive"
role="alert"
>
{error}
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -22,7 +22,7 @@ interface AggregateCategoryItem {
score: number;
}
/** 사이트 전체 집계 점수 패널 */
/** 사이트 크롤링 집계 점수 패널 */
export function AggregateScorePanel({
aggregateScores,
rootUrl,
@ -54,7 +54,7 @@ export function AggregateScorePanel({
<div>
{/* 상단 URL 및 검사 요약 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-1"> </h2>
<h2 className="text-lg font-semibold mb-1"> </h2>
<p className="text-sm text-muted-foreground">
<a
href={rootUrl}
@ -113,7 +113,7 @@ export function AggregateScorePanel({
<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}

View File

@ -71,7 +71,7 @@ export function PageTree({
{/* 트리 본문 */}
<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",
@ -84,7 +84,7 @@ export function PageTree({
>
<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>
<span className="font-medium truncate flex-1"> </span>
{aggregateScores && (
<span
className={cn(

View File

@ -25,7 +25,7 @@ interface SiteCrawlProgressProps {
}
/**
* 사이트 전체 검사 진행 상태 표시 컴포넌트.
* 사이트 크롤링 검사 진행 상태 표시 컴포넌트.
* 크롤링 단계와 검사 단계를 시각적으로 표현한다.
*/
export function SiteCrawlProgress({

View File

@ -0,0 +1,123 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore";
import { api } from "@/lib/api";
import type {
SSEBatchPageStart,
SSEBatchPageComplete,
SSEBatchAggregateUpdate,
SSEBatchComplete,
} from "@/types/batch-inspection";
/**
* SSE를 통해 배치 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 검사 진행 상태를 실시간 수신하고
* Zustand 스토어를 업데이트한다.
* 크롤링 단계 없이 page_start, page_complete, aggregate_update, complete, error만 리스닝.
*/
export function useBatchInspectionSSE(batchInspectionId: string | null) {
const {
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
} = useBatchInspectionStore();
const router = useRouter();
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!batchInspectionId) return;
const streamUrl = api.getBatchStreamUrl(batchInspectionId);
const eventSource = new EventSource(streamUrl);
eventSourceRef.current = eventSource;
/** 개별 페이지 검사 시작 이벤트 */
eventSource.addEventListener("page_start", (e: MessageEvent) => {
try {
const data: SSEBatchPageStart = JSON.parse(e.data);
updatePageStatus(data.page_url, "inspecting");
} catch {
// JSON 파싱 실패 무시
}
});
/** 개별 페이지 검사 완료 이벤트 */
eventSource.addEventListener("page_complete", (e: MessageEvent) => {
try {
const data: SSEBatchPageComplete = JSON.parse(e.data);
setPageComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 집계 점수 업데이트 이벤트 */
eventSource.addEventListener("aggregate_update", (e: MessageEvent) => {
try {
const data: SSEBatchAggregateUpdate = JSON.parse(e.data);
updateAggregateScores(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 배치 검사 완료 이벤트 */
eventSource.addEventListener("complete", (e: MessageEvent) => {
try {
const data: SSEBatchComplete = JSON.parse(e.data);
setCompleted(data.aggregate_scores);
eventSource.close();
// stale 캐시 제거 → 결과 페이지에서 fresh 데이터 로드
queryClient.removeQueries({
queryKey: ["batchInspection", batchInspectionId],
});
router.push(`/batch-inspections/${batchInspectionId}`);
} 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;
};
}, [
batchInspectionId,
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
router,
queryClient,
]);
}

View File

@ -15,7 +15,7 @@ import type {
} from "@/types/site-inspection";
/**
* SSE를 통해 사이트 전체 검사 진행 상태를 수신하는 커스텀 훅.
* SSE를 통해 사이트 크롤링 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고
* Zustand 스토어를 업데이트한다.
*/
@ -122,7 +122,7 @@ export function useSiteInspectionSSE(siteInspectionId: string | null) {
}
});
// SSE 연결 타임아웃 (10분 - 사이트 전체 검사는 시간이 더 소요됨)
// SSE 연결 타임아웃 (10분 - 사이트 크롤링 검사는 시간이 더 소요됨)
const timeout = setTimeout(() => {
eventSource.close();
setError("사이트 검사 시간이 초과되었습니다 (10분)");

View File

@ -10,8 +10,14 @@ import type {
import type {
StartSiteInspectionResponse,
SiteInspectionResult,
SiteInspectionPaginatedResponse,
InspectPageResponse,
} from "@/types/site-inspection";
import type {
StartBatchInspectionResponse,
BatchInspectionResult,
BatchInspectionPaginatedResponse,
} from "@/types/batch-inspection";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL ?? "";
@ -150,10 +156,10 @@ class ApiClient {
}
// ─────────────────────────────────────────────────────
// 사이트 전체 검사 API
// 사이트 크롤링 검사 API
// ─────────────────────────────────────────────────────
/** 사이트 전체 검사 시작 */
/** 사이트 크롤링 검사 시작 */
async startSiteInspection(
url: string,
maxPages?: number,
@ -194,6 +200,76 @@ class ApiClient {
getSiteStreamUrl(siteInspectionId: string): string {
return `${this.baseUrl}/api/site-inspections/${siteInspectionId}/stream`;
}
/** 사이트 검사 이력 목록 */
async getSiteInspections(params: {
page?: number;
limit?: number;
}): Promise<SiteInspectionPaginatedResponse> {
const qs = new URLSearchParams();
qs.set("page", String(params.page || 1));
qs.set("limit", String(params.limit || 20));
return this.request(`/api/site-inspections?${qs}`);
}
// ─────────────────────────────────────────────────────
// 배치 검사 API
// ─────────────────────────────────────────────────────
/** 배치 검사 시작 (multipart/form-data) */
async startBatchInspection(
file: File,
name: string,
concurrency?: number
): Promise<StartBatchInspectionResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("name", name);
if (concurrency !== undefined) {
formData.append("concurrency", String(concurrency));
}
// NOTE: Content-Type을 직접 설정하지 않아야 boundary가 자동 설정됨
const response = await fetch(`${this.baseUrl}/api/batch-inspections`, {
method: "POST",
body: formData,
});
if (!response.ok) {
let detail = "요청 처리 중 오류가 발생했습니다";
try {
const error = await response.json();
detail = error.detail || detail;
} catch {
// JSON 파싱 실패 시 기본 메시지 사용
}
throw new ApiError(response.status, detail);
}
return response.json();
}
/** 배치 검사 결과 조회 */
async getBatchInspection(id: string): Promise<BatchInspectionResult> {
return this.request(`/api/batch-inspections/${id}`);
}
/** 배치 검사 이력 목록 */
async getBatchInspections(params: {
page?: number;
limit?: number;
name?: string;
}): Promise<BatchInspectionPaginatedResponse> {
const qs = new URLSearchParams();
qs.set("page", String(params.page || 1));
qs.set("limit", String(params.limit || 20));
if (params.name) qs.set("name", params.name);
return this.request(`/api/batch-inspections?${qs}`);
}
/** 배치 검사 SSE 스트림 URL 반환 */
getBatchStreamUrl(batchInspectionId: string): string {
return `${this.baseUrl}/api/batch-inspections/${batchInspectionId}/stream`;
}
}
export const api = new ApiClient(API_BASE_URL);

View File

@ -56,7 +56,7 @@ export function useRecentInspections() {
});
}
/** 사이트 전체 검사 결과 조회 */
/** 사이트 크롤링 검사 결과 조회 */
export function useSiteInspectionResult(siteInspectionId: string | null) {
return useQuery({
queryKey: ["siteInspection", siteInspectionId],
@ -65,3 +65,49 @@ export function useSiteInspectionResult(siteInspectionId: string | null) {
staleTime: 5 * 60 * 1000,
});
}
/** 사이트 검사 이력 조회 (페이지네이션) */
export function useSiteInspectionHistory(page: number) {
return useQuery({
queryKey: ["site-inspection-history", page],
queryFn: () => api.getSiteInspections({ page, limit: 20 }),
placeholderData: keepPreviousData,
});
}
/** 최근 사이트 검사 이력 (메인 페이지용, 5건) */
export function useRecentSiteInspections() {
return useQuery({
queryKey: ["recent-site-inspections"],
queryFn: () => api.getSiteInspections({ page: 1, limit: 5 }),
staleTime: 60 * 1000,
});
}
/** 배치 검사 결과 조회 */
export function useBatchInspectionResult(batchInspectionId: string | null) {
return useQuery({
queryKey: ["batchInspection", batchInspectionId],
queryFn: () => api.getBatchInspection(batchInspectionId!),
enabled: !!batchInspectionId,
staleTime: 5 * 60 * 1000,
});
}
/** 배치 검사 이력 조회 (페이지네이션) */
export function useBatchInspectionHistory(page: number, name?: string) {
return useQuery({
queryKey: ["batch-inspection-history", page, name],
queryFn: () => api.getBatchInspections({ page, limit: 20, name }),
placeholderData: keepPreviousData,
});
}
/** 최근 배치 검사 이력 (메인 페이지용, 5건) */
export function useRecentBatchInspections() {
return useQuery({
queryKey: ["recent-batch-inspections"],
queryFn: () => api.getBatchInspections({ page: 1, limit: 5 }),
staleTime: 60 * 1000,
});
}

View File

@ -0,0 +1,143 @@
import { create } from "zustand";
import type { AggregateScores } from "@/types/site-inspection";
import type {
BatchPage,
BatchInspectionPhase,
BatchInspectionResult,
SSEBatchPageComplete,
SSEBatchAggregateUpdate,
} from "@/types/batch-inspection";
interface BatchInspectionState {
batchInspectionId: string | null;
name: string | null;
status: BatchInspectionPhase;
discoveredPages: BatchPage[];
aggregateScores: AggregateScores | null;
errorMessage: string | null;
// Actions
setBatchInspection: (id: string, name: string) => void;
initFromApi: (data: BatchInspectionResult) => void;
updatePageStatus: (
pageUrl: string,
status: BatchPage["status"],
extra?: {
inspection_id?: string;
overall_score?: number;
grade?: string;
}
) => void;
setPageComplete: (data: SSEBatchPageComplete) => void;
updateAggregateScores: (data: SSEBatchAggregateUpdate) => void;
setCompleted: (aggregateScores: AggregateScores) => void;
setError: (message: string) => void;
reset: () => void;
}
const initialState = {
batchInspectionId: null,
name: null,
status: "idle" as BatchInspectionPhase,
discoveredPages: [] as BatchPage[],
aggregateScores: null,
errorMessage: null,
};
export const useBatchInspectionStore = create<BatchInspectionState>(
(set) => ({
...initialState,
setBatchInspection: (id, name) =>
set({
...initialState,
batchInspectionId: id,
name,
status: "inspecting",
}),
initFromApi: (data) =>
set((state) => {
// Only init if store is idle (prevents overwriting live SSE data)
if (state.status !== "idle") return state;
return {
batchInspectionId: data.batch_inspection_id,
name: data.name,
status: data.status as BatchInspectionPhase,
discoveredPages: data.discovered_pages,
aggregateScores: data.aggregate_scores,
};
}),
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,
},
})),
setCompleted: (aggregateScores) =>
set({
status: "completed",
aggregateScores,
}),
setError: (message) =>
set({
status: "error",
errorMessage: message,
}),
reset: () => set({ ...initialState }),
})
);

View File

@ -0,0 +1,117 @@
import type { AggregateScores } from "@/types/site-inspection";
// ───────────────────────────────────────────────────────
// 배치 검사 도메인 타입
// ───────────────────────────────────────────────────────
/** 배치 검사 상태 */
export type BatchInspectionStatus = "inspecting" | "completed" | "error";
/** 배치 검사 진행 상태 (Zustand Store) */
export type BatchInspectionPhase =
| "idle"
| "inspecting"
| "completed"
| "error";
/** 배치 페이지 (site-inspection의 DiscoveredPage와 동일) */
export interface BatchPage {
url: string;
depth: number;
parent_url: string | null;
inspection_id: string | null;
status: "pending" | "inspecting" | "completed" | "error";
title: string | null;
overall_score: number | null;
grade: string | null;
}
/** 배치 검사 설정 */
export interface BatchInspectionConfig {
concurrency: number;
}
/** GET /api/batch-inspections/{id} 응답 - 배치 검사 결과 */
export interface BatchInspectionResult {
batch_inspection_id: string;
name: string;
status: BatchInspectionStatus;
created_at: string;
completed_at: string | null;
config: BatchInspectionConfig;
source_urls: string[];
discovered_pages: BatchPage[];
aggregate_scores: AggregateScores | null;
}
/** POST /api/batch-inspections 응답 */
export interface StartBatchInspectionResponse {
batch_inspection_id: string;
status: string;
name: string;
total_urls: number;
stream_url: string;
}
/** 이력 목록 항목 */
export interface BatchInspectionListItem {
batch_inspection_id: string;
name: string;
status: BatchInspectionStatus;
created_at: string;
total_urls: number;
pages_inspected: number;
overall_score: number | null;
grade: string | null;
}
/** GET /api/batch-inspections 응답 - 페이지네이션 */
export interface BatchInspectionPaginatedResponse {
items: BatchInspectionListItem[];
total: number;
page: number;
limit: number;
total_pages: number;
}
// ───────────────────────────────────────────────────────
// SSE 이벤트 타입
// ───────────────────────────────────────────────────────
/** SSE batch_page_start 이벤트 */
export interface SSEBatchPageStart {
batch_inspection_id: string;
page_url: string;
page_index: number;
}
/** SSE batch_page_complete 이벤트 */
export interface SSEBatchPageComplete {
batch_inspection_id: string;
page_url: string;
inspection_id: string;
overall_score: number;
grade: string;
}
/** SSE batch_aggregate_update 이벤트 */
export interface SSEBatchAggregateUpdate {
batch_inspection_id: string;
pages_inspected: number;
pages_total: number;
overall_score: number;
grade: string;
}
/** SSE batch_complete 이벤트 (배치 검사 완료) */
export interface SSEBatchComplete {
batch_inspection_id: string;
status: string;
aggregate_scores: AggregateScores;
}
/** SSE batch_error 이벤트 */
export interface SSEBatchError {
batch_inspection_id: string;
message: string;
}

View File

@ -30,7 +30,7 @@ export interface DiscoveredPage {
grade: string | null;
}
/** 사이트 전체 집계 점수 */
/** 사이트 크롤링 집계 점수 */
export interface AggregateScores {
overall_score: number;
grade: string;
@ -75,6 +75,28 @@ export interface InspectPageResponse {
inspection_id: string;
}
/** 사이트 검사 이력 목록 항목 */
export interface SiteInspectionListItem {
site_inspection_id: string;
root_url: string;
domain: string;
status: SiteInspectionStatus;
created_at: string;
pages_total: number;
pages_inspected: number;
overall_score: number | null;
grade: string | null;
}
/** GET /api/site-inspections 응답 - 페이지네이션 */
export interface SiteInspectionPaginatedResponse {
items: SiteInspectionListItem[];
total: number;
page: number;
limit: number;
total_pages: number;
}
// ───────────────────────────────────────────────────────
// SSE 이벤트 타입
// ───────────────────────────────────────────────────────