Commit b465708d authored by BangNSK's avatar BangNSK

feat: implement Dashboard page to manage and monitor crawl jobs with filtering and polling support

parent 3c1f03f8
...@@ -2,7 +2,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' ...@@ -2,7 +2,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../services/api'; import { api } from '../services/api';
import { useAuth } from '../context/auth'; 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'; import { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT']; const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
...@@ -31,6 +39,30 @@ interface JobsMeta { ...@@ -31,6 +39,30 @@ interface JobsMeta {
totalPages: number; 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 = () => { export const Dashboard: React.FC = () => {
const { isCrawler } = useAuth(); const { isCrawler } = useAuth();
const [jobs, setJobs] = useState<CrawlJob[]>([]); const [jobs, setJobs] = useState<CrawlJob[]>([]);
...@@ -40,6 +72,12 @@ export const Dashboard: React.FC = () => { ...@@ -40,6 +72,12 @@ export const Dashboard: React.FC = () => {
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const latestRequestRef = useRef(0); 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 // Form states
const [startUrl, setStartUrl] = useState(''); const [startUrl, setStartUrl] = useState('');
...@@ -55,7 +93,18 @@ export const Dashboard: React.FC = () => { ...@@ -55,7 +93,18 @@ export const Dashboard: React.FC = () => {
if (!silent) setLoading(true); if (!silent) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
try { 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) { if (response.data.success && requestId === latestRequestRef.current) {
setJobs(response.data.data.jobs || response.data.data.items || []); setJobs(response.data.data.jobs || response.data.data.items || []);
if (response.data.data.meta) setMeta(response.data.data.meta); if (response.data.data.meta) setMeta(response.data.data.meta);
...@@ -72,12 +121,23 @@ export const Dashboard: React.FC = () => { ...@@ -72,12 +121,23 @@ export const Dashboard: React.FC = () => {
setRefreshing(false); setRefreshing(false);
} }
} }
}, []); }, [modeFilter, pageSize, search, sort, statusFilter]);
useEffect(() => { useEffect(() => {
void fetchJobs(1); void fetchJobs(1);
}, [fetchJobs]); }, [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( const hasActiveJobs = useMemo(
() => jobs.some((job) => ACTIVE_STATUSES.includes(job.status)), () => jobs.some((job) => ACTIVE_STATUSES.includes(job.status)),
[jobs], [jobs],
...@@ -212,6 +272,115 @@ export const Dashboard: React.FC = () => { ...@@ -212,6 +272,115 @@ export const Dashboard: React.FC = () => {
{/* Main Table Card */} {/* Main Table Card */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> <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 ? ( {loading ? (
<div className="flex justify-center items-center py-20"> <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> <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 = () => { ...@@ -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"> <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" /> <Search className="h-6 w-6" />
</div> </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"> <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> </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 <button
onClick={() => setShowCreateModal(true)} 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" 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 = () => { ...@@ -311,8 +493,10 @@ export const Dashboard: React.FC = () => {
)} )}
{!loading && meta.totalPages > 1 && ( {!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"> <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} · {meta.total} job</span> <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"> <div className="flex gap-2">
<button <button
type="button" type="button"
......
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