Commit e60d8925 authored by ThinhNC's avatar ThinhNC

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

parent 8a7095fc
...@@ -23,6 +23,7 @@ import { ...@@ -23,6 +23,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { CreateExportModal } from "@/components/exports/create-export-modal";
import { formatDate, formatNumber } from "@/lib/utils"; import { formatDate, formatNumber } from "@/lib/utils";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { import {
...@@ -90,6 +91,9 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -90,6 +91,9 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
// Diff comparison target state // Diff comparison target state
const [compareJobId, setCompareJobId] = useState<string>(""); const [compareJobId, setCompareJobId] = useState<string>("");
// Export modal state
const [isExportModalOpen, setIsExportModalOpen] = useState<boolean>(false);
// Base Query for Job Details // Base Query for Job Details
const { const {
data: initialJob, data: initialJob,
...@@ -126,10 +130,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -126,10 +130,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{ refetchInterval: activeTab === "logs" ? 4000 : false } { 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( const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId, jobId,
{ limit: 50 } { limit: 50 },
{ refetchInterval: activeTab === "pages" && (liveProgressJob?.status === "RUNNING" || initialJob?.status === "RUNNING") ? 3000 : false }
); );
// Query Diff Report (active when on diff tab) // Query Diff Report (active when on diff tab)
...@@ -140,9 +145,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -140,9 +145,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
// Calculate Progress & Speed // Calculate Progress & Speed
const maxPages = Math.max(1, job?.maxPages || 20); const maxPages = Math.max(1, job?.maxPages || 20);
const currentPages = job?.totalPages || job?.successPages || 0; const targetPages = job?.totalPages && job.totalPages > 0 ? Math.min(maxPages, job.totalPages) : maxPages;
const progressPercent = Math.min(100, Math.round((currentPages / maxPages) * 100)); const processedPages = (job?.successPages || 0) + (job?.failedPages || 0);
const isJobRunning = job?.status === "RUNNING" || job?.status === "PROCESSING_EXPORT"; 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 // Filtered Logs
const filteredLogs = useMemo(() => { const filteredLogs = useMemo(() => {
...@@ -250,12 +260,35 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -250,12 +260,35 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{/* Action Group */} {/* Action Group */}
<div className="flex flex-wrap items-center gap-2"> <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 */} {/* Rerun */}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={isRerunning || isJobRunning} 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" className="rounded-2xl border-border/80 text-xs font-semibold gap-1.5 hover:bg-muted/40 cursor-pointer"
> >
{isRerunning ? ( {isRerunning ? (
...@@ -342,7 +375,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -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-xs text-muted-foreground font-medium">{t.jobDetail.liveProgress}</div>
<div className="text-lg font-bold text-foreground flex items-center gap-2"> <div className="text-lg font-bold text-foreground flex items-center gap-2">
<span> <span>
{currentPages} / {job?.maxPages} {t.jobDetail.processedPages} {processedPages} / {targetPages} {t.jobDetail.processedPages}
</span> </span>
<span className="text-sm text-emerald-600 dark:text-emerald-400 font-extrabold"> <span className="text-sm text-emerald-600 dark:text-emerald-400 font-extrabold">
({progressPercent}%) ({progressPercent}%)
...@@ -390,7 +423,16 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -390,7 +423,16 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{[ {[
{ id: "overview", label: t.jobDetail.overviewTab, icon: Layers }, { id: "overview", label: t.jobDetail.overviewTab, icon: Layers },
{ id: "logs", label: t.jobDetail.logsTab, icon: Terminal, count: logsData?.meta?.total }, { 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 }, { id: "diff", label: t.jobDetail.diffTab, icon: Sparkles, count: diffData?.summary?.changeRate ? `${Math.round(diffData.summary.changeRate * 100)}%` : undefined },
].map((tabItem) => { ].map((tabItem) => {
const isActive = activeTab === tabItem.id; const isActive = activeTab === tabItem.id;
...@@ -685,7 +727,21 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -685,7 +727,21 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{!isPagesLoading && (!pagesData?.items || pagesData.items.length === 0) && ( {!isPagesLoading && (!pagesData?.items || pagesData.items.length === 0) && (
<tr> <tr>
<td colSpan={5} className="py-12 text-center text-muted-foreground"> <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> </td>
</tr> </tr>
)} )}
...@@ -1024,6 +1080,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -1024,6 +1080,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{/* Create Export Modal */}
<CreateExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
defaultJobId={jobId}
defaultJobUrl={job?.domain || job?.startUrl}
/>
</div> </div>
); );
} }
......
...@@ -183,7 +183,10 @@ export default function DashboardPage() { ...@@ -183,7 +183,10 @@ export default function DashboardPage() {
trend={ trend={
hasActivity hasActivity
? { ? {
value: Number(successRate) >= 80 ? "Eco" : `${successRate}%`, value:
Number(successRate) >= 80
? t.stats.successRateGood
: t.stats.successRateLow,
isPositive: Number(successRate) >= 80, isPositive: Number(successRate) >= 80,
} }
: undefined : undefined
......
...@@ -391,12 +391,19 @@ export default function SchedulesPage() { ...@@ -391,12 +391,19 @@ export default function SchedulesPage() {
size="sm" size="sm"
onClick={() => handleRunNow(schedule.id)} onClick={() => handleRunNow(schedule.id)}
disabled={triggerRunMutation.isPending} 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" /> <Play className="h-3 w-3 md:mr-1 shrink-0" />
{triggerRunMutation.isPending <span className="hidden md:inline">
? t.schedules.card.runningNow {triggerRunMutation.isPending
: t.schedules.card.runNow} ? t.schedules.card.runningNow
: t.schedules.card.runNow}
</span>
</Button> </Button>
{/* View History Button */} {/* View History Button */}
...@@ -407,10 +414,11 @@ export default function SchedulesPage() { ...@@ -407,10 +414,11 @@ export default function SchedulesPage() {
setHistorySchedule(schedule); setHistorySchedule(schedule);
setIsHistoryModalOpen(true); 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" /> <History className="h-3 w-3 md:mr-1 shrink-0" />
{t.schedules.card.history} <span className="hidden md:inline">{t.schedules.card.history}</span>
</Button> </Button>
{/* Edit Button */} {/* Edit Button */}
......
...@@ -66,7 +66,7 @@ export function StatCard({ ...@@ -66,7 +66,7 @@ export function StatCard({
</div> </div>
</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 ? ( {isLoading ? (
<div className="h-7 sm:h-8 w-20 sm:w-24 rounded-lg bg-muted/60 animate-pulse" /> <div className="h-7 sm:h-8 w-20 sm:w-24 rounded-lg bg-muted/60 animate-pulse" />
) : ( ) : (
......
...@@ -121,7 +121,6 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -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"> <span className="text-xs font-bold uppercase tracking-wider rounded-md bg-rose-500/20 px-2 py-0.5">
{t.quota.criticalBadge} {t.quota.criticalBadge}
</span> </span>
<span className="font-semibold text-sm">{t.quota.criticalTitle}</span>
</div> </div>
<p className="text-xs opacity-90 leading-relaxed">{t.quota.criticalDesc}</p> <p className="text-xs opacity-90 leading-relaxed">{t.quota.criticalDesc}</p>
</div> </div>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs"; import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import { import {
Table, Table,
...@@ -23,7 +24,6 @@ import { ...@@ -23,7 +24,6 @@ import {
Clock, Clock,
Copy, Copy,
ExternalLink, ExternalLink,
Eye,
Layers, Layers,
Loader2, Loader2,
Pause, Pause,
...@@ -32,6 +32,7 @@ import { ...@@ -32,6 +32,7 @@ import {
export function RecentJobsWidget() { export function RecentJobsWidget() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const router = useRouter();
const { data: jobsData, isLoading, isError } = useCrawlJobs({ const { data: jobsData, isLoading, isError } = useCrawlJobs({
page: 1, page: 1,
...@@ -155,7 +156,7 @@ export function RecentJobsWidget() { ...@@ -155,7 +156,7 @@ export function RecentJobsWidget() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-border/70 bg-muted/40 hover:bg-muted/40"> <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} {t.table.id}
</TableHead> </TableHead>
<TableHead className="font-semibold text-xs py-3"> <TableHead className="font-semibold text-xs py-3">
...@@ -164,15 +165,9 @@ export function RecentJobsWidget() { ...@@ -164,15 +165,9 @@ export function RecentJobsWidget() {
<TableHead className="font-semibold text-xs py-3"> <TableHead className="font-semibold text-xs py-3">
{t.table.status} {t.table.status}
</TableHead> </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} {t.table.progress}
</TableHead> </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> </TableRow>
</TableHeader> </TableHeader>
...@@ -185,10 +180,11 @@ export function RecentJobsWidget() { ...@@ -185,10 +180,11 @@ export function RecentJobsWidget() {
return ( return (
<TableRow <TableRow
key={job.id} 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 */} {/* Job ID */}
<TableCell className="w-[125px]"> <TableCell className="w-[125px] max-[767px]:hidden">
<div <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" 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}`} title={`Job ID: ${job.id}`}
...@@ -219,13 +215,12 @@ export function RecentJobsWidget() { ...@@ -219,13 +215,12 @@ export function RecentJobsWidget() {
<TableCell className="max-w-[260px]"> <TableCell className="max-w-[260px]">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Link <span
href={`/crawl-jobs/${job.id}`} className="font-semibold text-sm text-foreground group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors truncate block"
className="font-semibold text-sm text-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors truncate block"
title={job.startUrl} title={job.startUrl}
> >
{job.domain || job.startUrl} {job.domain || job.startUrl}
</Link> </span>
<Badge <Badge
variant="secondary" 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" 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() { ...@@ -237,6 +232,7 @@ export function RecentJobsWidget() {
href={job.startUrl} href={job.startUrl}
target="_blank" target="_blank"
rel="noreferrer" 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" 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} title={job.startUrl}
> >
...@@ -246,11 +242,19 @@ export function RecentJobsWidget() { ...@@ -246,11 +242,19 @@ export function RecentJobsWidget() {
</div> </div>
</TableCell> </TableCell>
{/* Status */} {/* Status & Created Date */}
<TableCell>{getStatusBadge(job.status)}</TableCell> <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 */} {/* Progress */}
<TableCell> <TableCell className="max-[767px]:hidden">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span> <span>
...@@ -266,28 +270,6 @@ export function RecentJobsWidget() { ...@@ -266,28 +270,6 @@ export function RecentJobsWidget() {
</div> </div>
</div> </div>
</TableCell> </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> </TableRow>
); );
})} })}
......
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { import {
X, X,
Download, Download,
...@@ -25,6 +25,7 @@ interface CreateExportModalProps { ...@@ -25,6 +25,7 @@ interface CreateExportModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
defaultJobId?: string; defaultJobId?: string;
defaultJobUrl?: string;
} }
const EXPORT_FORMATS: { const EXPORT_FORMATS: {
...@@ -75,6 +76,7 @@ export function CreateExportModal({ ...@@ -75,6 +76,7 @@ export function CreateExportModal({
isOpen, isOpen,
onClose, onClose,
defaultJobId = "", defaultJobId = "",
defaultJobUrl = "",
}: CreateExportModalProps) { }: CreateExportModalProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const { data: jobsData } = useCrawlJobs({ limit: 10 }); const { data: jobsData } = useCrawlJobs({ limit: 10 });
...@@ -84,8 +86,32 @@ export function CreateExportModal({ ...@@ -84,8 +86,32 @@ export function CreateExportModal({
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV"); const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState(""); const [customFileName, setCustomFileName] = useState("");
useEffect(() => {
if (defaultJobId) {
setSelectedJobId(defaultJobId);
}
}, [defaultJobId, isOpen]);
const jobs = jobsData?.items || []; const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED"); 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; if (!isOpen) return null;
...@@ -134,9 +160,9 @@ export function CreateExportModal({ ...@@ -134,9 +160,9 @@ export function CreateExportModal({
<Label className="text-xs font-semibold"> <Label className="text-xs font-semibold">
{t.exports.modal.selectJob} {t.exports.modal.selectJob}
</Label> </Label>
{completedJobs.length > 0 ? ( {displayJobs.length > 0 ? (
<div className="space-y-2 max-h-36 overflow-y-auto pr-1"> <div className="space-y-2 max-h-36 overflow-y-auto pr-1">
{completedJobs.map((job) => ( {displayJobs.map((job) => (
<button <button
key={job.id} key={job.id}
type="button" type="button"
...@@ -150,7 +176,7 @@ export function CreateExportModal({ ...@@ -150,7 +176,7 @@ export function CreateExportModal({
<div className="truncate min-w-0 pr-2"> <div className="truncate min-w-0 pr-2">
<p className="font-semibold text-foreground truncate">{job.startUrl}</p> <p className="font-semibold text-foreground truncate">{job.startUrl}</p>
<p className="text-[10px] text-muted-foreground font-mono truncate"> <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> </p>
</div> </div>
{selectedJobId === job.id && ( {selectedJobId === job.id && (
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlJobService } from "@/services/crawl-job.service"; 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"; import { toast } from "sonner";
export const CRAWL_JOBS_QUERY_KEYS = { export const CRAWL_JOBS_QUERY_KEYS = {
...@@ -68,6 +68,8 @@ export function useCreateCrawlJob() { ...@@ -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) * Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/ */
...@@ -75,8 +77,19 @@ export function useRerunCrawlJob() { ...@@ -75,8 +77,19 @@ export function useRerunCrawlJob() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ 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) => { onSuccess: (job) => {
if (!job || !job.id) return;
toast.success("Đã kích hoạt chạy lại tác vụ", { 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.`, description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`,
}); });
...@@ -161,13 +174,15 @@ export function useCrawlJobLogs( ...@@ -161,13 +174,15 @@ export function useCrawlJobLogs(
*/ */
export function useCrawlJobPages( export function useCrawlJobPages(
id: string, id: string,
query?: { page?: number; limit?: number; search?: string } query?: { page?: number; limit?: number; search?: string },
options?: { refetchInterval?: number | false }
) { ) {
return useQuery({ return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.pages(id, query), queryKey: CRAWL_JOBS_QUERY_KEYS.pages(id, query),
queryFn: () => crawlJobService.getPagesPreview(id, query), queryFn: () => crawlJobService.getPagesPreview(id, query),
enabled: Boolean(id), enabled: Boolean(id),
staleTime: 5000, staleTime: 5000,
refetchInterval: options?.refetchInterval,
}); });
} }
......
...@@ -99,6 +99,8 @@ export const translations = { ...@@ -99,6 +99,8 @@ export const translations = {
extractedRecordsDesc: "Số bản ghi bóc tách thành công", extractedRecordsDesc: "Số bản ghi bóc tách thành công",
successRate: "Tỷ Lệ Thành Công", successRate: "Tỷ Lệ Thành Công",
successRateDesc: "Hiệu suất cào dữ liệu tối ưu", 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", realtime: "Thời gian thực",
}, },
// Quota & Usage Widget // Quota & Usage Widget
...@@ -121,7 +123,6 @@ export const translations = { ...@@ -121,7 +123,6 @@ export const translations = {
warningTitle: "Tiệm cận ngưỡng tài nguyên (≥ 80%)", 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 độ.", 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", 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ụ.", 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 // Service Health Monitoring
...@@ -171,10 +172,10 @@ export const translations = { ...@@ -171,10 +172,10 @@ export const translations = {
pause: "Tạm dừng", pause: "Tạm dừng",
resume: "Tiếp tục", resume: "Tiếp tục",
rerun: "Chạy lại", rerun: "Chạy lại",
cancel: "Hủy bỏ", cancel: "Dừng tác vụ",
delete: "Xóa 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.", 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ụ...", searchPlaceholder: "Tìm kiếm URL, tên miền hoặc ID tác vụ...",
filterAll: "Tất cả trạng thái", filterAll: "Tất cả trạng thái",
sortBy: "Sắp xếp theo", sortBy: "Sắp xếp theo",
...@@ -210,6 +211,7 @@ export const translations = { ...@@ -210,6 +211,7 @@ export const translations = {
// Job Detail Page & Tabs // Job Detail Page & Tabs
jobDetail: { jobDetail: {
backBtn: "Quay lại danh sách tác vụ", backBtn: "Quay lại danh sách tác vụ",
exportData: "Xuất dữ liệu",
liveSse: "Trực tiếp", liveSse: "Trực tiếp",
overviewTab: "Tổng quan & Tiến độ", overviewTab: "Tổng quan & Tiến độ",
logsTab: "Nhật ký Crawler", logsTab: "Nhật ký Crawler",
...@@ -284,6 +286,8 @@ export const translations = { ...@@ -284,6 +286,8 @@ export const translations = {
actionCol: "Thao tác", actionCol: "Thao tác",
previewBtn: "Xem trước", previewBtn: "Xem trước",
emptyPages: "Chưa có trang nào được cào thành công.", 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...", loading: "Đang nạp danh sách trang...",
pagesExtracted: "trang đã bóc tách", pagesExtracted: "trang đã bóc tách",
}, },
...@@ -899,6 +903,8 @@ export const translations = { ...@@ -899,6 +903,8 @@ export const translations = {
extractedRecordsDesc: "Successfully parsed data items", extractedRecordsDesc: "Successfully parsed data items",
successRate: "Success Rate", successRate: "Success Rate",
successRateDesc: "Optimal scraping efficiency", successRateDesc: "Optimal scraping efficiency",
successRateGood: "Optimal",
successRateLow: "Low",
realtime: "Realtime", realtime: "Realtime",
}, },
// Quota & Usage Widget // Quota & Usage Widget
...@@ -921,7 +927,6 @@ export const translations = { ...@@ -921,7 +927,6 @@ export const translations = {
warningTitle: "Approaching Resource Threshold (≥ 80%)", warningTitle: "Approaching Resource Threshold (≥ 80%)",
warningDesc: "You have consumed almost all of your daily allocated jobs. Subsequent tasks may experience rate-limiting.", warningDesc: "You have consumed almost all of your daily allocated jobs. Subsequent tasks may experience rate-limiting.",
criticalBadge: "Quota Exceeded", 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.", criticalDesc: "Your account has reached its maximum quota. New crawl jobs will remain pending until the quota reset window.",
}, },
// Service Health Monitoring // Service Health Monitoring
...@@ -971,10 +976,10 @@ export const translations = { ...@@ -971,10 +976,10 @@ export const translations = {
pause: "Pause", pause: "Pause",
resume: "Resume", resume: "Resume",
rerun: "Rerun", rerun: "Rerun",
cancel: "Cancel", cancel: "Stop task",
delete: "Delete task", delete: "Delete task",
confirmDelete: "Are you sure you want to delete this job? This action cannot be undone.", 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...", searchPlaceholder: "Search by URL, domain or Job ID...",
filterAll: "All Statuses", filterAll: "All Statuses",
sortBy: "Sort By", sortBy: "Sort By",
...@@ -1010,6 +1015,7 @@ export const translations = { ...@@ -1010,6 +1015,7 @@ export const translations = {
// Job Detail Page & Tabs // Job Detail Page & Tabs
jobDetail: { jobDetail: {
backBtn: "Back to tasks list", backBtn: "Back to tasks list",
exportData: "Export Data",
liveSse: "Live", liveSse: "Live",
overviewTab: "Overview & Progress", overviewTab: "Overview & Progress",
logsTab: "Crawler Logs", logsTab: "Crawler Logs",
...@@ -1084,6 +1090,8 @@ export const translations = { ...@@ -1084,6 +1090,8 @@ export const translations = {
actionCol: "Actions", actionCol: "Actions",
previewBtn: "Preview", previewBtn: "Preview",
emptyPages: "No pages have been crawled successfully yet.", 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...", loading: "Loading crawled pages...",
pagesExtracted: "pages extracted", pagesExtracted: "pages extracted",
}, },
......
...@@ -243,17 +243,11 @@ export const crawlerService = { ...@@ -243,17 +243,11 @@ export const crawlerService = {
}, },
/** /**
* Dừng / chạy tiếp task * Dừng task
*/ */
async toggleTaskStatus(id: string): Promise<CrawlerTask> { async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try { try {
// Thử gọi API cancel hoặc rerun tương ứng await apiClient.post(`/crawl-jobs/${id}/cancel`);
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`);
}
} catch { } catch {
// Ignored for local fallback // 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