import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { toast } from "sonner"; import { AlertCircle, ArrowLeft, ArrowRight, Ban, CheckCircle2, Globe, Loader2, PlayCircle, XCircle, } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select } from "@/components/ui/select"; import { Progress } from "@/components/ui/progress"; import { Badge, BadgeProps } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { useProject } from "@/hooks/useProjects"; import { useCancelCrawl, useCrawlJob, useStartSiteCrawl, } from "@/hooks/useCrawl"; import { isCrawlTerminal } from "@/lib/api/crawl"; const startCrawlSchema = z.object({ source_name: z.string().min(1, "소스를 선택하세요"), url: z.string().url("올바른 URL 형식이 아닙니다"), max_depth: z.number().int().min(0).max(10), max_pages: z.number().int().min(1).max(500), same_domain_only: z.boolean(), extraction_mode: z.enum(["rule_only", "llm_only", "hybrid", "compare"]), extractor_provider: z.enum(["lm_studio", "openai", "ollama"]), extractor_model: z.string().optional(), extractor_base_url: z.string().optional(), fallback_to_rules: z.boolean(), }); type StartCrawlFormValues = z.infer; function statusBadgeVariant(status: string): BadgeProps["variant"] { switch (status) { case "completed": return "success"; case "failed": return "destructive"; case "canceled": return "secondary"; case "cancel_requested": return "warning"; case "running": case "pending": return "default"; default: return "outline"; } } function StatusBadge({ status }: { status: string }) { return {status}; } export default function CrawlPage() { const navigate = useNavigate(); const { projectId } = useParams<{ projectId: string }>(); const { t } = useTranslation(); const projectName = projectId ?? ""; const { data: project, isLoading, isError, error, refetch } = useProject(projectName); const [activeJobId, setActiveJobId] = useState(null); const { data: job } = useCrawlJob(activeJobId); const startCrawl = useStartSiteCrawl(); const cancelCrawl = useCancelCrawl(); const { register, handleSubmit, setValue, watch, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(startCrawlSchema), defaultValues: { source_name: "", url: "", max_depth: 2, max_pages: 30, same_domain_only: true, extraction_mode: "hybrid", extractor_provider: "lm_studio", extractor_model: "", extractor_base_url: "http://localhost:1234/v1", fallback_to_rules: true, }, }); const onStart = async (values: StartCrawlFormValues) => { try { const usesLlm = values.extraction_mode !== "rule_only"; const created = await startCrawl.mutateAsync({ project_name: projectName, ...values, extractor_model: usesLlm ? values.extractor_model || null : null, extractor_base_url: usesLlm ? values.extractor_base_url || null : null, }); setActiveJobId(created.job_id); toast.success( t("crawl.started", "크롤이 시작되었습니다 (job #{{id}})", { id: created.job_id, }), ); } catch (e) { toast.error( t("crawl.startFailed", "시작 실패: {{msg}}", { msg: (e as Error).message, }), ); } }; const onCancel = async () => { if (!activeJobId) return; try { await cancelCrawl.mutateAsync(activeJobId); toast.info(t("crawl.cancelRequested", "취소 요청됨")); } catch (e) { toast.error( t("crawl.cancelFailed", "취소 실패: {{msg}}", { msg: (e as Error).message, }), ); } }; const sources = project?.sources ?? []; const sourceName = watch("source_name"); const extractionMode = watch("extraction_mode"); const usesLlm = extractionMode !== "rule_only"; const selectedSource = sources.find((s) => s.name === sourceName); const progress = job?.progress; const visited = progress?.visited_count ?? 0; const queued = progress?.queued_count ?? 0; const analyzed = progress?.analyzed_count ?? 0; const errors_ = progress?.errors ?? []; const max_pages = watch("max_pages"); const progressPct = job && max_pages ? Math.min(100, (visited / max_pages) * 100) : 0; const terminal = job ? isCrawlTerminal(job.status) : false; const running = job && !terminal; return (

{t("crawl.title", "시드 크롤")}

{project && (

{project.name} {" "} · {project.domain}

)}
{terminal && job?.status === "completed" && ( )}
{isError && (
{(error as Error).message}
)}
{t("crawl.formTitle", "크롤 설정")} {t( "crawl.formDesc", "시드 URL에서 시작해 링크를 따라가며 정보를 추출합니다", )}
{isLoading ? ( ) : ( )} {sources.length === 0 && !isLoading && (

{t( "crawl.noSources", "등록된 소스가 없습니다. 먼저 참고 소스를 추가하세요.", )}{" "}

)} {errors.source_name && (

{errors.source_name.message}

)}
{errors.url && (

{errors.url.message}

)}
{errors.max_depth && (

{errors.max_depth.message}

)}
{errors.max_pages && (

{errors.max_pages.message}

)}
{usesLlm && ( <>
)}
{t("crawl.progressTitle", "진행 상태")} {job && ( job #{job.job_id} )}
{running && ( )}
{!job && (

{t( "crawl.idleHint", "왼쪽에서 시드 URL을 입력하고 시작하세요", )}

)} {job && (
{t("crawl.visited", "방문")} {visited} {" / "} {max_pages} {t("crawl.queued", "대기")} {queued} ·{" "} {t("crawl.analyzed", "분석")} {analyzed}
{job.url && (
{t("crawl.seedUrl", "시드 URL")}
{job.url}
)} {progress?.latest_page && (
{t("crawl.latestPage", "최근 페이지")}
{progress.latest_page.title || progress.latest_page.url}
{progress.latest_page.page_type && ( {progress.latest_page.page_type} )}
)} {job.error && (
{job.error}
)} {errors_.length > 0 && (
{t("crawl.errorsCount", "에러 {{count}}건", { count: errors_.length, })}
    {errors_.map((e, i) => (
  • {e}
  • ))}
)} {terminal && job.status === "completed" && (
{t("crawl.doneHint", "크롤 완료. 구축 파이프라인으로 이동하세요.")}
)}
)}
); }