Commit 54cfeda8 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/crawler-row-navigation-and-rerun-guard' into 'develop'

feat(crawler): add table row navigation, duplicate rerun debounce and export enhancements

See merge request !10
parents 580535d8 e60d8925
......@@ -23,6 +23,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { CreateExportModal } from "@/components/exports/create-export-modal";
import { formatDate, formatNumber } from "@/lib/utils";
import { useLanguage } from "@/providers/language-provider";
import {
......@@ -90,6 +91,9 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
// Diff comparison target state
const [compareJobId, setCompareJobId] = useState<string>("");
// Export modal state
const [isExportModalOpen, setIsExportModalOpen] = useState<boolean>(false);
// Base Query for Job Details
const {
data: initialJob,
......@@ -126,10 +130,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{ refetchInterval: activeTab === "logs" ? 4000 : false }
);
// Query Crawled Pages (active when on pages tab)
// Query Crawled Pages (active when on pages tab, auto-poll while running)
const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId,
{ limit: 50 }
{ limit: 50 },
{ refetchInterval: activeTab === "pages" && (liveProgressJob?.status === "RUNNING" || initialJob?.status === "RUNNING") ? 3000 : false }
);
// Query Diff Report (active when on diff tab)
......@@ -140,9 +145,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
// 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 targetPages = job?.totalPages && job.totalPages > 0 ? Math.min(maxPages, job.totalPages) : maxPages;
const processedPages = (job?.successPages || 0) + (job?.failedPages || 0);
const isJobRunning = job?.status === "RUNNING" || job?.status === "PROCESSING_EXPORT";
const progressPercent = job?.status === "COMPLETED"
? 100
: targetPages > 0
? Math.min(isJobRunning ? 99 : 100, Math.round((processedPages / targetPages) * 100))
: 0;
// Filtered Logs
const filteredLogs = useMemo(() => {
......@@ -250,12 +260,35 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{/* Action Group */}
<div className="flex flex-wrap items-center gap-2">
{/* Export Data */}
<Button
size="sm"
variant="outline"
disabled={isJobRunning}
onClick={() => setIsExportModalOpen(true)}
className="rounded-2xl border-border/80 text-xs font-semibold gap-1.5 hover:bg-muted/40 cursor-pointer"
title={t.jobDetail.exportData}
>
<Download className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.jobDetail.exportData}</span>
</Button>
{/* Rerun */}
<Button
size="sm"
variant="outline"
disabled={isRerunning || isJobRunning}
onClick={() => rerunJob(jobId)}
onClick={(e) => {
e.stopPropagation();
if (isRerunning || isJobRunning) return;
rerunJob(jobId, {
onSuccess: (newJob) => {
if (newJob?.id && newJob.id !== jobId) {
router.push(`/crawl-jobs/${newJob.id}`);
}
},
});
}}
className="rounded-2xl border-border/80 text-xs font-semibold gap-1.5 hover:bg-muted/40 cursor-pointer"
>
{isRerunning ? (
......@@ -342,7 +375,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
<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}
{processedPages} / {targetPages} {t.jobDetail.processedPages}
</span>
<span className="text-sm text-emerald-600 dark:text-emerald-400 font-extrabold">
({progressPercent}%)
......@@ -390,7 +423,16 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{[
{ 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: "pages",
label: t.jobDetail.pagesTab,
icon: FileText,
count: (pagesData?.total && pagesData.total > 0)
? pagesData.total
: isJobRunning && job?.successPages
? job.successPages
: 0,
},
{ 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;
......@@ -685,7 +727,21 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{!isPagesLoading && (!pagesData?.items || pagesData.items.length === 0) && (
<tr>
<td colSpan={5} className="py-12 text-center text-muted-foreground">
{t.jobDetail.pages.emptyPages}
{isJobRunning ? (
<div className="flex flex-col items-center justify-center gap-2.5 max-w-md mx-auto py-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-semibold text-sm">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
<span>
{t.jobDetail.pages.crawlingInProgress.replace("{count}", String(job?.successPages || 0))}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t.jobDetail.pages.crawlingInProgressDesc}
</p>
</div>
) : (
t.jobDetail.pages.emptyPages
)}
</td>
</tr>
)}
......@@ -1024,6 +1080,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
</DialogContent>
</Dialog>
)}
{/* Create Export Modal */}
<CreateExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
defaultJobId={jobId}
defaultJobUrl={job?.domain || job?.startUrl}
/>
</div>
);
}
......
......@@ -183,7 +183,10 @@ export default function DashboardPage() {
trend={
hasActivity
? {
value: Number(successRate) >= 80 ? "Eco" : `${successRate}%`,
value:
Number(successRate) >= 80
? t.stats.successRateGood
: t.stats.successRateLow,
isPositive: Number(successRate) >= 80,
}
: undefined
......
......@@ -391,12 +391,19 @@ export default function SchedulesPage() {
size="sm"
onClick={() => handleRunNow(schedule.id)}
disabled={triggerRunMutation.isPending}
className="rounded-xl h-8 px-2.5 text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
title={
triggerRunMutation.isPending
? t.schedules.card.runningNow
: t.schedules.card.runNow
}
className="rounded-xl h-8 w-8 md:w-auto p-0 md:px-2.5 text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Play className="h-3 w-3 mr-1" />
{triggerRunMutation.isPending
? t.schedules.card.runningNow
: t.schedules.card.runNow}
<Play className="h-3 w-3 md:mr-1 shrink-0" />
<span className="hidden md:inline">
{triggerRunMutation.isPending
? t.schedules.card.runningNow
: t.schedules.card.runNow}
</span>
</Button>
{/* View History Button */}
......@@ -407,10 +414,11 @@ export default function SchedulesPage() {
setHistorySchedule(schedule);
setIsHistoryModalOpen(true);
}}
className="rounded-xl h-8 px-2.5 text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
title={t.schedules.card.history}
className="rounded-xl h-8 w-8 md:w-auto p-0 md:px-2.5 text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
>
<History className="h-3 w-3 mr-1" />
{t.schedules.card.history}
<History className="h-3 w-3 md:mr-1 shrink-0" />
<span className="hidden md:inline">{t.schedules.card.history}</span>
</Button>
{/* Edit Button */}
......
......@@ -66,7 +66,7 @@ export function StatCard({
</div>
</div>
<div className="mt-2 sm:mt-3 flex items-baseline gap-1.5 sm:gap-2">
<div className="mt-2 sm:mt-3 flex items-center gap-1.5 sm:gap-2">
{isLoading ? (
<div className="h-7 sm:h-8 w-20 sm:w-24 rounded-lg bg-muted/60 animate-pulse" />
) : (
......
......@@ -2,13 +2,13 @@
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useCrawlJobs,
useRerunCrawlJob,
useCancelCrawlJob,
useDeleteCrawlJob,
} from "@/hooks/use-crawl-jobs";
import { useToggleCrawlerTask } from "@/hooks/use-crawler";
import {
Table,
TableBody,
......@@ -36,7 +36,6 @@ import {
Layers,
Loader2,
Pause,
Play,
RefreshCw,
RotateCcw,
Search,
......@@ -46,6 +45,7 @@ import {
import { toast } from "sonner";
export function CrawlerTaskTable() {
const router = useRouter();
const { t, locale } = useLanguage();
// State: Server-side pagination, sorting, filtering
......@@ -57,10 +57,6 @@ export function CrawlerTaskTable() {
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(() => {
......@@ -87,10 +83,9 @@ export function CrawlerTaskTable() {
});
// 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: rerunJob, isPending: isRerunning, variables: rerunningId } = useRerunCrawlJob();
const { mutate: cancelJob, isPending: isCanceling, variables: cancelingId } = useCancelCrawlJob();
const { mutate: deleteJob, isPending: isDeleting, variables: deletingId } = useDeleteCrawlJob();
const handleToggleSort = (column: typeof sortBy) => {
if (sortBy === column) {
......@@ -276,7 +271,7 @@ export function CrawlerTaskTable() {
<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]">
<TableHead className="font-semibold text-xs py-3.5 w-[130px] max-[767px]:hidden">
<span>{t.table.id}</span>
</TableHead>
......@@ -291,9 +286,9 @@ export function CrawlerTaskTable() {
</div>
</TableHead>
{/* Column 3: Status */}
{/* Column 3: Status & Created Date (Sorts by createdAt) */}
<TableHead
onClick={() => handleToggleSort("status")}
onClick={() => handleToggleSort("createdAt")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
......@@ -305,7 +300,7 @@ export function CrawlerTaskTable() {
{/* Column 4: Crawl Progress */}
<TableHead
onClick={() => handleToggleSort("totalPages")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5 min-w-[170px]"
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5 min-w-[170px] max-[767px]:hidden"
>
<div className="flex items-center gap-1.5">
<span>{t.table.progress}</span>
......@@ -314,23 +309,12 @@ export function CrawlerTaskTable() {
</TableHead>
{/* Column 5: Extracted Items */}
<TableHead className="font-semibold text-xs py-3.5">
<TableHead className="font-semibold text-xs py-3.5 max-[767px]:hidden">
{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>
</TableHead>
{/* Column 7: Action Buttons Group */}
<TableHead className="text-right font-semibold text-xs py-3.5">
<TableHead className="text-right font-semibold text-xs py-3.5 max-[767px]:hidden">
{t.table.actions}
</TableHead>
</TableRow>
......@@ -339,17 +323,28 @@ export function CrawlerTaskTable() {
<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 targetPages = job.totalPages && job.totalPages > 0 ? Math.min(maxPages, job.totalPages) : maxPages;
const processedPages = (job.successPages || 0) + (job.failedPages || 0);
const isRunning = job.status === "RUNNING" || job.status === "PROCESSING_EXPORT";
const progressPct = job.status === "COMPLETED"
? 100
: targetPages > 0
? Math.min(isRunning ? 99 : 100, Math.round((processedPages / targetPages) * 100))
: 0;
const isJobActive =
job.status === "RUNNING" ||
job.status === "PROCESSING_EXPORT" ||
job.status === "PENDING" ||
job.status === "QUEUED";
return (
<TableRow
key={job.id}
className="border-border/60 hover:bg-muted/20 transition-colors"
onClick={() => router.push(`/crawl-jobs/${job.id}`)}
className="border-border/60 hover:bg-muted/30 transition-colors cursor-pointer group"
>
{/* Column 1: Job ID Dedicated Column */}
<TableCell className="w-[130px]">
<TableCell className="w-[130px] max-[767px]:hidden">
<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}`}
......@@ -382,6 +377,7 @@ export function CrawlerTaskTable() {
<div className="flex items-center gap-2 min-w-0">
<Link
href={`/crawl-jobs/${job.id}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold text-sm text-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors truncate block"
title={job.startUrl}
>
......@@ -398,6 +394,7 @@ export function CrawlerTaskTable() {
href={job.startUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
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}
>
......@@ -407,15 +404,23 @@ export function CrawlerTaskTable() {
</div>
</TableCell>
{/* Status Badge */}
<TableCell>{getStatusBadge(job.status)}</TableCell>
{/* Status Badge & Created Time */}
<TableCell>
<div className="space-y-1">
<div>{getStatusBadge(job.status)}</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="h-3 w-3 text-muted-foreground/80 shrink-0" />
<span>{formatDate(job.createdAt, locale)}</span>
</div>
</div>
</TableCell>
{/* Progress Bar */}
<TableCell>
<TableCell className="max-[767px]:hidden">
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{currentPages} / {job.maxPages} {t.table.pagesUnit}
{processedPages} / {targetPages} {t.table.pagesUnit}
</span>
<span className="font-semibold text-foreground">{progressPct}%</span>
</div>
......@@ -429,106 +434,95 @@ export function CrawlerTaskTable() {
</TableCell>
{/* Extracted Records */}
<TableCell>
<TableCell className="max-[767px]:hidden">
<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">
<TableCell className="text-right max-[767px]:hidden">
<div className="flex items-center justify-end gap-1.5">
{/* 1. View Detail Link */}
<Link href={`/crawl-jobs/${job.id}`}>
<Link href={`/crawl-jobs/${job.id}`} onClick={(e) => e.stopPropagation()}>
<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"
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 cursor-pointer"
title={t.table.viewDetails}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
{/* 2. Pause / Resume Toggle */}
{isRunning ? (
{/* 2. Stop/Cancel Button (Only when job is active) */}
{isJobActive && (
<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"
title={t.table.cancel}
disabled={isCanceling && cancelingId === job.id}
onClick={(e) => {
e.stopPropagation();
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 cursor-pointer"
>
<Pause className="h-3.5 w-3.5" />
{isCanceling && cancelingId === job.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<XCircle className="h-3.5 w-3.5" />
)}
</Button>
) : (
)}
{/* 3. Rerun Button (Available when job is stopped/finished) */}
{!isJobActive && (
<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"
title={t.table.rerun}
disabled={isRerunning}
onClick={(e) => {
e.stopPropagation();
if (isRerunning) return;
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 cursor-pointer"
>
<Play className="h-3.5 w-3.5" />
{isRerunning && rerunningId === job.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw 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 && (
{/* 4. Delete Button (Available when job is stopped/finished) */}
{!isJobActive && (
<Button
size="sm"
variant="secondary"
title={t.table.cancel}
disabled={isCanceling}
onClick={() => {
if (window.confirm(t.table.confirmCancel)) {
cancelJob(job.id);
title={t.table.delete}
disabled={isDeleting && deletingId === job.id}
onClick={(e) => {
e.stopPropagation();
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-amber-500/10 hover:border-amber-500/30 text-amber-600 dark:text-amber-400 shadow-xs transition-colors"
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 cursor-pointer"
>
<XCircle className="h-3.5 w-3.5" />
{isDeleting && deletingId === job.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 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>
......
......@@ -121,7 +121,6 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
<span className="text-xs font-bold uppercase tracking-wider rounded-md bg-rose-500/20 px-2 py-0.5">
{t.quota.criticalBadge}
</span>
<span className="font-semibold text-sm">{t.quota.criticalTitle}</span>
</div>
<p className="text-xs opacity-90 leading-relaxed">{t.quota.criticalDesc}</p>
</div>
......
......@@ -2,6 +2,7 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import {
Table,
......@@ -23,7 +24,6 @@ import {
Clock,
Copy,
ExternalLink,
Eye,
Layers,
Loader2,
Pause,
......@@ -32,6 +32,7 @@ import {
export function RecentJobsWidget() {
const { t, locale } = useLanguage();
const router = useRouter();
const { data: jobsData, isLoading, isError } = useCrawlJobs({
page: 1,
......@@ -155,7 +156,7 @@ export function RecentJobsWidget() {
<Table>
<TableHeader>
<TableRow className="border-border/70 bg-muted/40 hover:bg-muted/40">
<TableHead className="font-semibold text-xs py-3 w-[125px]">
<TableHead className="font-semibold text-xs py-3 w-[125px] max-[767px]:hidden">
{t.table.id}
</TableHead>
<TableHead className="font-semibold text-xs py-3">
......@@ -164,15 +165,9 @@ export function RecentJobsWidget() {
<TableHead className="font-semibold text-xs py-3">
{t.table.status}
</TableHead>
<TableHead className="font-semibold text-xs py-3 min-w-[150px]">
<TableHead className="font-semibold text-xs py-3 min-w-[150px] max-[767px]:hidden">
{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>
......@@ -185,10 +180,11 @@ export function RecentJobsWidget() {
return (
<TableRow
key={job.id}
className="border-border/60 hover:bg-muted/20 transition-colors"
onClick={() => router.push(`/crawl-jobs/${job.id}`)}
className="group border-border/60 hover:bg-muted/30 transition-colors cursor-pointer"
>
{/* Job ID */}
<TableCell className="w-[125px]">
<TableCell className="w-[125px] max-[767px]:hidden">
<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}`}
......@@ -219,13 +215,12 @@ export function RecentJobsWidget() {
<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"
<span
className="font-semibold text-sm text-foreground group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors truncate block"
title={job.startUrl}
>
{job.domain || job.startUrl}
</Link>
</span>
<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"
......@@ -237,6 +232,7 @@ export function RecentJobsWidget() {
href={job.startUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
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}
>
......@@ -246,11 +242,19 @@ export function RecentJobsWidget() {
</div>
</TableCell>
{/* Status */}
<TableCell>{getStatusBadge(job.status)}</TableCell>
{/* Status & Created Date */}
<TableCell>
<div className="space-y-1">
<div>{getStatusBadge(job.status)}</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="h-3 w-3 text-muted-foreground/80 shrink-0" />
<span>{formatDate(job.createdAt, locale)}</span>
</div>
</div>
</TableCell>
{/* Progress */}
<TableCell>
<TableCell className="max-[767px]:hidden">
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
......@@ -266,28 +270,6 @@ export function RecentJobsWidget() {
</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>
);
})}
......
"use client";
import React, { useState } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
X,
Download,
......@@ -25,6 +25,7 @@ interface CreateExportModalProps {
isOpen: boolean;
onClose: () => void;
defaultJobId?: string;
defaultJobUrl?: string;
}
const EXPORT_FORMATS: {
......@@ -75,6 +76,7 @@ export function CreateExportModal({
isOpen,
onClose,
defaultJobId = "",
defaultJobUrl = "",
}: CreateExportModalProps) {
const { t } = useLanguage();
const { data: jobsData } = useCrawlJobs({ limit: 10 });
......@@ -84,8 +86,32 @@ export function CreateExportModal({
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState("");
useEffect(() => {
if (defaultJobId) {
setSelectedJobId(defaultJobId);
}
}, [defaultJobId, isOpen]);
const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId);
const displayJobs = useMemo<
Array<{ id: string; startUrl: string; successPages?: number; status?: string }>
>(() => {
if (defaultJobId && !isDefaultInCompleted) {
return [
{
id: defaultJobId,
startUrl: defaultJobUrl || `Job: ${defaultJobId.slice(0, 16)}...`,
successPages: 0,
status: "COMPLETED",
},
...completedJobs,
];
}
return completedJobs;
}, [completedJobs, defaultJobId, defaultJobUrl, isDefaultInCompleted]);
if (!isOpen) return null;
......@@ -134,9 +160,9 @@ export function CreateExportModal({
<Label className="text-xs font-semibold">
{t.exports.modal.selectJob}
</Label>
{completedJobs.length > 0 ? (
{displayJobs.length > 0 ? (
<div className="space-y-2 max-h-36 overflow-y-auto pr-1">
{completedJobs.map((job) => (
{displayJobs.map((job) => (
<button
key={job.id}
type="button"
......@@ -150,7 +176,7 @@ export function CreateExportModal({
<div className="truncate min-w-0 pr-2">
<p className="font-semibold text-foreground truncate">{job.startUrl}</p>
<p className="text-[10px] text-muted-foreground font-mono truncate">
ID: {job.id.slice(0, 16)}...{job.successPages} trang
ID: {job.id.slice(0, 16)}...{job.successPages ? ` • ${job.successPages} trang` : ""}
</p>
</div>
{selectedJobId === job.id && (
......
......@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlJobService } from "@/services/crawl-job.service";
import { CrawlJobQueryDto, CreateCrawlJobDto } from "@/types/crawl-job";
import { CrawlJob, CrawlJobQueryDto, CreateCrawlJobDto } from "@/types/crawl-job";
import { toast } from "sonner";
export const CRAWL_JOBS_QUERY_KEYS = {
......@@ -68,6 +68,8 @@ export function useCreateCrawlJob() {
});
}
const rerunInFlight = new Set<string>();
/**
* Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/
......@@ -75,8 +77,19 @@ export function useRerunCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.rerunJob(id),
mutationFn: async (id: string) => {
if (rerunInFlight.has(id)) {
return null as unknown as CrawlJob;
}
rerunInFlight.add(id);
try {
return await crawlJobService.rerunJob(id);
} finally {
setTimeout(() => rerunInFlight.delete(id), 2500);
}
},
onSuccess: (job) => {
if (!job || !job.id) return;
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.`,
});
......@@ -161,13 +174,15 @@ export function useCrawlJobLogs(
*/
export function useCrawlJobPages(
id: string,
query?: { page?: number; limit?: number; search?: string }
query?: { page?: number; limit?: number; search?: string },
options?: { refetchInterval?: number | false }
) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.pages(id, query),
queryFn: () => crawlJobService.getPagesPreview(id, query),
enabled: Boolean(id),
staleTime: 5000,
refetchInterval: options?.refetchInterval,
});
}
......
......@@ -99,6 +99,8 @@ export const translations = {
extractedRecordsDesc: "Số bản ghi bóc tách thành công",
successRate: "Tỷ Lệ Thành Công",
successRateDesc: "Hiệu suất cào dữ liệu tối ưu",
successRateGood: "Ổn định",
successRateLow: "Thấp",
realtime: "Thời gian thực",
},
// Quota & Usage Widget
......@@ -121,7 +123,6 @@ export const translations = {
warningTitle: "Tiệm cận ngưỡng tài nguyên (≥ 80%)",
warningDesc: "Bạn đã sử dụng gần hết hạn mức tác vụ cho phép trong ngày. Các tác vụ tiếp theo có thể bị giới hạn tốc độ.",
criticalBadge: "Chạm ngưỡng tối đa",
criticalTitle: "Đã đạt giới hạn tài nguyên (100%)",
criticalDesc: "Tài khoản của bạn đã đạt giới hạn tài nguyên tối đa cho phép. Vui lòng chờ đến mốc làm mới tiếp theo để tiếp tục chạy tác vụ.",
},
// Service Health Monitoring
......@@ -171,10 +172,10 @@ export const translations = {
pause: "Tạm dừng",
resume: "Tiếp tục",
rerun: "Chạy lại",
cancel: "Hủy bỏ",
cancel: "Dừng tác vụ",
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?",
confirmCancel: "Xác nhận dừng 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",
......@@ -210,6 +211,7 @@ export const translations = {
// Job Detail Page & Tabs
jobDetail: {
backBtn: "Quay lại danh sách tác vụ",
exportData: "Xuất dữ liệu",
liveSse: "Trực tiếp",
overviewTab: "Tổng quan & Tiến độ",
logsTab: "Nhật ký Crawler",
......@@ -284,6 +286,8 @@ export const translations = {
actionCol: "Thao tác",
previewBtn: "Xem trước",
emptyPages: "Chưa có trang nào được cào thành công.",
crawlingInProgress: "Đang cào dữ liệu ({count} trang đã xử lý)...",
crawlingInProgressDesc: "Dữ liệu chi tiết của từng trang sẽ tự động xuất hiện và sẵn sàng xem trước ngay khi tác vụ hoàn tất.",
loading: "Đang nạp danh sách trang...",
pagesExtracted: "trang đã bóc tách",
},
......@@ -899,6 +903,8 @@ export const translations = {
extractedRecordsDesc: "Successfully parsed data items",
successRate: "Success Rate",
successRateDesc: "Optimal scraping efficiency",
successRateGood: "Optimal",
successRateLow: "Low",
realtime: "Realtime",
},
// Quota & Usage Widget
......@@ -921,7 +927,6 @@ export const translations = {
warningTitle: "Approaching Resource Threshold (≥ 80%)",
warningDesc: "You have consumed almost all of your daily allocated jobs. Subsequent tasks may experience rate-limiting.",
criticalBadge: "Quota Exceeded",
criticalTitle: "Resource Limit Reached (100%)",
criticalDesc: "Your account has reached its maximum quota. New crawl jobs will remain pending until the quota reset window.",
},
// Service Health Monitoring
......@@ -971,10 +976,10 @@ export const translations = {
pause: "Pause",
resume: "Resume",
rerun: "Rerun",
cancel: "Cancel",
cancel: "Stop task",
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?",
confirmCancel: "Are you sure you want to stop the running job?",
searchPlaceholder: "Search by URL, domain or Job ID...",
filterAll: "All Statuses",
sortBy: "Sort By",
......@@ -1010,6 +1015,7 @@ export const translations = {
// Job Detail Page & Tabs
jobDetail: {
backBtn: "Back to tasks list",
exportData: "Export Data",
liveSse: "Live",
overviewTab: "Overview & Progress",
logsTab: "Crawler Logs",
......@@ -1084,6 +1090,8 @@ export const translations = {
actionCol: "Actions",
previewBtn: "Preview",
emptyPages: "No pages have been crawled successfully yet.",
crawlingInProgress: "Crawling in progress ({count} pages processed)...",
crawlingInProgressDesc: "Detailed page data will automatically appear and be ready for preview once the task completes.",
loading: "Loading crawled pages...",
pagesExtracted: "pages extracted",
},
......
......@@ -243,17 +243,11 @@ export const crawlerService = {
},
/**
* Dừng / chạy tiếp task
* Dừng task
*/
async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try {
// Thử gọi API cancel hoặc rerun tương ứng
const currentTask = localTasksState.find((t) => t.id === id);
if (currentTask?.status === "RUNNING") {
await apiClient.post(`/crawl-jobs/${id}/cancel`);
} else {
await apiClient.post(`/crawl-jobs/${id}/rerun`);
}
await apiClient.post(`/crawl-jobs/${id}/cancel`);
} catch {
// Ignored for local fallback
}
......
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