Commit 6d8c57c4 authored by ThinhNC's avatar ThinhNC

feat(fe): standardize i18n, fix active header highlight and convert template...

feat(fe): standardize i18n, fix active header highlight and convert template select to custom dropdown
parent e204f321
......@@ -129,3 +129,18 @@ Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC P
- `/verify-email`: `Xác Thực Email | Data Crawler`
- `/forbidden`: `Truy Cập Bị Từ Chối (403) | Data Crawler`
---
## 8. Quy Chuẩn Giao Diện Dropdown & Đa Ngôn Ngữ (Dropdown & i18n Standards)
- **Quy chuẩn Custom Dropdown (ThemeToggle Pattern)**:
- **TUYỆT ĐỐI KHÔNG** dùng thẻ `<select>` mặc định của trình duyệt cho các bộ chọn tùy chỉnh trong modal/form.
- Bắt buộc thiết kế Custom Dropdown theo phong cách của `ThemeToggle`:
- **Trigger Button**: Bo góc mềm `rounded-2xl`, viền `border-border/80`, có icon phân loại, tên tùy chọn đang chọn và icon `<ChevronDown />` xoay 180° khi mở (`isDropdownOpen ? "rotate-180" : ""`).
- **Menu Popup**: `absolute``rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150`.
- **Option Item**: Thẻ `button` bo góc `rounded-xl px-2.5 py-2 text-xs flex items-center justify-between`. Khi được chọn (`isSelected`), hiển thị nền xanh nhẹ `bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20` kèm icon `<Check />`.
- **Xử lý đóng an toàn**: Sử dụng `useEffect` lắng nghe `mousedown` bên ngoài (`contains(target)`) và phím `Escape` để tự động đóng dropdown.
- **Quy chuẩn Đa Ngôn Ngữ (i18n)**:
- **Bản dịch Tiếng Việt thuần túy**: TUYỆT ĐỐI KHÔNG mở ngoặc kèm từ tiếng Anh hay ký hiệu thừa trong nhãn tiếng Việt (ví dụ: cấm `(Target URL)`, `(Depth)`, `(Normal)`, `(JSON)`, `(+)`, `(~)`, `(-)`, `(=)`).
- **Đồng bộ hóa 100% Tiếng Anh**: Mọi khóa trong `translations.vi` đều phải có khóa tương ứng trong `translations.en`.
- **Định dạng ngày tháng theo Locale**: Hàm `formatDate(date, locale)` bắt buộc phải nhận `locale` từ hook `useLanguage()`, định dạng `en-US` khi Tiếng Anh và `vi-VN` khi Tiếng Việt để tránh dính chữ tiếng Việt (như `thg`) trên giao diện tiếng Anh.
......@@ -103,6 +103,19 @@ async function handleProxy(
return NextResponse.json(data, { status: backendRes.status });
}
// Handle SSE streaming without buffering
if (responseContentType && responseContentType.includes("text/event-stream")) {
return new NextResponse(backendRes.body, {
status: backendRes.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
const data = await backendRes.arrayBuffer();
return new NextResponse(data, {
status: backendRes.status,
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Chi Tiết Tác Vụ Cào",
description:
"Trang giám sát chi tiết tiến độ realtime qua SSE, nhật ký crawler worker, xem trước dữ liệu trích xuất và so sánh biến động nội dung.",
};
export default function CrawlJobDetailLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState, useMemo, use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useCrawlJob,
useCrawlJobLogs,
useCrawlJobPages,
useCrawlJobDiff,
useRerunCrawlJob,
useCancelCrawlJob,
useDeleteCrawlJob,
} from "@/hooks/use-crawl-jobs";
import { useEventSource } from "@/hooks/use-event-source";
import { CrawlJob, CrawlPagePreview, LogLevel } from "@/types/crawl-job";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatDate, formatNumber } from "@/lib/utils";
import { useLanguage } from "@/providers/language-provider";
import {
Activity,
AlertCircle,
ArrowLeft,
CheckCircle2,
Clock,
Copy,
Download,
ExternalLink,
Eye,
FileCode,
FileText,
Filter,
Globe2,
Layers,
ListOrdered,
Loader2,
Pause,
Play,
Radio,
RefreshCw,
RotateCcw,
Search,
Settings2,
Shield,
Sparkles,
Terminal,
Trash2,
TrendingDown,
TrendingUp,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function CrawlJobDetailPage({ params }: PageProps) {
const resolvedParams = use(params);
const jobId = resolvedParams.id;
const router = useRouter();
const { t, locale } = useLanguage();
const getStatusText = (status?: string) => {
if (!status) return "";
const key = status.toLowerCase() as keyof typeof t.status;
return t.status[key] || status;
};
// Active Tab: "overview" | "logs" | "pages" | "diff"
const [activeTab, setActiveTab] = useState<"overview" | "logs" | "pages" | "diff">("overview");
// Logs filters
const [logLevelFilter, setLogLevelFilter] = useState<LogLevel | "ALL">("ALL");
const [logSearchQuery, setLogSearchQuery] = useState<string>("");
const [autoScrollLogs, setAutoScrollLogs] = useState<boolean>(true);
// Pages preview modal state
const [previewPage, setPreviewPage] = useState<CrawlPagePreview | null>(null);
const [previewTab, setPreviewTab] = useState<"markdown" | "cleanText" | "rawHtml" | "json">("markdown");
// Diff comparison target state
const [compareJobId, setCompareJobId] = useState<string>("");
// Base Query for Job Details
const {
data: initialJob,
isLoading: isJobLoading,
isError: isJobError,
error: jobError,
refetch: refetchJob,
} = useCrawlJob(jobId);
// Mutations
const { mutate: rerunJob, isPending: isRerunning } = useRerunCrawlJob();
const { mutate: cancelJob, isPending: isCanceling } = useCancelCrawlJob();
const { mutate: deleteJob, isPending: isDeleting } = useDeleteCrawlJob();
// Realtime Progress via SSE Hook: GET /api/proxy/crawl-jobs/:id/events
const [liveProgressJob, setLiveProgressJob] = useState<CrawlJob | null>(null);
const { status: sseStatus } = useEventSource<CrawlJob>(
jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null,
{
enabled: Boolean(jobId),
onInitial: (data) => setLiveProgressJob(data),
onProgress: (data) => setLiveProgressJob(data),
onDone: () => refetchJob(),
}
);
// Merged Job State (SSE takes live precedence if available)
const job = liveProgressJob || initialJob;
// Query Logs (active when on logs tab)
const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs(
jobId,
{ limit: 100 },
{ refetchInterval: activeTab === "logs" ? 4000 : false }
);
// Query Crawled Pages (active when on pages tab)
const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId,
{ limit: 50 }
);
// Query Diff Report (active when on diff tab)
const { data: diffData, isLoading: isDiffLoading, refetch: refetchDiff } = useCrawlJobDiff(
jobId,
compareJobId || undefined
);
// Calculate Progress & Speed
const maxPages = Math.max(1, job?.maxPages || 20);
const currentPages = job?.totalPages || job?.successPages || 0;
const progressPercent = Math.min(100, Math.round((currentPages / maxPages) * 100));
const isJobRunning = job?.status === "RUNNING" || job?.status === "PROCESSING_EXPORT";
// Filtered Logs
const filteredLogs = useMemo(() => {
if (!logsData?.items) return [];
return logsData.items.filter((log) => {
const matchLevel = logLevelFilter === "ALL" || log.level === logLevelFilter;
const matchQuery =
!logSearchQuery ||
log.message.toLowerCase().includes(logSearchQuery.toLowerCase()) ||
log.step.toLowerCase().includes(logSearchQuery.toLowerCase());
return matchLevel && matchQuery;
});
}, [logsData?.items, logLevelFilter, logSearchQuery]);
const copyToClipboard = (text: string, label?: string) => {
navigator.clipboard.writeText(text);
toast.success(label ? `${t.jobDetail.copiedNotice} (${label})` : t.jobDetail.copiedNotice);
};
if (isJobLoading && !job) {
return (
<div className="flex h-96 flex-col items-center justify-center gap-3 rounded-3xl border border-border/70 bg-card/40 p-8 text-center backdrop-blur-md">
<Loader2 className="h-10 w-10 animate-spin text-emerald-500" />
<p className="text-sm font-semibold text-foreground">{t.jobDetail.loading}</p>
<p className="text-xs text-muted-foreground">ID: {jobId}</p>
</div>
);
}
if (isJobError && !job) {
return (
<div className="flex h-96 flex-col items-center justify-center gap-3 rounded-3xl border border-rose-500/30 bg-rose-500/10 p-8 text-center text-rose-600 dark:text-rose-400">
<AlertCircle className="h-10 w-10 text-rose-500" />
<p className="text-base font-bold">{t.jobDetail.notFound}</p>
<p className="text-xs opacity-80 max-w-md">
{(jobError as Error)?.message || t.jobDetail.notFoundDesc}
</p>
<div className="flex items-center gap-3 pt-2">
<Button
size="sm"
variant="outline"
onClick={() => refetchJob()}
className="rounded-2xl border-rose-500/30 text-xs"
>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
{t.table.retry}
</Button>
<Link href="/">
<Button size="sm" className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs">
<ArrowLeft className="mr-2 h-3.5 w-3.5" />
{t.jobDetail.backBtn}
</Button>
</Link>
</div>
</div>
);
}
return (
<div className="space-y-6 sm:space-y-8">
{/* 1. Header Navigation & Quick Actions */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-emerald-500 transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
<span>{t.jobDetail.backBtn}</span>
</Link>
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground flex items-center gap-2">
<Globe2 className="h-6 w-6 text-emerald-500 shrink-0" />
<span className="truncate max-w-[320px] sm:max-w-lg">{job?.domain || job?.startUrl}</span>
</h1>
<Badge
variant="secondary"
className="text-xs font-mono uppercase rounded-full px-2.5 py-1 leading-none inline-flex items-center justify-center"
>
{job?.mode || "SCRAPE"}
</Badge>
{/* SSE Live Status Pill */}
<div
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold border transition-all ${
sseStatus === "OPEN"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-border/80 bg-muted/60 text-muted-foreground"
}`}
>
<Radio className={`h-3 w-3 ${sseStatus === "OPEN" ? "animate-pulse text-emerald-500" : ""}`} />
<span>{sseStatus === "OPEN" ? t.jobDetail.liveSse : `SSE: ${sseStatus}`}</span>
</div>
</div>
<a
href={job?.startUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:underline max-w-md truncate"
>
<span>{job?.startUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
{/* Action Group */}
<div className="flex flex-wrap items-center gap-2">
{/* Rerun */}
<Button
size="sm"
variant="outline"
disabled={isRerunning || isJobRunning}
onClick={() => rerunJob(jobId)}
className="rounded-2xl border-border/80 text-xs font-semibold gap-1.5 hover:bg-muted/40 cursor-pointer"
>
{isRerunning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-500" />
) : (
<RotateCcw className="h-3.5 w-3.5 text-emerald-500" />
)}
<span>{t.table.rerun}</span>
</Button>
{/* Cancel */}
{isJobRunning && (
<Button
size="sm"
variant="outline"
disabled={isCanceling}
onClick={() => {
if (window.confirm(t.table.confirmCancel)) {
cancelJob(jobId);
}
}}
className="rounded-2xl border-amber-500/30 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10 text-xs font-semibold gap-1.5 cursor-pointer"
>
{isCanceling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<XCircle className="h-3.5 w-3.5" />
)}
<span>{t.table.cancel}</span>
</Button>
)}
{/* Delete */}
<Button
size="sm"
variant="outline"
disabled={isDeleting}
onClick={() => {
if (window.confirm(t.table.confirmDelete)) {
deleteJob(jobId, {
onSuccess: () => router.push("/"),
});
}
}}
className="rounded-2xl border-rose-500/30 text-rose-600 dark:text-rose-400 hover:bg-rose-500/10 text-xs font-semibold gap-1.5 cursor-pointer"
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
<span>{t.table.delete}</span>
</Button>
{/* Refresh */}
<Button
size="sm"
variant="ghost"
onClick={() => {
refetchJob();
refetchLogs();
refetchPages();
refetchDiff();
}}
className="rounded-2xl h-8 w-8 p-0 cursor-pointer"
title={t.jobDetail.refreshAll}
>
<RefreshCw className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</div>
{/* 2. Realtime SSE Progress Card - Biophilic Live Banner */}
<div className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card via-card/95 to-emerald-950/15 p-6 shadow-sm backdrop-blur-md">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-48 w-48 rounded-full bg-emerald-500/10 blur-2xl pointer-events-none" />
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
<Activity className="h-5 w-5" />
</div>
<div>
<div className="text-xs text-muted-foreground font-medium">{t.jobDetail.liveProgress}</div>
<div className="text-lg font-bold text-foreground flex items-center gap-2">
<span>
{currentPages} / {job?.maxPages} {t.jobDetail.processedPages}
</span>
<span className="text-sm text-emerald-600 dark:text-emerald-400 font-extrabold">
({progressPercent}%)
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={job?.status === "COMPLETED" ? "info" : job?.status === "RUNNING" ? "default" : "secondary"}
className="rounded-full px-3 py-1 font-bold text-xs"
>
{getStatusText(job?.status)}
</Badge>
{job?.startedAt && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{t.jobDetail.startedAt}: {formatDate(job.startedAt, locale)}
</span>
)}
</div>
</div>
{/* Animated Biophilic Progress Bar */}
<div className="space-y-1">
<div className="h-3 w-full rounded-full bg-muted/60 overflow-hidden p-0.5 border border-border/50">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 transition-all duration-700 shadow-sm"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-muted-foreground pt-0.5">
<span>{t.jobDetail.maxDepthLabel}: {job?.maxDepth} {t.jobDetail.levelsUnit}</span>
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">
{t.jobDetail.successCount}: {job?.successPages || 0}{t.jobDetail.failedCount}: {job?.failedPages || 0}
</span>
</div>
</div>
</div>
</div>
{/* 3. 4 Specialized Tabs Header */}
<div className="flex flex-wrap items-center gap-2 border-b border-border/70 pb-3">
{[
{ id: "overview", label: t.jobDetail.overviewTab, icon: Layers },
{ id: "logs", label: t.jobDetail.logsTab, icon: Terminal, count: logsData?.meta?.total },
{ id: "pages", label: t.jobDetail.pagesTab, icon: FileText, count: pagesData?.total },
{ id: "diff", label: t.jobDetail.diffTab, icon: Sparkles, count: diffData?.summary?.changeRate ? `${Math.round(diffData.summary.changeRate * 100)}%` : undefined },
].map((tabItem) => {
const isActive = activeTab === tabItem.id;
const Icon = tabItem.icon;
return (
<button
key={tabItem.id}
type="button"
onClick={() => setActiveTab(tabItem.id as typeof activeTab)}
className={`flex items-center gap-2 rounded-2xl px-4 py-2 text-xs font-bold transition-all cursor-pointer ${
isActive
? "bg-emerald-600 text-white shadow-md shadow-emerald-600/20"
: "bg-muted/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground border border-border/60"
}`}
>
<Icon className="h-4 w-4" />
<span>{tabItem.label}</span>
{tabItem.count !== undefined && (
<span
className={`rounded-full px-2 py-0.2 text-[10px] font-mono ${
isActive ? "bg-white/20 text-white" : "bg-card text-muted-foreground"
}`}
>
{tabItem.count}
</span>
)}
</button>
);
})}
</div>
{/* 4. Tab 1: Overview & Configuration */}
{activeTab === "overview" && (
<div className="space-y-6">
{/* KPI Cards Grid */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<div className="rounded-3xl border border-border/70 bg-card/60 p-4 backdrop-blur-sm">
<div className="text-xs text-muted-foreground">{t.jobDetail.metrics.totalPages}</div>
<div className="text-2xl font-extrabold text-foreground mt-1">{formatNumber(job?.totalPages || 0)}</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{t.jobDetail.limitLabel}: {job?.maxPages} {t.table.pagesUnit}
</div>
</div>
<div className="rounded-3xl border border-emerald-500/20 bg-emerald-500/5 p-4 backdrop-blur-sm">
<div className="text-xs text-emerald-600 dark:text-emerald-400 font-semibold">
{t.jobDetail.metrics.successPages}
</div>
<div className="text-2xl font-extrabold text-emerald-600 dark:text-emerald-400 mt-1">
{formatNumber(job?.successPages || 0)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{t.jobDetail.metrics.http200Desc}</div>
</div>
<div className="rounded-3xl border border-rose-500/20 bg-rose-500/5 p-4 backdrop-blur-sm">
<div className="text-xs text-rose-600 dark:text-rose-400 font-semibold">
{t.jobDetail.metrics.failedPages}
</div>
<div className="text-2xl font-extrabold text-rose-600 dark:text-rose-400 mt-1">
{formatNumber(job?.failedPages || 0)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{t.jobDetail.metrics.failedDesc}</div>
</div>
<div className="rounded-3xl border border-border/70 bg-card/60 p-4 backdrop-blur-sm">
<div className="text-xs text-muted-foreground">{t.jobDetail.metrics.itemsExtracted}</div>
<div className="text-2xl font-extrabold text-foreground mt-1">
{formatNumber(job?.successPages || 0)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{t.jobDetail.completeData}</div>
</div>
</div>
{/* Configuration Parameters Table */}
<div className="rounded-3xl border border-border/80 bg-card/60 p-6 backdrop-blur-sm space-y-4">
<h3 className="font-bold text-sm text-foreground flex items-center gap-2">
<Settings2 className="h-4 w-4 text-emerald-500" />
{t.jobDetail.config.title}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.startUrl}</span>
<span className="font-mono text-foreground truncate max-w-xs">{job?.startUrl}</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.mode}</span>
<span className="font-semibold text-foreground uppercase">{job?.mode}</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.maxDepth}</span>
<span className="font-semibold text-foreground">
{job?.maxDepth} {t.jobDetail.levelsUnit}
</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.maxPages}</span>
<span className="font-semibold text-foreground">
{job?.maxPages} {t.table.pagesUnit}
</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.delay}</span>
<span className="font-semibold text-foreground">{job?.delayMs} ms</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.robotsTxt}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{job?.respectRobotsTxt ? t.jobDetail.config.robotsYes : t.jobDetail.config.robotsNo}
</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.userAgent}</span>
<span className="font-mono text-muted-foreground truncate max-w-xs">
{job?.userAgent || t.jobDetail.config.defaultUserAgent}
</span>
</div>
<div className="flex justify-between py-2 border-b border-border/60">
<span className="text-muted-foreground">{t.jobDetail.config.priority}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{t.jobDetail.config.priorityNormal}
</span>
</div>
</div>
</div>
</div>
)}
{/* 5. Tab 2: Logs Console (GET /crawl-jobs/:id/logs) */}
{activeTab === "logs" && (
<div className="space-y-4">
{/* Logs Control Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-2xl border border-border/70 bg-card/60 p-3.5 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={logSearchQuery}
onChange={(e) => setLogSearchQuery(e.target.value)}
placeholder={t.jobDetail.logs.searchLogs}
className="pl-8 h-8 text-xs rounded-xl border-border/70 bg-background w-48 sm:w-60"
/>
</div>
{/* Level Filter */}
<div className="flex items-center gap-1">
{(["ALL", "INFO", "WARNING", "ERROR"] as const).map((lvl) => {
const isActive = logLevelFilter === lvl;
return (
<button
key={lvl}
type="button"
onClick={() => setLogLevelFilter(lvl)}
className={`px-2.5 py-1 text-[11px] font-semibold rounded-xl border transition-all cursor-pointer ${
isActive
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/40"
: "border-border/60 text-muted-foreground hover:bg-muted/40"
}`}
>
{lvl}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={autoScrollLogs}
onChange={(e) => setAutoScrollLogs(e.target.checked)}
className="h-3.5 w-3.5 rounded accent-emerald-600"
/>
<span>{t.jobDetail.logs.autoScroll}</span>
</label>
<Button
size="sm"
variant="outline"
onClick={() => {
const allText = filteredLogs
.map((l) => `[${l.createdAt}] [${l.level}] [${l.step}] ${l.message}`)
.join("\n");
copyToClipboard(allText, t.jobDetail.logs.allLogsLabel);
}}
className="rounded-xl h-7 px-2.5 text-xs gap-1.5"
>
<Copy className="h-3 w-3" />
<span>{t.jobDetail.logs.copyLogs}</span>
</Button>
</div>
</div>
{/* Terminal Box */}
<div className="rounded-3xl border border-slate-800 bg-[#080d16] p-4 font-mono text-xs shadow-inner overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-800 pb-2 mb-3 text-[11px] text-slate-400">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-red-500/80 inline-block" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500/80 inline-block" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500/80 inline-block" />
<span className="ml-2 text-slate-300 font-semibold">crawler-worker-output.log</span>
</div>
<div>
{filteredLogs.length} / {logsData?.meta?.total || 0} {t.jobDetail.linesUnit}
</div>
</div>
<div className="max-h-[500px] overflow-y-auto space-y-1.5 pr-2">
{isLogsLoading && (
<div className="flex items-center justify-center py-10 text-slate-500 gap-2">
<Loader2 className="h-4 w-4 animate-spin text-emerald-400" />
<span>{t.jobDetail.logs.syncing}</span>
</div>
)}
{!isLogsLoading && filteredLogs.length === 0 && (
<div className="py-8 text-center text-slate-500">
{t.jobDetail.logs.emptyLogs}
</div>
)}
{filteredLogs.map((log) => {
const isError = log.level === "ERROR";
const isWarn = log.level === "WARNING";
return (
<div
key={log.id}
className="leading-relaxed hover:bg-slate-900/60 p-1 rounded transition-colors flex items-start gap-2"
>
<span className="text-slate-500 shrink-0 select-none">
{new Date(log.createdAt).toLocaleTimeString()}
</span>
<span
className={`px-1.5 py-0.2 rounded text-[10px] font-bold shrink-0 ${
isError
? "bg-rose-500/20 text-rose-400 border border-rose-500/30"
: isWarn
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
}`}
>
{log.level}
</span>
<span className="text-cyan-400 shrink-0 font-semibold">[{log.step}]</span>
<span className={isError ? "text-rose-300" : isWarn ? "text-amber-200" : "text-slate-200"}>
{log.message}
</span>
</div>
);
})}
</div>
</div>
</div>
)}
{/* 6. Tab 3: Crawled Pages & Preview (GET /crawl-jobs/:id/pages/preview) */}
{activeTab === "pages" && (
<div className="space-y-4">
<div className="rounded-3xl border border-border/80 bg-card/60 backdrop-blur-md overflow-hidden shadow-sm">
<div className="p-4 border-b border-border/70 flex items-center justify-between">
<div>
<h3 className="font-bold text-sm text-foreground">{t.jobDetail.pages.title}</h3>
<p className="text-xs text-muted-foreground">{t.jobDetail.pages.subtitle}</p>
</div>
<Badge variant="secondary" className="rounded-full">
{pagesData?.total || 0} {t.jobDetail.pages.pagesExtracted}
</Badge>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-muted/40 border-b border-border/60 text-muted-foreground font-semibold">
<tr>
<th className="py-3 px-4">{t.jobDetail.pages.urlCol}</th>
<th className="py-3 px-4">{t.jobDetail.pages.statusCol}</th>
<th className="py-3 px-4">{t.jobDetail.pages.wordsCol}</th>
<th className="py-3 px-4">{t.jobDetail.pages.timeCol}</th>
<th className="py-3 px-4 text-right">{t.jobDetail.pages.actionCol}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{isPagesLoading && (
<tr>
<td colSpan={5} className="py-12 text-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-emerald-500 mb-2" />
{t.jobDetail.pages.loading}
</td>
</tr>
)}
{!isPagesLoading && (!pagesData?.items || pagesData.items.length === 0) && (
<tr>
<td colSpan={5} className="py-12 text-center text-muted-foreground">
{t.jobDetail.pages.emptyPages}
</td>
</tr>
)}
{pagesData?.items?.map((pageItem) => {
const isOk = pageItem.statusCode === 200;
return (
<tr key={pageItem.id} className="hover:bg-muted/20 transition-colors">
<td className="py-3 px-4 max-w-sm">
<div className="font-semibold text-foreground truncate" title={pageItem.title || ""}>
{pageItem.title || pageItem.url}
</div>
<a
href={pageItem.url}
target="_blank"
rel="noreferrer"
className="text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline truncate block"
>
{pageItem.url}
</a>
</td>
<td className="py-3 px-4">
<Badge
variant={isOk ? "default" : "destructive"}
className="rounded-full text-[10px] px-2"
>
HTTP {pageItem.statusCode || 200}
</Badge>
</td>
<td className="py-3 px-4 font-mono font-semibold">
{formatNumber(pageItem.wordCount || 0)} {t.jobDetail.wordsUnit}
</td>
<td className="py-3 px-4 text-muted-foreground">
{pageItem.crawledAt ? formatDate(pageItem.crawledAt, locale) : t.jobDetail.justNow}
</td>
<td className="py-3 px-4 text-right">
<Button
size="sm"
variant="secondary"
onClick={() => {
setPreviewPage(pageItem);
setPreviewTab("markdown");
}}
className="rounded-xl h-7 px-2.5 text-xs font-semibold gap-1 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10 cursor-pointer"
>
<Eye className="h-3 w-3" />
<span>{t.jobDetail.pages.previewBtn}</span>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* 7. Tab 4: Data Diff Comparison (GET /crawl-jobs/:id/diff) */}
{activeTab === "diff" && (
<div className="space-y-6">
{/* Diff Header & Actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-3xl border border-border/80 bg-card/60 p-4 backdrop-blur-sm">
<div>
<h3 className="font-bold text-sm text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-emerald-500" />
{t.jobDetail.diff.title}
</h3>
<p className="text-xs text-muted-foreground">{t.jobDetail.diff.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<a
href={`/api/proxy/crawl-jobs/${jobId}/diff/download`}
download
className="inline-flex items-center gap-1.5 rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white px-3.5 py-1.5 text-xs font-bold shadow-sm transition-all"
>
<Download className="h-3.5 w-3.5" />
<span>{t.jobDetail.diff.downloadBtn}</span>
</a>
</div>
</div>
{/* Diff Metric Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{/* New Pages */}
<div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-3.5 text-center">
<div className="text-xs font-semibold text-emerald-600 dark:text-emerald-400">
{t.jobDetail.diff.newPages}
</div>
<div className="text-2xl font-black text-emerald-600 dark:text-emerald-400 mt-1">
+{diffData?.summary?.newPagesCount || 0}
</div>
</div>
{/* Modified Pages */}
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3.5 text-center">
<div className="text-xs font-semibold text-amber-600 dark:text-amber-400">
{t.jobDetail.diff.modifiedPages}
</div>
<div className="text-2xl font-black text-amber-600 dark:text-amber-400 mt-1">
~{diffData?.summary?.modifiedPagesCount || 0}
</div>
</div>
{/* Deleted Pages */}
<div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 p-3.5 text-center">
<div className="text-xs font-semibold text-rose-600 dark:text-rose-400">
{t.jobDetail.diff.deletedPages}
</div>
<div className="text-2xl font-black text-rose-600 dark:text-rose-400 mt-1">
-{diffData?.summary?.deletedPagesCount || 0}
</div>
</div>
{/* Unchanged */}
<div className="rounded-2xl border border-border/70 bg-card/40 p-3.5 text-center">
<div className="text-xs font-semibold text-muted-foreground">
{t.jobDetail.diff.unchangedPages}
</div>
<div className="text-2xl font-black text-foreground mt-1">
={diffData?.summary?.unchangedPagesCount || 0}
</div>
</div>
{/* Change Rate */}
<div className="col-span-2 sm:col-span-1 rounded-2xl border border-cyan-500/30 bg-cyan-500/10 p-3.5 text-center">
<div className="text-xs font-semibold text-cyan-600 dark:text-cyan-400">
{t.jobDetail.diff.changeRate}
</div>
<div className="text-2xl font-black text-cyan-600 dark:text-cyan-400 mt-1">
{diffData?.summary?.changeRate ? `${Math.round(diffData.summary.changeRate * 100)}%` : "0%"}
</div>
</div>
</div>
{/* Detailed Changes List */}
<div className="rounded-3xl border border-border/80 bg-card/60 p-5 backdrop-blur-md space-y-4">
<h4 className="font-bold text-xs uppercase tracking-wider text-muted-foreground">
{t.jobDetail.diff.changesListTitle}
</h4>
{isDiffLoading && (
<div className="py-10 text-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-emerald-500 mb-2" />
{t.jobDetail.diff.comparing}
</div>
)}
{!isDiffLoading &&
(!diffData?.newPages?.length && !diffData?.modifiedPages?.length && !diffData?.deletedPages?.length) && (
<div className="py-8 text-center text-muted-foreground text-xs">
{t.jobDetail.diff.noChanges}
</div>
)}
{/* Modified Items */}
{diffData?.modifiedPages?.map((mod, idx) => (
<div
key={idx}
className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-3.5 space-y-1.5 text-xs transition-colors"
>
<div className="flex items-center justify-between">
<Badge variant="warning" className="rounded-full text-[10px]">
{t.jobDetail.diff.badgeModified}
</Badge>
<span className="font-mono text-amber-600 dark:text-amber-400 font-semibold">
{t.jobDetail.diff.wordDiff}: {mod.wordCountDiff > 0 ? `+${mod.wordCountDiff}` : mod.wordCountDiff} {t.jobDetail.wordsUnit}
</span>
</div>
<div className="font-semibold text-foreground">{mod.title || mod.url}</div>
{mod.oldTitle && mod.oldTitle !== mod.title && (
<div className="text-[11px] text-muted-foreground">
{t.jobDetail.diff.oldTitle}: <span className="line-through">{mod.oldTitle}</span>
</div>
)}
<div className="text-[11px] text-emerald-600 dark:text-emerald-400 truncate">{mod.url}</div>
</div>
))}
{/* New Items */}
{diffData?.newPages?.map((newPage, idx) => (
<div
key={idx}
className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-3.5 space-y-1.5 text-xs transition-colors"
>
<div className="flex items-center justify-between">
<Badge variant="default" className="rounded-full text-[10px]">
{t.jobDetail.diff.badgeNew}
</Badge>
<span className="font-mono text-emerald-600 dark:text-emerald-400 font-semibold">
{formatNumber(newPage.wordCount)} {t.jobDetail.wordsUnit}
</span>
</div>
<div className="font-semibold text-foreground">{newPage.title || newPage.url}</div>
<div className="text-[11px] text-emerald-600 dark:text-emerald-400 truncate">{newPage.url}</div>
</div>
))}
{/* Deleted Items */}
{diffData?.deletedPages?.map((del, idx) => (
<div
key={idx}
className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-3.5 space-y-1.5 text-xs transition-colors"
>
<div className="flex items-center justify-between">
<Badge variant="destructive" className="rounded-full text-[10px]">
{t.jobDetail.diff.badgeDeleted}
</Badge>
<span className="text-[11px] text-muted-foreground">
{t.jobDetail.diff.previousCrawl}: {del.lastCrawledAt ? formatDate(del.lastCrawledAt, locale) : "N/A"}
</span>
</div>
<div className="font-semibold text-foreground line-through opacity-75">{del.title || del.url}</div>
<div className="text-[11px] text-rose-500/80 truncate">{del.url}</div>
</div>
))}
</div>
</div>
)}
{/* 8. Crawled Page Preview Modal */}
{previewPage && (
<Dialog open={Boolean(previewPage)} onOpenChange={(open) => !open && setPreviewPage(null)}>
<DialogContent className="sm:max-w-3xl rounded-3xl border-border/80 bg-card text-card-foreground p-6 max-h-[85vh] flex flex-col">
<DialogHeader className="border-b border-border/60 pb-3">
<div className="flex items-center justify-between pr-6">
<DialogTitle className="text-base font-bold text-foreground truncate max-w-xl">
{previewPage.title || previewPage.url}
</DialogTitle>
<Badge variant="default" className="rounded-full text-xs">
HTTP {previewPage.statusCode || 200}
</Badge>
</div>
<div className="text-xs text-emerald-600 dark:text-emerald-400 truncate pt-1">
{previewPage.url}
</div>
</DialogHeader>
{/* Modal Tabs Bar */}
<div className="flex items-center gap-1.5 border-b border-border/60 py-2">
{[
{ id: "markdown", label: t.jobDetail.previewModal.tabMarkdown, icon: FileText },
{ id: "cleanText", label: t.jobDetail.previewModal.tabCleanText, icon: AlignLeftIcon },
{ id: "rawHtml", label: t.jobDetail.previewModal.tabHtml, icon: FileCode },
{ id: "json", label: t.jobDetail.previewModal.tabJson, icon: ListOrdered },
].map((mTab) => {
const isSelected = previewTab === mTab.id;
const TabIcon = mTab.icon;
return (
<button
key={mTab.id}
type="button"
onClick={() => setPreviewTab(mTab.id as typeof previewTab)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
isSelected
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"
: "text-muted-foreground hover:bg-muted/40"
}`}
>
<TabIcon className="h-3.5 w-3.5" />
<span>{mTab.label}</span>
</button>
);
})}
<div className="ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => {
const textToCopy =
previewTab === "markdown"
? previewPage.rawMarkdown || previewPage.markdownContent || ""
: previewTab === "cleanText"
? previewPage.cleanText || previewPage.content || ""
: previewTab === "json"
? JSON.stringify(previewPage.structuredData || previewPage, null, 2)
: previewPage.htmlContentPath || "";
copyToClipboard(textToCopy, t.jobDetail.previewModal.previewContentLabel);
}}
className="rounded-xl text-xs h-7 gap-1.5"
>
<Copy className="h-3 w-3" />
<span>{t.jobDetail.previewModal.copyBtn}</span>
</Button>
</div>
</div>
{/* Modal Body Content */}
<div className="flex-1 overflow-y-auto p-2 text-xs space-y-3 font-sans">
{previewTab === "markdown" && (
<div className="prose dark:prose-invert max-w-none font-sans text-xs leading-relaxed whitespace-pre-wrap rounded-2xl bg-muted/30 p-4 border border-border/60">
{previewPage.rawMarkdown || previewPage.markdownContent || t.jobDetail.previewModal.noMarkdown}
</div>
)}
{previewTab === "cleanText" && (
<div className="rounded-2xl bg-muted/30 p-4 border border-border/60 font-sans text-xs leading-relaxed whitespace-pre-wrap text-foreground">
{previewPage.cleanText || previewPage.content || t.jobDetail.previewModal.noCleanText}
</div>
)}
{previewTab === "rawHtml" && (
<div className="rounded-2xl bg-[#080d16] p-4 border border-slate-800 font-mono text-xs text-slate-300 overflow-x-auto">
<div className="text-[11px] text-slate-500 mb-2">{t.jobDetail.previewModal.htmlPathLabel}</div>
<div className="text-emerald-400 font-semibold mb-3">
{previewPage.htmlContentPath || t.jobDetail.previewModal.storedInLocal}
</div>
<div className="text-slate-400 italic">
&lt;!DOCTYPE html&gt;&#10;&lt;html&gt;&#10; &lt;head&gt;&lt;title&gt;{previewPage.title}&lt;/title&gt;&lt;/head&gt;&#10; &lt;body&gt;...{t.jobDetail.previewModal.extractedSnippet} {previewPage.wordCount} {t.jobDetail.wordsUnit}...&lt;/body&gt;&#10;&lt;/html&gt;
</div>
</div>
)}
{previewTab === "json" && (
<pre className="rounded-2xl bg-[#080d16] p-4 border border-slate-800 font-mono text-xs text-cyan-300 overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(
{
id: previewPage.id,
url: previewPage.url,
title: previewPage.title,
wordCount: previewPage.wordCount,
dataQualityScore: previewPage.dataQualityScore,
contentHash: previewPage.contentHash,
structuredData: previewPage.structuredData,
crawledAt: previewPage.crawledAt,
},
null,
2
)}
</pre>
)}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
function AlignLeftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="21" x2="3" y1="6" y2="6" />
<line x1="15" x2="3" y1="12" y2="12" />
<line x1="17" x2="3" y1="18" y2="18" />
</svg>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Tác Vụ Cào",
description:
"Phân hệ quản lý toàn diện các nhiệm vụ cào dữ liệu web, phân trang server-side, bộ lọc đa tiêu chí và khởi tạo tác vụ nâng cao.",
};
export default function CrawlJobsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState } from "react";
import { CrawlerTaskTable } from "@/components/crawler/crawler-task-table";
import { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Globe2, Plus, Server } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export default function CrawlJobsPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useLanguage();
return (
<div className="space-y-6 sm:space-y-8">
{/* Top Banner / Hero */}
<div className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card via-card/90 to-emerald-950/10 p-6 sm:p-8 shadow-sm backdrop-blur-md">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-2">
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground flex items-center gap-2.5">
<Server className="h-7 w-7 text-emerald-500" />
<span>{t.table.title}</span>
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.table.subtitle}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-md shadow-emerald-600/20 px-5 py-2.5 h-auto cursor-pointer transition-all hover:scale-[1.02]">
<Plus className="mr-2 h-4 w-4" />
{t.hero.createTaskBtn}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl rounded-3xl border-border/80 bg-card text-card-foreground">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Globe2 className="h-5 w-5 text-emerald-500" />
{t.dialog.title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t.dialog.description}
</DialogDescription>
</DialogHeader>
<CrawlerTaskForm onSuccess={() => setIsDialogOpen(false)} />
</DialogContent>
</Dialog>
</div>
</div>
</div>
{/* Main Jobs Table Component */}
<CrawlerTaskTable />
</div>
);
}
......@@ -5,7 +5,7 @@ import { useCrawlerStats } from "@/hooks/use-crawler";
import { StatCard } from "@/components/common/stat-card";
import { ServiceHealthBar } from "@/components/dashboard/service-health-bar";
import { QuotaUsageWidget } from "@/components/dashboard/quota-usage-widget";
import { CrawlerTaskTable } from "@/components/crawler/crawler-task-table";
import { RecentJobsWidget } from "@/components/dashboard/recent-jobs-widget";
import { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button";
import {
......@@ -22,12 +22,10 @@ import {
Briefcase,
CheckCircle2,
Database,
FileCode2,
Globe2,
Plus,
Radio,
RefreshCw,
Server,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
......@@ -197,50 +195,9 @@ export default function DashboardPage() {
{/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */}
<QuotaUsageWidget initialUsage={stats?.quotaAndUsage} />
{/* 5. Main Content: Tasks Table */}
<div className="space-y-4" id="tasks">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Server className="h-5 w-5 text-emerald-500" />
{t.table.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.table.subtitle}
</p>
</div>
</div>
<CrawlerTaskTable />
</div>
{/* 6. Architecture & Tech Stack Highlights */}
<div className="rounded-3xl border border-border/70 bg-card/60 p-6 backdrop-blur-sm transition-colors duration-200">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mb-4">
<FileCode2 className="h-4 w-4 text-emerald-500" />
Architecture & System Highlights
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-muted-foreground">
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-emerald-600 dark:text-emerald-400">Realtime Analytics & Live APIs</span>
<p>
Đồng bộ dữ liệu thống kê từ GET /api/v1/dashboard/stats và theo dõi hạn mức GET /api/v1/auth/me/usage.
</p>
</div>
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-cyan-600 dark:text-cyan-400">Biophilic Ecosystem Health</span>
<p>
Giám sát kết nối PostgreSQL, Redis/BullMQ, chỉ số hàng đợi và Uptime theo thời gian thực.
</p>
</div>
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-teal-600 dark:text-teal-400">Resilient Server State</span>
<p>
TanStack Query v5 kết hợp Next.js BFF Proxy, xử lý 4 trạng thái UI và chuyển đổi mượt mà giữa Sáng/Tối.
</p>
</div>
</div>
{/* 5. Recent Crawl Tasks Widget */}
<div id="tasks">
<RecentJobsWidget />
</div>
</div>
);
......
......@@ -2,6 +2,7 @@
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Bot, Database, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle";
......@@ -10,6 +11,10 @@ import { useLanguage } from "@/providers/language-provider";
export function Navbar() {
const { t } = useLanguage();
const pathname = usePathname();
const isDashboard = pathname === "/";
const isCrawlJobs = pathname.startsWith("/crawl-jobs");
return (
<header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200">
......@@ -26,14 +31,22 @@ export function Navbar() {
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
<Link
href="/"
className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 transition-colors font-semibold"
className={`flex items-center gap-1.5 transition-colors ${
isDashboard
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
{t.nav.dashboard}
</Link>
<Link
href="#tasks"
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
href="/crawl-jobs"
className={`flex items-center gap-1.5 transition-colors ${
isCrawlJobs
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<Globe className="h-4 w-4" />
{t.nav.tasks}
......@@ -47,17 +60,8 @@ export function Navbar() {
</Link>
</nav>
{/* Right Controls: Theme Toggle, Language Toggle, Status & User Menu */}
{/* Right Controls: Theme Toggle, Language Toggle & User Menu */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Active Status Badge */}
<div className="hidden sm:flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3 py-1 text-xs text-emerald-700 dark:text-emerald-300">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span>
<span>{t.nav.statusActive}</span>
</div>
{/* Language Switcher */}
<LanguageToggle />
......
"use client";
import React from "react";
import { useForm } from "react-hook-form";
import React, { useState, useRef, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createCrawlerTaskSchema,
CreateCrawlerTaskInput,
} from "@/schemas/crawler.schema";
import { useCreateCrawlerTask } from "@/hooks/use-crawler";
createCrawlJobSchema,
CreateCrawlJobInput,
} from "@/schemas/crawl-job.schema";
import { useCreateCrawlJob, useExtractionTemplates } from "@/hooks/use-crawl-jobs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, PlusCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useLanguage } from "@/providers/language-provider";
import {
Check,
ChevronDown,
ChevronUp,
FileCode,
Globe,
Layers,
Loader2,
PlusCircle,
Settings2,
Shield,
Sparkles,
Sliders,
} from "lucide-react";
interface CrawlerTaskFormProps {
onSuccess?: () => void;
}
export function CrawlerTaskForm({ onSuccess }: CrawlerTaskFormProps) {
const { mutate: createTask, isPending } = useCreateCrawlerTask();
const { t } = useLanguage();
const [showAdvanced, setShowAdvanced] = useState(false);
const [isTemplateOpen, setIsTemplateOpen] = useState(false);
const templateDropdownRef = useRef<HTMLDivElement>(null);
const { mutate: createJob, isPending } = useCreateCrawlJob();
const { data: templates = [], isLoading: isLoadingTemplates } = useExtractionTemplates();
const {
register,
handleSubmit,
control,
watch,
setValue,
reset,
formState: { errors },
} = useForm<CreateCrawlerTaskInput>({
resolver: zodResolver(createCrawlerTaskSchema),
} = useForm({
resolver: zodResolver(createCrawlJobSchema),
defaultValues: {
name: "",
targetUrl: "",
startUrl: "",
mode: "SCRAPE",
maxDepth: 2,
maxPages: 50,
rateLimit: 1000,
extractSelectors: "h1, .content, article",
priority: "NORMAL",
userAgent: "",
cookie: "",
templateId: "",
delayMs: 1000,
respectRobotsTxt: true,
urls: [],
},
});
const onSubmit = (data: CreateCrawlerTaskInput) => {
createTask(data, {
onSuccess: () => {
reset();
onSuccess?.();
const selectedMode = watch("mode");
const selectedPriority = watch("priority");
const selectedTemplateId = watch("templateId");
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const isUrlListMode = selectedMode === "URL_LIST";
// Close template dropdown on click outside or Escape key
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (templateDropdownRef.current && !templateDropdownRef.current.contains(event.target as Node)) {
setIsTemplateOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsTemplateOpen(false);
}
}
if (isTemplateOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isTemplateOpen]);
const onSubmit = (data: CreateCrawlJobInput) => {
createJob(
{
startUrl: data.startUrl,
mode: data.mode,
maxDepth: data.maxDepth,
maxPages: data.maxPages,
urls: data.urls,
priority: data.priority,
userAgent: data.userAgent || undefined,
cookie: data.cookie || undefined,
templateId: data.templateId || undefined,
delayMs: data.delayMs,
respectRobotsTxt: data.respectRobotsTxt,
},
});
{
onSuccess: () => {
reset();
onSuccess?.();
},
}
);
};
const setPresetUserAgent = (ua: string) => {
setValue("userAgent", ua, { shouldValidate: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Task Name */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-h-[80vh] overflow-y-auto pr-1">
{/* 1. Crawl Mode Selector */}
<div className="space-y-1.5">
<Label htmlFor="name">Tên Tác Vụ Crawl</Label>
<Input
id="name"
placeholder="vd: Cào bài viết công nghệ hàng ngày"
{...register("name")}
disabled={isPending}
/>
{errors.name && (
<p className="text-xs text-red-400">{errors.name.message}</p>
)}
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-emerald-500" />
{t.dialog.modeLabel}
</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
{[
{ id: "SCRAPE", label: "SCRAPE", desc: t.dialog.modeScrapeDesc },
{ id: "CRAWL", label: "CRAWL", desc: t.dialog.modeCrawlDesc },
{ id: "SITEMAP", label: "SITEMAP", desc: t.dialog.modeSitemapDesc },
{ id: "URL_LIST", label: "URL LIST", desc: t.dialog.modeUrlListDesc },
].map((modeItem) => {
const isSelected = selectedMode === modeItem.id;
return (
<button
key={modeItem.id}
type="button"
onClick={() => setValue("mode", modeItem.id as CreateCrawlJobInput["mode"])}
className={`flex flex-col items-center justify-center p-2 rounded-2xl border text-xs font-medium transition-all duration-200 cursor-pointer ${
isSelected
? "border-emerald-500 bg-emerald-500/15 text-emerald-600 dark:text-emerald-300 font-semibold shadow-sm"
: "border-border/60 bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
}`}
>
<span>{modeItem.label}</span>
<span className="text-[10px] opacity-75">{modeItem.desc}</span>
</button>
);
})}
</div>
</div>
{/* Target URL */}
<div className="space-y-1.5">
<Label htmlFor="targetUrl">Địa Chỉ URL Đích (Target URL)</Label>
<Input
id="targetUrl"
type="url"
placeholder="https://example.com/news"
{...register("targetUrl")}
disabled={isPending}
/>
{errors.targetUrl && (
<p className="text-xs text-red-400">{errors.targetUrl.message}</p>
)}
</div>
{/* 2. Target URL (hoặc Textarea URLs nếu là URL_LIST) */}
{!isUrlListMode ? (
<div className="space-y-1.5">
<Label htmlFor="startUrl" className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-emerald-500" />
{t.dialog.urlLabel}
</Label>
<Input
id="startUrl"
type="url"
placeholder={t.dialog.urlPlaceholder}
className="rounded-2xl border-border/80 bg-background focus-visible:ring-emerald-500"
{...register("startUrl")}
disabled={isPending}
/>
{errors.startUrl && (
<p className="text-xs text-rose-500 font-medium">{errors.startUrl.message}</p>
)}
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="urls" className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-emerald-500" />
{t.dialog.urlsListLabel}
</Label>
<span className="text-[10px] text-muted-foreground">{t.dialog.urlsPerLineHint}</span>
</div>
<textarea
id="urls"
rows={4}
placeholder={t.dialog.urlsPlaceholder}
className="w-full rounded-2xl border border-border/80 bg-background p-3 text-xs font-mono text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-50"
onChange={(e) => {
const lines = e.target.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
setValue("urls", lines, { shouldValidate: true });
}}
disabled={isPending}
/>
{errors.urls && (
<p className="text-xs text-rose-500 font-medium">{errors.urls.message}</p>
)}
</div>
)}
{/* Grid: Max Depth & Max Pages */}
{/* 3. Grid: Max Depth & Max Pages */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="maxDepth">Độ Sâu (Depth Level)</Label>
<div className="flex items-center justify-between">
<Label htmlFor="maxDepth" className="text-xs font-semibold text-foreground flex items-center gap-1">
<Sliders className="h-3 w-3 text-muted-foreground" />
{t.dialog.depthLabel}
</Label>
<span className="text-[10px] text-muted-foreground">1 - 10</span>
</div>
<Input
id="maxDepth"
type="number"
min={1}
max={10}
className="rounded-2xl border-border/80 bg-background"
{...register("maxDepth", { valueAsNumber: true })}
disabled={isPending}
disabled={isPending || selectedMode === "SCRAPE"}
/>
{errors.maxDepth && (
<p className="text-xs text-red-400">{errors.maxDepth.message}</p>
<p className="text-xs text-rose-500 font-medium">{errors.maxDepth.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="maxPages">Số Trang Tối Đa</Label>
<div className="flex items-center justify-between">
<Label htmlFor="maxPages" className="text-xs font-semibold text-foreground flex items-center gap-1">
<Sliders className="h-3 w-3 text-muted-foreground" />
{t.dialog.pagesLabel}
</Label>
<span className="text-[10px] text-muted-foreground">1 - 1000</span>
</div>
<Input
id="maxPages"
type="number"
min={1}
max={5000}
max={1000}
className="rounded-2xl border-border/80 bg-background"
{...register("maxPages", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.maxPages && (
<p className="text-xs text-red-400">{errors.maxPages.message}</p>
<p className="text-xs text-rose-500 font-medium">{errors.maxPages.message}</p>
)}
</div>
</div>
{/* Rate Limit */}
<div className="space-y-1.5">
<Label htmlFor="rateLimit">Độ Trễ Giữa Các Request (ms)</Label>
<Input
id="rateLimit"
type="number"
min={100}
step={100}
{...register("rateLimit", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.rateLimit && (
<p className="text-xs text-red-400">{errors.rateLimit.message}</p>
)}
</div>
{/* 4. Collapsible Advanced Options Toggle */}
<div className="pt-1">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5 text-xs font-semibold text-foreground hover:bg-muted/40 transition-colors cursor-pointer"
>
<span className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-emerald-500" />
{t.dialog.advancedTitle}
</span>
{showAdvanced ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* CSS Selectors */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="extractSelectors">CSS Selectors Cần Bóc Tách</Label>
<span className="text-[11px] text-slate-400">Tùy chọn</span>
</div>
<Input
id="extractSelectors"
placeholder="h1.title, .article-content, span.price"
{...register("extractSelectors")}
disabled={isPending}
/>
{showAdvanced && (
<div className="mt-3 space-y-4 rounded-2xl border border-border/70 bg-card/60 p-4 transition-all duration-300 backdrop-blur-sm">
{/* Priority Flags */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-emerald-500" />
{t.dialog.priorityLabel}
</Label>
<div className="grid grid-cols-4 gap-1.5">
{(
[
{ id: "LOW", label: t.dialog.priorityLow, color: "text-slate-500 border-slate-700/50" },
{ id: "NORMAL", label: t.dialog.priorityNormal, color: "text-emerald-500 border-emerald-500/50" },
{ id: "HIGH", label: t.dialog.priorityHigh, color: "text-amber-500 border-amber-500/50" },
{ id: "URGENT", label: t.dialog.priorityUrgent, color: "text-rose-500 border-rose-500/50" },
] as const
).map((p) => {
const isChecked = selectedPriority === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setValue("priority", p.id)}
className={`py-1.5 px-2 rounded-xl text-xs font-semibold border transition-all cursor-pointer ${
isChecked
? `bg-card shadow-sm ring-2 ring-emerald-500/30 ${p.color}`
: "border-border/50 text-muted-foreground hover:bg-muted/40"
}`}
>
{p.label}
</button>
);
})}
</div>
</div>
{/* Custom Extraction Template Selection - Biophilic Dropdown (ThemeToggle style) */}
<div className="space-y-1.5" ref={templateDropdownRef}>
<Label className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>{t.dialog.templateLabel}</span>
{selectedTemplate && (
<button
type="button"
onClick={() => setValue("templateId", "", { shouldValidate: true })}
className="text-[10px] text-muted-foreground hover:text-rose-500 transition-colors cursor-pointer"
>
{t.dialog.cancelBtn}
</button>
)}
</Label>
<input type="hidden" {...register("templateId")} />
<div className="relative">
<button
type="button"
onClick={() => setIsTemplateOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isTemplateOpen}
disabled={isPending || isLoadingTemplates}
className={`flex w-full items-center justify-between rounded-2xl border px-3 py-2 text-xs transition-all cursor-pointer shadow-sm ${
isTemplateOpen
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-2 ring-emerald-500/20"
: "border-border/80 bg-background text-foreground hover:bg-muted/40"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="flex items-center gap-2 truncate">
<FileCode
className={`h-4 w-4 shrink-0 ${
selectedTemplate ? "text-emerald-500" : "text-muted-foreground"
}`}
/>
<span
className={`truncate ${
selectedTemplate ? "font-semibold text-foreground" : "text-muted-foreground"
}`}
>
{selectedTemplate ? selectedTemplate.name : t.dialog.templateNone}
</span>
{selectedTemplate?.domain && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground shrink-0">
{selectedTemplate.domain}
</span>
)}
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
isTemplateOpen ? "rotate-180" : ""
}`}
/>
</button>
{isTemplateOpen && (
<div
role="listbox"
className="absolute left-0 right-0 mt-1.5 w-full origin-top rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150 max-h-56 overflow-y-auto space-y-0.5"
>
{/* Option: None */}
<button
type="button"
role="option"
aria-selected={!selectedTemplateId}
onClick={() => {
setValue("templateId", "", { shouldValidate: true });
setIsTemplateOpen(false);
}}
className={`flex w-full items-center justify-between gap-2 rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer ${
!selectedTemplateId
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent"
}`}
>
<div className="flex items-center gap-2">
<Layers className="h-3.5 w-3.5 opacity-70" />
<span>{t.dialog.templateNone}</span>
</div>
{!selectedTemplateId && (
<Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</button>
{/* Template Options */}
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id;
return (
<button
key={tpl.id}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
setValue("templateId", tpl.id, { shouldValidate: true });
setIsTemplateOpen(false);
}}
className={`flex w-full items-center justify-between gap-2 rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<FileCode
className={`h-3.5 w-3.5 shrink-0 ${
isSelected ? "text-emerald-500" : "opacity-70"
}`}
/>
<span className="truncate font-medium text-foreground">{tpl.name}</span>
<span className="rounded-md bg-muted/80 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground shrink-0">
{tpl.domain}
</span>
</div>
{isSelected && (
<Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</button>
);
})}
</div>
)}
</div>
</div>
{/* Custom User-Agent */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="userAgent" className="text-xs font-semibold text-foreground">
{t.dialog.userAgentLabel}
</Label>
<span className="text-[10px] text-muted-foreground">{t.dialog.optional}</span>
</div>
<Input
id="userAgent"
placeholder={t.dialog.userAgentPlaceholder}
className="rounded-2xl border-border/80 bg-background font-mono text-xs"
{...register("userAgent")}
disabled={isPending}
/>
<div className="flex items-center gap-1.5 pt-1">
<span className="text-[10px] text-muted-foreground">{t.dialog.presetLabel}</span>
<Badge
variant="secondary"
className="text-[10px] cursor-pointer hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() =>
setPresetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/128.0.0.0 Safari/537.36")
}
>
Chrome Windows
</Badge>
<Badge
variant="secondary"
className="text-[10px] cursor-pointer hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() => setPresetUserAgent("DataCrawler-Bot/2.0 (+https://datacrawler.internal)")}
>
DataCrawler-Bot
</Badge>
</div>
</div>
{/* Custom Cookie */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="cookie" className="text-xs font-semibold text-foreground">
{t.dialog.cookieLabel}
</Label>
<span className="text-[10px] text-muted-foreground">{t.dialog.optional}</span>
</div>
<Input
id="cookie"
placeholder={t.dialog.cookiePlaceholder}
className="rounded-2xl border-border/80 bg-background font-mono text-xs"
{...register("cookie")}
disabled={isPending}
/>
</div>
{/* Delay & Respect Robots */}
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="space-y-1.5">
<Label htmlFor="delayMs" className="text-xs font-semibold text-foreground">
{t.dialog.rateLabel}
</Label>
<Input
id="delayMs"
type="number"
min={100}
step={100}
className="rounded-2xl border-border/80 bg-background text-xs"
{...register("delayMs", { valueAsNumber: true })}
disabled={isPending}
/>
</div>
<div className="flex flex-col justify-end pb-1.5">
<Controller
name="respectRobotsTxt"
control={control}
render={({ field }) => (
<label className="flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground">
<input
type="checkbox"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
className="h-4 w-4 rounded accent-emerald-600"
disabled={isPending}
/>
<span>{t.dialog.respectRobotsTxt}</span>
</label>
)}
/>
</div>
</div>
</div>
)}
</div>
{/* Submit Button */}
<div className="pt-2">
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-500 font-semibold"
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-500 font-bold py-2.5 h-auto text-white shadow-md shadow-emerald-600/20 hover:scale-[1.01] transition-all cursor-pointer"
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Đang khởi tạo tác vụ...
{t.dialog.submitting}
</>
) : (
<>
<PlusCircle className="mr-2 h-4 w-4" />
Khởi Chạy Tác Vụ Crawl
{t.dialog.submitBtn}
</>
)}
</Button>
......
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import {
useCrawlerTasks,
useDeleteCrawlerTask,
useToggleCrawlerTask,
} from "@/hooks/use-crawler";
useCrawlJobs,
useRerunCrawlJob,
useCancelCrawlJob,
useDeleteCrawlJob,
} from "@/hooks/use-crawl-jobs";
import { useToggleCrawlerTask } from "@/hooks/use-crawler";
import {
Table,
TableBody,
......@@ -16,212 +19,580 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatDate, formatNumber } from "@/lib/utils";
import { CrawlerStatus } from "@/types/crawler";
import { CrawlJob, CrawlJobStatus } from "@/types/crawl-job";
import { useLanguage } from "@/providers/language-provider";
import {
AlertCircle,
ArrowUpDown,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
Copy,
ExternalLink,
Eye,
Layers,
Loader2,
Pause,
Play,
RefreshCw,
RotateCcw,
Search,
Trash2,
AlertCircle,
Clock,
Layers,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
export function CrawlerTaskTable() {
const { data, isLoading, isError, error } = useCrawlerTasks();
const { t, locale } = useLanguage();
// State: Server-side pagination, sorting, filtering
const [page, setPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(10);
const [statusFilter, setStatusFilter] = useState<CrawlJobStatus | "ALL">("ALL");
const [searchTerm, setSearchTerm] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
const [sortBy, setSortBy] = useState<"createdAt" | "totalPages" | "status" | "startUrl">("createdAt");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// Action confirmation state
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<string | null>(null);
// Debounce search input
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchTerm);
setPage(1); // Reset to page 1 on search
}, 350);
return () => clearTimeout(handler);
}, [searchTerm]);
// Query jobs
const {
data: jobsData,
isLoading,
isError,
error,
refetch,
} = useCrawlJobs({
page,
limit,
status: statusFilter === "ALL" ? undefined : statusFilter,
search: debouncedSearch || undefined,
sortBy,
order: sortOrder,
});
// Mutations
const { mutate: rerunJob, isPending: isRerunning } = useRerunCrawlJob();
const { mutate: cancelJob, isPending: isCanceling } = useCancelCrawlJob();
const { mutate: deleteJob, isPending: isDeleting } = useDeleteCrawlJob();
const { mutate: toggleStatus, isPending: isToggling } = useToggleCrawlerTask();
const { mutate: deleteTask, isPending: isDeleting } = useDeleteCrawlerTask();
const getStatusBadge = (status: CrawlerStatus) => {
const handleToggleSort = (column: typeof sortBy) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
setPage(1);
};
const getStatusBadge = (status: CrawlJobStatus) => {
switch (status) {
case "RUNNING":
case "PROCESSING_EXPORT":
return (
<Badge variant="default" className="gap-1.5 py-1">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
ĐANG CHẠY
<Badge
variant="default"
className="gap-1.5 py-1 rounded-full border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 font-semibold"
>
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
{t.status.running}
</Badge>
);
case "COMPLETED":
return (
<Badge variant="info" className="gap-1.5 py-1">
HOÀN THÀNH
<Badge
variant="info"
className="gap-1.5 py-1 rounded-full border border-teal-500/30 bg-teal-500/15 text-teal-600 dark:text-teal-400 font-semibold"
>
<CheckCircle2 className="h-3 w-3" />
{t.status.completed}
</Badge>
);
case "PAUSED":
case "CANCELED":
return (
<Badge variant="warning" className="gap-1.5 py-1">
TẠM DỪNG
<Badge
variant="warning"
className="gap-1.5 py-1 rounded-full border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 font-semibold"
>
<Pause className="h-3 w-3" />
{t.status.canceled}
</Badge>
);
case "FAILED":
return (
<Badge variant="destructive" className="gap-1.5 py-1">
THẤT BẠI
<Badge
variant="destructive"
className="gap-1.5 py-1 rounded-full border border-rose-500/30 bg-rose-500/15 text-rose-600 dark:text-rose-400 font-semibold"
>
<XCircle className="h-3 w-3" />
{t.status.failed}
</Badge>
);
case "PENDING":
case "QUEUED":
default:
return (
<Badge variant="secondary" className="gap-1.5 py-1">
CHỜ XỬ LÝ
<Badge
variant="secondary"
className="gap-1.5 py-1 rounded-full border border-border/80 bg-muted/60 text-muted-foreground font-semibold"
>
<Clock className="h-3 w-3" />
{t.status.pending}
</Badge>
);
}
};
if (isLoading) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 p-8 text-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
<p className="text-sm text-slate-400">Đang tải danh sách tác vụ crawl...</p>
</div>
);
}
if (isError) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-red-900/30 bg-red-950/20 p-8 text-center text-red-300">
<AlertCircle className="h-8 w-8 text-red-400" />
<p className="text-sm font-semibold">Lỗi tải dữ liệu</p>
<p className="text-xs text-red-400/80">{(error as Error)?.message || "Vui lòng thử lại"}</p>
</div>
);
}
const tasks = data?.items || [];
if (tasks.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-800 bg-slate-900/30 p-8 text-center">
<Layers className="h-10 w-10 text-slate-600" />
<h3 className="font-semibold text-slate-300">Chưa có tác vụ crawl nào</h3>
<p className="text-xs text-slate-500 max-w-sm">
Bấm nút &quot;Tạo Tác Vụ Mới&quot; phía trên để bắt đầu cấu hình và thu thập dữ liệu web.
</p>
</div>
);
}
const jobs = jobsData?.items || [];
const rawTotal =
jobsData?.total ??
(jobsData as unknown as { meta?: { total?: number } })?.meta?.total;
const total =
rawTotal !== undefined && rawTotal > 0 ? rawTotal : jobs.length;
const rawTotalPages =
jobsData?.totalPages ??
(jobsData as unknown as { meta?: { totalPages?: number } })?.meta?.totalPages;
const totalPages = Math.max(1, rawTotalPages ?? Math.ceil(total / limit));
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl shadow-black/20">
<Table>
<TableHeader>
<TableRow className="border-slate-800 bg-slate-950/40">
<TableHead>Tên Tác Vụ & Target</TableHead>
<TableHead>Trạng Thái</TableHead>
<TableHead>Tiến Độ Cào</TableHead>
<TableHead>Dữ Liệu Bóc Tách</TableHead>
<TableHead>Thời Gian Tạo</TableHead>
<TableHead className="text-right">Hành Động</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => {
const progress = Math.min(
100,
Math.round((task.pagesCrawled / Math.max(1, task.maxPages)) * 100)
);
<div className="space-y-4">
{/* 1. Filter, Search & Sorting Controls Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 rounded-2xl border border-border/70 bg-card/60 p-3.5 backdrop-blur-sm">
{/* Search Bar */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t.table.searchPlaceholder}
className="pl-9 rounded-2xl border-border/70 bg-background text-xs"
/>
</div>
{/* Status Filter Badges */}
<div className="flex flex-wrap items-center gap-1.5">
{(
[
{ id: "ALL", label: t.table.filterAll },
{ id: "RUNNING", label: t.status.running },
{ id: "COMPLETED", label: t.status.completed },
{ id: "FAILED", label: t.status.failed },
{ id: "CANCELED", label: t.status.canceled },
{ id: "PENDING", label: t.status.pending },
] as const
).map((filterItem) => {
const isActive = statusFilter === filterItem.id;
return (
<TableRow key={task.id} className="border-slate-800/80">
{/* Name & Target */}
<TableCell className="max-w-[280px]">
<div className="font-medium text-slate-100">{task.name}</div>
<a
href={task.targetUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-400/80 hover:text-emerald-300 hover:underline truncate max-w-full mt-0.5"
>
<span className="truncate">{task.targetUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</TableCell>
{/* Status */}
<TableCell>{getStatusBadge(task.status)}</TableCell>
{/* Progress */}
<TableCell className="min-w-[160px]">
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>{task.pagesCrawled} / {task.maxPages} trang</span>
<span className="font-semibold text-slate-300">{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-cyan-400 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<button
key={filterItem.id}
type="button"
onClick={() => {
setStatusFilter(filterItem.id);
setPage(1);
}}
className={`rounded-2xl px-3 py-1 text-xs font-semibold border transition-all cursor-pointer ${
isActive
? "border-emerald-500 bg-emerald-500/15 text-emerald-600 dark:text-emerald-300 shadow-sm"
: "border-border/60 bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
}`}
>
{filterItem.label}
</button>
);
})}
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
className="rounded-2xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground text-xs gap-1.5 h-7 px-2.5 ml-1 transition-colors shadow-xs"
title="Làm mới bảng"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</div>
{/* 2. Loading State */}
{isLoading && (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-3xl border border-border/70 bg-card/40 p-8 text-center backdrop-blur-md">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
<p className="text-sm text-muted-foreground font-medium">Đang tải danh sách tác vụ crawl...</p>
</div>
)}
{/* 3. Error State */}
{isError && !isLoading && (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-3xl border border-rose-500/30 bg-rose-500/10 p-8 text-center text-rose-600 dark:text-rose-400">
<AlertCircle className="h-8 w-8 text-rose-500" />
<p className="text-sm font-semibold">Lỗi nạp dữ liệu tác vụ</p>
<p className="text-xs opacity-80">{(error as Error)?.message || "Vui lòng thử lại"}</p>
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
className="mt-2 rounded-2xl border-rose-500/30 hover:bg-rose-500/20 text-xs gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" />
{t.table.retry}
</Button>
</div>
)}
{/* 4. Empty State */}
{!isLoading && !isError && jobs.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-3xl border border-dashed border-border/80 bg-card/30 p-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-500">
<Layers className="h-6 w-6" />
</div>
<h3 className="font-semibold text-foreground">{t.table.empty}</h3>
<p className="text-xs text-muted-foreground max-w-sm">
Thử thay đổi bộ lọc trạng thái hoặc từ khóa tìm kiếm để tìm tác vụ bạn cần.
</p>
</div>
)}
{/* 5. Main Table */}
{!isLoading && !isError && jobs.length > 0 && (
<div className="rounded-3xl border border-border/80 bg-card/60 backdrop-blur-md overflow-hidden shadow-sm transition-all duration-200">
<Table>
<TableHeader>
<TableRow className="border-border/70 bg-muted/40 hover:bg-muted/40">
{/* Column 1: Job ID */}
<TableHead className="font-semibold text-xs py-3.5 w-[130px]">
<span>{t.table.id}</span>
</TableHead>
{/* Column 2: Start URL / Domain */}
<TableHead
onClick={() => handleToggleSort("startUrl")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.name}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
</TableCell>
</TableHead>
{/* Extracted Count */}
<TableCell>
<span
className="font-semibold text-emerald-400"
suppressHydrationWarning
>
{formatNumber(task.itemsExtracted)}
</span>{" "}
<span className="text-xs text-slate-400">mục</span>
</TableCell>
{/* Created At */}
<TableCell className="text-xs text-slate-400">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-slate-500" />
{formatDate(task.createdAt)}
{/* Column 3: Status */}
<TableHead
onClick={() => handleToggleSort("status")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.status}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
</TableCell>
{/* Actions */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
{task.status === "RUNNING" ? (
<Button
size="sm"
variant="secondary"
title="Tạm dừng"
disabled={isToggling}
onClick={() => toggleStatus(task.id)}
className="h-8 px-2.5 text-amber-400 hover:text-amber-300"
>
<Pause className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="sm"
variant="secondary"
title="Tiếp tục chạy"
disabled={isToggling}
onClick={() => toggleStatus(task.id)}
className="h-8 px-2.5 text-emerald-400 hover:text-emerald-300"
>
<Play className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="destructive"
title="Xóa tác vụ"
disabled={isDeleting}
onClick={() => deleteTask(task.id)}
className="h-8 px-2.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableHead>
{/* Column 4: Crawl Progress */}
<TableHead
onClick={() => handleToggleSort("totalPages")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5 min-w-[170px]"
>
<div className="flex items-center gap-1.5">
<span>{t.table.progress}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
</TableHead>
{/* Column 5: Extracted Items */}
<TableHead className="font-semibold text-xs py-3.5">
{t.table.extractedCount}
</TableHead>
{/* Column 6: Created Date */}
<TableHead
onClick={() => handleToggleSort("createdAt")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.createdAt}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
</TableCell>
</TableHead>
{/* Column 7: Action Buttons Group */}
<TableHead className="text-right font-semibold text-xs py-3.5">
{t.table.actions}
</TableHead>
</TableRow>
);
})}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{jobs.map((job) => {
const maxPages = Math.max(1, job.maxPages || 20);
const currentPages = job.totalPages || job.successPages || 0;
const progressPct = Math.min(100, Math.round((currentPages / maxPages) * 100));
const isRunning = job.status === "RUNNING" || job.status === "PROCESSING_EXPORT";
return (
<TableRow
key={job.id}
className="border-border/60 hover:bg-muted/20 transition-colors"
>
{/* Column 1: Job ID Dedicated Column */}
<TableCell className="w-[130px]">
<div
className="inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground transition-all hover:bg-muted hover:border-emerald-500/40 group"
title={`Job ID: ${job.id}`}
>
<span className="font-semibold text-foreground select-all">
{job.id.slice(0, 8)}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(job.id);
toast.success(
t.table.copiedId
? `${t.table.copiedId}: ${job.id.slice(0, 8)}...`
: `Đã sao chép Job ID: ${job.id.slice(0, 8)}...`
);
}}
className="text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer p-0.5 rounded hover:bg-background"
title="Sao chép toàn bộ Job ID"
>
<Copy className="h-3 w-3" />
</button>
</div>
</TableCell>
{/* Column 2: Name & Source URL */}
<TableCell className="max-w-[280px]">
<div className="space-y-1">
<div className="flex items-center gap-2 min-w-0">
<Link
href={`/crawl-jobs/${job.id}`}
className="font-semibold text-sm text-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors truncate block"
title={job.startUrl}
>
{job.domain || job.startUrl}
</Link>
<Badge
variant="secondary"
className="shrink-0 inline-flex items-center justify-center text-[10px] leading-none font-mono font-semibold uppercase px-2 py-0.5 rounded-md border border-border/80 bg-muted/80 text-foreground/80 dark:text-muted-foreground shadow-xs"
>
{job.mode}
</Badge>
</div>
<a
href={job.startUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:underline truncate max-w-full"
title={job.startUrl}
>
<span className="truncate">{job.startUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
</TableCell>
{/* Status Badge */}
<TableCell>{getStatusBadge(job.status)}</TableCell>
{/* Progress Bar */}
<TableCell>
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{currentPages} / {job.maxPages} {t.table.pagesUnit}
</span>
<span className="font-semibold text-foreground">{progressPct}%</span>
</div>
<div className="h-2 w-full rounded-full bg-muted/60 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400 transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</TableCell>
{/* Extracted Records */}
<TableCell>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{formatNumber(job.successPages || 0)}
</span>{" "}
<span className="text-xs text-muted-foreground">{t.table.itemsUnit}</span>
</TableCell>
{/* Created Time */}
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground/80" />
{formatDate(job.createdAt, locale)}
</div>
</TableCell>
{/* Action Group */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
{/* 1. View Detail Link */}
<Link href={`/crawl-jobs/${job.id}`}>
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-emerald-500/10 hover:border-emerald-500/30 text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 shadow-xs transition-colors"
title={t.table.viewDetails}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
{/* 2. Pause / Resume Toggle */}
{isRunning ? (
<Button
size="sm"
variant="secondary"
title={t.table.pause}
disabled={isToggling}
onClick={() => toggleStatus(job.id)}
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-amber-500/10 hover:border-amber-500/30 text-amber-600 dark:text-amber-400 shadow-xs transition-colors"
>
<Pause className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="sm"
variant="secondary"
title={t.table.resume}
disabled={isToggling}
onClick={() => toggleStatus(job.id)}
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-emerald-500/10 hover:border-emerald-500/30 text-emerald-600 dark:text-emerald-400 shadow-xs transition-colors"
>
<Play className="h-3.5 w-3.5" />
</Button>
)}
{/* 3. Rerun Button */}
<Button
size="sm"
variant="secondary"
title={t.table.rerun}
disabled={isRerunning || isRunning}
onClick={() => rerunJob(job.id)}
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-cyan-500/10 hover:border-cyan-500/30 text-cyan-600 dark:text-cyan-400 shadow-xs transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
{/* 4. Cancel Button */}
{isRunning && (
<Button
size="sm"
variant="secondary"
title={t.table.cancel}
disabled={isCanceling}
onClick={() => {
if (window.confirm(t.table.confirmCancel)) {
cancelJob(job.id);
}
}}
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-amber-500/10 hover:border-amber-500/30 text-amber-600 dark:text-amber-400 shadow-xs transition-colors"
>
<XCircle className="h-3.5 w-3.5" />
</Button>
)}
{/* 5. Delete Button */}
<Button
size="sm"
variant="secondary"
title={t.table.delete}
disabled={isDeleting}
onClick={() => {
if (window.confirm(t.table.confirmDelete)) {
deleteJob(job.id);
}
}}
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-rose-500/10 hover:border-rose-500/30 text-rose-600 dark:text-rose-400 shadow-xs transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{/* 6. Server-Side Pagination Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-border/70 bg-muted/20 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span>
{t.table.totalJobs}: <strong className="text-foreground">{total}</strong>
</span>
<span></span>
<span>
{t.table.page} <strong className="text-foreground">{page}</strong> {t.table.of}{" "}
<strong className="text-foreground">{totalPages}</strong>
</span>
</div>
<div className="flex items-center gap-3">
{/* Page size select */}
<div className="flex items-center gap-1.5">
<span>{t.table.perPage}:</span>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setPage(1);
}}
className="rounded-xl border border-border/80 bg-background px-2 py-1 text-xs text-foreground cursor-pointer"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
{/* Prev / Next Page Buttons */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
"use client";
import React from "react";
import Link from "next/link";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate, formatNumber } from "@/lib/utils";
import { CrawlJobStatus } from "@/types/crawl-job";
import { useLanguage } from "@/providers/language-provider";
import { toast } from "sonner";
import {
ArrowRight,
CheckCircle2,
Clock,
Copy,
ExternalLink,
Eye,
Layers,
Loader2,
Pause,
XCircle,
} from "lucide-react";
export function RecentJobsWidget() {
const { t, locale } = useLanguage();
const { data: jobsData, isLoading, isError } = useCrawlJobs({
page: 1,
limit: 5,
sortBy: "createdAt",
order: "desc",
});
const getStatusBadge = (status: CrawlJobStatus) => {
switch (status) {
case "RUNNING":
case "PROCESSING_EXPORT":
return (
<Badge
variant="default"
className="gap-1.5 py-0.5 rounded-full border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-[11px] font-semibold"
>
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
{t.status.running}
</Badge>
);
case "COMPLETED":
return (
<Badge
variant="info"
className="gap-1 py-0.5 rounded-full border border-teal-500/30 bg-teal-500/15 text-teal-600 dark:text-teal-400 text-[11px] font-semibold"
>
<CheckCircle2 className="h-3 w-3" />
{t.status.completed}
</Badge>
);
case "CANCELED":
return (
<Badge
variant="warning"
className="gap-1 py-0.5 rounded-full border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 text-[11px] font-semibold"
>
<Pause className="h-3 w-3" />
{t.status.canceled}
</Badge>
);
case "FAILED":
return (
<Badge
variant="destructive"
className="gap-1 py-0.5 rounded-full border border-rose-500/30 bg-rose-500/15 text-rose-600 dark:text-rose-400 text-[11px] font-semibold"
>
<XCircle className="h-3 w-3" />
{t.status.failed}
</Badge>
);
case "PENDING":
case "QUEUED":
default:
return (
<Badge
variant="secondary"
className="gap-1 py-0.5 rounded-full border border-border/80 bg-muted/60 text-muted-foreground text-[11px] font-semibold"
>
<Clock className="h-3 w-3" />
{t.status.pending}
</Badge>
);
}
};
const jobs = jobsData?.items || [];
const rawTotal =
jobsData?.total ??
(jobsData as unknown as { meta?: { total?: number } })?.meta?.total;
const total =
rawTotal !== undefined && rawTotal > 0 ? rawTotal : jobs.length;
return (
<div className="space-y-3.5">
{/* Header bar with View All link */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
<div>
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Clock className="h-5 w-5 text-emerald-500" />
{t.table.recentTitle}
</h2>
<p className="text-xs text-muted-foreground">
{t.table.recentSubtitle}
</p>
</div>
<Link href="/crawl-jobs">
<Button
size="sm"
variant="outline"
className="rounded-2xl border border-border/80 bg-background hover:bg-muted text-xs font-semibold gap-1.5 h-8 px-3 text-foreground transition-all hover:scale-[1.02] shadow-xs cursor-pointer"
>
<span>{t.table.viewAll}</span>
<ArrowRight className="h-3.5 w-3.5 text-emerald-500" />
</Button>
</Link>
</div>
{/* Loading Skeleton */}
{isLoading && (
<div className="flex h-44 flex-col items-center justify-center gap-2 rounded-3xl border border-border/70 bg-card/40 p-6 text-center backdrop-blur-md">
<Loader2 className="h-7 w-7 animate-spin text-emerald-500" />
<p className="text-xs text-muted-foreground font-medium">Đang tải tác vụ gần đây...</p>
</div>
)}
{/* Empty State */}
{!isLoading && !isError && jobs.length === 0 && (
<div className="flex h-40 flex-col items-center justify-center gap-2 rounded-3xl border border-dashed border-border/80 bg-card/30 p-6 text-center">
<Layers className="h-6 w-6 text-muted-foreground/60" />
<p className="text-xs text-muted-foreground">
Chưa có tác vụ nào gần đây. Hãy tạo tác vụ mới để bắt đầu cào dữ liệu.
</p>
</div>
)}
{/* Main Table */}
{!isLoading && !isError && jobs.length > 0 && (
<div className="rounded-3xl border border-border/80 bg-card/60 backdrop-blur-md overflow-hidden shadow-sm transition-all duration-200">
<Table>
<TableHeader>
<TableRow className="border-border/70 bg-muted/40 hover:bg-muted/40">
<TableHead className="font-semibold text-xs py-3 w-[125px]">
{t.table.id}
</TableHead>
<TableHead className="font-semibold text-xs py-3">
{t.table.name}
</TableHead>
<TableHead className="font-semibold text-xs py-3">
{t.table.status}
</TableHead>
<TableHead className="font-semibold text-xs py-3 min-w-[150px]">
{t.table.progress}
</TableHead>
<TableHead className="font-semibold text-xs py-3">
{t.table.createdAt}
</TableHead>
<TableHead className="text-right font-semibold text-xs py-3">
{t.table.actions}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => {
const maxPages = Math.max(1, job.maxPages || 20);
const currentPages = job.totalPages || job.successPages || 0;
const progressPct = Math.min(100, Math.round((currentPages / maxPages) * 100));
return (
<TableRow
key={job.id}
className="border-border/60 hover:bg-muted/20 transition-colors"
>
{/* Job ID */}
<TableCell className="w-[125px]">
<div
className="inline-flex items-center gap-1 rounded-lg border border-border/80 bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground transition-all hover:bg-muted hover:border-emerald-500/40"
title={`Job ID: ${job.id}`}
>
<span className="font-semibold text-foreground select-all">
{job.id.slice(0, 8)}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(job.id);
toast.success(
t.table.copiedId
? `${t.table.copiedId}: ${job.id.slice(0, 8)}...`
: `Đã sao chép Job ID: ${job.id.slice(0, 8)}...`
);
}}
className="text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer p-0.5 rounded hover:bg-background"
title="Sao chép Job ID"
>
<Copy className="h-3 w-3" />
</button>
</div>
</TableCell>
{/* Name & URL */}
<TableCell className="max-w-[260px]">
<div className="space-y-0.5">
<div className="flex items-center gap-2 min-w-0">
<Link
href={`/crawl-jobs/${job.id}`}
className="font-semibold text-sm text-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors truncate block"
title={job.startUrl}
>
{job.domain || job.startUrl}
</Link>
<Badge
variant="secondary"
className="shrink-0 inline-flex items-center justify-center text-[10px] leading-none font-mono font-semibold uppercase px-1.5 py-0.5 rounded-md border border-border/80 bg-muted/80 text-foreground/80 dark:text-muted-foreground shadow-xs"
>
{job.mode}
</Badge>
</div>
<a
href={job.startUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:underline truncate max-w-full"
title={job.startUrl}
>
<span className="truncate">{job.startUrl}</span>
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
</a>
</div>
</TableCell>
{/* Status */}
<TableCell>{getStatusBadge(job.status)}</TableCell>
{/* Progress */}
<TableCell>
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{currentPages} / {job.maxPages} {t.table.pagesUnit}
</span>
<span className="font-semibold text-foreground">{progressPct}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted/60 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400 transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</TableCell>
{/* Created Date */}
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground/80" />
{formatDate(job.createdAt, locale)}
</div>
</TableCell>
{/* View Details Action */}
<TableCell className="text-right">
<Link href={`/crawl-jobs/${job.id}`}>
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0 rounded-xl border border-border/80 bg-background hover:bg-emerald-500/10 hover:border-emerald-500/30 text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 shadow-xs transition-colors"
title={t.table.viewDetails}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{/* Bottom Card Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border/70 bg-muted/20 text-xs text-muted-foreground">
<span>
{t.table.showing} <strong>{jobs.length}</strong> {t.table.ofTotal}{" "}
<strong className="text-foreground">{formatNumber(total)}</strong> {t.table.tasksUnit}
</span>
<Link
href="/crawl-jobs"
className="inline-flex items-center gap-1 font-semibold text-emerald-600 dark:text-emerald-400 hover:underline"
>
<span>{t.table.viewAll}</span>
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
)}
</div>
);
}
......@@ -3,20 +3,20 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center justify-center leading-none rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
"border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
secondary:
"border-transparent bg-slate-800 text-slate-300",
"border-border/80 bg-muted text-foreground/80 dark:text-muted-foreground",
destructive:
"border-transparent bg-red-500/15 text-red-400 border-red-500/30",
outline: "text-slate-300 border-slate-700",
"border-rose-500/30 bg-rose-500/15 text-rose-700 dark:text-rose-300",
outline: "text-foreground border-border",
warning:
"border-amber-500/30 bg-amber-500/15 text-amber-400",
info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-400",
"border-amber-500/30 bg-amber-500/15 text-amber-700 dark:text-amber-300",
info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
},
},
defaultVariants: {
......
......@@ -13,11 +13,11 @@ const buttonVariants = cva(
destructive:
"bg-red-500 text-white shadow-sm hover:bg-red-600 active:scale-[0.98]",
outline:
"border border-slate-700 bg-slate-900/60 hover:bg-slate-800 hover:text-emerald-400 text-slate-200",
"border border-border bg-background hover:bg-muted hover:text-emerald-600 dark:hover:text-emerald-400 text-foreground",
secondary:
"bg-slate-800 text-slate-100 hover:bg-slate-700",
ghost: "hover:bg-slate-800/80 hover:text-slate-100 text-slate-300",
link: "text-emerald-400 underline-offset-4 hover:underline",
"bg-muted text-foreground hover:bg-muted/80 border border-border/70",
ghost: "hover:bg-muted hover:text-foreground text-muted-foreground",
link: "text-emerald-600 dark:text-emerald-400 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
......
......@@ -32,13 +32,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-800 bg-slate-900 p-6 shadow-2xl duration-200 sm:rounded-xl text-slate-100",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/80 bg-card p-6 shadow-2xl duration-200 sm:rounded-2xl text-card-foreground",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none text-slate-400 hover:text-slate-100">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Đóng</span>
</DialogPrimitive.Close>
......@@ -82,7 +82,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-slate-100",
"text-lg font-semibold leading-none tracking-tight text-foreground",
className
)}
{...props}
......@@ -96,7 +96,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-400", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
......
......@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-xs font-semibold uppercase tracking-wider text-slate-400 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
"text-xs font-semibold uppercase tracking-wider text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
......
......@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b border-slate-800", className)} {...props} />
<thead ref={ref} className={cn("[&_tr]:border-b border-border", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
......@@ -42,7 +42,7 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-slate-900/50 font-medium [&>tr]:last:border-b-0",
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
......@@ -57,7 +57,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b border-slate-800/60 transition-colors hover:bg-slate-800/40 data-[state=selected]:bg-slate-800",
"border-b border-border/60 transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted",
className
)}
{...props}
......@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-semibold text-xs text-slate-400 uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
"h-10 px-4 text-left align-middle font-semibold text-xs text-muted-foreground uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
......@@ -86,7 +86,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle text-slate-200 [&:has([role=checkbox])]:pr-0", className)}
className={cn("p-4 align-middle text-foreground [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
......
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlJobService } from "@/services/crawl-job.service";
import { CrawlJobQueryDto, CreateCrawlJobDto } from "@/types/crawl-job";
import { toast } from "sonner";
export const CRAWL_JOBS_QUERY_KEYS = {
all: ["crawl-jobs"] as const,
lists: () => [...CRAWL_JOBS_QUERY_KEYS.all, "list"] as const,
list: (query?: CrawlJobQueryDto) => [...CRAWL_JOBS_QUERY_KEYS.lists(), query] as const,
details: () => [...CRAWL_JOBS_QUERY_KEYS.all, "detail"] as const,
detail: (id: string) => [...CRAWL_JOBS_QUERY_KEYS.details(), id] as const,
logs: (id: string, query?: { page?: number; limit?: number }) =>
[...CRAWL_JOBS_QUERY_KEYS.detail(id), "logs", query] as const,
pages: (id: string, query?: { page?: number; limit?: number; search?: string }) =>
[...CRAWL_JOBS_QUERY_KEYS.detail(id), "pages", query] as const,
diff: (id: string, compareWithJobId?: string) =>
[...CRAWL_JOBS_QUERY_KEYS.detail(id), "diff", compareWithJobId] as const,
templates: ["extraction-templates"] as const,
};
/**
* Hook lấy danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Filter
*/
export function useCrawlJobs(query?: CrawlJobQueryDto) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.list(query),
queryFn: () => crawlJobService.getJobs(query),
placeholderData: (previousData) => previousData,
staleTime: 5000,
});
}
/**
* Hook lấy chi tiết một Crawl Job
*/
export function useCrawlJob(id: string) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.detail(id),
queryFn: () => crawlJobService.getJobById(id),
enabled: Boolean(id),
staleTime: 4000,
});
}
/**
* Hook tạo tác vụ cào dữ liệu mới
*/
export function useCreateCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateCrawlJobDto) => crawlJobService.createJob(dto),
onSuccess: (newJob) => {
toast.success("Khởi tạo tác vụ cào thành công", {
description: `Tác vụ cho "${newJob.startUrl}" đã bắt đầu.`,
});
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể khởi tạo tác vụ", {
description: err.message || "Vui lòng kiểm tra lại đường dẫn và thử lại.",
});
},
});
}
/**
* Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/
export function useRerunCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.rerunJob(id),
onSuccess: (job) => {
toast.success("Đã kích hoạt chạy lại tác vụ", {
description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`,
});
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.detail(job.id) });
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể chạy lại tác vụ", {
description: err.message || "Có lỗi xảy ra từ máy chủ.",
});
},
});
}
/**
* Hook hủy bỏ tác vụ (POST /crawl-jobs/:id/cancel)
*/
export function useCancelCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.cancelJob(id),
onSuccess: (job) => {
toast.info("Đã hủy tác vụ cào dữ liệu", {
description: `Job ID: ${job.id.slice(0, 8)} đã được chuyển sang trạng thái đã hủy.`,
});
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.detail(job.id) });
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể hủy tác vụ", {
description: err.message || "Không thể gửi yêu cầu hủy.",
});
},
});
}
/**
* Hook xóa tác vụ (DELETE /crawl-jobs/:id)
*/
export function useDeleteCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.deleteJob(id),
onSuccess: () => {
toast.success("Đã xóa tác vụ cào dữ liệu thành công");
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể xóa tác vụ", {
description: err.message || "Đã có lỗi xảy ra.",
});
},
});
}
/**
* Hook lấy nhật ký log của tác vụ (GET /crawl-jobs/:id/logs)
*/
export function useCrawlJobLogs(
id: string,
query?: { page?: number; limit?: number },
options?: { refetchInterval?: number | false }
) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query),
queryFn: () => crawlJobService.getJobLogs(id, query),
enabled: Boolean(id),
refetchInterval: options?.refetchInterval ?? 5000,
});
}
/**
* Hook lấy danh sách trang đã cào và preview (GET /crawl-jobs/:id/pages/preview)
*/
export function useCrawlJobPages(
id: string,
query?: { page?: number; limit?: number; search?: string }
) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.pages(id, query),
queryFn: () => crawlJobService.getPagesPreview(id, query),
enabled: Boolean(id),
staleTime: 5000,
});
}
/**
* Hook lấy báo cáo so sánh biến động dữ liệu (GET /crawl-jobs/:id/diff)
*/
export function useCrawlJobDiff(id: string, compareWithJobId?: string) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.diff(id, compareWithJobId),
queryFn: () => crawlJobService.getDiff(id, compareWithJobId),
enabled: Boolean(id),
staleTime: 10000,
});
}
/**
* Hook lấy danh sách Extraction Templates có sẵn
*/
export function useExtractionTemplates() {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.templates,
queryFn: () => crawlJobService.getExtractionTemplates(),
staleTime: 60000,
});
}
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
export type SSEConnectionStatus = "CONNECTING" | "OPEN" | "CLOSED" | "ERROR";
export interface UseEventSourceOptions<T = unknown> {
enabled?: boolean;
withCredentials?: boolean;
onInitial?: (data: T) => void;
onProgress?: (data: T) => void;
onDone?: (data: { status?: string; message?: string }) => void;
onMessage?: (data: T, eventName: string) => void;
onError?: (error: Event) => void;
reconnectInterval?: number;
maxRetries?: number;
}
export interface UseEventSourceResult<T = unknown> {
status: SSEConnectionStatus;
data: T | null;
lastEventName: string | null;
error: Event | null;
reconnect: () => void;
close: () => void;
}
/**
* Custom hook kết nối Server-Sent Events (SSE) theo thời gian thực.
* Tận dụng endpoint SSE GET /api/v1/crawl-jobs/:id/events (qua BFF proxy /api/proxy/crawl-jobs/:id/events).
*/
export function useEventSource<T = unknown>(
url: string | null,
options: UseEventSourceOptions<T> = {}
): UseEventSourceResult<T> {
const {
enabled = true,
withCredentials = true,
onInitial,
onProgress,
onDone,
onMessage,
onError,
reconnectInterval = 4000,
maxRetries = 5,
} = options;
const [status, setStatus] = useState<SSEConnectionStatus>("CONNECTING");
const [data, setData] = useState<T | null>(null);
const [lastEventName, setLastEventName] = useState<string | null>(null);
const [error, setError] = useState<Event | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
// Safe ref callbacks to avoid unnecessary reconnections
const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError });
useEffect(() => {
callbacksRef.current = { onInitial, onProgress, onDone, onMessage, onError };
}, [onInitial, onProgress, onDone, onMessage, onError]);
const cleanUp = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
const connect = useCallback(() => {
cleanUp();
if (!url || !enabled || typeof window === "undefined") {
setStatus("CLOSED");
return;
}
try {
setStatus("CONNECTING");
const es = new EventSource(url, { withCredentials });
eventSourceRef.current = es;
es.onopen = () => {
setStatus("OPEN");
setError(null);
retryCountRef.current = 0;
};
// Handle custom SSE event: "initial"
es.addEventListener("initial", (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as T;
setData(parsed);
setLastEventName("initial");
callbacksRef.current.onInitial?.(parsed);
callbacksRef.current.onMessage?.(parsed, "initial");
} catch {
// Ignored parse error
}
});
// Handle custom SSE event: "progress"
es.addEventListener("progress", (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as T;
setData(parsed);
setLastEventName("progress");
callbacksRef.current.onProgress?.(parsed);
callbacksRef.current.onMessage?.(parsed, "progress");
} catch {
// Ignored parse error
}
});
// Handle custom SSE event: "done"
es.addEventListener("done", (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data);
setLastEventName("done");
setStatus("CLOSED");
callbacksRef.current.onDone?.(parsed);
es.close();
} catch {
setStatus("CLOSED");
es.close();
}
});
// Generic onmessage
es.onmessage = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as T;
setData(parsed);
setLastEventName("message");
callbacksRef.current.onMessage?.(parsed, "message");
} catch {
// Plain text fallback
}
};
es.onerror = (err) => {
setError(err);
callbacksRef.current.onError?.(err);
// If readyState is CLOSED or we can retry
if (es.readyState === EventSource.CLOSED) {
setStatus("CLOSED");
es.close();
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
setStatus("CONNECTING");
retryTimerRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
} else {
setStatus("ERROR");
}
} else {
setStatus("ERROR");
}
};
} catch {
setStatus("ERROR");
}
}, [cleanUp, enabled, maxRetries, reconnectInterval, url, withCredentials]);
useEffect(() => {
connect();
return () => {
cleanUp();
};
}, [connect, cleanUp]);
const reconnect = useCallback(() => {
retryCountRef.current = 0;
connect();
}, [connect]);
const close = useCallback(() => {
cleanUp();
setStatus("CLOSED");
}, [cleanUp]);
return {
status,
data,
lastEventName,
error,
reconnect,
close,
};
}
......@@ -35,16 +35,45 @@ export const translations = {
dialog: {
title: "Tạo Tác Vụ Cào Dữ Liệu",
description:
"Điền thông tin URL và tiêu chí bóc tách. Form được xác thực tự động bởi React Hook Form + Zod.",
urlLabel: "URL nguồn cào",
"Điền thông tin URL và cấu hình bóc tách.",
urlLabel: "URL mục tiêu",
urlPlaceholder: "https://example.com/news/...",
nameLabel: "Tên tác vụ",
namePlaceholder: "Ví dụ: Tin tức Công nghệ",
depthLabel: "Độ sâu cào (Depth)",
rateLabel: "Tốc độ yêu cầu (req/s)",
submitBtn: "Khởi tạo tác vụ",
namePlaceholder: "Ví dụ: Tin tức Công nghệ & AI",
depthLabel: "Độ sâu",
pagesLabel: "Số trang tối đa",
modeLabel: "Chế độ cào",
modeScrape: "Bóc tách đơn trang",
modeCrawl: "Cào sâu toàn site",
modeSitemap: "Theo sơ đồ trang",
modeUrlList: "Danh sách URL tùy chỉnh",
modeScrapeDesc: "Đơn trang",
modeCrawlDesc: "Đa tầng",
modeSitemapDesc: "Sơ đồ site",
modeUrlListDesc: "Danh sách",
urlsListLabel: "Danh sách URLs",
urlsPlaceholder: "https://example.com/item-1\nhttps://example.com/item-2",
urlsPerLineHint: "Mỗi dòng 1 URL",
rateLabel: "Độ trễ request (ms)",
submitBtn: "Khởi Chạy Tác Vụ Crawl",
cancelBtn: "Hủy bỏ",
submitting: "Đang khởi tạo...",
advancedTitle: "Tùy chọn nâng cao",
priorityLabel: "Mức độ ưu tiên",
priorityLow: "Thấp",
priorityNormal: "Tiêu chuẩn",
priorityHigh: "Cao",
priorityUrgent: "Khẩn cấp",
customHeadersLabel: "Headers tùy chỉnh",
userAgentLabel: "User-Agent tùy chỉnh",
userAgentPlaceholder: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
cookieLabel: "Cookie tùy chỉnh",
cookiePlaceholder: "session_id=...; auth=...",
templateLabel: "Mẫu trích xuất có sẵn",
templateNone: "-- Không sử dụng mẫu --",
respectRobotsTxt: "Tuân thủ quy tắc robots.txt của website",
optional: "Tùy chọn",
presetLabel: "Mẫu:",
},
// Stats & KPI Cards
stats: {
......@@ -58,7 +87,7 @@ export const translations = {
failedTasksDesc: "Cần kiểm tra lại kết nối",
crawledPages: "Trang Đã Bóc Tách",
crawledPagesDesc: "Tổng số trang web đã tải về",
ecoScore: "Chỉ Số Sinh Thái (Eco)",
ecoScore: "Chỉ Số Sinh Thái",
totalJobs: "Tổng Số Job",
totalJobsDesc: "Tổng tác vụ cào trong hệ thống",
runningJobs: "Job Đang Chạy",
......@@ -107,7 +136,7 @@ export const translations = {
redisDegraded: "Redis Bị Chậm / Degraded",
redisDown: "Redis Mất Kết Nối",
latency: "Độ trễ",
uptime: "Thời gian chạy (Uptime)",
uptime: "Thời gian chạy",
memory: "Bộ nhớ RAM",
queueWaiting: "Hàng đợi",
queueActive: "Đang chạy",
......@@ -121,17 +150,45 @@ export const translations = {
table: {
title: "Danh Sách Tác Vụ Đang Quản Lý",
subtitle: "Theo dõi trạng thái và tiến độ xử lý dữ liệu cào theo thời gian thực",
id: "Mã Tác Vụ",
id: "Mã Job",
name: "Tên Tác Vụ & Nguồn",
status: "Trạng Thái",
progress: "Tiến Độ",
extractedCount: "Bản Ghi",
createdAt: "Thời Gian Tạo",
actions: "Thao Tác",
empty: "Chưa có tác vụ cào dữ liệu nào. Hãy tạo tác vụ mới!",
actions: "Hành Động",
empty: "Chưa có tác vụ crawl nào. Hãy bấm 'Tạo Tác Vụ Mới' để bắt đầu!",
retry: "Thử lại",
viewDetails: "Chi tiết",
pause: "Tạm dừng",
resume: "Tiếp tục",
rerun: "Chạy lại",
cancel: "Hủy bỏ",
delete: "Xóa tác vụ",
confirmDelete: "Bạn có chắc chắn muốn xóa tác vụ này? Hành động không thể hoàn tác.",
confirmCancel: "Xác nhận hủy tác vụ đang chạy?",
searchPlaceholder: "Tìm kiếm URL, tên miền hoặc ID tác vụ...",
filterAll: "Tất cả trạng thái",
sortBy: "Sắp xếp theo",
sortDate: "Ngày tạo",
sortPages: "Số trang",
sortStatus: "Trạng thái",
sortUrl: "Đường dẫn URL",
orderAsc: "Tăng dần",
orderDesc: "Giảm dần",
page: "Trang",
of: "trên",
perPage: "bản ghi / trang",
totalJobs: "Tổng số",
copiedId: "Đã sao chép Job ID",
recentTitle: "Tác Vụ Gần Đây",
recentSubtitle: "5 tác vụ cào dữ liệu được khởi tạo mới nhất trên hệ thống",
viewAll: "Xem tất cả tác vụ",
pagesUnit: "trang",
itemsUnit: "mục",
showing: "Hiển thị",
ofTotal: "trên tổng số",
tasksUnit: "tác vụ",
},
// Status Badges
status: {
......@@ -140,6 +197,123 @@ export const translations = {
completed: "Hoàn tất",
failed: "Thất bại",
paused: "Tạm dừng",
canceled: "Đã hủy",
},
// Job Detail Page & Tabs
jobDetail: {
backBtn: "Quay lại danh sách tác vụ",
liveSse: "Trực tiếp",
overviewTab: "Tổng quan & Tiến độ",
logsTab: "Nhật ký Crawler",
pagesTab: "Trang đã cào & Xem trước",
diffTab: "So sánh Biến động",
loading: "Đang tải chi tiết tác vụ cào...",
notFound: "Không tìm thấy tác vụ cào",
notFoundDesc: "Tác vụ có thể đã bị xóa hoặc bạn không có quyền truy cập.",
refreshAll: "Làm mới tất cả",
liveProgress: "Tiến độ cào thời gian thực",
processedPages: "trang đã xử lý",
startedAt: "Bắt đầu",
maxDepthLabel: "Độ sâu tối đa",
levelsUnit: "tầng",
wordsUnit: "từ",
linesUnit: "dòng",
justNow: "Vừa xong",
successCount: "Thành công",
failedCount: "Thất bại",
limitLabel: "Giới hạn",
completeData: "Dữ liệu hoàn chỉnh",
copiedNotice: "Đã sao chép vào bộ nhớ tạm!",
metrics: {
totalPages: "Tổng số trang",
successPages: "Trang thành công",
failedPages: "Trang thất bại",
itemsExtracted: "Bản ghi đã bóc tách",
crawlSpeed: "Tốc độ xử lý",
timeElapsed: "Thời gian thực thi",
http200Desc: "Mã HTTP 200 OK",
failedDesc: "Timeout / 404 / 403",
},
config: {
title: "Cấu Hình & Tham Số Thực Thi",
startUrl: "URL Bắt đầu",
mode: "Chế độ cào",
maxDepth: "Độ sâu tối đa",
maxPages: "Giới hạn số trang",
delay: "Độ trễ request",
robotsTxt: "Tuân thủ Robots.txt",
userAgent: "User-Agent sử dụng",
priority: "Mức độ ưu tiên",
template: "Mẫu bóc tách",
robotsYes: "Có",
robotsNo: "Không",
defaultUserAgent: "Mặc định",
priorityNormal: "Tiêu chuẩn",
},
logs: {
title: "Bảng Điều Khiển Nhật Ký",
subtitle: "Xem log thời gian thực được xuất ra từ Crawler Worker và Queue Processors",
filterLevel: "Lọc cấp độ log",
allLevels: "Tất cả cấp độ",
searchLogs: "Tìm kiếm trong log...",
autoScroll: "Tự động cuộn",
copyLogs: "Sao chép toàn bộ",
emptyLogs: "Chưa có bản ghi nhật ký nào cho tác vụ này.",
syncing: "Đang đồng bộ nhật ký...",
allLogsLabel: "toàn bộ nhật ký",
levelInfo: "INFO",
levelWarn: "CẢNH BÁO",
levelError: "LỖI",
},
pages: {
title: "Danh Sách Trang Đã Cào",
subtitle: "Các trang web được tải về và bóc tách thành công kèm mã phản hồi HTTP",
urlCol: "Đường dẫn URL",
statusCol: "Mã HTTP",
wordsCol: "Số từ",
hashCol: "Mã băm nội dung",
timeCol: "Thời gian cào",
actionCol: "Thao tác",
previewBtn: "Xem trước",
emptyPages: "Chưa có trang nào được cào thành công.",
loading: "Đang nạp danh sách trang...",
pagesExtracted: "trang đã bóc tách",
},
previewModal: {
title: "Xem Trước Dữ Liệu Trang",
tabMarkdown: "Định dạng Markdown",
tabCleanText: "Văn bản sạch",
tabHtml: "Mã HTML gốc",
tabJson: "Dữ liệu JSON",
copyBtn: "Sao chép nội dung",
closeBtn: "Đóng cửa sổ",
noMarkdown: "Không có nội dung Markdown.",
noCleanText: "Không có văn bản sạch.",
htmlPathLabel: "Đường dẫn tệp lưu trữ HTML:",
storedInLocal: "Được lưu tại bộ nhớ cục bộ.",
previewContentLabel: "nội dung xem trước",
extractedSnippet: "đã trích xuất",
},
diff: {
title: "So Sánh Biến Động Dữ Liệu",
subtitle: "Phát hiện nội dung mới được thêm, bài viết sửa đổi hoặc trang bị gỡ bỏ",
newPages: "Trang mới thêm",
modifiedPages: "Trang đã sửa đổi",
deletedPages: "Trang bị xóa",
unchangedPages: "Trang không đổi",
changeRate: "Tỷ lệ biến động",
downloadBtn: "Tải báo cáo biến động",
compareWith: "So sánh với Job ID",
wordDiff: "Chênh lệch từ vựng",
noChanges: "Không phát hiện thay đổi nào so với phiên cào trước.",
changesListTitle: "Chi tiết các trang phát hiện biến động",
comparing: "Đang đối chiếu dữ liệu giữa các phiên cào...",
badgeModified: "Đã sửa đổi",
badgeNew: "Thêm mới",
badgeDeleted: "Đã gỡ bỏ",
oldTitle: "Tiêu đề cũ",
previousCrawl: "Lần cào trước",
},
},
// Authentication & RBAC
auth: {
......@@ -283,16 +457,45 @@ export const translations = {
dialog: {
title: "Create Crawler Task",
description:
"Enter target URL and extraction rules. Fully validated with React Hook Form + Zod.",
"Enter target URL and extraction rules.",
urlLabel: "Target Source URL",
urlPlaceholder: "https://example.com/news/...",
nameLabel: "Task Name",
namePlaceholder: "e.g., Tech News Crawler",
namePlaceholder: "e.g., Tech & AI News Crawler",
depthLabel: "Crawl Depth",
rateLabel: "Request Rate (req/s)",
submitBtn: "Launch Task",
pagesLabel: "Max Pages Limit",
modeLabel: "Crawl Mode",
modeScrape: "Single Page Scrape",
modeCrawl: "Multi-depth Crawl",
modeSitemap: "Sitemap Discovery",
modeUrlList: "Custom URL List",
modeScrapeDesc: "Single page",
modeCrawlDesc: "Multi-depth",
modeSitemapDesc: "Sitemap",
modeUrlListDesc: "URL List",
urlsListLabel: "URL List",
urlsPlaceholder: "https://example.com/item-1\nhttps://example.com/item-2",
urlsPerLineHint: "One URL per line",
rateLabel: "Request Delay (ms)",
submitBtn: "Launch Crawler Task",
cancelBtn: "Cancel",
submitting: "Creating...",
submitting: "Launching...",
advancedTitle: "Advanced Configuration",
priorityLabel: "Task Priority",
priorityLow: "Low",
priorityNormal: "Normal",
priorityHigh: "High",
priorityUrgent: "Urgent",
customHeadersLabel: "Custom Headers",
userAgentLabel: "Custom User-Agent",
userAgentPlaceholder: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
cookieLabel: "Custom Cookie",
cookiePlaceholder: "session_id=...; auth=...",
templateLabel: "Available Extraction Template",
templateNone: "-- No template --",
respectRobotsTxt: "Respect website robots.txt rules",
optional: "Optional",
presetLabel: "Preset:",
},
// Stats & KPI Cards
stats: {
......@@ -369,17 +572,45 @@ export const translations = {
table: {
title: "Managed Scraper Tasks",
subtitle: "Monitor scraping status and extraction progress in real-time",
id: "Task ID",
id: "Job ID",
name: "Name & Source URL",
status: "Status",
progress: "Progress",
extractedCount: "Records",
createdAt: "Created At",
actions: "Actions",
empty: "No crawler tasks yet. Create your first task to get started!",
empty: "No crawler tasks yet. Click 'Create New Task' to begin!",
retry: "Retry",
viewDetails: "Details",
pause: "Pause",
resume: "Resume",
rerun: "Rerun",
cancel: "Cancel",
delete: "Delete task",
confirmDelete: "Are you sure you want to delete this job? This action cannot be undone.",
confirmCancel: "Are you sure you want to cancel the running job?",
searchPlaceholder: "Search by URL, domain or Job ID...",
filterAll: "All Statuses",
sortBy: "Sort By",
sortDate: "Creation Date",
sortPages: "Pages",
sortStatus: "Status",
sortUrl: "URL",
orderAsc: "Ascending",
orderDesc: "Descending",
page: "Page",
of: "of",
perPage: "records / page",
totalJobs: "Total",
copiedId: "Copied Job ID",
recentTitle: "Recent Crawl Tasks",
recentSubtitle: "Latest 5 scraper operations initialized on the system",
viewAll: "View All Tasks",
pagesUnit: "pages",
itemsUnit: "items",
showing: "Showing",
ofTotal: "of",
tasksUnit: "tasks",
},
// Status Badges
status: {
......@@ -388,6 +619,123 @@ export const translations = {
completed: "Completed",
failed: "Failed",
paused: "Paused",
canceled: "Cancelled",
},
// Job Detail Page & Tabs
jobDetail: {
backBtn: "Back to tasks list",
liveSse: "Live",
overviewTab: "Overview & Progress",
logsTab: "Crawler Logs",
pagesTab: "Crawled Pages & Preview",
diffTab: "Data Diff",
loading: "Loading crawler job details...",
notFound: "Crawler job not found",
notFoundDesc: "The task may have been deleted or you do not have permission to access it.",
refreshAll: "Refresh all",
liveProgress: "Real-time scraping progress",
processedPages: "processed pages",
startedAt: "Started",
maxDepthLabel: "Max depth",
levelsUnit: "levels",
wordsUnit: "words",
linesUnit: "lines",
justNow: "Just now",
successCount: "Success",
failedCount: "Failed",
limitLabel: "Limit",
completeData: "Complete records",
copiedNotice: "Copied to clipboard!",
metrics: {
totalPages: "Total Pages",
successPages: "Successful Pages",
failedPages: "Failed Pages",
itemsExtracted: "Extracted Records",
crawlSpeed: "Processing Speed",
timeElapsed: "Run Duration",
http200Desc: "HTTP 200 OK Status",
failedDesc: "Timeout / 404 / 403",
},
config: {
title: "Execution Parameters & Configuration",
startUrl: "Start URL",
mode: "Crawl Mode",
maxDepth: "Max Depth",
maxPages: "Max Pages Limit",
delay: "Request Delay",
robotsTxt: "Respect Robots.txt",
userAgent: "Applied User-Agent",
priority: "Priority Flag",
template: "Extraction Template",
robotsYes: "Yes",
robotsNo: "No",
defaultUserAgent: "Default",
priorityNormal: "Standard",
},
logs: {
title: "Live Logs Console",
subtitle: "View real-time crawler logs emitted by workers and queue processors",
filterLevel: "Filter log level",
allLevels: "All Levels",
searchLogs: "Search logs...",
autoScroll: "Auto Scroll",
copyLogs: "Copy All",
emptyLogs: "No log records found for this crawl job.",
syncing: "Syncing logs...",
allLogsLabel: "all logs",
levelInfo: "INFO",
levelWarn: "WARN",
levelError: "ERROR",
},
pages: {
title: "Crawled Pages List",
subtitle: "Web pages successfully retrieved and extracted with HTTP status codes",
urlCol: "Page URL",
statusCol: "HTTP Code",
wordsCol: "Word Count",
hashCol: "Content Hash",
timeCol: "Crawled At",
actionCol: "Actions",
previewBtn: "Preview",
emptyPages: "No pages have been crawled successfully yet.",
loading: "Loading crawled pages...",
pagesExtracted: "pages extracted",
},
previewModal: {
title: "Page Content Preview",
tabMarkdown: "Rendered Markdown",
tabCleanText: "Clean Text",
tabHtml: "Raw HTML",
tabJson: "Structured JSON",
copyBtn: "Copy Content",
closeBtn: "Close Window",
noMarkdown: "No Markdown content available.",
noCleanText: "No clean text available.",
htmlPathLabel: "HTML storage file path:",
storedInLocal: "Stored in local storage.",
previewContentLabel: "preview content",
extractedSnippet: "extracted",
},
diff: {
title: "Data Diff Comparison Report",
subtitle: "Detect newly added pages, modified content, or removed articles between crawl runs",
newPages: "New Pages",
modifiedPages: "Modified Pages",
deletedPages: "Deleted Pages",
unchangedPages: "Unchanged Pages",
changeRate: "Change Rate",
downloadBtn: "Download Diff Report",
compareWith: "Compare with Job ID",
wordDiff: "Word count diff",
noChanges: "No content changes detected compared to previous crawl run.",
changesListTitle: "Details of pages with detected changes",
comparing: "Comparing data between crawl runs...",
badgeModified: "Modified",
badgeNew: "New",
badgeDeleted: "Deleted",
oldTitle: "Previous title",
previousCrawl: "Previous crawl",
},
},
// Authentication & RBAC
auth: {
......
......@@ -5,8 +5,8 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date | number): string {
return new Intl.DateTimeFormat("vi-VN", {
export function formatDate(date: string | Date | number, locale: string = "vi"): string {
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", {
year: "numeric",
month: "short",
day: "numeric",
......
......@@ -18,6 +18,8 @@ export const crawlJobStatusEnum = z.enum([
"EXPIRED",
]);
export const jobPriorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
export const createCrawlJobSchema = z
.object({
startUrl: z
......@@ -26,19 +28,17 @@ export const createCrawlJobSchema = z
.url("Vui lòng nhập định dạng URL hợp lệ (vd: https://example.com)")
.optional()
.or(z.literal("")),
mode: crawlModeEnum.default("SCRAPE"),
mode: crawlModeEnum,
maxPages: z
.number()
.int("Số trang phải là số nguyên")
.min(1, "Tối thiểu phải cào 1 trang")
.max(1000, "Tối đa cào 1000 trang trong một phiên")
.default(20),
.max(1000, "Tối đa cào 1000 trang trong một phiên"),
maxDepth: z
.number()
.int("Độ sâu phải là số nguyên")
.min(1, "Độ sâu tối thiểu là 1")
.max(10, "Độ sâu tối đa là 10")
.default(1),
.max(10, "Độ sâu tối đa là 10"),
urls: z
.array(
z
......@@ -49,14 +49,16 @@ export const createCrawlJobSchema = z
.max(1000, "Danh sách tối đa 1000 URLs")
.optional()
.default([]),
priority: jobPriorityEnum,
userAgent: z.string().trim().max(500, "User-Agent tối đa 500 ký tự").optional(),
cookie: z.string().trim().max(2000, "Cookie tối đa 2000 ký tự").optional(),
templateId: z.string().optional().or(z.literal("")),
delayMs: z
.number()
.int()
.min(100, "Độ trễ tối thiểu 100ms")
.max(30000, "Độ trễ tối đa 30000ms")
.optional()
.default(1000),
respectRobotsTxt: z.boolean().default(true),
.max(30000, "Độ trễ tối đa 30000ms"),
respectRobotsTxt: z.boolean(),
scheduleId: z.string().uuid().optional(),
})
.superRefine((data, ctx) => {
......
import apiClient from "@/lib/api-client";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import {
CrawlJob,
CrawlJobLog,
CrawlJobQueryDto,
CrawlPagePreview,
CreateCrawlJobDto,
DiffReportEnvelope,
} from "@/types/crawl-job";
import { ExtractionTemplate } from "@/types/extraction-template";
// Initial realistic mock data for local fallback resilience
const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
{
id: "tpl-news-01",
userId: "usr-01",
name: "Trích xuất Tin tức Chuẩn (Article / News)",
domain: "vnexpress.net",
fields: [
{ name: "title", selector: "h1.title-detail", attr: "text", required: true },
{ name: "description", selector: "p.description", attr: "text", required: false },
{ name: "content", selector: "article.fck_detail", attr: "text", required: true },
{ name: "author", selector: ".author-name", attr: "text", required: false },
{ name: "publishedAt", selector: "span.date", attr: "text", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 7).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
},
{
id: "tpl-ecommerce-02",
userId: "usr-01",
name: "Trích xuất Sản phẩm E-Commerce (Tiki/Shopee)",
domain: "tiki.vn",
fields: [
{ name: "productName", selector: "h1.title", attr: "text", required: true },
{ name: "price", selector: ".product-price__current-price", attr: "text", required: true },
{ name: "originalPrice", selector: ".product-price__original-price", attr: "text", required: false },
{ name: "rating", selector: ".rating-stars", attr: "text", required: false },
{ name: "thumbnail", selector: ".thumbnail img", attr: "src", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 14).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 5).toISOString(),
},
{
id: "tpl-yellowpages-03",
userId: "usr-01",
name: "Trích xuất Danh bạ Doanh nghiệp",
domain: "yellowpages.vn",
fields: [
{ name: "companyName", selector: ".company-name", attr: "text", required: true },
{ name: "phone", selector: ".phone-number", attr: "text", required: true },
{ name: "address", selector: ".company-address", attr: "text", required: true },
{ name: "taxCode", selector: ".tax-code", attr: "text", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 10).toISOString(),
},
];
let localMockJobs: CrawlJob[] = [
{
id: "088f635c-9c3a-4467-93bb-e58f001bf001",
userId: "usr-01",
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
mode: "CRAWL",
status: "RUNNING",
maxPages: 100,
maxDepth: 3,
urls: [],
totalPages: 52,
successPages: 49,
failedPages: 3,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 12,
modifiedPagesCount: 7,
deletedPagesCount: 2,
unchangedPagesCount: 33,
changeRate: 0.4,
},
startedAt: new Date(Date.now() - 3600000 * 1.5).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "199a746d-ad4b-5578-84cc-f69a112cf002",
userId: "usr-01",
startUrl: "https://tiki.vn/laptop/c8095",
domain: "tiki.vn",
mode: "SCRAPE",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 50,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0 (+https://datacrawler.internal)",
delayMs: 1500,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: "/storage/diffs/diff_report_199a746d.json",
diffSummary: {
totalCurrentPages: 50,
totalPreviousPages: 48,
newPagesCount: 5,
modifiedPagesCount: 18,
deletedPagesCount: 3,
unchangedPagesCount: 27,
changeRate: 0.54,
},
startedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 13).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
},
{
id: "2aab857e-be5c-6689-95dd-07ab223df003",
userId: "usr-01",
startUrl: "https://yellowpages.vn/danh-ba-doanh-nghiep",
domain: "yellowpages.vn",
mode: "CRAWL",
status: "CANCELED",
maxPages: 200,
maxDepth: 2,
urls: [],
totalPages: 84,
successPages: 82,
failedPages: 2,
timeoutMs: 30000,
retryCount: 2,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1200,
errorMessage: "Tác vụ bị hủy bỏ theo yêu cầu của người dùng",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 24).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 25).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
},
{
id: "3bbc968f-cf6d-7790-06ee-18bc334ef004",
userId: "usr-01",
startUrl: "https://cafef.vn/tai-chinh-quoc-te",
domain: "cafef.vn",
mode: "SCRAPE",
status: "FAILED",
maxPages: 40,
maxDepth: 1,
urls: [],
totalPages: 12,
successPages: 6,
failedPages: 6,
timeoutMs: 15000,
retryCount: 3,
respectRobotsTxt: false,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
delayMs: 500,
errorMessage: "Mục tiêu phản hồi HTTP 403 Forbidden (Cloudflare bot protection triggered)",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 48).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 49).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
},
{
id: "4ccd0790-d07e-8801-17ff-29cd445ff005",
userId: "usr-01",
startUrl: "https://genk.vn/tin-ict.chn",
domain: "genk.vn",
mode: "CRAWL",
status: "PENDING",
maxPages: 80,
maxDepth: 2,
urls: [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: null,
finishedAt: null,
createdAt: new Date(Date.now() - 600000).toISOString(),
updatedAt: new Date(Date.now() - 600000).toISOString(),
},
];
export const crawlJobService = {
/**
* Danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Multi-criteria Filtering
*/
async getJobs(query?: CrawlJobQueryDto): Promise<PaginatedResponse<CrawlJob>> {
const page = Number(query?.page) || 1;
const limit = Number(query?.limit) || 20;
try {
const params = new URLSearchParams();
if (query?.status) params.append("status", query.status);
if (query?.mode) params.append("mode", query.mode);
if (query?.search) params.append("search", query.search);
if (query?.sortBy) params.append("sortBy", query.sortBy);
if (query?.order) params.append("order", query.order);
params.append("page", String(page));
params.append("limit", String(limit));
const response = await apiClient.get<
ApiResponse<{
items: CrawlJob[];
total?: number;
page?: number;
pageSize?: number;
totalPages?: number;
meta?: {
total?: number;
page?: number;
limit?: number;
totalPages?: number;
};
}>
>(`/crawl-jobs?${params.toString()}`);
const raw = response.data?.data;
if (raw && Array.isArray(raw.items)) {
const total = raw.total ?? raw.meta?.total ?? raw.items.length;
const totalPages =
raw.totalPages ??
raw.meta?.totalPages ??
Math.max(1, Math.ceil(total / limit));
const pageNum = raw.page ?? raw.meta?.page ?? page;
const pageSize = raw.pageSize ?? raw.meta?.limit ?? limit;
return {
items: raw.items,
total,
totalPages,
page: pageNum,
pageSize,
};
}
throw new Error("Invalid response format");
} catch {
// Fallback local memory filtering and pagination
let filtered = [...localMockJobs];
if (query?.status) {
filtered = filtered.filter((j) => j.status === query.status);
}
if (query?.mode) {
filtered = filtered.filter((j) => j.mode === query.mode);
}
if (query?.search) {
const term = query.search.toLowerCase();
filtered = filtered.filter(
(j) =>
j.startUrl.toLowerCase().includes(term) ||
(j.domain && j.domain.toLowerCase().includes(term)) ||
j.id.toLowerCase().includes(term)
);
}
if (query?.sortBy) {
filtered.sort((a, b) => {
const valA = a[query.sortBy as keyof CrawlJob];
const valB = b[query.sortBy as keyof CrawlJob];
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
const result = valA > valB ? 1 : -1;
return query.order === "asc" ? result : -result;
});
} else {
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / limit));
const startIndex = (page - 1) * limit;
const items = filtered.slice(startIndex, startIndex + limit);
return {
items,
total,
page,
pageSize: limit,
totalPages,
};
}
},
/**
* Lấy chi tiết một Crawl Job theo ID
*/
async getJobById(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Job not found");
} catch {
const found = localMockJobs.find((j) => j.id === id);
if (found) return found;
// Tạo mock job nếu không tìm thấy
const fallbackJob: CrawlJob = {
id,
userId: "usr-current",
startUrl: "https://example.com/data",
domain: "example.com",
mode: "CRAWL",
status: "RUNNING",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 18,
successPages: 17,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 300000).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 300000).toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [fallbackJob, ...localMockJobs];
return fallbackJob;
}
},
/**
* Tạo Crawl Job mới toàn diện
*/
async createJob(dto: CreateCrawlJobDto): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to create crawl job");
} catch {
let domain: string | null = null;
try {
if (dto.startUrl) {
domain = new URL(dto.startUrl).hostname;
}
} catch {
domain = "custom-target.com";
}
const newJob: CrawlJob = {
id: `job-${Date.now()}`,
userId: "usr-current",
startUrl: dto.startUrl || "https://example.com",
domain,
mode: dto.mode || "SCRAPE",
status: "RUNNING",
maxPages: dto.maxPages || 20,
maxDepth: dto.maxDepth || 1,
urls: dto.urls || [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: dto.respectRobotsTxt ?? true,
userAgent: dto.userAgent || null,
delayMs: dto.delayMs || 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: dto.scheduleId || null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date().toISOString(),
finishedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [newJob, ...localMockJobs];
return newJob;
}
},
/**
* Chạy lại Job (POST /crawl-jobs/:id/rerun)
*/
async rerunJob(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to rerun job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "RUNNING";
existing.startedAt = new Date().toISOString();
existing.finishedAt = null;
existing.totalPages = 0;
existing.successPages = 0;
existing.failedPages = 0;
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
}
},
/**
* Hủy bỏ Job (POST /crawl-jobs/:id/cancel)
*/
async cancelJob(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to cancel job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "CANCELED";
existing.finishedAt = new Date().toISOString();
existing.errorMessage = "Tác vụ đã được hủy bởi người dùng.";
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
}
},
/**
* Xóa Job (DELETE /crawl-jobs/:id)
*/
async deleteJob(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawl-jobs/${id}`);
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
} catch {
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
}
},
/**
* Lấy Nhật ký Crawler & Worker (GET /crawl-jobs/:id/logs)
*/
async getJobLogs(
id: string,
query?: { page?: number; limit?: number }
): Promise<{ items: CrawlJobLog[]; meta: { total: number; page: number; limit: number; totalPages: number } }> {
const page = query?.page || 1;
const limit = query?.limit || 50;
try {
const response = await apiClient.get<
ApiResponse<{
items: CrawlJobLog[];
meta: { total: number; page: number; limit: number; totalPages: number };
}>
>(`/crawl-jobs/${id}/logs?page=${page}&limit=${limit}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid logs response");
} catch {
// Fallback realistic crawler logs
const mockLogs: CrawlJobLog[] = [
{
id: `log-${id}-01`,
jobId: id,
level: "INFO",
step: "INIT",
message: `Khởi tạo tiến trình cào dữ liệu cho tác vụ [${id}]. Đã nạp cấu hình bộ thu thập.`,
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `log-${id}-02`,
jobId: id,
level: "INFO",
step: "ROBOTS_TXT",
message: "Tải và phân tích robots.txt: Tuân thủ quy tắc Crawl-Delay 1.0s, Allow: /*.",
createdAt: new Date(Date.now() - 3550000).toISOString(),
},
{
id: `log-${id}-03`,
jobId: id,
level: "INFO",
step: "DISPATCH",
message: "Phân phối URL gốc vào hàng đợi Redis/BullMQ: https://vnexpress.net/so-hoa/cong-nghe",
createdAt: new Date(Date.now() - 3500000).toISOString(),
},
{
id: `log-${id}-04`,
jobId: id,
level: "INFO",
step: "SCRAPING",
message: "HTTP 200 OK: Đã tải về thành công DOM HTML (kích thước: 142.4 KB, thời gian: 238ms).",
createdAt: new Date(Date.now() - 3400000).toISOString(),
},
{
id: `log-${id}-05`,
jobId: id,
level: "INFO",
step: "EXTRACT",
message: "Trích xuất 14 liên kết con hợp lệ thuộc domain chính theo giới hạn depth = 2.",
createdAt: new Date(Date.now() - 3350000).toISOString(),
},
{
id: `log-${id}-06`,
jobId: id,
level: "WARNING",
step: "CONTENT_CLEAN",
message: "Phát hiện mã theo dõi quảng cáo bên thứ ba (Google Ads/Taboola). Đã loại bỏ thành công.",
createdAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `log-${id}-07`,
jobId: id,
level: "INFO",
step: "DATABASE",
message: "Lưu bản ghi trang #42 vào PostgreSQL: 1,842 từ, DataQualityScore = 96/100.",
createdAt: new Date(Date.now() - 3100000).toISOString(),
},
{
id: `log-${id}-08`,
jobId: id,
level: "ERROR",
step: "NETWORK",
message: "HTTP 404 Not Found: Bỏ qua đường dẫn /tin-cu/bai-viet-da-xoa.html sau 3 lần thử.",
createdAt: new Date(Date.now() - 2900000).toISOString(),
},
{
id: `log-${id}-09`,
jobId: id,
level: "INFO",
step: "STREAM",
message: "Đồng bộ tiến độ qua SSE Event: 52/100 trang đã xử lý (52% hoàn tất).",
createdAt: new Date(Date.now() - 2500000).toISOString(),
},
];
return {
items: mockLogs,
meta: {
total: mockLogs.length,
page: 1,
limit: 50,
totalPages: 1,
},
};
}
},
/**
* Lấy danh sách trang đã cào kèm xem trước (GET /crawl-jobs/:id/pages/preview)
*/
async getPagesPreview(
id: string,
query?: { page?: number; limit?: number; search?: string }
): Promise<{ items: CrawlPagePreview[]; total: number; page: number; limit: number; totalPages: number }> {
const page = query?.page || 1;
const limit = query?.limit || 20;
try {
const params = new URLSearchParams();
params.append("page", String(page));
params.append("limit", String(limit));
if (query?.search) params.append("search", query.search);
const response = await apiClient.get<
ApiResponse<{
items: CrawlPagePreview[];
total: number;
page: number;
limit: number;
totalPages: number;
}>
>(`/crawl-jobs/${id}/pages/preview?${params.toString()}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid pages preview response");
} catch {
// Fallback realistic crawled pages
const mockPages: CrawlPagePreview[] = [
{
id: `page-${id}-01`,
jobId: id,
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Tin tức công nghệ mới nhất hôm nay",
description: "Cập nhật nhanh tin tức công nghệ, thiết bị mới, trí tuệ nhân tạo AI và viễn thông.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1420,
dataQualityScore: 98,
contentHash: "sha256-a1b2c3d4e5f67890",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "CollectionPage",
headline: "Tin tức Công nghệ",
},
markdownContent: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM) và giải pháp điện toán đám mây sinh thái...\n\n## 1. Trí tuệ nhân tạo thế hệ mới\nCác giải pháp AI đang được tối ưu hóa nhằm giảm thiểu lượng khí thải carbon và điện năng tiêu thụ tại các trung tâm dữ liệu.\n\n## 2. Thiết bị phần cứng tiết kiệm năng lượng\nChip xử lý tiến trình 3nm mang lại hiệu suất vượt trội mà vẫn giữ được nhiệt độ vận hành lý tưởng.",
rawMarkdown: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
mainContent: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
cleanText: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
htmlContentPath: `/storage/crawled/${id}/page_01.html`,
content: "Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về AI...",
createdAt: new Date(Date.now() - 3600000).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `page-${id}-02`,
jobId: id,
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
description: "Các kỹ sư phát triển thuật toán điều phối thông minh giúp trung tâm dữ liệu xanh hóa quy trình xử lý.",
status: "SUCCESS",
statusCode: 200,
wordCount: 2150,
dataQualityScore: 95,
contentHash: "sha256-b2c3d4e5f6a78901",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "NewsArticle",
headline: "Giải pháp AI xanh giúp giảm 40% điện năng",
datePublished: new Date(Date.now() - 3600000).toISOString(),
},
markdownContent: "# Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu\n\nNghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa và caching thông minh đã cắt giảm mạnh mức tiêu thụ điện của các cụm máy chủ GPU.\n\n> Đổi mới sáng tạo cần đi đôi với bảo vệ môi trường và phát triển bền vững.\n\n### Kết quả thử nghiệm thực tế\n- Mức tải tiêu thụ điện giảm từ 1.2MW xuống còn 720kW.\n- Tốc độ phản hồi (Latency) duy trì dưới 45ms.",
rawMarkdown: "# Giải pháp AI xanh...",
mainContent: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
cleanText: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
htmlContentPath: `/storage/crawled/${id}/page_02.html`,
content: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận...",
createdAt: new Date(Date.now() - 3200000).toISOString(),
updatedAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `page-${id}-03`,
jobId: id,
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
description: "Các nhà máy đúc chip hàng đầu thế giới công bố tiến độ thương mại hóa chip 2nm vào cuối năm.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1890,
dataQualityScore: 92,
contentHash: "sha256-c3d4e5f6a7b89012",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
warnings: [],
structuredData: null,
markdownContent: "# Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa\n\nTiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA (Gate-All-Around) hứa hẹn tăng 15% hiệu năng và tiết kiệm 30% năng lượng so với thế hệ trước.",
rawMarkdown: "# Thế hệ vi xử lý bán dẫn 2nm...",
mainContent: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
cleanText: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
htmlContentPath: `/storage/crawled/${id}/page_03.html`,
content: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
createdAt: new Date(Date.now() - 2700000).toISOString(),
updatedAt: new Date(Date.now() - 2700000).toISOString(),
},
{
id: `page-${id}-04`,
jobId: id,
url: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
normalizedUrl: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
title: "Trang không tồn tại (404 Not Found)",
description: null,
status: "FAILED",
statusCode: 404,
wordCount: 45,
dataQualityScore: 0,
contentHash: null,
errorMessage: "Mã trạng thái phản hồi HTTP 404 Not Found",
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2100000).toISOString(),
warnings: ["URL trả về lỗi 404"],
structuredData: null,
markdownContent: "# 404 Not Found\n\nTrang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
rawMarkdown: "# 404 Not Found",
mainContent: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
cleanText: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
htmlContentPath: null,
content: "404 Not Found",
createdAt: new Date(Date.now() - 2100000).toISOString(),
updatedAt: new Date(Date.now() - 2100000).toISOString(),
},
];
return {
items: mockPages,
total: mockPages.length,
page: 1,
limit: 20,
totalPages: 1,
};
}
},
/**
* Lấy báo cáo so sánh biến động dữ liệu Diff (GET /crawl-jobs/:id/diff)
*/
async getDiff(id: string, compareWithJobId?: string): Promise<DiffReportEnvelope> {
try {
const url = compareWithJobId
? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}`
: `/crawl-jobs/${id}/diff`;
const response = await apiClient.get<ApiResponse<DiffReportEnvelope>>(url);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid diff response");
} catch {
// Fallback realistic Diff Report
return {
schemaVersion: "1.0.0",
generatedAt: new Date().toISOString(),
jobId: id,
previousJobId: compareWithJobId || "199a746d-ad4b-5578-84cc-f69a112cf002",
scheduleId: null,
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
summary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 8,
modifiedPagesCount: 14,
deletedPagesCount: 3,
unchangedPagesCount: 30,
changeRate: 0.48,
},
newPages: [
{
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
contentHash: "sha256-b2c3d4e5f6a78901",
wordCount: 2150,
statusCode: 200,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
contentHash: "sha256-c3d4e5f6a7b89012",
wordCount: 1890,
statusCode: 200,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
title: "Mạng lưới vệ tinh quỹ đạo thấp phủ sóng internet vùng sâu vùng xa",
contentHash: "sha256-d4e5f6a7b8c90123",
wordCount: 1450,
statusCode: 200,
crawledAt: new Date(Date.now() - 2200000).toISOString(),
},
],
modifiedPages: [
{
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Cập nhật thông tin công nghệ nóng nhất",
oldTitle: "Công nghệ - Tin tức công nghệ trong ngày",
oldContentHash: "sha256-old-998877665544",
newContentHash: "sha256-a1b2c3d4e5f67890",
oldWordCount: 1200,
newWordCount: 1420,
wordCountDiff: 220,
statusCode: 200,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
normalizedUrl: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
title: "Giá smartphone cao cấp điều chỉnh giảm kích cầu quý 3",
oldTitle: "Giá smartphone cao cấp duy trì ổn định",
oldContentHash: "sha256-old-112233445566",
newContentHash: "sha256-mod-556677889900",
oldWordCount: 1650,
newWordCount: 1510,
wordCountDiff: -140,
statusCode: 200,
crawledAt: new Date(Date.now() - 3100000).toISOString(),
},
],
deletedPages: [
{
url: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
normalizedUrl: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
title: "Sự kiện tường thuật trực tiếp công nghệ mùa xuân (Đã đóng)",
previousContentHash: "sha256-del-443322110099",
previousWordCount: 3200,
lastCrawledAt: new Date(Date.now() - 86400000 * 3).toISOString(),
},
],
unchangedPages: [
{
url: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
normalizedUrl: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
title: "Điều khoản sử dụng và chính sách nội dung công nghệ",
contentHash: "sha256-static-unchanged-01",
wordCount: 850,
},
],
};
}
},
/**
* Tải tệp Diff Report JSON
*/
getDiffDownloadUrl(id: string, compareWithJobId?: string): string {
const query = compareWithJobId ? `?compareWithJobId=${compareWithJobId}` : "";
return `/api/proxy/crawl-jobs/${id}/diff/download${query}`;
},
/**
* Lấy danh sách Extraction Templates có sẵn
*/
async getExtractionTemplates(): Promise<ExtractionTemplate[]> {
try {
const response = await apiClient.get<ApiResponse<ExtractionTemplate[]>>("/extraction-templates");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return MOCK_EXTRACTION_TEMPLATES;
} catch {
return MOCK_EXTRACTION_TEMPLATES;
}
},
};
......@@ -108,6 +108,80 @@ export interface CrawlJobLog {
createdAt: string;
}
export type JobPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT";
export interface DiffSummary {
totalCurrentPages: number;
totalPreviousPages: number;
newPagesCount: number;
modifiedPagesCount: number;
deletedPagesCount: number;
unchangedPagesCount: number;
changeRate: number;
}
export interface NewPageItem {
url: string;
normalizedUrl: string;
title: string | null;
contentHash: string | null;
wordCount: number;
statusCode: number | null;
crawledAt: string | null;
}
export interface ModifiedPageItem {
url: string;
normalizedUrl: string;
title: string | null;
oldTitle: string | null;
oldContentHash: string | null;
newContentHash: string | null;
oldWordCount: number;
newWordCount: number;
wordCountDiff: number;
statusCode: number | null;
crawledAt: string | null;
}
export interface DeletedPageItem {
url: string;
normalizedUrl: string;
title: string | null;
previousContentHash: string | null;
previousWordCount: number;
lastCrawledAt: string | null;
}
export interface UnchangedPageItem {
url: string;
normalizedUrl: string;
title: string | null;
contentHash: string | null;
wordCount: number;
}
export interface DiffReportEnvelope {
schemaVersion: string;
generatedAt: string;
jobId: string;
previousJobId: string | null;
scheduleId: string | null;
startUrl: string;
domain: string | null;
summary: DiffSummary;
newPages: NewPageItem[];
modifiedPages: ModifiedPageItem[];
deletedPages: DeletedPageItem[];
unchangedPages: UnchangedPageItem[];
}
export interface CrawlPagePreview extends CrawlPage {
rawMarkdown?: string | null;
mainContent?: string | null;
cleanText?: string | null;
}
export interface CreateCrawlJobDto {
startUrl?: string;
mode?: CrawlMode;
......@@ -115,6 +189,12 @@ export interface CreateCrawlJobDto {
maxDepth?: number;
urls?: string[];
scheduleId?: string;
priority?: JobPriority;
userAgent?: string;
cookie?: string;
templateId?: string;
delayMs?: number;
respectRobotsTxt?: boolean;
}
export interface CrawlJobQueryDto {
......@@ -147,3 +227,4 @@ export interface DiffSummaryDto {
removedUrls?: string[];
modifiedUrls?: string[];
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment