Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
frontend-data-crawler-be
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
BangNSK
frontend-data-crawler-be
Commits
b465708d
Commit
b465708d
authored
Jul 23, 2026
by
BangNSK
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement Dashboard page to manage and monitor crawl jobs with filtering and polling support
parent
3c1f03f8
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
192 additions
and
8 deletions
+192
-8
Dashboard.tsx
src/pages/Dashboard.tsx
+192
-8
No files found.
src/pages/Dashboard.tsx
View file @
b465708d
...
...
@@ -2,7 +2,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import
{
Link
}
from
'react-router-dom'
;
import
{
api
}
from
'../services/api'
;
import
{
useAuth
}
from
'../context/auth'
;
import
{
Plus
,
XCircle
,
Search
,
RefreshCw
,
ExternalLink
}
from
'lucide-react'
;
import
{
Plus
,
XCircle
,
Search
,
RefreshCw
,
ExternalLink
,
SlidersHorizontal
,
RotateCcw
,
}
from
'lucide-react'
;
import
{
getJobProgress
}
from
'../utils/jobProgress'
;
const
ACTIVE_STATUSES
=
[
'PENDING'
,
'QUEUED'
,
'RUNNING'
,
'PROCESSING_EXPORT'
];
...
...
@@ -31,6 +39,30 @@ interface JobsMeta {
totalPages
:
number
;
}
type
JobStatus
=
CrawlJob
[
'status'
];
type
JobMode
=
CrawlJob
[
'mode'
];
type
SortOption
=
'createdAt:desc'
|
'createdAt:asc'
|
'status:asc'
|
'totalPages:desc'
;
const
STATUS_OPTIONS
:
Array
<
{
value
:
JobStatus
|
''
;
label
:
string
}
>
=
[
{
value
:
''
,
label
:
'Tất cả trạng thái'
},
{
value
:
'PENDING'
,
label
:
'Khởi tạo'
},
{
value
:
'QUEUED'
,
label
:
'Đang chờ'
},
{
value
:
'RUNNING'
,
label
:
'Đang chạy'
},
{
value
:
'PROCESSING_EXPORT'
,
label
:
'Đang xuất file'
},
{
value
:
'COMPLETED'
,
label
:
'Hoàn thành'
},
{
value
:
'FAILED'
,
label
:
'Thất bại'
},
{
value
:
'CANCELED'
,
label
:
'Đã hủy'
},
{
value
:
'EXPIRED'
,
label
:
'Đã hết hạn'
},
];
const
MODE_OPTIONS
:
Array
<
{
value
:
JobMode
|
''
;
label
:
string
}
>
=
[
{
value
:
''
,
label
:
'Tất cả chế độ'
},
{
value
:
'SCRAPE'
,
label
:
'Scrape một trang'
},
{
value
:
'CRAWL'
,
label
:
'Crawl website'
},
{
value
:
'SITEMAP'
,
label
:
'Sitemap'
},
{
value
:
'URL_LIST'
,
label
:
'Danh sách URL'
},
];
export
const
Dashboard
:
React
.
FC
=
()
=>
{
const
{
isCrawler
}
=
useAuth
();
const
[
jobs
,
setJobs
]
=
useState
<
CrawlJob
[]
>
([]);
...
...
@@ -40,6 +72,12 @@ export const Dashboard: React.FC = () => {
const
[
loadError
,
setLoadError
]
=
useState
(
''
);
const
[
showCreateModal
,
setShowCreateModal
]
=
useState
(
false
);
const
latestRequestRef
=
useRef
(
0
);
const
[
searchInput
,
setSearchInput
]
=
useState
(
''
);
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
statusFilter
,
setStatusFilter
]
=
useState
<
JobStatus
|
''
>
(
''
);
const
[
modeFilter
,
setModeFilter
]
=
useState
<
JobMode
|
''
>
(
''
);
const
[
sort
,
setSort
]
=
useState
<
SortOption
>
(
'createdAt:desc'
);
const
[
pageSize
,
setPageSize
]
=
useState
(
20
);
// Form states
const
[
startUrl
,
setStartUrl
]
=
useState
(
''
);
...
...
@@ -55,7 +93,18 @@ export const Dashboard: React.FC = () => {
if
(
!
silent
)
setLoading
(
true
);
else
setRefreshing
(
true
);
try
{
const
response
=
await
api
.
get
(
'/crawl-jobs'
,
{
params
:
{
page
,
limit
:
20
}
});
const
[
sortBy
,
order
]
=
sort
.
split
(
':'
);
const
response
=
await
api
.
get
(
'/crawl-jobs'
,
{
params
:
{
page
,
limit
:
pageSize
,
...(
search
&&
{
search
}),
...(
statusFilter
&&
{
status
:
statusFilter
}),
...(
modeFilter
&&
{
mode
:
modeFilter
}),
sortBy
,
order
,
},
});
if
(
response
.
data
.
success
&&
requestId
===
latestRequestRef
.
current
)
{
setJobs
(
response
.
data
.
data
.
jobs
||
response
.
data
.
data
.
items
||
[]);
if
(
response
.
data
.
data
.
meta
)
setMeta
(
response
.
data
.
data
.
meta
);
...
...
@@ -72,12 +121,23 @@ export const Dashboard: React.FC = () => {
setRefreshing
(
false
);
}
}
},
[]);
},
[
modeFilter
,
pageSize
,
search
,
sort
,
statusFilter
]);
useEffect
(()
=>
{
void
fetchJobs
(
1
);
},
[
fetchJobs
]);
const
hasFilters
=
Boolean
(
search
||
statusFilter
||
modeFilter
||
sort
!==
'createdAt:desc'
||
pageSize
!==
20
);
const
resetFilters
=
()
=>
{
setSearchInput
(
''
);
setSearch
(
''
);
setStatusFilter
(
''
);
setModeFilter
(
''
);
setSort
(
'createdAt:desc'
);
setPageSize
(
20
);
};
const
hasActiveJobs
=
useMemo
(
()
=>
jobs
.
some
((
job
)
=>
ACTIVE_STATUSES
.
includes
(
job
.
status
)),
[
jobs
],
...
...
@@ -212,6 +272,115 @@ export const Dashboard: React.FC = () => {
{
/* Main Table Card */
}
<
div
className=
"bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"
>
<
div
className=
"border-b border-slate-200 bg-slate-50/60 p-4"
>
<
form
className=
"flex flex-col gap-3 xl:flex-row xl:items-center"
onSubmit=
{
(
event
)
=>
{
event
.
preventDefault
();
setSearch
(
searchInput
.
trim
());
}
}
role=
"search"
>
<
div
className=
"relative min-w-0 flex-1"
>
<
Search
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
/>
<
input
type=
"search"
value=
{
searchInput
}
onChange=
{
(
event
)
=>
setSearchInput
(
event
.
target
.
value
)
}
placeholder=
"Tìm theo URL hoặc tên miền..."
aria
-
label=
"Tìm kiếm crawl jobs"
className=
"h-10 w-full rounded-lg border border-slate-300 bg-white pl-9 pr-24 text-sm text-slate-800 outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100"
/>
<
button
type=
"submit"
className=
"absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-300"
>
Tìm kiếm
</
button
>
</
div
>
<
div
className=
"grid grid-cols-2 gap-3 sm:grid-cols-4 xl:flex"
>
<
label
className=
"relative"
>
<
span
className=
"sr-only"
>
Lọc theo trạng thái
</
span
>
<
select
value=
{
statusFilter
}
onChange=
{
(
event
)
=>
setStatusFilter
(
event
.
target
.
value
as
JobStatus
|
''
)
}
className=
"h-10 w-full appearance-none rounded-lg border border-slate-300 bg-white py-2 pl-3 pr-8 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-44"
>
{
STATUS_OPTIONS
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
}
</
select
>
<
SlidersHorizontal
className=
"pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
/>
</
label
>
<
label
>
<
span
className=
"sr-only"
>
Lọc theo chế độ crawl
</
span
>
<
select
value=
{
modeFilter
}
onChange=
{
(
event
)
=>
setModeFilter
(
event
.
target
.
value
as
JobMode
|
''
)
}
className=
"h-10 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-40"
>
{
MODE_OPTIONS
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
}
</
select
>
</
label
>
<
label
>
<
span
className=
"sr-only"
>
Sắp xếp danh sách
</
span
>
<
select
value=
{
sort
}
onChange=
{
(
event
)
=>
setSort
(
event
.
target
.
value
as
SortOption
)
}
className=
"h-10 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-44"
>
<
option
value=
"createdAt:desc"
>
Mới nhất trước
</
option
>
<
option
value=
"createdAt:asc"
>
Cũ nhất trước
</
option
>
<
option
value=
"status:asc"
>
Theo trạng thái
</
option
>
<
option
value=
"totalPages:desc"
>
Nhiều trang nhất
</
option
>
</
select
>
</
label
>
<
label
>
<
span
className=
"sr-only"
>
Số jobs mỗi trang
</
span
>
<
select
value=
{
pageSize
}
onChange=
{
(
event
)
=>
setPageSize
(
Number
(
event
.
target
.
value
))
}
className=
"h-10 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-28"
>
<
option
value=
{
10
}
>
10 / trang
</
option
>
<
option
value=
{
20
}
>
20 / trang
</
option
>
<
option
value=
{
50
}
>
50 / trang
</
option
>
</
select
>
</
label
>
</
div
>
{
hasFilters
&&
(
<
button
type=
"button"
onClick=
{
resetFilters
}
className=
"inline-flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-slate-500 transition hover:bg-slate-200 hover:text-slate-800"
>
<
RotateCcw
className=
"h-4 w-4"
/>
Xóa lọc
</
button
>
)
}
</
form
>
<
div
className=
"mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-500"
>
<
p
>
{
loading
?
'Đang tải danh sách…'
:
`Tìm thấy ${meta.total} job`
}
{
search
&&
<>
cho “
<
span
className=
"font-semibold text-slate-700"
>
{
search
}
</
span
>
”
</>
}
</
p
>
{
hasActiveJobs
&&
(
<
span
className=
"inline-flex items-center gap-1.5 font-medium text-blue-600"
>
<
span
className=
"h-2 w-2 animate-pulse rounded-full bg-blue-500"
/>
Tự động cập nhật mỗi 5 giây
</
span
>
)
}
</
div
>
</
div
>
{
loading
?
(
<
div
className=
"flex justify-center items-center py-20"
>
<
div
className=
"h-8 w-8 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"
></
div
>
...
...
@@ -221,11 +390,24 @@ export const Dashboard: React.FC = () => {
<
div
className=
"mx-auto w-12 h-12 flex items-center justify-center bg-slate-100 rounded-full text-slate-400 mb-3"
>
<
Search
className=
"h-6 w-6"
/>
</
div
>
<
h3
className=
"font-semibold text-slate-800 text-lg"
>
Chưa có job nào
</
h3
>
<
h3
className=
"font-semibold text-slate-800 text-lg"
>
{
hasFilters
?
'Không tìm thấy job phù hợp'
:
'Chưa có job nào'
}
</
h3
>
<
p
className=
"text-slate-500 text-sm mt-1 max-w-md mx-auto"
>
Hệ thống chưa ghi nhận tiến trình cào dữ liệu nào. Hãy bắt đầu bằng việc tạo một Job mới!
{
hasFilters
?
'Thử thay đổi từ khóa hoặc xóa bớt bộ lọc để xem thêm kết quả.'
:
'Hệ thống chưa ghi nhận tiến trình cào dữ liệu nào. Hãy bắt đầu bằng việc tạo một Job mới!'
}
</
p
>
{
isCrawler
&&
(
{
hasFilters
?
(
<
button
type=
"button"
onClick=
{
resetFilters
}
className=
"mt-4 inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<
RotateCcw
className=
"h-4 w-4"
/>
Xóa tất cả bộ lọc
</
button
>
)
:
isCrawler
&&
(
<
button
onClick=
{
()
=>
setShowCreateModal
(
true
)
}
className=
"mt-4 inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 text-sm transition-colors"
...
...
@@ -311,8 +493,10 @@ export const Dashboard: React.FC = () => {
)
}
{
!
loading
&&
meta
.
totalPages
>
1
&&
(
<
div
className=
"flex items-center justify-between border-t border-slate-100 bg-slate-50 px-6 py-3 text-sm"
>
<
span
className=
"text-slate-500"
>
Trang
{
meta
.
page
}
/
{
meta
.
totalPages
}
·
{
meta
.
total
}
job
</
span
>
<
div
className=
"flex flex-col gap-3 border-t border-slate-100 bg-slate-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-6"
>
<
span
className=
"text-slate-500"
>
Trang
{
meta
.
page
}
/
{
meta
.
totalPages
}
· Hiển thị
{
(
meta
.
page
-
1
)
*
meta
.
limit
+
1
}
–
{
Math
.
min
(
meta
.
page
*
meta
.
limit
,
meta
.
total
)
}
trên
{
meta
.
total
}
job
</
span
>
<
div
className=
"flex gap-2"
>
<
button
type=
"button"
...
...
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