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
## ⚙️ 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
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.
> **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.
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`.
---
......
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
interface GuestRouteProps {
children: React.ReactNode;
......
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import type { UserRole } from '../context/AuthContext';
import { useAuth, type UserRole } from '../context/auth';
interface ProtectedRouteProps {
children: React.ReactNode;
......
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { api } from '../services/api';
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);
import { AuthContext, type User } from './auth';
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
......@@ -86,11 +64,3 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
</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 { 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';
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 (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800">
{/* Sidebar */}
......@@ -60,7 +65,7 @@ export const DashboardLayout: React.FC = () => {
{navItems
.filter((item) => item.allowed)
.map((item) => {
const isActive = location.pathname === item.path;
const isActive = isPathActive(item.path);
return (
<Link
key={item.name}
......@@ -130,8 +135,28 @@ export const DashboardLayout: React.FC = () => {
</div>
</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 */}
<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 />
</main>
</div>
......
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
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 { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
const POLL_INTERVAL_MS = 5000;
interface CrawlJob {
id: string;
......@@ -14,16 +18,28 @@ interface CrawlJob {
totalPages: number;
successPages: number;
failedPages: number;
processedPages?: number;
progressTotal?: number;
errorMessage: string | null;
createdAt: string;
}
interface JobsMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export const Dashboard: React.FC = () => {
const { isCrawler } = useAuth();
const [jobs, setJobs] = useState<CrawlJob[]>([]);
const [meta, setMeta] = useState<JobsMeta>({ total: 0, page: 1, limit: 20, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const latestRequestRef = useRef(0);
// Form states
const [startUrl, setStartUrl] = useState('');
......@@ -34,43 +50,62 @@ export const Dashboard: React.FC = () => {
const [createError, setCreateError] = useState('');
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);
else setRefreshing(true);
try {
const response = await api.get('/crawl-jobs');
if (response.data.success) {
const response = await api.get('/crawl-jobs', { params: { page, limit: 20 } });
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);
setLoadError('');
}
} catch (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 {
setLoading(false);
setRefreshing(false);
if (requestId === latestRequestRef.current) {
setLoading(false);
setRefreshing(false);
}
}
};
}, []);
// Poll for job updates if any job is in active state
useEffect(() => {
fetchJobs();
void fetchJobs(1);
}, [fetchJobs]);
const interval = setInterval(() => {
const hasActiveJobs = jobs.some(
(job) => ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'].includes(job.status)
);
if (hasActiveJobs || jobs.length === 0) {
fetchJobs(true);
}
}, 5000);
const hasActiveJobs = useMemo(
() => jobs.some((job) => ACTIVE_STATUSES.includes(job.status)),
[jobs],
);
// Self-scheduling polling avoids overlapping requests and always uses current state.
useEffect(() => {
if (!hasActiveJobs && jobs.length > 0) return;
return () => clearInterval(interval);
}, [jobs.length]);
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) => {
if (!window.confirm('Bạn có chắc chắn muốn hủy Job này không?')) return;
try {
await api.post(`/crawl-jobs/${id}/cancel`);
fetchJobs(true);
await fetchJobs(meta.page, true);
} catch (error: any) {
alert(error.response?.data?.message || 'Không thể hủy Job này.');
}
......@@ -88,10 +123,10 @@ export const Dashboard: React.FC = () => {
: undefined;
const response = await api.post('/crawl-jobs', {
...(isUrlListMode ? { urls: parsedUrls } : { startUrl }),
...(isUrlListMode
? { urls: parsedUrls }
: { startUrl, maxPages: Number(maxPages), maxDepth: Number(maxDepth) }),
mode,
maxPages: Number(maxPages),
maxDepth: isUrlListMode ? undefined : Number(maxDepth),
});
if (response.data.success) {
......@@ -102,7 +137,7 @@ export const Dashboard: React.FC = () => {
setMaxPages(20);
setMaxDepth(1);
setUrlsList('');
fetchJobs(true);
await fetchJobs(1, true);
}
} catch (error: any) {
setCreateError(error.response?.data?.message || 'Có lỗi xảy ra khi tạo Job.');
......@@ -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>;
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>;
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:
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 (
<div className="space-y-6">
{/* 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>
<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>
</div>
<div className="flex space-x-2">
<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"
title="Làm mới"
>
......@@ -162,6 +201,15 @@ export const Dashboard: React.FC = () => {
</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 */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
{loading ? (
......@@ -200,7 +248,11 @@ export const Dashboard: React.FC = () => {
</tr>
</thead>
<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">
<td className="px-6 py-4 max-w-xs md:max-w-md truncate">
<div className="flex flex-col">
......@@ -219,7 +271,9 @@ export const Dashboard: React.FC = () => {
<td className="px-6 py-4">
<div className="flex flex-col">
<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>
{job.failedPages > 0 && (
<span className="text-xs text-rose-500">{job.failedPages} lỗi</span>
......@@ -249,19 +303,44 @@ export const Dashboard: React.FC = () => {
)}
</td>
</tr>
))}
);
})}
</tbody>
</table>
</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>
{/* Create Job Modal */}
{showCreateModal && (
<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">
<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
onClick={() => setShowCreateModal(false)}
className="text-slate-400 hover:text-slate-600"
......@@ -325,7 +404,7 @@ export const Dashboard: React.FC = () => {
</select>
</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>
<input
type="number"
......@@ -335,10 +414,10 @@ export const Dashboard: React.FC = () => {
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"
/>
</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>
<input
type="number"
......@@ -348,7 +427,7 @@ export const Dashboard: React.FC = () => {
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"
/>
</div>
</div>}
<div className="flex justify-end space-x-2 pt-4 border-t border-slate-100">
<button
......
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../services/api';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
import {
ArrowLeft,
RefreshCw,
......@@ -14,6 +14,7 @@ import {
Download,
Loader2,
} from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
const POLL_INTERVAL_MS = 3000;
......@@ -29,6 +30,8 @@ interface CrawlJob {
totalPages: number;
successPages: number;
failedPages: number;
processedPages?: number;
progressTotal?: number;
errorMessage: string | null;
startedAt: string | null;
finishedAt: string | null;
......@@ -68,40 +71,56 @@ export const JobDetail: React.FC = () => {
const [loadingJob, setLoadingJob] = useState(true);
const [loadingPages, setLoadingPages] = useState(true);
const [loadingExport, setLoadingExport] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [selectedExportType, setSelectedExportType] = useState('JSON');
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 {
const res = await api.get(`/crawl-jobs/${id}`);
if (res.data.success) {
if (res.data.success && requestId === latestJobRequestRef.current) {
setJob(res.data.data);
setJobError('');
setLastUpdatedAt(new Date());
}
} catch (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 {
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 {
if (!silent) setLoadingPages(true);
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 || []);
setPagesMeta(res.data.data.meta);
setPagesError('');
}
} catch (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 {
if (!silent) setLoadingPages(false);
if (!silent && requestId === latestPagesRequestRef.current) setLoadingPages(false);
}
};
}, [id]);
const fetchExports = async () => {
const fetchExports = useCallback(async () => {
try {
const res = await api.get(`/crawl-jobs/${id}/exports`);
if (res.data.success) {
......@@ -110,40 +129,35 @@ export const JobDetail: React.FC = () => {
} catch (e) {
console.error('Failed to fetch exports', e);
}
};
}, [id]);
// Initial load
useEffect(() => {
if (!id) return;
fetchJob();
fetchPages();
fetchExports();
}, [id]);
void fetchJob();
void fetchPages();
void fetchExports();
}, [fetchExports, fetchJob, fetchPages, id]);
const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false;
// Auto-polling while job is active
// Self-scheduling polling prevents overlap and keeps the currently viewed page.
useEffect(() => {
if (!job) return;
const isActive = ACTIVE_STATUSES.includes(job.status);
if (isActive) {
intervalRef.current = setInterval(async () => {
await fetchJob();
await fetchPages(pagesMeta.page, true);
}, POLL_INTERVAL_MS);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
if (!isActive) return;
let stopped = false;
let timeout: ReturnType<typeof setTimeout>;
const poll = async () => {
await Promise.all([fetchJob(), fetchPages(pagesMeta.page, true)]);
if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
};
timeout = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
stopped = true;
clearTimeout(timeout);
};
}, [job?.status]);
}, [fetchJob, fetchPages, isActive, pagesMeta.page]);
const handleCreateExport = async () => {
setExportError('');
......@@ -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) => {
try {
console.log(`Downloading export ${exportId}, requesting blob...`);
......@@ -226,13 +253,10 @@ export const JobDetail: React.FC = () => {
}
};
const progressPercent = job
? job.totalPages > 0
? Math.min(100, Math.round((job.successPages / job.totalPages) * 100))
: job.status === 'COMPLETED' ? 100 : 0
: 0;
const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false;
const progress = job ? getJobProgress(job) : { completed: 0, total: 0, percent: 0 };
const hasProgressData = job
? progress.completed > 0 || job.totalPages > 0 || (job.progressTotal ?? 0) > 0
: false;
if (loadingJob) {
return (
......@@ -245,7 +269,7 @@ export const JobDetail: React.FC = () => {
if (!job) {
return (
<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">
<ArrowLeft className="h-4 w-4 mr-1" /> Quay lại danh sách
</Link>
......@@ -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="h-2 w-2 rounded-full bg-blue-500 inline-block"></span>
Đang cập nhật tự động
{lastUpdatedAt && <span className="font-normal text-slate-400">· {lastUpdatedAt.toLocaleTimeString('vi-VN')}</span>}
</span>
)}
{isCrawler && isActive && (
<button
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={() => { fetchJob(); fetchPages(); fetchExports(); }}
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"
title="Làm mới"
>
......@@ -313,25 +349,31 @@ export const JobDetail: React.FC = () => {
<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-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>}
{isActive && <span className="text-slate-400 ml-2 text-xs">(cập nhật mỗi {POLL_INTERVAL_MS / 1000}s)</span>}
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden">
<div
className={`h-3 rounded-full transition-all duration-500 ${
job.status === 'FAILED' ? 'bg-rose-500' :
job.status === 'CANCELED' ? 'bg-slate-400' :
'bg-indigo-500'
} ${isActive ? 'animate-pulse' : ''}`}
style={{ width: `${progressPercent}%` }}
/>
{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
className={`h-3 rounded-full transition-all duration-500 ${
job.status === 'FAILED' ? 'bg-rose-500' :
job.status === 'CANCELED' ? 'bg-slate-400' :
'bg-indigo-500'
} ${isActive ? 'animate-pulse' : ''}`}
style={{ width: `${progress.percent}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs text-slate-400 mt-1">
<span>0</span>
<span>{progressPercent}%</span>
<span>{job.maxPages} trang</span>
<span>{isActive && !hasProgressData ? 'Đang chạy' : `${progress.percent}%`}</span>
<span>{hasProgressData ? progress.total : job.maxPages} trang</span>
</div>
</div>
......@@ -426,6 +468,13 @@ export const JobDetail: React.FC = () => {
)}
</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 ? (
<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>
......
import React, { useState } from 'react';
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';
export const Login: React.FC = () => {
......
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { api } from '../services/api';
import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react';
......@@ -37,6 +37,7 @@ export const Logs: React.FC = () => {
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [userIdFilter, setUserIdFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
......@@ -45,7 +46,7 @@ export const Logs: React.FC = () => {
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);
else setRefreshing(true);
try {
......@@ -60,18 +61,20 @@ export const Logs: React.FC = () => {
if (res.data.success) {
setLogs(res.data.data.logs || []);
setMeta(res.data.data.meta);
setLoadError('');
}
} catch (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 {
setLoading(false);
setRefreshing(false);
}
};
}, [actionFilter, userIdFilter]);
useEffect(() => {
fetchLogs(1);
}, [userIdFilter, actionFilter]);
}, [fetchLogs]);
const handleFilterSubmit = (e: React.FormEvent) => {
e.preventDefault();
......@@ -117,6 +120,13 @@ export const Logs: React.FC = () => {
</button>
</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 */}
<form onSubmit={handleFilterSubmit} className="flex flex-wrap gap-2 items-end">
<div className="relative">
......
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
import { Shield, Mail, UserCheck } from 'lucide-react';
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>
<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 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="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'}
</div>
<div>
......
import React, { useState } from 'react';
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';
export const Register: 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 [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
......@@ -84,9 +85,11 @@ export const Users: React.FC = () => {
if (res.data.success) {
setUsers(res.data.data.users || []);
setMeta(res.data.data.meta);
setLoadError('');
}
} catch (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 {
setLoading(false);
setRefreshing(false);
......@@ -201,6 +204,13 @@ export const Users: React.FC = () => {
</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 */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1 max-w-sm">
......
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({
baseURL: API_BASE_URL,
......@@ -68,6 +69,8 @@ api.interceptors.response.use(
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
processQueue(error, null);
isRefreshing = false;
handleLogout();
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