Commit 2fcba00c authored by ThinhNC's avatar ThinhNC

Merge branch 'fix/frontend-audit-findings-and-bff-proxy' into 'develop'

fix: resolve 20 audit findings and stabilize BFF proxy integration

See merge request !17
parents f048c91d be29c7a3
......@@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js ."
},
"dependencies": {
"@hookform/resolvers": "^5.9.1",
......
......@@ -13,17 +13,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getSafeRedirectUrl } from "@/lib/utils";
function LoginForm() {
const { t } = useLanguage();
const { login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const rawRedirect = searchParams.get("redirect") || "/";
// Never redirect back to auth pages to prevent loops
const redirectUrl =
rawRedirect.startsWith("/login") || rawRedirect.startsWith("/register")
? "/"
: rawRedirect;
const redirectUrl = getSafeRedirectUrl(searchParams.get("redirect"), "/");
const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
......@@ -45,8 +42,8 @@ function LoginForm() {
setAuthError(null);
try {
await login(data, data.rememberMe ?? true);
// Force hard reload navigation so Next.js server components and middleware receive fresh HttpOnly cookies
window.location.href = redirectUrl;
router.replace(redirectUrl);
router.refresh();
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
......
......@@ -26,12 +26,13 @@ function VerifyEmailContent() {
const searchParams = useSearchParams();
const tokenParam = searchParams.get("token");
const [status, setStatus] = useState<VerifyStatus>("idle");
const [status, setStatus] = useState<VerifyStatus>(tokenParam ? "verifying" : "idle");
const [errorMessage, setErrorMessage] = useState<string>("");
const [manualToken, setManualToken] = useState("");
const [resendEmail, setResendEmail] = useState("");
const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const verifiedRef = React.useRef(false);
const handleVerify = useCallback(async (tokenToVerify: string) => {
if (!tokenToVerify) return;
......@@ -52,10 +53,25 @@ function VerifyEmailContent() {
}, []);
useEffect(() => {
if (tokenParam) {
handleVerify(tokenParam);
}
}, [tokenParam, handleVerify]);
if (!tokenParam || verifiedRef.current) return;
verifiedRef.current = true;
let isMounted = true;
authService.verifyEmail(tokenParam)
.then(() => {
if (isMounted) setStatus("success");
})
.catch((err: unknown) => {
if (isMounted) {
setStatus("error");
setErrorMessage(err instanceof Error ? err.message : "Mã kích hoạt không hợp lệ hoặc đã hết hạn.");
}
});
return () => {
isMounted = false;
};
}, [tokenParam]);
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault();
......
......@@ -41,7 +41,7 @@ async function handleProxy(
}
let body: BodyInit | undefined = undefined;
if (["POST", "PUT", "PATCH"].includes(request.method)) {
if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) {
const rawBody = await request.arrayBuffer();
if (rawBody.byteLength > 0) {
body = Buffer.from(rawBody);
......
......@@ -114,6 +114,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
const wasRunningRef = useRef<boolean>(false);
const hasNotifiedRef = useRef<boolean>(false);
const isJobInitialActive = !initialJob || initialJob.status === "RUNNING" || initialJob.status === "PROCESSING_EXPORT" || initialJob.status === "PENDING";
const isJobActive = liveProgressJob
? liveProgressJob.status === "RUNNING" || liveProgressJob.status === "PROCESSING_EXPORT" || liveProgressJob.status === "PENDING"
: isJobInitialActive;
const {
status: sseStatus,
retryCount: sseRetryCount,
......@@ -122,7 +127,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
} = useEventSource<CrawlJob>(
jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null,
{
enabled: Boolean(jobId),
enabled: Boolean(jobId) && isJobActive,
onInitial: (data) => setLiveProgressJob(data),
onProgress: (data) => {
setLiveProgressJob(data);
......@@ -179,18 +184,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
}
}, [job?.status, job?.id, job?.successPages, job?.totalPages, job?.domain, t.notifications]);
// Query Logs (active when on logs tab)
// Query Logs (active when on logs tab and job is active)
const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs(
jobId,
{ limit: 100 },
{ refetchInterval: activeTab === "logs" ? 4000 : false }
{ refetchInterval: activeTab === "logs" && isJobActive ? 4000 : false }
);
// Query Crawled Pages (active when on pages tab, auto-poll while running)
const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId,
{ limit: 50 },
{ refetchInterval: activeTab === "pages" && (liveProgressJob?.status === "RUNNING" || initialJob?.status === "RUNNING") ? 3000 : false }
{ refetchInterval: activeTab === "pages" && isJobActive ? 3000 : false }
);
// Query Diff Report (active when on diff tab)
......
......@@ -12,6 +12,7 @@ import {
import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function ApiKeysPage() {
const { t, locale } = useLanguage();
......@@ -19,6 +20,7 @@ export default function ApiKeysPage() {
const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
......@@ -51,8 +53,13 @@ console.log('Job Created:', response.data);`;
};
const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) {
revokeApiKeyMutation.mutate(key.id);
setKeyToRevoke(key);
};
const confirmRevokeKey = () => {
if (keyToRevoke) {
revokeApiKeyMutation.mutate(keyToRevoke.id);
setKeyToRevoke(null);
}
};
......@@ -257,6 +264,25 @@ console.log('Job Created:', response.data);`;
isOpen={isKeyModalOpen}
onClose={() => setIsKeyModalOpen(false)}
/>
{/* Revoke Confirmation Dialog */}
<ConfirmDialog
open={!!keyToRevoke}
onOpenChange={(open) => !open && setKeyToRevoke(null)}
title={t.developer.apiKeys.table.revoke}
description={
keyToRevoke ? (
<span>
{t.developer.apiKeys.table.revokeConfirm}:{" "}
<strong className="text-foreground">{keyToRevoke.name}</strong>
</span>
) : undefined
}
confirmText={t.developer.apiKeys.table.revoke}
variant="destructive"
isLoading={revokeApiKeyMutation.isPending}
onConfirm={confirmRevokeKey}
/>
</div>
);
}
......@@ -29,6 +29,7 @@ import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
import { CopyButton } from "@/components/common/copy-button";
import { JsonViewer } from "@/components/common/json-viewer";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function WebhooksPage() {
const { t, locale } = useLanguage();
......@@ -41,6 +42,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const [webhookToDelete, setWebhookToDelete] = useState<WebhookConfig | null>(null);
const deliveries = deliveriesData?.items || [];
......@@ -49,8 +51,13 @@ export default function WebhooksPage() {
};
const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) {
deleteWebhookMutation.mutate(wh.id);
setWebhookToDelete(wh);
};
const confirmDeleteWebhook = () => {
if (webhookToDelete) {
deleteWebhookMutation.mutate(webhookToDelete.id);
setWebhookToDelete(null);
}
};
......
......@@ -53,8 +53,12 @@ export default function ProfileSettingsPage() {
const revokeSessionsMutation = useRevokeAllSessions();
// Form states
const [fullName, setFullName] = useState(user?.fullName || "");
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatarUrl || null);
const [userEnteredFullName, setUserEnteredFullName] = useState<string | null>(null);
const fullName = userEnteredFullName ?? (profile?.fullName ?? user?.fullName ?? "");
const [localAvatarPreview, setLocalAvatarPreview] = useState<string | null>(null);
const avatarPreview = localAvatarPreview ?? (profile?.avatarUrl ?? user?.avatarUrl ?? null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Copy ID state
......@@ -73,21 +77,6 @@ export default function ProfileSettingsPage() {
const [deactivatePassword, setDeactivatePassword] = useState("");
const [showDeactivatePassword, setShowDeactivatePassword] = useState(false);
// Keep state synced with user profile
React.useEffect(() => {
if (profile) {
if (profile.fullName !== undefined && profile.fullName !== null) {
setFullName(profile.fullName);
}
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) {
if (user.fullName !== undefined && user.fullName !== null) {
setFullName(user.fullName);
}
if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
}
}, [profile, user]);
// Compute if personal profile has changes
const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim();
const isProfileChanged =
......@@ -112,7 +101,7 @@ export default function ProfileSettingsPage() {
// Instant local preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
setLocalAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
......@@ -124,12 +113,19 @@ export default function ProfileSettingsPage() {
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() });
updateProfileMutation.mutate(
{ fullName: fullName.trim() },
{
onSuccess: () => {
setUserEnteredFullName(null);
},
}
);
};
// Revert Profile Name Handler
const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? "");
setUserEnteredFullName(null);
};
// Change Password Handler
......@@ -341,7 +337,7 @@ export default function ProfileSettingsPage() {
<Input
id="prof-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
onChange={(e) => setUserEnteredFullName(e.target.value)}
placeholder={t.profile.personal.namePlaceholder}
className="rounded-2xl text-xs"
/>
......
......@@ -9,6 +9,7 @@ import { AuditLogItem, AuditLogQuery } from "@/types/audit-log";
import { AuditLogsFilters, DatePreset } from "./audit-logs-filters";
import { AuditLogsTable } from "./audit-logs-table";
import { AuditLogDetailDrawer } from "./audit-log-detail-drawer";
import { getLocalDateRangeISO } from "@/lib/utils";
interface AuditLogsViewProps {
showHeader?: boolean;
......@@ -59,10 +60,11 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
const now = new Date();
if (datePreset === "today") {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
return {
queryStartDate: startOfDay.toISOString(),
queryEndDate: now.toISOString(),
queryEndDate: endOfDay.toISOString(),
};
}
if (datePreset === "7d") {
......@@ -80,9 +82,10 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
};
}
if (datePreset === "custom") {
const { startDate: qStart, endDate: qEnd } = getLocalDateRangeISO(startDate, endDate);
return {
queryStartDate: startDate ? new Date(startDate).toISOString() : undefined,
queryEndDate: endDate ? new Date(`${endDate}T23:59:59.999Z`).toISOString() : undefined,
queryStartDate: qStart,
queryEndDate: qEnd,
};
}
......
......@@ -6,14 +6,12 @@ import { usePathname } from "next/navigation";
import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
const emptySubscribe = () => () => {};
export function BottomNav() {
const { t } = useLanguage();
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const mounted = React.useSyncExternalStore(emptySubscribe, () => true, () => false);
const navItems = [
{
......
......@@ -99,13 +99,13 @@ export function SafeHtmlPreview({
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<span>Cô lập bảo mật Sandbox &amp; DOMPurify (Chống XSS)</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;allow-same-origin&quot;</span>
<span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;&quot; (Strict Isolation)</span>
</div>
<iframe
title={title}
srcDoc={srcDoc}
sandbox="allow-same-origin"
sandbox=""
className="w-full border-0 transition-all bg-background"
style={{ height: maxHeight }}
/>
......
......@@ -121,6 +121,9 @@ export function UserMenu() {
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label={user.fullName || user.email}
className="flex items-center gap-2 rounded-2xl border border-border/80 bg-card/60 p-1.5 pl-2 pr-2.5 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
>
{/* Avatar */}
......@@ -146,7 +149,11 @@ export function UserMenu() {
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
<div
role="menu"
aria-orientation="vertical"
className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"
>
{/* User Header */}
<div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2">
<p className="font-semibold text-xs text-foreground truncate">
......@@ -170,6 +177,7 @@ export function UserMenu() {
<div className="space-y-1">
<Link
href="/settings/profile"
role="menuitem"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
......@@ -180,6 +188,7 @@ export function UserMenu() {
{canAccessDeveloper && (
<Link
href={developerHref}
role="menuitem"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
......@@ -194,6 +203,7 @@ export function UserMenu() {
{/* Logout Action */}
<button
role="menuitem"
onClick={() => {
setIsOpen(false);
logout();
......
......@@ -158,15 +158,8 @@ export function CrawlerTaskTable() {
};
const jobs = jobsData?.items || [];
const rawTotal =
jobsData?.total ??
(jobsData as unknown as { meta?: { total?: number } })?.meta?.total;
const total =
rawTotal !== undefined && rawTotal > 0 ? rawTotal : jobs.length;
const rawTotalPages =
jobsData?.totalPages ??
(jobsData as unknown as { meta?: { totalPages?: number } })?.meta?.totalPages;
const totalPages = Math.max(1, rawTotalPages ?? Math.ceil(total / limit));
const total = jobsData?.total !== undefined && jobsData.total > 0 ? jobsData.total : jobs.length;
const totalPages = Math.max(1, jobsData?.totalPages ?? Math.ceil(total / limit));
return (
<div className="space-y-4">
......
......@@ -2,12 +2,18 @@
import React, { useState, useRef, useEffect } from "react";
import {
X,
KeyRound,
Sparkles,
ChevronDown,
Check,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -66,8 +72,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
......@@ -90,34 +94,26 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-lg flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-lg rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<KeyRound className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.developer.apiKeys.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.modal.desc}
</p>
</div>
<DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<KeyRound className="h-5 w-5" />
</div>
<button
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div>
<DialogTitle className="text-base font-bold tracking-tight text-foreground">
{t.developer.apiKeys.modal.title}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t.developer.apiKeys.modal.desc}
</DialogDescription>
</div>
</DialogHeader>
{/* Content: Form or Secret Key View */}
{createdKeyData ? (
<div className="py-4 space-y-4">
<div className="py-2 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<Sparkles className="h-4 w-4" />
......@@ -157,7 +153,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4">
<form onSubmit={handleSubmit} className="py-2 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-xs font-semibold">
{t.developer.apiKeys.modal.nameLabel}
......@@ -227,7 +223,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
<Button
type="button"
variant="outline"
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.apiKeys.modal.cancel}
......@@ -244,7 +240,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div>
</form>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
......@@ -2,12 +2,18 @@
import React, { useState } from "react";
import {
X,
Webhook,
Check,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -82,8 +88,6 @@ export function CreateWebhookModal({
onClose();
};
if (!isOpen) return null;
const toggleEvent = (eventVal: string) => {
setSelectedEvents((prev) =>
prev.includes(eventVal)
......@@ -123,40 +127,32 @@ export function CreateWebhookModal({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-xl rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Webhook className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.title
: isEditing
? t.developer.webhooks.modal.editTitle
: t.developer.webhooks.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.desc
: t.developer.webhooks.modal.desc}
</p>
</div>
<DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Webhook className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base font-bold tracking-tight text-foreground">
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.title
: isEditing
? t.developer.webhooks.modal.editTitle
: t.developer.webhooks.modal.title}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.desc
: t.developer.webhooks.modal.desc}
</DialogDescription>
</div>
<button
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
</DialogHeader>
{/* Content: Form or 1-Time Secret View */}
{createdSecretData ? (
<div className="py-4 space-y-4">
<div className="py-2 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<ShieldCheck className="h-4 w-4" />
......@@ -198,106 +194,106 @@ export function CreateWebhookModal({
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.urlLabel}
</Label>
<Input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t.developer.webhooks.modal.urlPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
{/* HMAC Secret */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<form onSubmit={handleSubmit} className="py-2 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel}
{t.developer.webhooks.modal.urlLabel}
</Label>
<button
type="button"
onClick={generateRandomSecret}
className="inline-flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
>
<RefreshCw className="h-3 w-3" />
<span>{t.developer.webhooks.modal.generateSecret}</span>
</button>
<Input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t.developer.webhooks.modal.urlPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
<Input
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={isEditing ? "Giữ nguyên nếu không muốn đổi khóa..." : t.developer.webhooks.modal.secretPlaceholder}
className="rounded-2xl font-mono text-xs"
minLength={isEditing ? 0 : 16}
/>
</div>
{/* Events Multi-select */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.eventsLabel}
</Label>
<div className="space-y-2">
{AVAILABLE_EVENTS.map((ev) => {
const isChecked = selectedEvents.includes(ev.value);
return (
<label
key={ev.value}
onClick={() => toggleEvent(ev.value)}
className={`flex items-center justify-between p-2.5 rounded-2xl border transition-colors cursor-pointer select-none ${
isChecked
? "border-emerald-500/40 bg-emerald-500/10 text-foreground"
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`}
>
<div>
<p className="text-xs font-semibold text-foreground font-mono">{ev.value}</p>
<p className="text-[11px] text-muted-foreground">{ev[locale === "vi" ? "labelVi" : "labelEn"]}</p>
</div>
<div
className={`h-4 w-4 rounded-md border flex items-center justify-center transition-colors ${
{/* HMAC Secret */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel}
</Label>
<button
type="button"
onClick={generateRandomSecret}
className="inline-flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
>
<RefreshCw className="h-3 w-3" />
<span>{t.developer.webhooks.modal.generateSecret}</span>
</button>
</div>
<Input
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={isEditing ? "Giữ nguyên nếu không muốn đổi khóa..." : t.developer.webhooks.modal.secretPlaceholder}
className="rounded-2xl font-mono text-xs"
minLength={isEditing ? 0 : 16}
/>
</div>
{/* Events Multi-select */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.eventsLabel}
</Label>
<div className="space-y-2">
{AVAILABLE_EVENTS.map((ev) => {
const isChecked = selectedEvents.includes(ev.value);
return (
<label
key={ev.value}
onClick={() => toggleEvent(ev.value)}
className={`flex items-center justify-between p-2.5 rounded-2xl border transition-colors cursor-pointer select-none ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-border"
? "border-emerald-500/40 bg-emerald-500/10 text-foreground"
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`}
>
{isChecked && <Check className="h-3 w-3" />}
</div>
</label>
);
})}
<div>
<p className="text-xs font-semibold text-foreground font-mono">{ev.value}</p>
<p className="text-[11px] text-muted-foreground">{ev[locale === "vi" ? "labelVi" : "labelEn"]}</p>
</div>
<div
className={`h-4 w-4 rounded-md border flex items-center justify-center transition-colors ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-border"
}`}
>
{isChecked && <Check className="h-3 w-3" />}
</div>
</label>
);
})}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.webhooks.modal.cancel}
</Button>
<Button
type="submit"
disabled={isSubmitting || !url.trim() || selectedEvents.length === 0}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{isSubmitting
? t.developer.webhooks.modal.submitting
: t.developer.webhooks.modal.submit}
</Button>
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.webhooks.modal.cancel}
</Button>
<Button
type="submit"
disabled={isSubmitting || !url.trim() || selectedEvents.length === 0}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{isSubmitting
? t.developer.webhooks.modal.submitting
: t.developer.webhooks.modal.submit}
</Button>
</div>
</form>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
......@@ -82,16 +82,12 @@ export function CreateExportModal({
const { data: jobsData } = useCrawlJobs({ limit: 10 });
const createExportMutation = useCreateCrawlExport();
const [selectedJobId, setSelectedJobId] = useState(defaultJobId);
const [userSelectedJobId, setUserSelectedJobId] = useState<string | null>(null);
const selectedJobId = userSelectedJobId ?? defaultJobId;
const setSelectedJobId = (id: string) => setUserSelectedJobId(id);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState("");
useEffect(() => {
if (defaultJobId) {
setSelectedJobId(defaultJobId);
}
}, [defaultJobId, isOpen]);
const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId);
......@@ -166,7 +162,7 @@ export function CreateExportModal({
<button
key={job.id}
type="button"
onClick={() => setSelectedJobId(job.id)}
onClick={() => setUserSelectedJobId(job.id)}
className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${
selectedJobId === job.id
? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm"
......
......@@ -129,24 +129,26 @@ export function PermissionMatrixDialog({
// Mutation to save matrix: PUT /roles/:id/permissions
const setPermissionsMutation = useSetRolePermissions();
// Local state of selected permission IDs
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// User modified permission IDs (or null if using server assigned permissions)
const [userSelectedIds, setUserSelectedIds] = useState<string[] | null>(null);
const [lastRoleId, setLastRoleId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// Sync initial permissions into local state with equality check
useEffect(() => {
if (!isOpen || !rolePermissions) return;
const initialIds = rolePermissions.map((p) => p.id);
setSelectedIds((prev) => {
if (
prev.length === initialIds.length &&
prev.every((id) => initialIds.includes(id))
) {
return prev;
}
return initialIds;
});
}, [isOpen, rolePermissions]);
if (role?.id !== lastRoleId) {
setLastRoleId(role?.id || null);
setUserSelectedIds(null);
}
const rolePermIds = useMemo(() => rolePermissions?.map((p) => p.id) || [], [rolePermissions]);
const selectedIds = userSelectedIds ?? rolePermIds;
const setSelectedIds = (action: string[] | ((prev: string[]) => string[])) => {
if (typeof action === "function") {
setUserSelectedIds(action(selectedIds));
} else {
setUserSelectedIds(action);
}
};
// Handle escape key
useEffect(() => {
......
......@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { X, ShieldPlus, Edit3, Sparkles, Gauge, Sliders, ChevronDown } from "lucide-react";
import { X, ShieldPlus, Edit3, Sparkles, Gauge, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -11,8 +11,8 @@ import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useCreateRole, useUpdateRole } from "@/hooks/use-roles";
import {
createRoleSchema,
CreateRoleInput,
roleFormSchema,
RoleFormInput,
} from "@/schemas/role.schema";
interface RoleFormModalProps {
......@@ -21,7 +21,7 @@ interface RoleFormModalProps {
roleToEdit?: RoleItem | null;
}
type RoleFormData = CreateRoleInput & { syncUsersQuota?: boolean };
type RoleFormData = RoleFormInput;
export function RoleFormModal({
isOpen,
......@@ -43,7 +43,7 @@ export function RoleFormModal({
reset,
formState: { errors, isSubmitting },
} = useForm<RoleFormData>({
resolver: zodResolver(createRoleSchema) as any,
resolver: zodResolver(roleFormSchema),
defaultValues: {
name: "",
slug: "",
......@@ -118,7 +118,7 @@ export function RoleFormModal({
if (!isOpen) return null;
const onSubmit = async (data: RoleFormData) => {
const parseOptionalNumber = (val: any) => {
const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null;
}
......
......@@ -22,21 +22,20 @@ export function RoleResetQuotaDialog({
const resetMutation = useResetRoleQuota();
const [syncLimits, setSyncLimits] = useState(false);
useEffect(() => {
if (isOpen) {
setSyncLimits(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
}, [isOpen]);
const handleClose = () => {
setSyncLimits(false);
onClose();
};
if (!isOpen || !role) return null;
......@@ -45,7 +44,7 @@ export function RoleResetQuotaDialog({
id: role.id,
payload: { syncLimits },
});
onClose();
handleClose();
};
return (
......
......@@ -77,9 +77,12 @@ export function RolesManagementView({
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
const [prevFilterKey, setPrevFilterKey] = useState("");
const currentFilterKey = `${debouncedSearch}|${selectedStatus}|${selectedType}`;
if (currentFilterKey !== prevFilterKey) {
setPrevFilterKey(currentFilterKey);
setPage(1);
}, [debouncedSearch, selectedStatus, selectedType]);
}
// Click outside dropdowns
useEffect(() => {
......
......@@ -72,7 +72,11 @@ export function SystemConfigFormModal({
const categoryRef = useRef<HTMLDivElement>(null);
// Detect and initialize values when modal opens or config changes
useEffect(() => {
const [lastConfigKey, setLastConfigKey] = useState<string | null>(null);
const currentKey = isOpen ? (config?.key || "__new__") : "__closed__";
if (currentKey !== lastConfigKey) {
setLastConfigKey(currentKey);
if (config) {
setKey(config.key);
setDescription(config.description || "");
......@@ -106,7 +110,7 @@ export function SystemConfigFormModal({
setIsPublic(false);
}
setValidationError(null);
}, [config, isOpen]);
}
// Click outside category dropdown
useEffect(() => {
......
......@@ -114,13 +114,13 @@ export function SystemConfigsManagementView({
const sortedItems = useMemo(() => {
if (!data?.items) return [];
return [...data.items].sort((a, b) => {
let aVal: any = a[sortBy as keyof SystemConfigItem];
let bVal: any = b[sortBy as keyof SystemConfigItem];
const aVal = a[sortBy as keyof SystemConfigItem];
const bVal = b[sortBy as keyof SystemConfigItem];
if (typeof aVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(String(bVal))
: String(bVal).localeCompare(aVal);
? aVal.localeCompare(String(bVal ?? ""))
: String(bVal ?? "").localeCompare(aVal);
}
if (typeof aVal === "boolean") {
return sortOrder === "asc"
......@@ -259,19 +259,21 @@ export function SystemConfigsManagementView({
{/* Category Pills */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0">
{[
{ key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
{ key: "SECURITY", label: t.systemConfigs.tabs.security },
].map((tab) => {
{(
[
{ key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
{ key: "SECURITY", label: t.systemConfigs.tabs.security },
] as const
).map((tab) => {
const isSelected = selectedCategory === tab.key;
return (
<button
key={tab.key}
onClick={() => {
setSelectedCategory(tab.key as any);
setSelectedCategory(tab.key);
setPage(1);
}}
className={`rounded-2xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all cursor-pointer ${
......
"use client";
import React from "react";
import { AlertTriangle, AlertCircle, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "default";
isLoading?: boolean;
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmText,
cancelText,
variant = "destructive",
isLoading = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { locale } = useLanguage();
const defaultConfirmText =
confirmText ??
(variant === "destructive"
? locale === "vi"
? "Xác nhận xóa"
: "Confirm Delete"
: locale === "vi"
? "Xác nhận"
: "Confirm");
const defaultCancelText =
cancelText ?? (locale === "vi" ? "Hủy bỏ" : "Cancel");
const handleClose = () => {
if (isLoading) return;
onOpenChange(false);
onCancel?.();
};
const handleConfirm = async () => {
await onConfirm();
};
const isDestructive = variant === "destructive";
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className={`sm:max-w-[440px] rounded-3xl border bg-card/95 backdrop-blur-xl p-6 shadow-2xl ${
isDestructive
? "border-rose-500/20 shadow-rose-950/20"
: "border-emerald-500/20 shadow-emerald-950/20"
}`}
>
<DialogHeader className="space-y-3">
<div
className={`mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border ${
isDestructive
? "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}`}
>
{isDestructive ? (
<AlertTriangle className="h-6 w-6" />
) : (
<AlertCircle className="h-6 w-6" />
)}
</div>
<DialogTitle className="text-center text-lg font-bold text-foreground">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-center text-xs text-muted-foreground leading-relaxed">
{description}
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="flex flex-col sm:flex-row gap-2 mt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="w-full sm:w-1/2 rounded-2xl border-border/80 text-xs font-semibold hover:bg-muted/70 cursor-pointer"
>
{defaultCancelText}
</Button>
<Button
type="button"
variant={isDestructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={isLoading}
className={`w-full sm:w-1/2 rounded-2xl text-xs font-bold shadow-md cursor-pointer ${
isDestructive
? "bg-rose-600 hover:bg-rose-700 text-white shadow-rose-600/20"
: "bg-emerald-600 hover:bg-emerald-700 text-white shadow-emerald-600/20"
}`}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
defaultConfirmText
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
......@@ -141,7 +141,7 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
if (!isOpen) return null;
const onSubmit = async (data: CreateUserInput) => {
const parseOptionalNumber = (val: any) => {
const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null;
}
......
......@@ -22,21 +22,20 @@ export function UserResetQuotaDialog({
const resetMutation = useResetUserQuota();
const [resetLimitsToRole, setResetLimitsToRole] = useState(false);
useEffect(() => {
if (isOpen) {
setResetLimitsToRole(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
}, [isOpen]);
const handleClose = () => {
setResetLimitsToRole(false);
onClose();
};
if (!isOpen || !user) return null;
......@@ -45,7 +44,7 @@ export function UserResetQuotaDialog({
id: user.id,
payload: { resetLimitsToRole },
});
onClose();
handleClose();
};
return (
......
......@@ -70,8 +70,6 @@ export function useCreateCrawlJob() {
});
}
const rerunInFlight = new Set<string>();
/**
* Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/
......@@ -79,18 +77,8 @@ export function useRerunCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
if (rerunInFlight.has(id)) {
return null as unknown as CrawlJob;
}
rerunInFlight.add(id);
try {
return await crawlJobService.rerunJob(id);
} finally {
setTimeout(() => rerunInFlight.delete(id), 2500);
}
},
onSuccess: (job) => {
mutationFn: (id: string) => crawlJobService.rerunJob(id),
onSuccess: (job: CrawlJob) => {
if (!job || !job.id) return;
toast.success("Đã kích hoạt chạy lại tác vụ", {
description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`,
......@@ -170,7 +158,7 @@ export function useCrawlJobLogs(
queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query),
queryFn: () => crawlJobService.getJobLogs(id, query),
enabled: Boolean(id),
refetchInterval: options?.refetchInterval ?? 5000,
refetchInterval: options?.refetchInterval ?? false,
});
}
......
......@@ -7,7 +7,7 @@ import { Locale, translations } from "./i18n/translations";
export function isConnectionLossError(
rawError: string | null | undefined
): boolean {
if (!rawError) return true;
if (!rawError) return false;
const err = rawError.toLowerCase();
......@@ -37,12 +37,8 @@ export function isConnectionLossError(
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
// Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504)
err.includes("402") ||
/\b(402|500|502|503|504)\b/.test(err) ||
err.includes("credit") ||
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("gateway") ||
err.includes("service unavailable") ||
......@@ -90,7 +86,7 @@ export function getLocalizedCrawlErrorInfo(
const err = raw.toLowerCase();
if (
err.includes("402") ||
/\b(402)\b/.test(err) ||
err.includes("credit") ||
err.includes("xác thực") ||
err.includes("api key") ||
......@@ -169,10 +165,7 @@ export function getLocalizedCrawlErrorInfo(
}
if (
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
/\b(500|502|503|504)\b/.test(err) ||
err.includes("bad gateway") ||
err.includes("service unavailable")
) {
......
......@@ -67,7 +67,7 @@ export function downloadBlob(
type: mimeType || "text/plain;charset=utf-8",
});
} else {
blob = new Blob([content as unknown as BlobPart], {
blob = new Blob([content as BlobPart], {
type: mimeType || "application/octet-stream",
});
}
......
......@@ -5,16 +5,92 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date | number, locale: string = "vi"): string {
export function formatDate(
date: string | Date | number | null | undefined,
locale: string = "vi",
options?: Intl.DateTimeFormatOptions
): string {
if (!date) return "-";
const d = new Date(date);
if (isNaN(d.getTime())) return "-";
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
...options,
}).format(d);
}
/**
* Validates and returns a safe relative redirect URL, preventing Open Redirect vulnerabilities.
*/
export function getSafeRedirectUrl(
url?: string | null,
defaultUrl: string = "/"
): string {
if (!url || typeof url !== "string") return defaultUrl;
const trimmed = url.trim();
// Must start with '/' and must NOT start with '//' (protocol-relative URL)
// or contain backslashes / protocol schemes (http:, https:, javascript:)
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
return defaultUrl;
}
// Avoid redirecting back to auth pages to prevent redirect loops
if (
trimmed.startsWith("/login") ||
trimmed.startsWith("/register") ||
trimmed.startsWith("/forgot-password") ||
trimmed.startsWith("/reset-password") ||
trimmed.startsWith("/verify-email")
) {
return defaultUrl;
}
// Safe relative URL path
return trimmed;
}
const VN_OFFSET_MS = 7 * 60 * 60 * 1000;
/**
* Converts YYYY-MM-DD local dates (Asia/Ho_Chi_Minh, UTC+7) into ISO UTC string range for start and end of day.
*/
export function getLocalDateRangeISO(
startDateStr?: string | null,
endDateStr?: string | null
): { startDate?: string; endDate?: string } {
let startDate: string | undefined = undefined;
let endDate: string | undefined = undefined;
if (startDateStr) {
const parts = startDateStr.split("-").map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
const [year, month, day] = parts;
const utcMs = Date.UTC(year, month - 1, day, 0, 0, 0, 0) - VN_OFFSET_MS;
startDate = new Date(utcMs).toISOString();
}
}
if (endDateStr) {
const parts = endDateStr.split("-").map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
const [year, month, day] = parts;
const utcMs = Date.UTC(year, month - 1, day, 23, 59, 59, 999) - VN_OFFSET_MS;
endDate = new Date(utcMs).toISOString();
}
}
return { startDate, endDate };
}
export function formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return "0 Bytes";
const k = 1024;
......
......@@ -10,20 +10,8 @@ const AUTH_ROUTES = [
"/verify-email",
];
// Routes strictly requiring ADMIN role (RBAC)
const ADMIN_ROUTES = [
"/admin",
"/users",
"/audit-logs",
"/roles",
"/system-configs",
"/settings/developer/users",
"/settings/developer/roles",
"/settings/developer/audit-logs",
"/settings/developer/system-configs",
"/settings/developer/cron",
"/cron",
];
// Strict ADMIN-only root routes
const STRICT_ADMIN_ROUTES = ["/admin", "/settings/developer"];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
const OPEN_ROUTES = [
......@@ -48,23 +36,35 @@ function decodeJwtPayload(token: string): { role?: string; exp?: number } | null
}
}
/**
* Validates that redirect URL is a safe local relative path
*/
function getSafeRedirectUrl(url?: string | null, fallback = "/"): string {
if (!url || typeof url !== "string") return fallback;
const trimmed = url.trim();
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
return fallback;
}
if (AUTH_ROUTES.some((route) => trimmed.startsWith(route))) {
return fallback;
}
return trimmed;
}
export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const authToken = request.cookies.get("auth_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value;
const userRoleCookie = request.cookies.get("user_role")?.value;
// Determine user role and token expiration
let role = userRoleCookie;
// Determine user role and token expiration strictly from signed JWT
let role: string | undefined = undefined;
let isTokenExpired = false;
if (authToken) {
const payload = decodeJwtPayload(authToken);
if (payload) {
if (payload.role && !role) {
role = payload.role;
}
role = payload.role;
if (payload.exp && payload.exp * 1000 < Date.now()) {
isTokenExpired = true;
}
......@@ -77,17 +77,11 @@ export function middleware(request: NextRequest) {
const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route));
const isOpenRoute = OPEN_ROUTES.some((route) => pathname.startsWith(route));
const isAdminRoute = ADMIN_ROUTES.some((route) => pathname.startsWith(route));
const isStrictAdminRoute = STRICT_ADMIN_ROUTES.some((route) => pathname.startsWith(route));
// 1. If user is already authenticated and visits /login, /register, etc. -> redirect to home or returnUrl
if (isAuthRoute && isAuthenticated) {
let redirectUrl = request.nextUrl.searchParams.get("redirect") || "/";
if (
redirectUrl.startsWith("/login") ||
redirectUrl.startsWith("/register")
) {
redirectUrl = "/";
}
const redirectUrl = getSafeRedirectUrl(request.nextUrl.searchParams.get("redirect"), "/");
return NextResponse.redirect(new URL(redirectUrl, request.url));
}
......@@ -110,9 +104,8 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl);
}
// 4. Role-Based Access Control (RBAC): restrict ADMIN routes
if (isAdminRoute && role !== "ADMIN") {
// Redirect unauthorized role to /forbidden
// 4. Role-Based Access Control (RBAC): restrict strict ADMIN root routes
if (isStrictAdminRoute && role?.toUpperCase() !== "ADMIN") {
return NextResponse.redirect(new URL("/forbidden", request.url));
}
......
......@@ -5,6 +5,7 @@ import React, {
useContext,
useEffect,
useCallback,
useMemo,
ReactNode,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -59,9 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const isAuthenticated = !!user && !isError;
const role: UserRole | null = (user?.role as UserRole) || null;
const roles = user?.roles || (role ? [role.toLowerCase()] : []);
const permissions = user?.permissions || [];
const isAdmin = role === "ADMIN" || roles.includes("admin") || roles.includes("super_admin");
const roles = useMemo(
() => (user?.roles || (role ? [role] : [])).map((r) => r.toLowerCase()).filter(Boolean),
[user?.roles, role]
);
const permissions = useMemo(() => user?.permissions || [], [user?.permissions]);
const isAdmin = (role && role.toUpperCase() === "ADMIN") || roles.includes("admin") || roles.includes("super_admin");
const hasPermission = useCallback(
(permission: string): boolean => {
......@@ -104,41 +108,47 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[role, roles]
);
const login = async (
input: LoginInput,
rememberMe: boolean = true
): Promise<BffLoginResponse> => {
try {
const response = await authService.login({ ...input, rememberMe });
// Proactively update query cache with logged in user data
if (response.user) {
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user);
const login = useCallback(
async (
input: LoginInput,
rememberMe: boolean = true
): Promise<BffLoginResponse> => {
try {
const response = await authService.login({ ...input, rememberMe });
// Proactively update query cache with logged in user data
if (response.user) {
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user);
}
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me });
toast.success(t.auth.login.success);
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
}
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me });
toast.success(t.auth.login.success);
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
}
};
},
[queryClient, t]
);
const register = async (input: RegisterInput): Promise<User> => {
try {
const newUser = await authService.register(input);
toast.success(t.auth.register.success);
return newUser;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng ký không thành công";
toast.error(message);
throw error;
}
};
const register = useCallback(
async (input: RegisterInput): Promise<User> => {
try {
const newUser = await authService.register(input);
toast.success(t.auth.register.success);
return newUser;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng ký không thành công";
toast.error(message);
throw error;
}
},
[t]
);
const logout = useCallback(async (): Promise<void> => {
try {
......@@ -152,9 +162,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [queryClient, router, t]);
const refreshProfile = async (): Promise<void> => {
const refreshProfile = useCallback(async (): Promise<void> => {
await refetch();
};
}, [refetch]);
// Listen for unauthorized 401 events emitted by apiClient
useEffect(() => {
......@@ -169,23 +179,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [queryClient, router]);
const contextValue: AuthContextType = {
user: user || null,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
refreshProfile,
};
const contextValue: AuthContextType = useMemo(
() => ({
user: user || null,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
refreshProfile,
}),
[
user,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
refreshProfile,
]
);
return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
......
......@@ -13,22 +13,29 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
function getInitialLocale(): Locale {
if (typeof window === "undefined") return "vi";
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
return saved;
}
const browserLang = navigator.language?.toLowerCase() || "";
return browserLang.startsWith("vi") ? "vi" : "en";
} catch {
return "vi";
}
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
let targetLocale: Locale = "vi";
if (saved && (saved === "vi" || saved === "en")) {
targetLocale = saved;
} else {
const browserLang = navigator.language?.toLowerCase() || "";
targetLocale = browserLang.startsWith("vi") ? "vi" : "en";
}
if (targetLocale !== "vi") {
queueMicrotask(() => {
setLocaleState(targetLocale);
});
}
document.documentElement.lang = targetLocale;
} catch {
// Ignore storage or navigator errors
}
}, []);
React.useEffect(() => {
try {
......
......@@ -31,7 +31,9 @@ export function Providers({ children }: ProvidersProps) {
<NetworkStatusBanner />
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</AuthProvider>
</QueryClientProvider>
</LanguageProvider>
......
......@@ -12,7 +12,21 @@ export function isValidCronExpression(cron: string | null | undefined): boolean
if (!cron || typeof cron !== "string") return false;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
return parts.every((p) => p.length > 0);
const patterns = [
// Minute: 0-59
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Hour: 0-23
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Day of month: 1-31
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Month: 1-12 or JAN-DEC
/^(\*|\d{1,2}|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-\w+)?(\/\d{1,2})?(,(\*|\d{1,2}|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-\w+)?(\/\d{1,2})?)*$/i,
// Day of week: 0-7 or SUN-SAT
/^(\*|\d{1,2}|SUN|MON|TUE|WED|THU|FRI|SAT)(-\w+)?(\/\d{1,2})?(,(\*|\d{1,2}|SUN|MON|TUE|WED|THU|FRI|SAT)(-\w+)?(\/\d{1,2})?)*$/i,
];
return parts.every((part, i) => patterns[i].test(part));
}
export const createCrawlScheduleSchema = z
......
......@@ -55,6 +55,11 @@ export const createRoleSchema = z.object({
export type CreateRoleInput = z.infer<typeof createRoleSchema>;
export const roleFormSchema = createRoleSchema.extend({
syncUsersQuota: z.boolean().optional(),
});
export type RoleFormInput = z.infer<typeof roleFormSchema>;
export const updateRoleSchema = z.object({
name: z
.string()
......
......@@ -9,6 +9,7 @@ import {
DiffReportEnvelope,
} from "@/types/crawl-job";
import { ExtractionTemplate } from "@/types/extraction-template";
import { DashboardStats } from "@/types/dashboard";
// Initial realistic mock data for local fallback resilience
const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
......@@ -58,166 +59,20 @@ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
},
];
let localMockJobs: CrawlJob[] = [
{
id: "088f635c-9c3a-4467-93bb-e58f001bf001",
userId: "usr-01",
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
mode: "CRAWL",
status: "RUNNING",
maxPages: 100,
maxDepth: 3,
urls: [],
totalPages: 52,
successPages: 49,
failedPages: 3,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 12,
modifiedPagesCount: 7,
deletedPagesCount: 2,
unchangedPagesCount: 33,
changeRate: 0.4,
},
startedAt: new Date(Date.now() - 3600000 * 1.5).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "199a746d-ad4b-5578-84cc-f69a112cf002",
userId: "usr-01",
startUrl: "https://tiki.vn/laptop/c8095",
domain: "tiki.vn",
mode: "SCRAPE",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 50,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0 (+https://datacrawler.internal)",
delayMs: 1500,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: "/storage/diffs/diff_report_199a746d.json",
diffSummary: {
totalCurrentPages: 50,
totalPreviousPages: 48,
newPagesCount: 5,
modifiedPagesCount: 18,
deletedPagesCount: 3,
unchangedPagesCount: 27,
changeRate: 0.54,
},
startedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 13).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
},
{
id: "2aab857e-be5c-6689-95dd-07ab223df003",
userId: "usr-01",
startUrl: "https://yellowpages.vn/danh-ba-doanh-nghiep",
domain: "yellowpages.vn",
mode: "CRAWL",
status: "CANCELED",
maxPages: 200,
maxDepth: 2,
urls: [],
totalPages: 84,
successPages: 82,
failedPages: 2,
timeoutMs: 30000,
retryCount: 2,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1200,
errorMessage: "Tác vụ bị hủy bỏ theo yêu cầu của người dùng",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 24).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 25).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
},
{
id: "3bbc968f-cf6d-7790-06ee-18bc334ef004",
userId: "usr-01",
startUrl: "https://cafef.vn/tai-chinh-quoc-te",
domain: "cafef.vn",
mode: "SCRAPE",
status: "FAILED",
maxPages: 40,
maxDepth: 1,
urls: [],
totalPages: 12,
successPages: 6,
failedPages: 6,
timeoutMs: 15000,
retryCount: 3,
respectRobotsTxt: false,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
delayMs: 500,
errorMessage: "Mục tiêu phản hồi HTTP 403 Forbidden (Cloudflare bot protection triggered)",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 48).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 49).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
},
{
id: "4ccd0790-d07e-8801-17ff-29cd445ff005",
userId: "usr-01",
startUrl: "https://genk.vn/tin-ict.chn",
domain: "genk.vn",
mode: "CRAWL",
status: "PENDING",
maxPages: 80,
maxDepth: 2,
urls: [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: null,
finishedAt: null,
createdAt: new Date(Date.now() - 600000).toISOString(),
updatedAt: new Date(Date.now() - 600000).toISOString(),
},
];
export const crawlJobService = {
/**
* Lấy thống kê tổng quan thời gian thực (GET /dashboard/stats)
*/
async getStats(): Promise<DashboardStats> {
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid dashboard stats response");
},
/**
* Danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Multi-criteria Filtering
*/
......@@ -225,262 +80,102 @@ export const crawlJobService = {
const page = Number(query?.page) || 1;
const limit = Number(query?.limit) || 20;
try {
const params = new URLSearchParams();
if (query?.status) params.append("status", query.status);
if (query?.mode) params.append("mode", query.mode);
if (query?.search) params.append("search", query.search);
if (query?.sortBy) params.append("sortBy", query.sortBy);
if (query?.order) params.append("order", query.order);
params.append("page", String(page));
params.append("limit", String(limit));
const params = new URLSearchParams();
if (query?.status) params.append("status", query.status);
if (query?.mode) params.append("mode", query.mode);
if (query?.search) params.append("search", query.search);
if (query?.sortBy) params.append("sortBy", query.sortBy);
if (query?.order) params.append("order", query.order);
params.append("page", String(page));
params.append("limit", String(limit));
const response = await apiClient.get<
ApiResponse<{
items: CrawlJob[];
const response = await apiClient.get<
ApiResponse<{
items: CrawlJob[];
total?: number;
page?: number;
pageSize?: number;
totalPages?: number;
meta?: {
total?: number;
page?: number;
pageSize?: number;
limit?: number;
totalPages?: number;
meta?: {
total?: number;
page?: number;
limit?: number;
totalPages?: number;
};
}>
>(`/crawl-jobs?${params.toString()}`);
const raw = response.data?.data;
if (raw && Array.isArray(raw.items)) {
const total = raw.total ?? raw.meta?.total ?? raw.items.length;
const totalPages =
raw.totalPages ??
raw.meta?.totalPages ??
Math.max(1, Math.ceil(total / limit));
const pageNum = raw.page ?? raw.meta?.page ?? page;
const pageSize = raw.pageSize ?? raw.meta?.limit ?? limit;
return {
items: raw.items,
total,
totalPages,
page: pageNum,
pageSize,
};
}
throw new Error("Invalid response format");
} catch {
// Fallback local memory filtering and pagination
let filtered = [...localMockJobs];
if (query?.status) {
filtered = filtered.filter((j) => j.status === query.status);
}
if (query?.mode) {
filtered = filtered.filter((j) => j.mode === query.mode);
}
if (query?.search) {
const term = query.search.toLowerCase();
filtered = filtered.filter(
(j) =>
j.startUrl.toLowerCase().includes(term) ||
(j.domain && j.domain.toLowerCase().includes(term)) ||
j.id.toLowerCase().includes(term)
);
}
if (query?.sortBy) {
filtered.sort((a, b) => {
const valA = a[query.sortBy as keyof CrawlJob];
const valB = b[query.sortBy as keyof CrawlJob];
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
const result = valA > valB ? 1 : -1;
return query.order === "asc" ? result : -result;
});
} else {
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
}>
>(`/crawl-jobs?${params.toString()}`);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / limit));
const startIndex = (page - 1) * limit;
const items = filtered.slice(startIndex, startIndex + limit);
const raw = response.data?.data;
if (raw && Array.isArray(raw.items)) {
const total = raw.total ?? raw.meta?.total ?? raw.items.length;
const totalPages =
raw.totalPages ??
raw.meta?.totalPages ??
Math.max(1, Math.ceil(total / limit));
const pageNum = raw.page ?? raw.meta?.page ?? page;
const pageSize = raw.pageSize ?? raw.meta?.limit ?? limit;
return {
items,
items: raw.items,
total,
page,
pageSize: limit,
totalPages,
page: pageNum,
pageSize,
};
}
throw new Error("Invalid response format");
},
/**
* Lấy chi tiết một Crawl Job theo ID
*/
async getJobById(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Job not found");
} catch {
const found = localMockJobs.find((j) => j.id === id);
if (found) return found;
// Tạo mock job nếu không tìm thấy
const fallbackJob: CrawlJob = {
id,
userId: "usr-current",
startUrl: "https://example.com/data",
domain: "example.com",
mode: "CRAWL",
status: "RUNNING",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 18,
successPages: 17,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 300000).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 300000).toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [fallbackJob, ...localMockJobs];
return fallbackJob;
const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Job not found");
},
/**
* Tạo Crawl Job mới toàn diện
*/
async createJob(dto: CreateCrawlJobDto): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to create crawl job");
} catch {
let domain: string | null = null;
try {
if (dto.startUrl) {
domain = new URL(dto.startUrl).hostname;
}
} catch {
domain = "custom-target.com";
}
const newJob: CrawlJob = {
id: `job-${Date.now()}`,
userId: "usr-current",
startUrl: dto.startUrl || "https://example.com",
domain,
mode: dto.mode || "SCRAPE",
status: "RUNNING",
maxPages: dto.maxPages || 20,
maxDepth: dto.maxDepth || 1,
urls: dto.urls || [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: dto.respectRobotsTxt ?? true,
userAgent: dto.userAgent || null,
delayMs: dto.delayMs || 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: dto.scheduleId || null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date().toISOString(),
finishedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [newJob, ...localMockJobs];
return newJob;
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to create crawl job");
},
/**
* Chạy lại Job (POST /crawl-jobs/:id/rerun)
*/
async rerunJob(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to rerun job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "RUNNING";
existing.startedAt = new Date().toISOString();
existing.finishedAt = null;
existing.totalPages = 0;
existing.successPages = 0;
existing.failedPages = 0;
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to rerun job");
},
/**
* Hủy bỏ Job (POST /crawl-jobs/:id/cancel)
*/
async cancelJob(id: string): Promise<CrawlJob> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to cancel job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "CANCELED";
existing.finishedAt = new Date().toISOString();
existing.errorMessage = "Tác vụ đã được hủy bởi người dùng.";
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Failed to cancel job");
},
/**
* Xóa Job (DELETE /crawl-jobs/:id)
*/
async deleteJob(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawl-jobs/${id}`);
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
} catch {
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
}
await apiClient.delete(`/crawl-jobs/${id}`);
return true;
},
/**
......@@ -493,105 +188,17 @@ export const crawlJobService = {
const page = query?.page || 1;
const limit = query?.limit || 50;
try {
const response = await apiClient.get<
ApiResponse<{
items: CrawlJobLog[];
meta: { total: number; page: number; limit: number; totalPages: number };
}>
>(`/crawl-jobs/${id}/logs?page=${page}&limit=${limit}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid logs response");
} catch {
// Fallback realistic crawler logs
const mockLogs: CrawlJobLog[] = [
{
id: `log-${id}-01`,
jobId: id,
level: "INFO",
step: "INIT",
message: `Khởi tạo tiến trình cào dữ liệu cho tác vụ [${id}]. Đã nạp cấu hình bộ thu thập.`,
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `log-${id}-02`,
jobId: id,
level: "INFO",
step: "ROBOTS_TXT",
message: "Tải và phân tích robots.txt: Tuân thủ quy tắc Crawl-Delay 1.0s, Allow: /*.",
createdAt: new Date(Date.now() - 3550000).toISOString(),
},
{
id: `log-${id}-03`,
jobId: id,
level: "INFO",
step: "DISPATCH",
message: "Phân phối URL gốc vào hàng đợi Redis/BullMQ: https://vnexpress.net/so-hoa/cong-nghe",
createdAt: new Date(Date.now() - 3500000).toISOString(),
},
{
id: `log-${id}-04`,
jobId: id,
level: "INFO",
step: "SCRAPING",
message: "HTTP 200 OK: Đã tải về thành công DOM HTML (kích thước: 142.4 KB, thời gian: 238ms).",
createdAt: new Date(Date.now() - 3400000).toISOString(),
},
{
id: `log-${id}-05`,
jobId: id,
level: "INFO",
step: "EXTRACT",
message: "Trích xuất 14 liên kết con hợp lệ thuộc domain chính theo giới hạn depth = 2.",
createdAt: new Date(Date.now() - 3350000).toISOString(),
},
{
id: `log-${id}-06`,
jobId: id,
level: "WARNING",
step: "CONTENT_CLEAN",
message: "Phát hiện mã theo dõi quảng cáo bên thứ ba (Google Ads/Taboola). Đã loại bỏ thành công.",
createdAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `log-${id}-07`,
jobId: id,
level: "INFO",
step: "DATABASE",
message: "Lưu bản ghi trang #42 vào PostgreSQL: 1,842 từ, DataQualityScore = 96/100.",
createdAt: new Date(Date.now() - 3100000).toISOString(),
},
{
id: `log-${id}-08`,
jobId: id,
level: "ERROR",
step: "NETWORK",
message: "HTTP 404 Not Found: Bỏ qua đường dẫn /tin-cu/bai-viet-da-xoa.html sau 3 lần thử.",
createdAt: new Date(Date.now() - 2900000).toISOString(),
},
{
id: `log-${id}-09`,
jobId: id,
level: "INFO",
step: "STREAM",
message: "Đồng bộ tiến độ qua SSE Event: 52/100 trang đã xử lý (52% hoàn tất).",
createdAt: new Date(Date.now() - 2500000).toISOString(),
},
];
const response = await apiClient.get<
ApiResponse<{
items: CrawlJobLog[];
meta: { total: number; page: number; limit: number; totalPages: number };
}>
>(`/crawl-jobs/${id}/logs?page=${page}&limit=${limit}`);
return {
items: mockLogs,
meta: {
total: mockLogs.length,
page: 1,
limit: 50,
totalPages: 1,
},
};
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid logs response");
},
/**
......@@ -604,264 +211,39 @@ export const crawlJobService = {
const page = query?.page || 1;
const limit = query?.limit || 20;
try {
const params = new URLSearchParams();
params.append("page", String(page));
params.append("limit", String(limit));
if (query?.search) params.append("search", query.search);
const params = new URLSearchParams();
params.append("page", String(page));
params.append("limit", String(limit));
if (query?.search) params.append("search", query.search);
const response = await apiClient.get<
ApiResponse<{
items: CrawlPagePreview[];
total: number;
page: number;
limit: number;
totalPages: number;
}>
>(`/crawl-jobs/${id}/pages/preview?${params.toString()}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid pages preview response");
} catch {
// Fallback realistic crawled pages
const mockPages: CrawlPagePreview[] = [
{
id: `page-${id}-01`,
jobId: id,
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Tin tức công nghệ mới nhất hôm nay",
description: "Cập nhật nhanh tin tức công nghệ, thiết bị mới, trí tuệ nhân tạo AI và viễn thông.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1420,
dataQualityScore: 98,
contentHash: "sha256-a1b2c3d4e5f67890",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "CollectionPage",
headline: "Tin tức Công nghệ",
},
markdownContent: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM) và giải pháp điện toán đám mây sinh thái...\n\n## 1. Trí tuệ nhân tạo thế hệ mới\nCác giải pháp AI đang được tối ưu hóa nhằm giảm thiểu lượng khí thải carbon và điện năng tiêu thụ tại các trung tâm dữ liệu.\n\n## 2. Thiết bị phần cứng tiết kiệm năng lượng\nChip xử lý tiến trình 3nm mang lại hiệu suất vượt trội mà vẫn giữ được nhiệt độ vận hành lý tưởng.",
rawMarkdown: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
mainContent: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
cleanText: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
htmlContentPath: `/storage/crawled/${id}/page_01.html`,
content: "Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về AI...",
createdAt: new Date(Date.now() - 3600000).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `page-${id}-02`,
jobId: id,
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
description: "Các kỹ sư phát triển thuật toán điều phối thông minh giúp trung tâm dữ liệu xanh hóa quy trình xử lý.",
status: "SUCCESS",
statusCode: 200,
wordCount: 2150,
dataQualityScore: 95,
contentHash: "sha256-b2c3d4e5f6a78901",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "NewsArticle",
headline: "Giải pháp AI xanh giúp giảm 40% điện năng",
datePublished: new Date(Date.now() - 3600000).toISOString(),
},
markdownContent: "# Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu\n\nNghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa và caching thông minh đã cắt giảm mạnh mức tiêu thụ điện của các cụm máy chủ GPU.\n\n> Đổi mới sáng tạo cần đi đôi với bảo vệ môi trường và phát triển bền vững.\n\n### Kết quả thử nghiệm thực tế\n- Mức tải tiêu thụ điện giảm từ 1.2MW xuống còn 720kW.\n- Tốc độ phản hồi (Latency) duy trì dưới 45ms.",
rawMarkdown: "# Giải pháp AI xanh...",
mainContent: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
cleanText: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
htmlContentPath: `/storage/crawled/${id}/page_02.html`,
content: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận...",
createdAt: new Date(Date.now() - 3200000).toISOString(),
updatedAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `page-${id}-03`,
jobId: id,
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
description: "Các nhà máy đúc chip hàng đầu thế giới công bố tiến độ thương mại hóa chip 2nm vào cuối năm.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1890,
dataQualityScore: 92,
contentHash: "sha256-c3d4e5f6a7b89012",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
warnings: [],
structuredData: null,
markdownContent: "# Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa\n\nTiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA (Gate-All-Around) hứa hẹn tăng 15% hiệu năng và tiết kiệm 30% năng lượng so với thế hệ trước.",
rawMarkdown: "# Thế hệ vi xử lý bán dẫn 2nm...",
mainContent: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
cleanText: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
htmlContentPath: `/storage/crawled/${id}/page_03.html`,
content: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
createdAt: new Date(Date.now() - 2700000).toISOString(),
updatedAt: new Date(Date.now() - 2700000).toISOString(),
},
{
id: `page-${id}-04`,
jobId: id,
url: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
normalizedUrl: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
title: "Trang không tồn tại (404 Not Found)",
description: null,
status: "FAILED",
statusCode: 404,
wordCount: 45,
dataQualityScore: 0,
contentHash: null,
errorMessage: "Mã trạng thái phản hồi HTTP 404 Not Found",
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2100000).toISOString(),
warnings: ["URL trả về lỗi 404"],
structuredData: null,
markdownContent: "# 404 Not Found\n\nTrang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
rawMarkdown: "# 404 Not Found",
mainContent: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
cleanText: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
htmlContentPath: null,
content: "404 Not Found",
createdAt: new Date(Date.now() - 2100000).toISOString(),
updatedAt: new Date(Date.now() - 2100000).toISOString(),
},
];
const response = await apiClient.get<
ApiResponse<{
items: CrawlPagePreview[];
total: number;
page: number;
limit: number;
totalPages: number;
}>
>(`/crawl-jobs/${id}/pages/preview?${params.toString()}`);
return {
items: mockPages,
total: mockPages.length,
page: 1,
limit: 20,
totalPages: 1,
};
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid pages preview response");
},
/**
* Lấy báo cáo so sánh biến động dữ liệu Diff (GET /crawl-jobs/:id/diff)
*/
async getDiff(id: string, compareWithJobId?: string): Promise<DiffReportEnvelope> {
try {
const url = compareWithJobId
? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}`
: `/crawl-jobs/${id}/diff`;
const response = await apiClient.get<ApiResponse<DiffReportEnvelope>>(url);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid diff response");
} catch {
// Fallback realistic Diff Report
return {
schemaVersion: "1.0.0",
generatedAt: new Date().toISOString(),
jobId: id,
previousJobId: compareWithJobId || "199a746d-ad4b-5578-84cc-f69a112cf002",
scheduleId: null,
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
summary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 8,
modifiedPagesCount: 14,
deletedPagesCount: 3,
unchangedPagesCount: 30,
changeRate: 0.48,
},
newPages: [
{
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
contentHash: "sha256-b2c3d4e5f6a78901",
wordCount: 2150,
statusCode: 200,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
contentHash: "sha256-c3d4e5f6a7b89012",
wordCount: 1890,
statusCode: 200,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
title: "Mạng lưới vệ tinh quỹ đạo thấp phủ sóng internet vùng sâu vùng xa",
contentHash: "sha256-d4e5f6a7b8c90123",
wordCount: 1450,
statusCode: 200,
crawledAt: new Date(Date.now() - 2200000).toISOString(),
},
],
modifiedPages: [
{
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Cập nhật thông tin công nghệ nóng nhất",
oldTitle: "Công nghệ - Tin tức công nghệ trong ngày",
oldContentHash: "sha256-old-998877665544",
newContentHash: "sha256-a1b2c3d4e5f67890",
oldWordCount: 1200,
newWordCount: 1420,
wordCountDiff: 220,
statusCode: 200,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
normalizedUrl: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
title: "Giá smartphone cao cấp điều chỉnh giảm kích cầu quý 3",
oldTitle: "Giá smartphone cao cấp duy trì ổn định",
oldContentHash: "sha256-old-112233445566",
newContentHash: "sha256-mod-556677889900",
oldWordCount: 1650,
newWordCount: 1510,
wordCountDiff: -140,
statusCode: 200,
crawledAt: new Date(Date.now() - 3100000).toISOString(),
},
],
deletedPages: [
{
url: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
normalizedUrl: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
title: "Sự kiện tường thuật trực tiếp công nghệ mùa xuân (Đã đóng)",
previousContentHash: "sha256-del-443322110099",
previousWordCount: 3200,
lastCrawledAt: new Date(Date.now() - 86400000 * 3).toISOString(),
},
],
unchangedPages: [
{
url: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
normalizedUrl: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
title: "Điều khoản sử dụng và chính sách nội dung công nghệ",
contentHash: "sha256-static-unchanged-01",
wordCount: 850,
},
],
};
const url = compareWithJobId
? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}`
: `/crawl-jobs/${id}/diff`;
const response = await apiClient.get<ApiResponse<DiffReportEnvelope>>(url);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid diff response");
},
/**
......
......@@ -8,319 +8,67 @@ import {
UpdateCrawlScheduleDto,
} from "@/types/crawl-schedule";
const MOCK_CRAWL_SCHEDULES: CrawlSchedule[] = [
{
id: "sch-01",
userId: "usr-01",
name: "Quét tin tức VnExpress sáng",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
frequency: "DAILY",
cronExpression: null,
hour: 6,
minute: 30,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 50,
maxDepth: 2,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000).toISOString(),
nextRunAt: new Date(Date.now() + 3600000 * 6).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "sch-02",
userId: "usr-01",
name: "Thu thập giá thị trường Tiki cuối tuần",
startUrl: "https://tiki.vn/dien-thoai-may-tinh-bang/c1789",
domain: "tiki.vn",
mode: "CRAWL",
frequency: "WEEKLY",
cronExpression: null,
hour: 9,
minute: 0,
dayOfWeek: 0, // Chủ nhật
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 100,
maxDepth: 3,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000 * 6).toISOString(),
nextRunAt: new Date(Date.now() + 86400000 * 1).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 6).toISOString(),
},
{
id: "sch-03",
userId: "usr-01",
name: "Giám sát thông số kỹ thuật (Custom Cron)",
startUrl: "https://dantri.com.vn/suc-manh-so.htm",
domain: "dantri.com.vn",
mode: "SCRAPE",
frequency: "CUSTOM",
cronExpression: "0 */3 * * *",
hour: 0,
minute: 0,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 20,
maxDepth: 1,
urls: [],
isActive: false,
autoDiff: false,
lastRunAt: new Date(Date.now() - 86400000 * 2).toISOString(),
nextRunAt: null,
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
},
];
let localSchedules = [...MOCK_CRAWL_SCHEDULES];
export class CrawlScheduleService {
async getSchedules(params?: CrawlScheduleQueryDto): Promise<PaginatedResponse<CrawlSchedule>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return this.getLocalFilteredSchedules(params);
} catch {
return this.getLocalFilteredSchedules(params);
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", {
params,
});
if (response.data?.data) {
return response.data.data;
}
}
private getLocalFilteredSchedules(params?: CrawlScheduleQueryDto): PaginatedResponse<CrawlSchedule> {
let list = [...localSchedules];
if (params?.search) {
const s = params.search.toLowerCase();
list = list.filter((item) => item.name.toLowerCase().includes(s) || item.startUrl.toLowerCase().includes(s));
}
if (params?.frequency) {
list = list.filter((item) => item.frequency === params.frequency);
}
if (params?.isActive !== undefined) {
list = list.filter((item) => item.isActive === params.isActive);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const paginatedItems = list.slice(start, start + limit);
return {
items: paginatedItems,
total,
page,
pageSize: limit,
totalPages,
};
throw new Error("Không thể tải danh sách lịch cào");
}
async getScheduleById(id: string): Promise<CrawlSchedule> {
try {
const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`);
if (response.data?.data) {
return response.data.data;
}
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
} catch {
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không tìm thấy lịch cào");
}
async createSchedule(dto: CreateCrawlScheduleDto): Promise<CrawlSchedule> {
try {
const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto);
if (response.data?.data) {
const created = response.data.data;
localSchedules = [created, ...localSchedules];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
let domain = "";
try {
domain = new URL(dto.startUrl).hostname;
} catch {
domain = "example.com";
}
const newSchedule: CrawlSchedule = {
id: `sch-${Date.now()}`,
userId: "current-user",
name: dto.name,
startUrl: dto.startUrl,
domain,
mode: dto.mode || "SCRAPE",
frequency: dto.frequency || "DAILY",
cronExpression: dto.cronExpression || null,
hour: dto.hour ?? 0,
minute: dto.minute ?? 0,
dayOfWeek: dto.dayOfWeek ?? null,
dayOfMonth: dto.dayOfMonth ?? null,
timezone: dto.timezone || "Asia/Ho_Chi_Minh",
maxPages: dto.maxPages ?? 20,
maxDepth: dto.maxDepth ?? 1,
urls: dto.urls || [],
isActive: dto.isActive ?? true,
autoDiff: dto.autoDiff ?? true,
lastRunAt: null,
nextRunAt: new Date(Date.now() + 3600000 * 24).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localSchedules = [newSchedule, ...localSchedules];
return newSchedule;
const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể tạo lịch cào");
}
async updateSchedule(id: string, dto: UpdateCrawlScheduleDto): Promise<CrawlSchedule> {
try {
const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto);
if (response.data?.data) {
const updated = response.data.data;
localSchedules = localSchedules.map((s) => (s.id === id ? updated : s));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localSchedules.findIndex((s) => s.id === id);
if (index === -1) throw new Error("Không tìm thấy lịch cào");
const existing = localSchedules[index];
const updated: CrawlSchedule = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localSchedules[index] = updated;
return updated;
const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể cập nhật lịch cào");
}
async deleteSchedule(id: string): Promise<void> {
try {
await apiClient.delete(`/crawl-schedules/${id}`);
localSchedules = localSchedules.filter((s) => s.id !== id);
} catch {
localSchedules = localSchedules.filter((s) => s.id !== id);
}
await apiClient.delete(`/crawl-schedules/${id}`);
}
async triggerRun(id: string): Promise<{ jobId: string; message: string }> {
try {
const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>(
`/crawl-schedules/${id}/run`
);
if (response.data?.data) {
return response.data.data;
}
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công!" };
} catch {
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công (mô phỏng)!" };
const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>(
`/crawl-schedules/${id}/run`
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể kích hoạt tác vụ cào");
}
async getHistory(
id: string,
params?: { page?: number; limit?: number }
): Promise<PaginatedResponse<CrawlJob>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>(
`/crawl-schedules/${id}/history`,
{ params }
);
if (response.data?.data) {
return response.data.data;
}
return this.getMockHistory(id, params);
} catch {
return this.getMockHistory(id, params);
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>(
`/crawl-schedules/${id}/history`,
{ params }
);
if (response.data?.data) {
return response.data.data;
}
}
private getMockHistory(scheduleId: string, params?: { page?: number; limit?: number }): PaginatedResponse<CrawlJob> {
const mockJobs: CrawlJob[] = [
{
id: `job-sch-${scheduleId}-01`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 48,
successPages: 48,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000).toISOString(),
finishedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
},
{
id: `job-sch-${scheduleId}-02`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 49,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
finishedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
},
];
return {
items: mockJobs,
total: mockJobs.length,
page: params?.page || 1,
pageSize: params?.limit || 20,
totalPages: 1,
};
throw new Error("Không thể tải lịch sử chạy");
}
}
......
......@@ -5,290 +5,109 @@ import { CrawlerTask } from "@/types/crawler";
import { DashboardStats } from "@/types/dashboard";
import { CrawlJob } from "@/types/crawl-job";
// Dữ liệu mẫu khởi đầu khi Backend chưa kết nối
const INITIAL_MOCK_TASKS: CrawlerTask[] = [
{
id: "task-01",
name: "Cào tin tức Công nghệ & AI",
targetUrl: "https://vnexpress.net/so-hoa/cong-nghe",
status: "RUNNING",
maxDepth: 3,
maxPages: 100,
pagesCrawled: 42,
itemsExtracted: 318,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date(Date.now() - 60000).toISOString(),
},
{
id: "task-02",
name: "Trích xuất giá sản phẩm Laptop",
targetUrl: "https://tiki.vn/laptop/c8095",
status: "COMPLETED",
maxDepth: 2,
maxPages: 50,
pagesCrawled: 50,
itemsExtracted: 620,
createdAt: new Date(Date.now() - 3600000 * 24).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 5).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "task-03",
name: "Thu thập danh bạ doanh nghiệp",
targetUrl: "https://yellowpages.vn/danh-ba",
status: "PAUSED",
maxDepth: 2,
maxPages: 200,
pagesCrawled: 75,
itemsExtracted: 180,
createdAt: new Date(Date.now() - 3600000 * 48).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 12).toISOString(),
},
];
let localTasksState = [...INITIAL_MOCK_TASKS];
const DEFAULT_FALLBACK_STATS: DashboardStats = {
jobs: {
total: 3,
completed: 1,
failed: 0,
running: 1,
pending: 1,
},
pages: {
total: 167,
successful: 162,
failed: 5,
},
schedules: {
total: 2,
active: 1,
},
exports: {
total: 4,
},
quotaAndUsage: {
quota: {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
usage: {
jobsUsedToday: 3,
jobsRemainingToday: 47,
concurrentJobsRunning: 1,
concurrentJobsAvailable: 4,
totalPagesCrawled: 167,
pagesCrawledToday: 24,
pagesRemainingToday: 976,
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
export const crawlerService = {
/**
* Lấy thống kê tổng quan thời gian thực từ endpoint chuẩn:
* GET /api/v1/dashboard/stats (qua Next.js BFF Proxy)
*/
async getStats(): Promise<DashboardStats> {
try {
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
return DEFAULT_FALLBACK_STATS;
} catch {
// Fallback tính toán từ local memory state hoặc default
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0);
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const runningCount = localTasksState.filter((t) => t.status === "RUNNING").length;
const completedCount = localTasksState.filter((t) => t.status === "COMPLETED").length;
const failedCount = localTasksState.filter((t) => t.status === "FAILED").length;
return {
jobs: {
total: localTasksState.length,
completed: completedCount,
failed: failedCount,
running: runningCount,
pending: Math.max(0, localTasksState.length - completedCount - failedCount - runningCount),
},
pages: {
total: totalPages || 167,
successful: totalItems || 162,
failed: 5,
},
schedules: {
total: 2,
active: 1,
},
exports: {
total: 3,
},
quotaAndUsage: {
quota: {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
usage: {
jobsUsedToday: localTasksState.length,
jobsRemainingToday: Math.max(0, 50 - localTasksState.length),
concurrentJobsRunning: runningCount,
concurrentJobsAvailable: Math.max(0, 5 - runningCount),
totalPagesCrawled: totalPages || 167,
pagesCrawledToday: totalPages || 0,
pagesRemainingToday: Math.max(0, 1000 - (totalPages || 0)),
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid dashboard stats response");
},
/**
* Lấy danh sách task cào dữ liệu (kết nối endpoint thực /crawl-jobs hoặc fallback)
* Lấy danh sách task cào dữ liệu từ endpoint chuẩn /crawl-jobs
*/
async getTasks(): Promise<PaginatedResponse<CrawlerTask>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20");
const data = response.data?.data;
if (data && Array.isArray(data.items) && data.items.length > 0) {
const mappedItems: CrawlerTask[] = data.items.map((job) => ({
id: job.id,
name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`,
targetUrl: job.startUrl,
status: (job.status === "PROCESSING_EXPORT" ? "RUNNING" : job.status === "CANCELED" ? "PAUSED" : job.status) as CrawlerTask["status"],
maxDepth: job.maxDepth ?? 1,
maxPages: job.maxPages ?? 20,
pagesCrawled: job.totalPages || job.successPages || 0,
itemsExtracted: job.successPages || 0,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
errorMessage: job.errorMessage || undefined,
}));
return {
items: mappedItems,
total: data.total ?? mappedItems.length,
page: data.page ?? 1,
pageSize: data.pageSize ?? 20,
totalPages: data.totalPages ?? 1,
};
}
return {
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
} catch {
// Fallback local memory state
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20");
const data = response.data?.data;
if (data && Array.isArray(data.items)) {
const mappedItems: CrawlerTask[] = data.items.map((job) => ({
id: job.id,
name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`,
targetUrl: job.startUrl,
status: (job.status === "PROCESSING_EXPORT" ? "RUNNING" : job.status === "CANCELED" ? "PAUSED" : job.status) as CrawlerTask["status"],
maxDepth: job.maxDepth ?? 1,
maxPages: job.maxPages ?? 20,
pagesCrawled: job.totalPages || job.successPages || 0,
itemsExtracted: job.successPages || 0,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
errorMessage: job.errorMessage || undefined,
}));
return {
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
items: mappedItems,
total: data.total ?? mappedItems.length,
page: data.page ?? 1,
pageSize: data.pageSize ?? 20,
totalPages: data.totalPages ?? 1,
};
}
throw new Error("Failed to fetch crawler tasks");
},
/**
* Tạo mới một task
*/
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", {
startUrl: input.targetUrl,
maxDepth: input.maxDepth,
maxPages: input.maxPages,
mode: "SCRAPE",
});
const job = response.data?.data;
if (job) {
return {
id: job.id,
name: input.name || job.domain || job.startUrl,
targetUrl: job.startUrl,
status: "RUNNING",
maxDepth: job.maxDepth ?? input.maxDepth,
maxPages: job.maxPages ?? input.maxPages,
pagesCrawled: 0,
itemsExtracted: 0,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
};
}
throw new Error("Invalid response");
} catch {
const newTask: CrawlerTask = {
id: `task-${Date.now()}`,
name: input.name,
targetUrl: input.targetUrl,
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", {
startUrl: input.targetUrl,
maxDepth: input.maxDepth,
maxPages: input.maxPages,
mode: "SCRAPE",
});
const job = response.data?.data;
if (job) {
return {
id: job.id,
name: input.name || job.domain || job.startUrl,
targetUrl: job.startUrl,
status: "RUNNING",
maxDepth: input.maxDepth,
maxPages: input.maxPages,
maxDepth: job.maxDepth ?? input.maxDepth,
maxPages: job.maxPages ?? input.maxPages,
pagesCrawled: 0,
itemsExtracted: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date().toISOString(),
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
};
localTasksState = [newTask, ...localTasksState];
return newTask;
}
throw new Error("Failed to create crawler task");
},
/**
* Dừng task
*/
async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try {
await apiClient.post(`/crawl-jobs/${id}/cancel`);
} catch {
// Ignored for local fallback
}
const task = localTasksState.find((t) => t.id === id);
if (!task) {
// Tạo mock task nếu là job ID từ server
const fallbackTask: CrawlerTask = {
id,
name: `Tác vụ #${id.slice(0, 8)}`,
targetUrl: "https://example.com",
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
const job = response.data?.data;
if (job) {
return {
id: job.id,
name: job.domain || job.startUrl,
targetUrl: job.startUrl,
status: "PAUSED",
maxDepth: 1,
maxPages: 20,
pagesCrawled: 1,
itemsExtracted: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
maxDepth: job.maxDepth ?? 1,
maxPages: job.maxPages ?? 20,
pagesCrawled: job.totalPages ?? 0,
itemsExtracted: job.successPages ?? 0,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
};
return fallbackTask;
}
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
throw new Error("Failed to toggle task status");
},
/**
* Xóa task
*/
async deleteTask(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawl-jobs/${id}`);
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
} catch {
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
}
await apiClient.delete(`/crawl-jobs/${id}`);
return true;
},
};
......@@ -11,308 +11,88 @@ import {
WebhookDeliveryQueryDto,
} from "@/types/developer";
const MOCK_API_KEYS: ApiKey[] = [
{
id: "key-01",
name: "Production Backend Integration",
keyPrefix: "dc_live_9f8a",
isActive: true,
expiresAt: new Date(Date.now() + 86400000 * 90).toISOString(),
lastUsedAt: new Date(Date.now() - 3600000 * 2).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
},
{
id: "key-02",
name: "Zapier Automated Workflows",
keyPrefix: "dc_live_12bc",
isActive: true,
expiresAt: null,
lastUsedAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
},
{
id: "key-03",
name: "Dev Test Script (Staging)",
keyPrefix: "dc_live_77ef",
isActive: false,
expiresAt: new Date(Date.now() - 86400000 * 5).toISOString(),
lastUsedAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 45).toISOString(),
},
];
const MOCK_WEBHOOK_CONFIGS: WebhookConfig[] = [
{
id: "wh-01",
url: "https://api.mycompany.com/v1/webhooks/crawler-events",
events: ["crawl.job.completed", "crawl.job.failed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
},
{
id: "wh-02",
url: "https://hooks.slack.com/services/T00/B00/XXXXX",
events: ["crawl.job.failed", "export.completed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
},
];
const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
{
id: "del-01",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-vnexpress-01",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"received": true}',
deliveredAt: new Date(Date.now() - 3600000).toISOString(),
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: "del-02",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-tiki-02",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"status": "ok"}',
deliveredAt: new Date(Date.now() - 3600000 * 5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "del-03",
webhookConfigId: "wh-02",
crawlJobId: "job-failed-test-03",
event: "crawl.job.failed",
status: "FAILED",
statusCode: 504,
attempt: 3,
errorMessage: "Gateway Timeout: Destination endpoint did not respond in 10000ms",
deliveredAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
const localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService {
// API Keys
async listKeys(): Promise<ApiKey[]> {
try {
const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return localApiKeys;
} catch {
return localApiKeys;
const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
throw new Error("Không thể tải danh sách khóa API");
}
async createKey(dto: CreateApiKeyDto): Promise<CreateApiKeyResponse> {
try {
const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto);
if (response.data?.data) {
const created = response.data.data;
localApiKeys = [
{
id: created.id,
name: created.name,
keyPrefix: created.keyPrefix,
key: created.key,
isActive: true,
expiresAt: created.expiresAt || null,
lastUsedAt: null,
createdAt: created.createdAt || new Date().toISOString(),
},
...localApiKeys,
];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const fullKey = `dc_live_${Math.random().toString(36).substring(2, 10)}${Math.random().toString(36).substring(2, 18)}`;
const prefix = fullKey.substring(0, 12);
const newKey: CreateApiKeyResponse = {
id: `key-${Date.now()}`,
name: dto.name,
key: fullKey,
keyPrefix: prefix,
expiresAt: dto.expiresAt || null,
createdAt: new Date().toISOString(),
};
localApiKeys = [
{
id: newKey.id,
name: newKey.name,
keyPrefix: prefix,
key: fullKey,
isActive: true,
expiresAt: newKey.expiresAt ?? null,
lastUsedAt: null,
createdAt: newKey.createdAt,
},
...localApiKeys,
];
return newKey;
const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể khởi tạo khóa API");
}
async toggleActive(id: string, isActive: boolean): Promise<ApiKey> {
try {
const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive });
if (response.data?.data) {
const updated = response.data.data;
localApiKeys = localApiKeys.map((k) => (k.id === id ? updated : k));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const key = localApiKeys.find((k) => k.id === id);
if (!key) throw new Error("Không tìm thấy khóa API");
key.isActive = isActive;
return { ...key };
const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive });
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể cập nhật trạng thái khóa API");
}
async revokeKey(id: string): Promise<void> {
try {
await apiClient.delete(`/api-keys/${id}`);
localApiKeys = localApiKeys.filter((k) => k.id !== id);
} catch {
localApiKeys = localApiKeys.filter((k) => k.id !== id);
}
await apiClient.delete(`/api-keys/${id}`);
}
// Webhook Configs
async listWebhookConfigs(): Promise<WebhookConfig[]> {
try {
const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return localWebhooks;
} catch {
return localWebhooks;
const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
throw new Error("Không thể tải danh sách cấu hình Webhook");
}
async createWebhookConfig(dto: CreateWebhookConfigDto): Promise<WebhookConfig> {
try {
const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto);
if (response.data?.data) {
const created = response.data.data;
localWebhooks = [created, ...localWebhooks];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const newWh: WebhookConfig = {
id: `wh-${Date.now()}`,
url: dto.url,
events: dto.events,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localWebhooks = [newWh, ...localWebhooks];
return newWh;
const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể tạo cấu hình Webhook");
}
async updateWebhookConfig(id: string, dto: UpdateWebhookConfigDto): Promise<WebhookConfig> {
try {
const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto);
if (response.data?.data) {
const updated = response.data.data;
localWebhooks = localWebhooks.map((w) => (w.id === id ? updated : w));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localWebhooks.findIndex((w) => w.id === id);
if (index === -1) throw new Error("Không tìm thấy Webhook");
const existing = localWebhooks[index];
const updated: WebhookConfig = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localWebhooks[index] = updated;
return updated;
const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể cập nhật cấu hình Webhook");
}
async deleteWebhookConfig(id: string): Promise<void> {
try {
await apiClient.delete(`/webhooks/configs/${id}`);
localWebhooks = localWebhooks.filter((w) => w.id !== id);
} catch {
localWebhooks = localWebhooks.filter((w) => w.id !== id);
}
await apiClient.delete(`/webhooks/configs/${id}`);
}
async testWebhookConfig(id: string): Promise<{ success: boolean; statusCode: number; message: string }> {
try {
const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>(
`/webhooks/configs/${id}/test`
);
if (response.data?.data) {
return response.data.data;
}
return { success: true, statusCode: 200, message: "Webhook Test Ping sent successfully!" };
} catch {
return { success: true, statusCode: 200, message: "Webhook Test Ping succeeded (simulated)!" };
const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>(
`/webhooks/configs/${id}/test`
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Gửi kiểm tra Webhook thất bại");
}
// Webhook Deliveries
async listWebhookDeliveries(params?: WebhookDeliveryQueryDto): Promise<PaginatedResponse<WebhookDelivery>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return this.getLocalDeliveries(params);
} catch {
return this.getLocalDeliveries(params);
const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", {
params,
});
if (response.data?.data) {
return response.data.data;
}
}
private getLocalDeliveries(params?: WebhookDeliveryQueryDto): PaginatedResponse<WebhookDelivery> {
let list = [...localDeliveries];
if (params?.status) {
list = list.filter((d) => d.status === params.status);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
return {
items: list.slice(start, start + limit),
total,
page,
pageSize: limit,
totalPages,
};
throw new Error("Không thể tải nhật ký gửi Webhook");
}
async redeliverWebhook(id: string): Promise<void> {
try {
await apiClient.post(`/webhooks/deliveries/${id}/redeliver`);
} catch {
// simulated success
}
await apiClient.post(`/webhooks/deliveries/${id}/redeliver`);
}
}
......
......@@ -22,11 +22,7 @@ export class ProfileService {
const formData = new FormData();
formData.append("avatar", file);
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData);
return response.data.data;
}
......
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