Commit 0c058896 authored by BangNSK's avatar BangNSK

feat: implement frontend core features including authentication, protected...

feat: implement frontend core features including authentication, protected routes, and crawl job dashboard management
parent 9ae40825
# URL gốc của backend, không có dấu / ở cuối.
VITE_API_BASE_URL=http://localhost:3000/api/v1
...@@ -44,15 +44,13 @@ pnpm install ...@@ -44,15 +44,13 @@ pnpm install
## ⚙️ Cấu hình môi trường ## ⚙️ Cấu hình môi trường
Hiện tại URL của backend được cấu hình trực tiếp trong file `src/services/api.ts`: URL backend được cấu hình bằng biến môi trường `VITE_API_BASE_URL`. Tạo file `.env` từ `.env.example`:
```ts ```ts
const API_BASE_URL = 'http://localhost:3000/api/v1'; VITE_API_BASE_URL=http://localhost:3000/api/v1
``` ```
Nếu backend của bạn chạy ở cổng hoặc host khác, hãy cập nhật giá trị này tương ứng. Nếu không khai báo, frontend giữ endpoint deploy hiện tại. Khi chạy backend local, hãy tạo `.env` và đặt `VITE_API_BASE_URL=http://localhost:3000/api/v1`.
> **Sắp tới:** Có thể chuyển sang dùng biến môi trường `.env` với `VITE_API_BASE_URL` để linh hoạt hơn khi deploy.
--- ---
......
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/auth';
interface GuestRouteProps { interface GuestRouteProps {
children: React.ReactNode; children: React.ReactNode;
......
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth, type UserRole } from '../context/auth';
import type { UserRole } from '../context/AuthContext';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
......
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { api } from '../services/api'; import { api } from '../services/api';
import { AuthContext, type User } from './auth';
export type UserRole = 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
export interface User {
id: string;
email: string;
role: UserRole;
fullName?: string | null;
createdAt?: string;
updatedAt?: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
isAdmin: boolean;
isCrawler: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
...@@ -86,11 +64,3 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children ...@@ -86,11 +64,3 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
import { createContext, useContext } from 'react';
export type UserRole = 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
export interface User {
id: string;
email: string;
role: UserRole;
fullName?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
isAdmin: boolean;
isCrawler: boolean;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};
import React from 'react'; import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'; import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/auth';
import { Database, Users, History, User, LogOut, Compass } from 'lucide-react'; import { Database, Users, History, User, LogOut, Compass } from 'lucide-react';
export const DashboardLayout: React.FC = () => { export const DashboardLayout: React.FC = () => {
...@@ -40,6 +40,11 @@ export const DashboardLayout: React.FC = () => { ...@@ -40,6 +40,11 @@ export const DashboardLayout: React.FC = () => {
}, },
]; ];
const isPathActive = (path: string) =>
path === '/'
? location.pathname === '/' || location.pathname.startsWith('/jobs/')
: location.pathname === path;
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800"> <div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800">
{/* Sidebar */} {/* Sidebar */}
...@@ -60,7 +65,7 @@ export const DashboardLayout: React.FC = () => { ...@@ -60,7 +65,7 @@ export const DashboardLayout: React.FC = () => {
{navItems {navItems
.filter((item) => item.allowed) .filter((item) => item.allowed)
.map((item) => { .map((item) => {
const isActive = location.pathname === item.path; const isActive = isPathActive(item.path);
return ( return (
<Link <Link
key={item.name} key={item.name}
...@@ -130,8 +135,28 @@ export const DashboardLayout: React.FC = () => { ...@@ -130,8 +135,28 @@ export const DashboardLayout: React.FC = () => {
</div> </div>
</header> </header>
<nav className="fixed inset-x-0 bottom-0 z-40 grid grid-flow-col auto-cols-fr border-t border-slate-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-lg backdrop-blur md:hidden" aria-label="Điều hướng chính">
{navItems
.filter((item) => item.allowed)
.map((item) => {
const isActive = isPathActive(item.path);
return (
<Link
key={item.name}
to={item.path}
className={`flex min-w-0 flex-col items-center gap-1 rounded-lg px-1 py-1.5 text-[10px] font-medium ${
isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'
}`}
>
{item.icon}
<span className="max-w-full truncate">{item.name}</span>
</Link>
);
})}
</nav>
{/* Page Content */} {/* Page Content */}
<main className="flex-1 overflow-y-auto p-4 md:p-8"> <main className="flex-1 overflow-y-auto p-4 pb-24 md:p-8">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
......
import React, { useEffect, useState } from 'react'; 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/AuthContext'; import { useAuth } from '../context/auth';
import { Plus, XCircle, Search, RefreshCw, ExternalLink } from 'lucide-react'; import { Plus, XCircle, Search, RefreshCw, ExternalLink } from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
const POLL_INTERVAL_MS = 5000;
interface CrawlJob { interface CrawlJob {
id: string; id: string;
...@@ -14,16 +18,28 @@ interface CrawlJob { ...@@ -14,16 +18,28 @@ interface CrawlJob {
totalPages: number; totalPages: number;
successPages: number; successPages: number;
failedPages: number; failedPages: number;
processedPages?: number;
progressTotal?: number;
errorMessage: string | null; errorMessage: string | null;
createdAt: string; createdAt: string;
} }
interface JobsMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
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[]>([]);
const [meta, setMeta] = useState<JobsMeta>({ total: 0, page: 1, limit: 20, totalPages: 1 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const latestRequestRef = useRef(0);
// Form states // Form states
const [startUrl, setStartUrl] = useState(''); const [startUrl, setStartUrl] = useState('');
...@@ -34,43 +50,62 @@ export const Dashboard: React.FC = () => { ...@@ -34,43 +50,62 @@ export const Dashboard: React.FC = () => {
const [createError, setCreateError] = useState(''); const [createError, setCreateError] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const fetchJobs = async (silent = false) => { const fetchJobs = useCallback(async (page = 1, silent = false) => {
const requestId = ++latestRequestRef.current;
if (!silent) setLoading(true); if (!silent) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
try { try {
const response = await api.get('/crawl-jobs'); const response = await api.get('/crawl-jobs', { params: { page, limit: 20 } });
if (response.data.success) { 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);
setLoadError('');
} }
} catch (error) { } catch (error) {
console.error('Error fetching jobs', error); console.error('Error fetching jobs', error);
if (requestId === latestRequestRef.current) {
setLoadError('Không thể tải danh sách job. Vui lòng kiểm tra kết nối và thử lại.');
}
} finally { } finally {
if (requestId === latestRequestRef.current) {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}; }
}, []);
// Poll for job updates if any job is in active state
useEffect(() => { useEffect(() => {
fetchJobs(); void fetchJobs(1);
}, [fetchJobs]);
const interval = setInterval(() => { const hasActiveJobs = useMemo(
const hasActiveJobs = jobs.some( () => jobs.some((job) => ACTIVE_STATUSES.includes(job.status)),
(job) => ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'].includes(job.status) [jobs],
); );
if (hasActiveJobs || jobs.length === 0) {
fetchJobs(true);
}
}, 5000);
return () => clearInterval(interval); // Self-scheduling polling avoids overlapping requests and always uses current state.
}, [jobs.length]); useEffect(() => {
if (!hasActiveJobs && jobs.length > 0) return;
let stopped = false;
let timeout: ReturnType<typeof setTimeout>;
const poll = async () => {
await fetchJobs(meta.page, true);
if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
};
timeout = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
stopped = true;
clearTimeout(timeout);
};
}, [fetchJobs, hasActiveJobs, jobs.length, meta.page]);
const handleCancel = async (id: string) => { const handleCancel = async (id: string) => {
if (!window.confirm('Bạn có chắc chắn muốn hủy Job này không?')) return; if (!window.confirm('Bạn có chắc chắn muốn hủy Job này không?')) return;
try { try {
await api.post(`/crawl-jobs/${id}/cancel`); await api.post(`/crawl-jobs/${id}/cancel`);
fetchJobs(true); await fetchJobs(meta.page, true);
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.message || 'Không thể hủy Job này.'); alert(error.response?.data?.message || 'Không thể hủy Job này.');
} }
...@@ -88,10 +123,10 @@ export const Dashboard: React.FC = () => { ...@@ -88,10 +123,10 @@ export const Dashboard: React.FC = () => {
: undefined; : undefined;
const response = await api.post('/crawl-jobs', { const response = await api.post('/crawl-jobs', {
...(isUrlListMode ? { urls: parsedUrls } : { startUrl }), ...(isUrlListMode
? { urls: parsedUrls }
: { startUrl, maxPages: Number(maxPages), maxDepth: Number(maxDepth) }),
mode, mode,
maxPages: Number(maxPages),
maxDepth: isUrlListMode ? undefined : Number(maxDepth),
}); });
if (response.data.success) { if (response.data.success) {
...@@ -102,7 +137,7 @@ export const Dashboard: React.FC = () => { ...@@ -102,7 +137,7 @@ export const Dashboard: React.FC = () => {
setMaxPages(20); setMaxPages(20);
setMaxDepth(1); setMaxDepth(1);
setUrlsList(''); setUrlsList('');
fetchJobs(true); await fetchJobs(1, true);
} }
} catch (error: any) { } catch (error: any) {
setCreateError(error.response?.data?.message || 'Có lỗi xảy ra khi tạo Job.'); setCreateError(error.response?.data?.message || 'Có lỗi xảy ra khi tạo Job.');
...@@ -129,22 +164,26 @@ export const Dashboard: React.FC = () => { ...@@ -129,22 +164,26 @@ export const Dashboard: React.FC = () => {
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-700 border border-slate-200">Khởi tạo</span>; return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-700 border border-slate-200">Khởi tạo</span>;
case 'CANCELED': case 'CANCELED':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500 border border-slate-200">Đã hủy</span>; return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500 border border-slate-200">Đã hủy</span>;
case 'PROCESSING_EXPORT':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-50 text-purple-700 border border-purple-100">Đang xuất file</span>;
case 'EXPIRED':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500 border border-slate-200">Đã hết hạn</span>;
default: default:
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-150 text-slate-600">{status}</span>; return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-600">{status}</span>;
} }
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */} {/* Page Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Danh sách Crawl Jobs</h1> <h1 className="text-2xl font-bold tracking-tight text-slate-900">Danh sách Crawl Jobs</h1>
<p className="text-sm text-slate-500">Quản lý và theo dõi tiến trình cào dữ liệu từ trang web.</p> <p className="text-sm text-slate-500">Quản lý và theo dõi tiến trình cào dữ liệu từ trang web.</p>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={() => fetchJobs(true)} onClick={() => void fetchJobs(meta.page, true)}
className="flex items-center justify-center p-2.5 bg-white border border-slate-200 rounded-lg text-slate-600 hover:text-slate-900 hover:bg-slate-50 transition-colors" className="flex items-center justify-center p-2.5 bg-white border border-slate-200 rounded-lg text-slate-600 hover:text-slate-900 hover:bg-slate-50 transition-colors"
title="Làm mới" title="Làm mới"
> >
...@@ -162,6 +201,15 @@ export const Dashboard: React.FC = () => { ...@@ -162,6 +201,15 @@ export const Dashboard: React.FC = () => {
</div> </div>
</div> </div>
{loadError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{loadError}</span>
<button type="button" onClick={() => void fetchJobs(meta.page)} className="font-semibold hover:underline">
Thử lại
</button>
</div>
)}
{/* 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">
{loading ? ( {loading ? (
...@@ -200,7 +248,11 @@ export const Dashboard: React.FC = () => { ...@@ -200,7 +248,11 @@ export const Dashboard: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-200 text-slate-700"> <tbody className="divide-y divide-slate-200 text-slate-700">
{jobs.map((job) => ( {jobs.map((job) => {
const progress = getJobProgress(job);
const hasProgressData = progress.completed > 0 || job.totalPages > 0 || (job.progressTotal ?? 0) > 0;
const isActive = ACTIVE_STATUSES.includes(job.status);
return (
<tr key={job.id} className="hover:bg-slate-50/50 transition-colors"> <tr key={job.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4 max-w-xs md:max-w-md truncate"> <td className="px-6 py-4 max-w-xs md:max-w-md truncate">
<div className="flex flex-col"> <div className="flex flex-col">
...@@ -219,7 +271,9 @@ export const Dashboard: React.FC = () => { ...@@ -219,7 +271,9 @@ export const Dashboard: React.FC = () => {
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-slate-900"> <span className="font-medium text-slate-900">
{job.successPages} / {job.maxPages} {isActive && !hasProgressData
? job.status === 'PENDING' || job.status === 'QUEUED' ? 'Đang chờ…' : 'Đang xử lý…'
: `${progress.completed} / ${progress.total}`}
</span> </span>
{job.failedPages > 0 && ( {job.failedPages > 0 && (
<span className="text-xs text-rose-500">{job.failedPages} lỗi</span> <span className="text-xs text-rose-500">{job.failedPages} lỗi</span>
...@@ -249,19 +303,44 @@ export const Dashboard: React.FC = () => { ...@@ -249,19 +303,44 @@ export const Dashboard: React.FC = () => {
)} )}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{!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 gap-2">
<button
type="button"
onClick={() => void fetchJobs(meta.page - 1)}
disabled={meta.page <= 1 || loading}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-slate-600 hover:bg-white disabled:opacity-40"
>
← Trước
</button>
<button
type="button"
onClick={() => void fetchJobs(meta.page + 1)}
disabled={meta.page >= meta.totalPages || loading}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-slate-600 hover:bg-white disabled:opacity-40"
>
Tiếp →
</button>
</div>
</div>
)}
</div> </div>
{/* Create Job Modal */} {/* Create Job Modal */}
{showCreateModal && ( {showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
<div className="w-full max-w-lg bg-white rounded-2xl p-6 shadow-2xl border border-slate-100 space-y-4"> <div className="max-h-[90vh] w-full max-w-lg overflow-y-auto bg-white rounded-2xl p-6 shadow-2xl border border-slate-100 space-y-4" role="dialog" aria-modal="true" aria-labelledby="create-job-title">
<div className="flex items-center justify-between border-b border-slate-100 pb-3"> <div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="text-lg font-bold text-slate-900">Tạo Job cào dữ liệu mới</h3> <h3 id="create-job-title" className="text-lg font-bold text-slate-900">Tạo Job cào dữ liệu mới</h3>
<button <button
onClick={() => setShowCreateModal(false)} onClick={() => setShowCreateModal(false)}
className="text-slate-400 hover:text-slate-600" className="text-slate-400 hover:text-slate-600"
...@@ -325,7 +404,7 @@ export const Dashboard: React.FC = () => { ...@@ -325,7 +404,7 @@ export const Dashboard: React.FC = () => {
</select> </select>
</div> </div>
<div> {mode !== 'URL_LIST' && <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Độ sâu tối đa (Max Depth)</label> <label className="block text-sm font-medium text-slate-700 mb-1">Độ sâu tối đa (Max Depth)</label>
<input <input
type="number" type="number"
...@@ -335,10 +414,10 @@ export const Dashboard: React.FC = () => { ...@@ -335,10 +414,10 @@ export const Dashboard: React.FC = () => {
onChange={(e) => setMaxDepth(Number(e.target.value))} onChange={(e) => setMaxDepth(Number(e.target.value))}
className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm" className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
/> />
</div> </div>}
</div> </div>
<div> {mode !== 'URL_LIST' && <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Số trang tối đa (Max Pages)</label> <label className="block text-sm font-medium text-slate-700 mb-1">Số trang tối đa (Max Pages)</label>
<input <input
type="number" type="number"
...@@ -348,7 +427,7 @@ export const Dashboard: React.FC = () => { ...@@ -348,7 +427,7 @@ export const Dashboard: React.FC = () => {
onChange={(e) => setMaxPages(Number(e.target.value))} onChange={(e) => setMaxPages(Number(e.target.value))}
className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm" className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
/> />
</div> </div>}
<div className="flex justify-end space-x-2 pt-4 border-t border-slate-100"> <div className="flex justify-end space-x-2 pt-4 border-t border-slate-100">
<button <button
......
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { api } from '../services/api'; import { api } from '../services/api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/auth';
import { import {
ArrowLeft, ArrowLeft,
RefreshCw, RefreshCw,
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
Download, Download,
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT']; const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
...@@ -29,6 +30,8 @@ interface CrawlJob { ...@@ -29,6 +30,8 @@ interface CrawlJob {
totalPages: number; totalPages: number;
successPages: number; successPages: number;
failedPages: number; failedPages: number;
processedPages?: number;
progressTotal?: number;
errorMessage: string | null; errorMessage: string | null;
startedAt: string | null; startedAt: string | null;
finishedAt: string | null; finishedAt: string | null;
...@@ -68,40 +71,56 @@ export const JobDetail: React.FC = () => { ...@@ -68,40 +71,56 @@ export const JobDetail: React.FC = () => {
const [loadingJob, setLoadingJob] = useState(true); const [loadingJob, setLoadingJob] = useState(true);
const [loadingPages, setLoadingPages] = useState(true); const [loadingPages, setLoadingPages] = useState(true);
const [loadingExport, setLoadingExport] = useState(false); const [loadingExport, setLoadingExport] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [selectedExportType, setSelectedExportType] = useState('JSON'); const [selectedExportType, setSelectedExportType] = useState('JSON');
const [exportError, setExportError] = useState(''); const [exportError, setExportError] = useState('');
const [jobError, setJobError] = useState('');
const [pagesError, setPagesError] = useState('');
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const latestJobRequestRef = useRef(0);
const latestPagesRequestRef = useRef(0);
const fetchJob = async () => { const fetchJob = useCallback(async () => {
const requestId = ++latestJobRequestRef.current;
try { try {
const res = await api.get(`/crawl-jobs/${id}`); const res = await api.get(`/crawl-jobs/${id}`);
if (res.data.success) { if (res.data.success && requestId === latestJobRequestRef.current) {
setJob(res.data.data); setJob(res.data.data);
setJobError('');
setLastUpdatedAt(new Date());
} }
} catch (e) { } catch (e) {
console.error('Failed to fetch job', e); console.error('Failed to fetch job', e);
if (requestId === latestJobRequestRef.current) {
setJobError('Không thể tải thông tin job. Vui lòng thử lại.');
}
} finally { } finally {
setLoadingJob(false); if (requestId === latestJobRequestRef.current) setLoadingJob(false);
} }
}; }, [id]);
const fetchPages = async (page = 1, silent = false) => { const fetchPages = useCallback(async (page = 1, silent = false) => {
const requestId = ++latestPagesRequestRef.current;
try { try {
if (!silent) setLoadingPages(true); if (!silent) setLoadingPages(true);
const res = await api.get(`/crawl-jobs/${id}/pages`, { params: { page, limit: 20 } }); const res = await api.get(`/crawl-jobs/${id}/pages`, { params: { page, limit: 20 } });
if (res.data.success) { if (res.data.success && requestId === latestPagesRequestRef.current) {
setPages(res.data.data.items || []); setPages(res.data.data.items || []);
setPagesMeta(res.data.data.meta); setPagesMeta(res.data.data.meta);
setPagesError('');
} }
} catch (e) { } catch (e) {
console.error('Failed to fetch pages', e); console.error('Failed to fetch pages', e);
if (requestId === latestPagesRequestRef.current) {
setPagesError('Không thể tải danh sách trang đã cào.');
}
} finally { } finally {
if (!silent) setLoadingPages(false); if (!silent && requestId === latestPagesRequestRef.current) setLoadingPages(false);
} }
}; }, [id]);
const fetchExports = async () => { const fetchExports = useCallback(async () => {
try { try {
const res = await api.get(`/crawl-jobs/${id}/exports`); const res = await api.get(`/crawl-jobs/${id}/exports`);
if (res.data.success) { if (res.data.success) {
...@@ -110,40 +129,35 @@ export const JobDetail: React.FC = () => { ...@@ -110,40 +129,35 @@ export const JobDetail: React.FC = () => {
} catch (e) { } catch (e) {
console.error('Failed to fetch exports', e); console.error('Failed to fetch exports', e);
} }
}; }, [id]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
fetchJob(); void fetchJob();
fetchPages(); void fetchPages();
fetchExports(); void fetchExports();
}, [id]); }, [fetchExports, fetchJob, fetchPages, id]);
// Auto-polling while job is active const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false;
useEffect(() => {
if (!job) return;
const isActive = ACTIVE_STATUSES.includes(job.status); // Self-scheduling polling prevents overlap and keeps the currently viewed page.
useEffect(() => {
if (!isActive) return;
if (isActive) { let stopped = false;
intervalRef.current = setInterval(async () => { let timeout: ReturnType<typeof setTimeout>;
await fetchJob(); const poll = async () => {
await fetchPages(pagesMeta.page, true); await Promise.all([fetchJob(), fetchPages(pagesMeta.page, true)]);
}, POLL_INTERVAL_MS); if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
} else { };
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
timeout = setTimeout(poll, POLL_INTERVAL_MS);
return () => { return () => {
if (intervalRef.current) { stopped = true;
clearInterval(intervalRef.current); clearTimeout(timeout);
}
}; };
}, [job?.status]); }, [fetchJob, fetchPages, isActive, pagesMeta.page]);
const handleCreateExport = async () => { const handleCreateExport = async () => {
setExportError(''); setExportError('');
...@@ -158,6 +172,19 @@ export const JobDetail: React.FC = () => { ...@@ -158,6 +172,19 @@ export const JobDetail: React.FC = () => {
} }
}; };
const handleCancel = async () => {
if (!id || !window.confirm('Bạn có chắc chắn muốn hủy job này không?')) return;
setCancelling(true);
try {
await api.post(`/crawl-jobs/${id}/cancel`);
await Promise.all([fetchJob(), fetchPages(pagesMeta.page, true)]);
} catch (e: any) {
alert(e.response?.data?.message || 'Không thể hủy job này.');
} finally {
setCancelling(false);
}
};
const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => { const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => {
try { try {
console.log(`Downloading export ${exportId}, requesting blob...`); console.log(`Downloading export ${exportId}, requesting blob...`);
...@@ -226,13 +253,10 @@ export const JobDetail: React.FC = () => { ...@@ -226,13 +253,10 @@ export const JobDetail: React.FC = () => {
} }
}; };
const progressPercent = job const progress = job ? getJobProgress(job) : { completed: 0, total: 0, percent: 0 };
? job.totalPages > 0 const hasProgressData = job
? Math.min(100, Math.round((job.successPages / job.totalPages) * 100)) ? progress.completed > 0 || job.totalPages > 0 || (job.progressTotal ?? 0) > 0
: job.status === 'COMPLETED' ? 100 : 0 : false;
: 0;
const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false;
if (loadingJob) { if (loadingJob) {
return ( return (
...@@ -245,7 +269,7 @@ export const JobDetail: React.FC = () => { ...@@ -245,7 +269,7 @@ export const JobDetail: React.FC = () => {
if (!job) { if (!job) {
return ( return (
<div className="text-center py-16"> <div className="text-center py-16">
<p className="text-slate-500">Không tìm thấy Job này.</p> <p className="text-slate-500">{jobError || 'Không tìm thấy Job này.'}</p>
<Link to="/" className="mt-4 inline-flex items-center text-indigo-600 hover:text-indigo-900 text-sm font-medium"> <Link to="/" className="mt-4 inline-flex items-center text-indigo-600 hover:text-indigo-900 text-sm font-medium">
<ArrowLeft className="h-4 w-4 mr-1" /> Quay lại danh sách <ArrowLeft className="h-4 w-4 mr-1" /> Quay lại danh sách
</Link> </Link>
...@@ -272,10 +296,22 @@ export const JobDetail: React.FC = () => { ...@@ -272,10 +296,22 @@ export const JobDetail: React.FC = () => {
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600 font-semibold animate-pulse"> <span className="inline-flex items-center gap-1.5 text-xs text-blue-600 font-semibold animate-pulse">
<span className="h-2 w-2 rounded-full bg-blue-500 inline-block"></span> <span className="h-2 w-2 rounded-full bg-blue-500 inline-block"></span>
Đang cập nhật tự động Đang cập nhật tự động
{lastUpdatedAt && <span className="font-normal text-slate-400">· {lastUpdatedAt.toLocaleTimeString('vi-VN')}</span>}
</span> </span>
)} )}
{isCrawler && isActive && (
<button <button
onClick={() => { fetchJob(); fetchPages(); fetchExports(); }} type="button"
onClick={() => void handleCancel()}
disabled={cancelling}
className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
Hủy job
</button>
)}
<button
onClick={() => { void fetchJob(); void fetchPages(pagesMeta.page); void fetchExports(); }}
className="p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors" className="p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
title="Làm mới" title="Làm mới"
> >
...@@ -313,25 +349,31 @@ export const JobDetail: React.FC = () => { ...@@ -313,25 +349,31 @@ export const JobDetail: React.FC = () => {
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700">Tiến độ cào dữ liệu</span> <span className="text-sm font-medium text-slate-700">Tiến độ cào dữ liệu</span>
<span className="text-sm font-bold text-slate-900"> <span className="text-sm font-bold text-slate-900">
{job.successPages} thành công {isActive && hasProgressData && <span>{progress.completed} / {progress.total} đã xử lý</span>}
{isActive && !hasProgressData && <span>Đang xử lý dữ liệu…</span>}
{!isActive && <span>{job.successPages} thành công</span>}
{job.failedPages > 0 && <span className="text-rose-500 ml-2">· {job.failedPages} lỗi</span>} {job.failedPages > 0 && <span className="text-rose-500 ml-2">· {job.failedPages} lỗi</span>}
{isActive && <span className="text-slate-400 ml-2 text-xs">(cập nhật mỗi {POLL_INTERVAL_MS / 1000}s)</span>} {isActive && <span className="text-slate-400 ml-2 text-xs">(cập nhật mỗi {POLL_INTERVAL_MS / 1000}s)</span>}
</span> </span>
</div> </div>
<div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden"> <div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden">
{isActive && !hasProgressData ? (
<div className="h-3 w-1/3 animate-pulse rounded-full bg-indigo-500" aria-label="Đang xử lý, chưa có số liệu tiến độ" />
) : (
<div <div
className={`h-3 rounded-full transition-all duration-500 ${ className={`h-3 rounded-full transition-all duration-500 ${
job.status === 'FAILED' ? 'bg-rose-500' : job.status === 'FAILED' ? 'bg-rose-500' :
job.status === 'CANCELED' ? 'bg-slate-400' : job.status === 'CANCELED' ? 'bg-slate-400' :
'bg-indigo-500' 'bg-indigo-500'
} ${isActive ? 'animate-pulse' : ''}`} } ${isActive ? 'animate-pulse' : ''}`}
style={{ width: `${progressPercent}%` }} style={{ width: `${progress.percent}%` }}
/> />
)}
</div> </div>
<div className="flex justify-between text-xs text-slate-400 mt-1"> <div className="flex justify-between text-xs text-slate-400 mt-1">
<span>0</span> <span>0</span>
<span>{progressPercent}%</span> <span>{isActive && !hasProgressData ? 'Đang chạy' : `${progress.percent}%`}</span>
<span>{job.maxPages} trang</span> <span>{hasProgressData ? progress.total : job.maxPages} trang</span>
</div> </div>
</div> </div>
...@@ -426,6 +468,13 @@ export const JobDetail: React.FC = () => { ...@@ -426,6 +468,13 @@ export const JobDetail: React.FC = () => {
)} )}
</div> </div>
{pagesError && (
<div className="mx-6 mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{pagesError}</span>
<button type="button" onClick={() => void fetchPages(pagesMeta.page)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{loadingPages ? ( {loadingPages ? (
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
<div className="h-7 w-7 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div> <div className="h-7 w-7 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
......
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2 } from 'lucide-react'; import { Database, Lock, Mail, Loader2 } from 'lucide-react';
export const Login: React.FC = () => { export const Login: React.FC = () => {
......
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { api } from '../services/api'; import { api } from '../services/api';
import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react'; import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react';
...@@ -37,6 +37,7 @@ export const Logs: React.FC = () => { ...@@ -37,6 +37,7 @@ export const Logs: React.FC = () => {
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 }); const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [userIdFilter, setUserIdFilter] = useState(''); const [userIdFilter, setUserIdFilter] = useState('');
const [actionFilter, setActionFilter] = useState(''); const [actionFilter, setActionFilter] = useState('');
...@@ -45,7 +46,7 @@ export const Logs: React.FC = () => { ...@@ -45,7 +46,7 @@ export const Logs: React.FC = () => {
const [expandedLogId, setExpandedLogId] = useState<string | null>(null); const [expandedLogId, setExpandedLogId] = useState<string | null>(null);
const fetchLogs = async (page = 1, silent = false) => { const fetchLogs = useCallback(async (page = 1, silent = false) => {
if (!silent) setLoading(true); if (!silent) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
try { try {
...@@ -60,18 +61,20 @@ export const Logs: React.FC = () => { ...@@ -60,18 +61,20 @@ export const Logs: React.FC = () => {
if (res.data.success) { if (res.data.success) {
setLogs(res.data.data.logs || []); setLogs(res.data.data.logs || []);
setMeta(res.data.data.meta); setMeta(res.data.data.meta);
setLoadError('');
} }
} catch (e) { } catch (e) {
console.error('Failed to fetch audit logs', e); console.error('Failed to fetch audit logs', e);
setLoadError('Không thể tải nhật ký hệ thống. Vui lòng thử lại.');
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}; }, [actionFilter, userIdFilter]);
useEffect(() => { useEffect(() => {
fetchLogs(1); fetchLogs(1);
}, [userIdFilter, actionFilter]); }, [fetchLogs]);
const handleFilterSubmit = (e: React.FormEvent) => { const handleFilterSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
...@@ -117,6 +120,13 @@ export const Logs: React.FC = () => { ...@@ -117,6 +120,13 @@ export const Logs: React.FC = () => {
</button> </button>
</div> </div>
{loadError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{loadError}</span>
<button type="button" onClick={() => void fetchLogs(meta.page)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{/* Filters */} {/* Filters */}
<form onSubmit={handleFilterSubmit} className="flex flex-wrap gap-2 items-end"> <form onSubmit={handleFilterSubmit} className="flex flex-wrap gap-2 items-end">
<div className="relative"> <div className="relative">
......
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/auth';
import { Shield, Mail, UserCheck } from 'lucide-react'; import { Shield, Mail, UserCheck } from 'lucide-react';
export const Profile: React.FC = () => { export const Profile: React.FC = () => {
...@@ -8,13 +8,13 @@ export const Profile: React.FC = () => { ...@@ -8,13 +8,13 @@ export const Profile: React.FC = () => {
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Hồ sơ cá nhân</h1> <h1 className="text-2xl font-bold tracking-tight text-slate-900">Hồ sơ cá nhân</h1>
<p className="text-sm text-slate-500">Xem và quản lý thông tin tài khoản của bạn.</p> <p className="text-sm text-slate-500">Xem thông tin tài khoản và quyền truy cập của bạn.</p>
</div> </div>
<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="p-6 sm:p-8 space-y-6"> <div className="p-6 sm:p-8 space-y-6">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-indigo-150 flex items-center justify-center text-indigo-700 font-bold uppercase text-2xl border border-indigo-200"> <div className="h-16 w-16 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-bold uppercase text-2xl border border-indigo-200">
{user?.email?.charAt(0) || 'U'} {user?.email?.charAt(0) || 'U'}
</div> </div>
<div> <div>
......
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2 } from 'lucide-react'; import { Database, Lock, Mail, Loader2 } from 'lucide-react';
export const Register: React.FC = () => { export const Register: React.FC = () => {
......
...@@ -55,6 +55,7 @@ export const Users: React.FC = () => { ...@@ -55,6 +55,7 @@ export const Users: React.FC = () => {
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 10, totalPages: 1 }); const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 10, totalPages: 1 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
...@@ -84,9 +85,11 @@ export const Users: React.FC = () => { ...@@ -84,9 +85,11 @@ export const Users: React.FC = () => {
if (res.data.success) { if (res.data.success) {
setUsers(res.data.data.users || []); setUsers(res.data.data.users || []);
setMeta(res.data.data.meta); setMeta(res.data.data.meta);
setLoadError('');
} }
} catch (e) { } catch (e) {
console.error('Failed to fetch users', e); console.error('Failed to fetch users', e);
setLoadError('Không thể tải danh sách người dùng. Vui lòng thử lại.');
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
...@@ -201,6 +204,13 @@ export const Users: React.FC = () => { ...@@ -201,6 +204,13 @@ export const Users: React.FC = () => {
</div> </div>
</div> </div>
{loadError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{loadError}</span>
<button type="button" onClick={() => void fetchUsers(meta.page, search)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{/* Search */} {/* Search */}
<form onSubmit={handleSearch} className="flex gap-2"> <form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1 max-w-sm"> <div className="relative flex-1 max-w-sm">
......
import axios from 'axios'; import axios from 'axios';
const API_BASE_URL = 'http://171.247.68.96:4011/api/v1'; const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://171.247.68.96:4011/api/v1')
.replace(/\/$/, '');
export const api = axios.create({ export const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
...@@ -68,6 +69,8 @@ api.interceptors.response.use( ...@@ -68,6 +69,8 @@ api.interceptors.response.use(
const refreshToken = localStorage.getItem('refreshToken'); const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) { if (!refreshToken) {
processQueue(error, null);
isRefreshing = false;
handleLogout(); handleLogout();
return Promise.reject(error); return Promise.reject(error);
} }
......
const ACTIVE_STATUSES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT']);
export interface JobProgressSource {
status: string;
processedPages?: number;
progressTotal?: number;
successPages: number;
failedPages: number;
totalPages: number;
maxPages: number;
}
export interface JobProgress {
completed: number;
total: number;
percent: number;
}
export function getJobProgress(job: JobProgressSource): JobProgress {
const hasLiveProgress =
ACTIVE_STATUSES.has(job.status) &&
typeof job.processedPages === 'number' &&
typeof job.progressTotal === 'number' &&
job.progressTotal > 0;
const completed = hasLiveProgress
? Math.max(0, job.processedPages ?? 0)
: Math.max(0, job.successPages + job.failedPages);
const total = hasLiveProgress
? Math.max(completed, job.progressTotal ?? 0)
: Math.max(completed, job.totalPages || job.progressTotal || job.maxPages || 0);
const percent = job.status === 'COMPLETED'
? 100
: total > 0
? Math.min(100, Math.round((completed / total) * 100))
: 0;
return { completed, total, percent };
}
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