Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
U
upgrade-data-crawler-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
upgrade-data-crawler-fe
Commits
e60d8925
Commit
e60d8925
authored
Sep 07, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(crawler): add table row navigation, duplicate rerun debounce and export enhancements
parent
8a7095fc
Changes
11
Show whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
270 additions
and
177 deletions
+270
-177
page.tsx
src/app/crawl-jobs/[id]/page.tsx
+72
-8
page.tsx
src/app/page.tsx
+4
-1
page.tsx
src/app/schedules/page.tsx
+16
-8
stat-card.tsx
src/components/common/stat-card.tsx
+1
-1
crawler-task-table.tsx
src/components/crawler/crawler-task-table.tsx
+91
-97
quota-usage-widget.tsx
src/components/dashboard/quota-usage-widget.tsx
+0
-1
recent-jobs-widget.tsx
src/components/dashboard/recent-jobs-widget.tsx
+22
-40
create-export-modal.tsx
src/components/exports/create-export-modal.tsx
+30
-4
use-crawl-jobs.ts
src/hooks/use-crawl-jobs.ts
+18
-3
translations.ts
src/lib/i18n/translations.ts
+14
-6
crawler.service.ts
src/services/crawler.service.ts
+2
-8
No files found.
src/app/crawl-jobs/[id]/page.tsx
View file @
e60d8925
...
@@ -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
pro
gressPercent
=
Math
.
min
(
100
,
Math
.
round
((
currentPages
/
maxPages
)
*
100
)
);
const
pro
cessedPages
=
(
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
?.
max
Pages
}
{
t
.
jobDetail
.
processedPages
}
{
processedPages
}
/
{
target
Pages
}
{
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
>
);
);
}
}
...
...
src/app/page.tsx
View file @
e60d8925
...
@@ -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
...
...
src/app/schedules/page.tsx
View file @
e60d8925
...
@@ -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"
/>
<
span
className=
"hidden md:inline"
>
{
triggerRunMutation
.
isPending
{
triggerRunMutation
.
isPending
?
t
.
schedules
.
card
.
runningNow
?
t
.
schedules
.
card
.
runningNow
:
t
.
schedules
.
card
.
runNow
}
:
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 m
r-1
"
/>
<
History
className=
"h-3 w-3 m
d:mr-1 shrink-0
"
/>
{
t
.
schedules
.
card
.
history
}
<
span
className=
"hidden md:inline"
>
{
t
.
schedules
.
card
.
history
}
</
span
>
</
Button
>
</
Button
>
{
/* Edit Button */
}
{
/* Edit Button */
}
...
...
src/components/common/stat-card.tsx
View file @
e60d8925
...
@@ -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"
/>
)
:
(
)
:
(
...
...
src/components/crawler/crawler-task-table.tsx
View file @
e60d8925
...
@@ -2,13 +2,13 @@
...
@@ -2,13 +2,13 @@
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
import
{
useCrawlJobs
,
useCrawlJobs
,
useRerunCrawlJob
,
useRerunCrawlJob
,
useCancelCrawlJob
,
useCancelCrawlJob
,
useDeleteCrawlJob
,
useDeleteCrawlJob
,
}
from
"@/hooks/use-crawl-jobs"
;
}
from
"@/hooks/use-crawl-jobs"
;
import
{
useToggleCrawlerTask
}
from
"@/hooks/use-crawler"
;
import
{
import
{
Table
,
Table
,
TableBody
,
TableBody
,
...
@@ -36,7 +36,6 @@ import {
...
@@ -36,7 +36,6 @@ import {
Layers
,
Layers
,
Loader2
,
Loader2
,
Pause
,
Pause
,
Play
,
RefreshCw
,
RefreshCw
,
RotateCcw
,
RotateCcw
,
Search
,
Search
,
...
@@ -46,6 +45,7 @@ import {
...
@@ -46,6 +45,7 @@ import {
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
export
function
CrawlerTaskTable
()
{
export
function
CrawlerTaskTable
()
{
const
router
=
useRouter
();
const
{
t
,
locale
}
=
useLanguage
();
const
{
t
,
locale
}
=
useLanguage
();
// State: Server-side pagination, sorting, filtering
// State: Server-side pagination, sorting, filtering
...
@@ -57,10 +57,6 @@ export function CrawlerTaskTable() {
...
@@ -57,10 +57,6 @@ export function CrawlerTaskTable() {
const
[
sortBy
,
setSortBy
]
=
useState
<
"createdAt"
|
"totalPages"
|
"status"
|
"startUrl"
>
(
"createdAt"
);
const
[
sortBy
,
setSortBy
]
=
useState
<
"createdAt"
|
"totalPages"
|
"status"
|
"startUrl"
>
(
"createdAt"
);
const
[
sortOrder
,
setSortOrder
]
=
useState
<
"asc"
|
"desc"
>
(
"desc"
);
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
// Debounce search input
useEffect
(()
=>
{
useEffect
(()
=>
{
const
handler
=
setTimeout
(()
=>
{
const
handler
=
setTimeout
(()
=>
{
...
@@ -87,10 +83,9 @@ export function CrawlerTaskTable() {
...
@@ -87,10 +83,9 @@ export function CrawlerTaskTable() {
});
});
// Mutations
// Mutations
const
{
mutate
:
rerunJob
,
isPending
:
isRerunning
}
=
useRerunCrawlJob
();
const
{
mutate
:
rerunJob
,
isPending
:
isRerunning
,
variables
:
rerunningId
}
=
useRerunCrawlJob
();
const
{
mutate
:
cancelJob
,
isPending
:
isCanceling
}
=
useCancelCrawlJob
();
const
{
mutate
:
cancelJob
,
isPending
:
isCanceling
,
variables
:
cancelingId
}
=
useCancelCrawlJob
();
const
{
mutate
:
deleteJob
,
isPending
:
isDeleting
}
=
useDeleteCrawlJob
();
const
{
mutate
:
deleteJob
,
isPending
:
isDeleting
,
variables
:
deletingId
}
=
useDeleteCrawlJob
();
const
{
mutate
:
toggleStatus
,
isPending
:
isToggling
}
=
useToggleCrawlerTask
();
const
handleToggleSort
=
(
column
:
typeof
sortBy
)
=>
{
const
handleToggleSort
=
(
column
:
typeof
sortBy
)
=>
{
if
(
sortBy
===
column
)
{
if
(
sortBy
===
column
)
{
...
@@ -276,7 +271,7 @@ export function CrawlerTaskTable() {
...
@@ -276,7 +271,7 @@ export function CrawlerTaskTable() {
<
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"
>
{
/* Column 1: Job ID */
}
{
/* 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
>
<
span
>
{
t
.
table
.
id
}
</
span
>
</
TableHead
>
</
TableHead
>
...
@@ -291,9 +286,9 @@ export function CrawlerTaskTable() {
...
@@ -291,9 +286,9 @@ export function CrawlerTaskTable() {
</
div
>
</
div
>
</
TableHead
>
</
TableHead
>
{
/* Column 3: Status */
}
{
/* Column 3: Status
& Created Date (Sorts by createdAt)
*/
}
<
TableHead
<
TableHead
onClick=
{
()
=>
handleToggleSort
(
"
status
"
)
}
onClick=
{
()
=>
handleToggleSort
(
"
createdAt
"
)
}
className=
"cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
className=
"cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
>
<
div
className=
"flex items-center gap-1.5"
>
<
div
className=
"flex items-center gap-1.5"
>
...
@@ -305,7 +300,7 @@ export function CrawlerTaskTable() {
...
@@ -305,7 +300,7 @@ export function CrawlerTaskTable() {
{
/* Column 4: Crawl Progress */
}
{
/* Column 4: Crawl Progress */
}
<
TableHead
<
TableHead
onClick=
{
()
=>
handleToggleSort
(
"totalPages"
)
}
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"
>
<
div
className=
"flex items-center gap-1.5"
>
<
span
>
{
t
.
table
.
progress
}
</
span
>
<
span
>
{
t
.
table
.
progress
}
</
span
>
...
@@ -314,23 +309,12 @@ export function CrawlerTaskTable() {
...
@@ -314,23 +309,12 @@ export function CrawlerTaskTable() {
</
TableHead
>
</
TableHead
>
{
/* Column 5: Extracted Items */
}
{
/* 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
}
{
t
.
table
.
extractedCount
}
</
TableHead
>
</
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 */
}
{
/* 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
}
{
t
.
table
.
actions
}
</
TableHead
>
</
TableHead
>
</
TableRow
>
</
TableRow
>
...
@@ -339,17 +323,28 @@ export function CrawlerTaskTable() {
...
@@ -339,17 +323,28 @@ export function CrawlerTaskTable() {
<
TableBody
>
<
TableBody
>
{
jobs
.
map
((
job
)
=>
{
{
jobs
.
map
((
job
)
=>
{
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
pro
gressPct
=
Math
.
min
(
100
,
Math
.
round
((
currentPages
/
maxPages
)
*
100
)
);
const
pro
cessedPages
=
(
job
.
successPages
||
0
)
+
(
job
.
failedPages
||
0
);
const
isRunning
=
job
.
status
===
"RUNNING"
||
job
.
status
===
"PROCESSING_EXPORT"
;
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
(
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=
"border-border/60 hover:bg-muted/30 transition-colors cursor-pointer group"
>
>
{
/* Column 1: Job ID Dedicated Column */
}
{
/* Column 1: Job ID Dedicated Column */
}
<
TableCell
className=
"w-[130px]"
>
<
TableCell
className=
"w-[130px]
max-[767px]:hidden
"
>
<
div
<
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"
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}`
}
title=
{
`Job ID: ${job.id}`
}
...
@@ -382,6 +377,7 @@ export function CrawlerTaskTable() {
...
@@ -382,6 +377,7 @@ export function CrawlerTaskTable() {
<
div
className=
"flex items-center gap-2 min-w-0"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
<
Link
<
Link
href=
{
`/crawl-jobs/${job.id}`
}
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"
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
}
>
>
...
@@ -398,6 +394,7 @@ export function CrawlerTaskTable() {
...
@@ -398,6 +394,7 @@ export function CrawlerTaskTable() {
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
}
>
>
...
@@ -407,15 +404,23 @@ export function CrawlerTaskTable() {
...
@@ -407,15 +404,23 @@ export function CrawlerTaskTable() {
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
{
/* Status Badge */
}
{
/* Status Badge & Created Time */
}
<
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 Bar */
}
{
/* Progress Bar */
}
<
TableCell
>
<
TableCell
className=
"max-[767px]:hidden"
>
<
div
className=
"space-y-1.5"
>
<
div
className=
"space-y-1.5"
>
<
div
className=
"flex justify-between text-xs text-muted-foreground"
>
<
div
className=
"flex justify-between text-xs text-muted-foreground"
>
<
span
>
<
span
>
{
currentPages
}
/
{
job
.
max
Pages
}
{
t
.
table
.
pagesUnit
}
{
processedPages
}
/
{
target
Pages
}
{
t
.
table
.
pagesUnit
}
</
span
>
</
span
>
<
span
className=
"font-semibold text-foreground"
>
{
progressPct
}
%
</
span
>
<
span
className=
"font-semibold text-foreground"
>
{
progressPct
}
%
</
span
>
</
div
>
</
div
>
...
@@ -429,106 +434,95 @@ export function CrawlerTaskTable() {
...
@@ -429,106 +434,95 @@ export function CrawlerTaskTable() {
</
TableCell
>
</
TableCell
>
{
/* Extracted Records */
}
{
/* Extracted Records */
}
<
TableCell
>
<
TableCell
className=
"max-[767px]:hidden"
>
<
span
className=
"font-semibold text-emerald-600 dark:text-emerald-400"
>
<
span
className=
"font-semibold text-emerald-600 dark:text-emerald-400"
>
{
formatNumber
(
job
.
successPages
||
0
)
}
{
formatNumber
(
job
.
successPages
||
0
)
}
</
span
>
{
" "
}
</
span
>
{
" "
}
<
span
className=
"text-xs text-muted-foreground"
>
{
t
.
table
.
itemsUnit
}
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
{
t
.
table
.
itemsUnit
}
</
span
>
</
TableCell
>
</
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 */
}
{
/* Action Group */
}
<
TableCell
className=
"text-right"
>
<
TableCell
className=
"text-right
max-[767px]:hidden
"
>
<
div
className=
"flex items-center justify-end gap-1.5"
>
<
div
className=
"flex items-center justify-end gap-1.5"
>
{
/* 1. View Detail Link */
}
{
/* 1. View Detail Link */
}
<
Link
href=
{
`/crawl-jobs/${job.id}`
}
>
<
Link
href=
{
`/crawl-jobs/${job.id}`
}
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
Button
<
Button
size=
"sm"
size=
"sm"
variant=
"secondary"
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
}
title=
{
t
.
table
.
viewDetails
}
>
>
<
Eye
className=
"h-3.5 w-3.5"
/>
<
Eye
className=
"h-3.5 w-3.5"
/>
</
Button
>
</
Button
>
</
Link
>
</
Link
>
{
/* 2.
Pause / Resume Toggle
*/
}
{
/* 2.
Stop/Cancel Button (Only when job is active)
*/
}
{
is
Running
?
(
{
is
JobActive
&&
(
<
Button
<
Button
size=
"sm"
size=
"sm"
variant=
"secondary"
variant=
"secondary"
title=
{
t
.
table
.
pause
}
title=
{
t
.
table
.
cancel
}
disabled=
{
isToggling
}
disabled=
{
isCanceling
&&
cancelingId
===
job
.
id
}
onClick=
{
()
=>
toggleStatus
(
job
.
id
)
}
onClick=
{
(
e
)
=>
{
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"
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
?
(
</
Button
>
<
Loader2
className=
"h-3.5 w-3.5 animate-spin"
/
>
)
:
(
)
:
(
<
Button
<
XCircle
className=
"h-3.5 w-3.5"
/>
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
>
</
Button
>
)
}
)
}
{
/* 3. Rerun Button */
}
{
/* 3. Rerun Button (Available when job is stopped/finished) */
}
{
!
isJobActive
&&
(
<
Button
<
Button
size=
"sm"
size=
"sm"
variant=
"secondary"
variant=
"secondary"
title=
{
t
.
table
.
rerun
}
title=
{
t
.
table
.
rerun
}
disabled=
{
isRerunning
||
isRunning
}
disabled=
{
isRerunning
}
onClick=
{
()
=>
rerunJob
(
job
.
id
)
}
onClick=
{
(
e
)
=>
{
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"
e
.
stopPropagation
();
>
if
(
isRerunning
)
return
;
<
RotateCcw
className=
"h-3.5 w-3.5"
/>
rerunJob
(
job
.
id
);
</
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
"
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
"
>
>
<
XCircle
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
>
</
Button
>
)
}
)
}
{
/* 5. Delete Button */
}
{
/* 4. Delete Button (Available when job is stopped/finished) */
}
{
!
isJobActive
&&
(
<
Button
<
Button
size=
"sm"
size=
"sm"
variant=
"secondary"
variant=
"secondary"
title=
{
t
.
table
.
delete
}
title=
{
t
.
table
.
delete
}
disabled=
{
isDeleting
}
disabled=
{
isDeleting
&&
deletingId
===
job
.
id
}
onClick=
{
()
=>
{
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
if
(
window
.
confirm
(
t
.
table
.
confirmDelete
))
{
if
(
window
.
confirm
(
t
.
table
.
confirmDelete
))
{
deleteJob
(
job
.
id
);
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
"
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
"
>
>
{
isDeleting
&&
deletingId
===
job
.
id
?
(
<
Loader2
className=
"h-3.5 w-3.5 animate-spin"
/>
)
:
(
<
Trash2
className=
"h-3.5 w-3.5"
/>
<
Trash2
className=
"h-3.5 w-3.5"
/>
)
}
</
Button
>
</
Button
>
)
}
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
...
...
src/components/dashboard/quota-usage-widget.tsx
View file @
e60d8925
...
@@ -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
>
...
...
src/components/dashboard/recent-jobs-widget.tsx
View file @
e60d8925
...
@@ -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
>
);
);
})
}
})
}
...
...
src/components/exports/create-export-modal.tsx
View file @
e60d8925
"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
>
{
completed
Jobs
.
length
>
0
?
(
{
display
Jobs
.
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"
>
{
completed
Jobs
.
map
((
job
)
=>
(
{
display
Jobs
.
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
&&
(
...
...
src/hooks/use-crawl-jobs.ts
View file @
e60d8925
...
@@ -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
,
CrawlJob
QueryDto
,
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
,
});
});
}
}
...
...
src/lib/i18n/translations.ts
View file @
e60d8925
...
@@ -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"
,
},
},
...
...
src/services/crawler.service.ts
View file @
e60d8925
...
@@ -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
const
currentTask
=
localTasksState
.
find
((
t
)
=>
t
.
id
===
id
);
if
(
currentTask
?.
status
===
"RUNNING"
)
{
await
apiClient
.
post
(
`/crawl-jobs/
${
id
}
/cancel`
);
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
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment