Commit e204f321 authored by ThinhNC's avatar ThinhNC

feat(dashboard): integrate live analytics stats, quota widget, and mobile 2-col layout

parent 9151bfc2
......@@ -3,6 +3,8 @@
import React, { useState } from "react";
import { useCrawlerStats } from "@/hooks/use-crawler";
import { StatCard } from "@/components/common/stat-card";
import { ServiceHealthBar } from "@/components/dashboard/service-health-bar";
import { QuotaUsageWidget } from "@/components/dashboard/quota-usage-widget";
import { CrawlerTaskTable } from "@/components/crawler/crawler-task-table";
import { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button";
......@@ -16,33 +18,54 @@ import {
} from "@/components/ui/dialog";
import {
Activity,
AlertCircle,
Briefcase,
CheckCircle2,
Database,
FileCode2,
Globe2,
Plus,
Radio,
RefreshCw,
Server,
Sparkles,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export default function DashboardPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: stats, isLoading: isStatsLoading } = useCrawlerStats();
const {
data: stats,
isLoading: isStatsLoading,
isError: isStatsError,
error: statsError,
refetch: refetchStats,
} = useCrawlerStats();
const { t } = useLanguage();
// Tính toán Tỷ lệ thành công thực tế (Success Rate %)
const totalPages = stats?.pages?.total ?? 0;
const successfulPages = stats?.pages?.successful ?? 0;
const totalJobs = stats?.jobs?.total ?? 0;
const completedJobs = stats?.jobs?.completed ?? 0;
const hasActivity = totalPages > 0 || totalJobs > 0;
const successRate =
totalPages > 0
? ((successfulPages / totalPages) * 100).toFixed(1)
: totalJobs > 0
? ((completedJobs / totalJobs) * 100).toFixed(1)
: "0";
return (
<div className="space-y-8">
{/* Top Banner / Hero - Organic Biophilic Styling */}
<div className="space-y-6 sm:space-y-8">
{/* 1. Service Health Bar - Biophilic Live Ecosystem Monitoring */}
<ServiceHealthBar />
{/* 2. Top Banner / Hero - Organic Biophilic Styling */}
<div className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card via-card/90 to-emerald-950/10 p-6 sm:p-8 shadow-sm backdrop-blur-md transition-all duration-200">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<Sparkles className="h-3.5 w-3.5" />
{t.hero.badge}
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.hero.title}
</h1>
......@@ -76,43 +99,105 @@ export default function DashboardPage() {
</div>
</div>
{/* Impact Metrics Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Error state for Stats */}
{isStatsError && (
<div className="flex items-center justify-between rounded-2xl border border-rose-500/30 bg-rose-500/10 p-4 text-rose-600 dark:text-rose-400">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">
Không thể tải chỉ số thời gian thực:{" "}
{(statsError as Error)?.message ?? "Lỗi kết nối máy chủ"}
</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => refetchStats()}
className="rounded-xl border-rose-500/30 hover:bg-rose-500/20 text-xs gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" />
Thử lại
</Button>
</div>
)}
{/* 3. 5 Real KPI Stat Cards (Lưới 5 thẻ số liệu thực tế) */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-5">
{/* Thẻ 1: Tổng số Job */}
<StatCard
title={t.stats.runningTasks}
value={isStatsLoading ? "..." : (stats?.activeTasks ?? 0)}
description={t.stats.runningTasksDesc}
icon={Radio}
trend={{ value: "Realtime", isPositive: true }}
title={t.stats.totalJobs}
value={totalJobs}
description={t.stats.totalJobsDesc}
icon={Briefcase}
isLoading={isStatsLoading}
badge={totalJobs > 0 ? `${completedJobs}/${totalJobs}` : undefined}
colorClassName="from-emerald-500/20 to-teal-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
/>
{/* Thẻ 2: Job đang chạy */}
<StatCard
title={t.stats.runningJobs}
value={stats?.jobs?.running ?? 0}
description={t.stats.runningJobsDesc}
icon={Radio}
isLoading={isStatsLoading}
trend={{ value: t.stats.realtime, isPositive: true }}
colorClassName="from-teal-500/20 to-cyan-500/5 text-teal-600 dark:text-teal-400 border-teal-500/30"
/>
{/* Thẻ 3: Số trang đã cào */}
<StatCard
title={t.stats.crawledPages}
value={isStatsLoading ? "..." : (stats?.totalPagesCrawled ?? 0).toLocaleString()}
description={t.stats.totalTasksDesc}
value={totalPages}
description={t.stats.crawledPagesDesc}
icon={Activity}
trend={{ value: "14.2%", isPositive: true }}
isLoading={isStatsLoading}
trend={hasActivity ? { value: "Live", isPositive: true } : undefined}
colorClassName="from-cyan-500/20 to-blue-500/5 text-cyan-600 dark:text-cyan-400 border-cyan-500/30"
/>
{/* Thẻ 4: Số bản ghi dữ liệu đã trích xuất */}
<StatCard
title={t.stats.completedTasks}
value={isStatsLoading ? "..." : (stats?.totalItemsExtracted ?? 0).toLocaleString()}
description={t.stats.completedTasksDesc}
title={t.stats.extractedRecords}
value={successfulPages}
description={t.stats.extractedRecordsDesc}
icon={Database}
trend={{ value: "28.5%", isPositive: true }}
colorClassName="from-teal-500/20 to-emerald-500/5 text-teal-600 dark:text-teal-400 border-teal-500/30"
isLoading={isStatsLoading}
trend={
hasActivity && totalPages > 0
? {
value: `${Math.round((successfulPages / totalPages) * 100)}%`,
isPositive: successfulPages > 0,
}
: undefined
}
colorClassName="from-emerald-600/20 to-lime-500/5 text-emerald-700 dark:text-emerald-300 border-emerald-600/30"
/>
{/* Thẻ 5: Tỷ lệ thành công */}
<StatCard
title={t.stats.ecoScore}
value={isStatsLoading ? "..." : `${stats?.successRate ?? 98.4}%`}
description={t.stats.failedTasksDesc}
title={t.stats.successRate}
value={`${successRate}%`}
description={t.stats.successRateDesc}
icon={CheckCircle2}
trend={{ value: "0.8%", isPositive: true }}
colorClassName="from-emerald-500/20 to-lime-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
isLoading={isStatsLoading}
className="col-span-2 sm:col-span-2 lg:col-span-1 xl:col-span-1"
trend={
hasActivity
? {
value: Number(successRate) >= 80 ? "Eco" : `${successRate}%`,
isPositive: Number(successRate) >= 80,
}
: undefined
}
colorClassName="from-lime-500/20 to-emerald-500/5 text-lime-600 dark:text-lime-400 border-lime-500/30"
/>
</div>
{/* Main Content: Tasks Table */}
{/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */}
<QuotaUsageWidget initialUsage={stats?.quotaAndUsage} />
{/* 5. Main Content: Tasks Table */}
<div className="space-y-4" id="tasks">
<div className="flex items-center justify-between">
<div>
......@@ -129,7 +214,7 @@ export default function DashboardPage() {
<CrawlerTaskTable />
</div>
{/* Architecture & Tech Stack Highlights */}
{/* 6. Architecture & Tech Stack Highlights */}
<div className="rounded-3xl border border-border/70 bg-card/60 p-6 backdrop-blur-sm transition-colors duration-200">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mb-4">
<FileCode2 className="h-4 w-4 text-emerald-500" />
......@@ -138,21 +223,21 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-muted-foreground">
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-emerald-600 dark:text-emerald-400">Next.js App Router (src/app)</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">Realtime Analytics & Live APIs</span>
<p>
Layouts, loading skeletons, error boundaries, and not-found pages structured for high reliability.
Đồng bộ dữ liệu thống kê từ GET /api/v1/dashboard/stats và theo dõi hạn mức GET /api/v1/auth/me/usage.
</p>
</div>
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-cyan-600 dark:text-cyan-400">Theme & Bilingual i18n</span>
<span className="font-semibold text-cyan-600 dark:text-cyan-400">Biophilic Ecosystem Health</span>
<p>
Light, Dark, and System modes powered by next-themes with instant English / Tiếng Việt localization.
Giám sát kết nối PostgreSQL, Redis/BullMQ, chỉ số hàng đợi và Uptime theo thời gian thực.
</p>
</div>
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-teal-600 dark:text-teal-400">TanStack Query & Axios Layer</span>
<span className="font-semibold text-teal-600 dark:text-teal-400">Resilient Server State</span>
<p>
Server state synchronization, interceptor tokens, standardized error boundaries, and mock fallback.
TanStack Query v5 kết hợp Next.js BFF Proxy, xử lý 4 trạng thái UI và chuyển đổi mượt mà giữa Sáng/Tối.
</p>
</div>
</div>
......
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn, formatNumber } from "@/lib/utils";
interface StatCardProps {
title: string;
......@@ -13,6 +13,9 @@ interface StatCardProps {
isPositive: boolean;
};
colorClassName?: string;
isLoading?: boolean;
badge?: string;
className?: string;
}
export function StatCard({
......@@ -22,53 +25,80 @@ export function StatCard({
icon: Icon,
trend,
colorClassName = "from-emerald-500/20 to-teal-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
isLoading = false,
badge,
className,
}: StatCardProps) {
return (
<Card className="relative overflow-hidden border border-border/70 bg-card/80 backdrop-blur-md rounded-2xl shadow-sm hover:scale-[1.01] transition-all duration-200">
{/* Subtle Glow background */}
<Card
className={cn(
"group relative overflow-hidden border border-border/70 bg-card/80 backdrop-blur-md rounded-2xl shadow-sm hover:scale-[1.01] hover:shadow-md hover:border-emerald-500/30 transition-all duration-300",
className
)}
>
{/* Subtle Biophilic Glow background */}
<div
className={cn(
"absolute -right-6 -top-6 h-28 w-28 rounded-full bg-gradient-to-br opacity-20 blur-2xl pointer-events-none",
"absolute -right-6 -top-6 h-28 w-28 rounded-full bg-gradient-to-br opacity-20 blur-2xl pointer-events-none group-hover:opacity-30 transition-opacity duration-300",
colorClassName
)}
/>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</p>
<CardContent className="p-3.5 sm:p-5">
<div className="flex items-center justify-between gap-1.5 sm:gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0">
<p className="text-[11px] sm:text-xs font-semibold uppercase tracking-wider text-muted-foreground truncate">
{title}
</p>
{badge && (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-1.5 sm:px-2 py-0.5 text-[9px] sm:text-[10px] font-medium text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shrink-0">
{badge}
</span>
)}
</div>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl border bg-gradient-to-br",
"flex h-7 w-7 sm:h-9 sm:w-9 shrink-0 items-center justify-center rounded-lg sm:rounded-xl border bg-gradient-to-br transition-transform duration-300 group-hover:scale-105",
colorClassName
)}
>
<Icon className="h-5 w-5" />
<Icon className="h-3.5 w-3.5 sm:h-5 sm:w-5" />
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground">
{value}
</span>
{trend && (
<div className="mt-2 sm:mt-3 flex items-baseline gap-1.5 sm:gap-2">
{isLoading ? (
<div className="h-7 sm:h-8 w-20 sm:w-24 rounded-lg bg-muted/60 animate-pulse" />
) : (
<span
className="text-xl sm:text-2xl lg:text-3xl font-extrabold tracking-tight text-foreground"
suppressHydrationWarning
>
{typeof value === "number" ? formatNumber(value) : value}
</span>
)}
{trend && !isLoading && (
<span
className={cn(
"text-xs font-semibold",
"inline-flex items-center text-[10px] sm:text-xs font-semibold px-1.5 py-0.5 rounded-full",
trend.isPositive
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/10 text-red-600 dark:text-red-400"
)}
>
{trend.isPositive ? "+" : ""}
{trend.isPositive && /^[\d.]+%?$/.test(trend.value) && !trend.value.startsWith("+")
? "+"
: ""}
{trend.value}
</span>
)}
</div>
{description && (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
<p className="mt-1 text-[11px] sm:text-xs text-muted-foreground line-clamp-1">
{description}
</p>
)}
</CardContent>
</Card>
......
......@@ -16,7 +16,7 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/utils";
import { formatDate, formatNumber } from "@/lib/utils";
import { CrawlerStatus } from "@/types/crawler";
import {
ExternalLink,
......@@ -161,8 +161,11 @@ export function CrawlerTaskTable() {
{/* Extracted Count */}
<TableCell>
<span className="font-semibold text-emerald-400">
{task.itemsExtracted.toLocaleString()}
<span
className="font-semibold text-emerald-400"
suppressHydrationWarning
>
{formatNumber(task.itemsExtracted)}
</span>{" "}
<span className="text-xs text-slate-400">mục</span>
</TableCell>
......
"use client";
import React from "react";
import { UserUsageDto } from "@/types/auth";
import { useUserUsage } from "@/hooks/use-usage";
import { useLanguage } from "@/providers/language-provider";
import {
AlertTriangle,
Clock,
Gauge,
Info,
Layers,
Sparkles,
Zap,
} from "lucide-react";
import { cn, formatNumber } from "@/lib/utils";
interface QuotaUsageWidgetProps {
initialUsage?: UserUsageDto;
className?: string;
}
export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetProps) {
const { data: fetchedUsage, isLoading } = useUserUsage();
const { t } = useLanguage();
// Ưu tiên dữ liệu truyền từ dashboard stats nếu có, fallback sang fetchedUsage
const usageData = initialUsage || fetchedUsage;
const quota = usageData?.quota || {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
};
const usage = usageData?.usage || {
jobsUsedToday: 0,
jobsRemainingToday: 50,
concurrentJobsRunning: 0,
concurrentJobsAvailable: 5,
totalPagesCrawled: 0,
};
// Tính tỷ lệ phần trăm sử dụng
const pagesPercent = Math.min(
100,
quota.maxPagesLimit > 0
? Math.round((usage.totalPagesCrawled / quota.maxPagesLimit) * 100)
: 0
);
const jobsDailyPercent = Math.min(
100,
quota.maxJobsPerDayLimit > 0
? Math.round((usage.jobsUsedToday / quota.maxJobsPerDayLimit) * 100)
: 0
);
const concurrentPercent = Math.min(
100,
quota.maxConcurrentJobsLimit > 0
? Math.round((usage.concurrentJobsRunning / quota.maxConcurrentJobsLimit) * 100)
: 0
);
// Ngưỡng cảnh báo: Cảnh báo khi chạm 80%, Báo động khi đạt 100%
const isCritical = jobsDailyPercent >= 100 || concurrentPercent >= 100 || pagesPercent >= 100;
const isWarning = !isCritical && (jobsDailyPercent >= 80 || concurrentPercent >= 80 || pagesPercent >= 80);
// Định dạng thời gian reset (deterministic hours:minutes để tránh hydration mismatch)
const formatResetTime = (isoString?: string) => {
if (!isoString) return "00:00 UTC+7";
try {
const date = new Date(isoString);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
} catch {
return "00:00";
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card/90 via-card/70 to-emerald-950/15 p-6 backdrop-blur-md shadow-sm transition-all duration-300",
className
)}
>
{/* Background Radial Glow */}
<div className="absolute -right-12 -bottom-12 h-44 w-44 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-0.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 mb-2">
<Gauge className="h-3.5 w-3.5" />
<span>{t.quota.title}</span>
</div>
<p className="text-xs text-muted-foreground">{t.quota.subtitle}</p>
</div>
{/* Reset At Badge */}
<div className="inline-flex items-center gap-1.5 self-start sm:self-auto rounded-xl bg-muted/40 border border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 text-emerald-500" />
<span suppressHydrationWarning>
{t.quota.resetCountdown}:{" "}
<strong className="text-foreground font-semibold">
{formatResetTime(usageData?.resetAt)}
</strong>
</span>
</div>
</div>
{/* Visual Threshold Alert Banner (Biophilic Alert Box) */}
{isCritical ? (
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-rose-500/40 bg-rose-500/10 p-4 text-rose-600 dark:text-rose-400">
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" />
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider rounded-md bg-rose-500/20 px-2 py-0.5">
{t.quota.criticalBadge}
</span>
<span className="font-semibold text-sm">{t.quota.criticalTitle}</span>
</div>
<p className="text-xs opacity-90 leading-relaxed">{t.quota.criticalDesc}</p>
</div>
</div>
) : isWarning ? (
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-amber-600 dark:text-amber-400">
<Info className="h-5 w-5 shrink-0 mt-0.5" />
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider rounded-md bg-amber-500/20 px-2 py-0.5">
{t.quota.warningBadge}
</span>
<span className="font-semibold text-sm">{t.quota.warningTitle}</span>
</div>
<p className="text-xs opacity-90 leading-relaxed">{t.quota.warningDesc}</p>
</div>
</div>
) : null}
{/* 3 Main Quota Progress Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Card 1: Số trang tối đa được cào */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-emerald-500" />
{t.quota.pagesLimit}
</span>
<span className="text-xs font-bold text-foreground">
{pagesPercent}%
</span>
</div>
<div className="flex items-baseline justify-between">
<span
className="text-xl font-extrabold text-foreground"
suppressHydrationWarning
>
{isLoading ? "..." : formatNumber(usage.totalPagesCrawled)}
</span>
<span
className="text-xs text-muted-foreground"
suppressHydrationWarning
>
/ {formatNumber(quota.maxPagesLimit)} {t.quota.pagesUsed.toLowerCase()}
</span>
</div>
{/* Organic Progress Bar */}
<div className="h-2 w-full rounded-full bg-muted/60 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
pagesPercent >= 90
? "bg-rose-500"
: pagesPercent >= 75
? "bg-amber-500"
: "bg-gradient-to-r from-emerald-500 to-teal-400"
)}
style={{ width: `${pagesPercent}%` }}
/>
</div>
</div>
{/* Card 2: Số tác vụ trong ngày */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-cyan-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-cyan-500" />
{t.quota.dailyJobs}
</span>
<span className="text-xs font-bold text-foreground">
{jobsDailyPercent}%
</span>
</div>
<div className="flex items-baseline justify-between">
<span
className="text-xl font-extrabold text-foreground"
suppressHydrationWarning
>
{isLoading ? "..." : formatNumber(usage.jobsUsedToday)}
</span>
<span
className="text-xs text-muted-foreground"
suppressHydrationWarning
>
/ {formatNumber(quota.maxJobsPerDayLimit)} ({t.quota.jobsRemaining}: <strong className="text-emerald-500 font-bold">{formatNumber(usage.jobsRemainingToday)}</strong>)
</span>
</div>
{/* Organic Progress Bar */}
<div className="h-2 w-full rounded-full bg-muted/60 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
jobsDailyPercent >= 90
? "bg-rose-500"
: jobsDailyPercent >= 75
? "bg-amber-500"
: "bg-gradient-to-r from-cyan-500 to-emerald-400"
)}
style={{ width: `${jobsDailyPercent}%` }}
/>
</div>
</div>
{/* Card 3: Số tác vụ đồng thời còn lại */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-teal-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-teal-500" />
{t.quota.concurrentAvailable}
</span>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold",
usage.concurrentJobsAvailable > 0
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-rose-500/15 text-rose-500"
)}
>
{usage.concurrentJobsAvailable} slots
</span>
</div>
<div className="flex items-baseline justify-between">
<span
className="text-xl font-extrabold text-foreground"
suppressHydrationWarning
>
{isLoading ? "..." : formatNumber(usage.concurrentJobsAvailable)}
</span>
<span
className="text-xs text-muted-foreground"
suppressHydrationWarning
>
{t.quota.concurrentRunning}: <strong className="text-teal-500 font-bold">{formatNumber(usage.concurrentJobsRunning)}</strong> / {formatNumber(quota.maxConcurrentJobsLimit)}
</span>
</div>
{/* Slot visual dots */}
<div className="flex items-center gap-1.5 pt-0.5">
{Array.from({ length: Math.min(10, quota.maxConcurrentJobsLimit) }).map((_, index) => {
const isOccupied = index < usage.concurrentJobsRunning;
return (
<div
key={index}
className={cn(
"h-2 flex-1 rounded-full transition-all duration-300",
isOccupied
? "bg-teal-500 shadow-sm shadow-teal-500/30"
: "bg-muted/70"
)}
title={isOccupied ? "Slot đang chạy" : "Slot khả dụng"}
/>
);
})}
</div>
</div>
</div>
</div>
);
}
"use client";
import React from "react";
import { useServiceHealth } from "@/hooks/use-system";
import { useLanguage } from "@/providers/language-provider";
import {
Activity,
CheckCircle2,
Database,
Layers,
RefreshCw,
Server,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export function ServiceHealthBar() {
const { readiness, metrics, isLoading, isFetching, refetch } = useServiceHealth();
const { t } = useLanguage();
const dbCheck = readiness?.checks?.database;
const redisCheck = readiness?.checks?.redis;
const isDbUp = dbCheck?.status === "up";
const isRedisUp = redisCheck?.status === "up";
const isDegraded = redisCheck?.status === "degraded";
const isOverallHealthy = readiness?.status === "ready" && isDbUp && (isRedisUp || isDegraded);
// Queue metrics from /health/metrics
const crawlQueue = typeof metrics?.queues?.crawl === "object" ? metrics.queues.crawl : null;
const waitingJobs = crawlQueue?.waiting ?? 0;
const activeJobs = crawlQueue?.active ?? 0;
// Process uptime & RAM
const uptimeSeconds = metrics?.process?.uptimeSeconds ?? 0;
const formatUptime = (sec: number) => {
if (sec < 60) return `${sec}s`;
const mins = Math.floor(sec / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
const remainMins = mins % 60;
return `${hrs}h ${remainMins}m`;
};
const memoryRss = metrics?.process?.memory?.rssMb ?? 0;
return (
<div className="relative overflow-hidden rounded-2xl border border-emerald-500/20 bg-gradient-to-r from-card/90 via-card/80 to-emerald-950/10 p-3 sm:p-4 backdrop-blur-md shadow-sm transition-all duration-300">
{/* Subtle organic light accent */}
<div className="absolute -left-10 -top-10 h-24 w-24 rounded-full bg-emerald-500/10 blur-2xl pointer-events-none" />
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
{/* Row 1 on mobile / Left group on desktop */}
<div className="flex items-center justify-between lg:justify-start gap-2.5">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping",
isOverallHealthy ? "bg-emerald-400" : isDegraded ? "bg-amber-400" : "bg-red-400"
)}
/>
<span
className={cn(
"relative inline-flex h-2.5 w-2.5 rounded-full",
isOverallHealthy ? "bg-emerald-500" : isDegraded ? "bg-amber-500" : "bg-red-500"
)}
/>
</span>
<span className="text-xs font-bold uppercase tracking-wider text-foreground flex items-center gap-1.5">
{t.health.title}
</span>
</div>
{/* Overall Status Badge (Tablet & Desktop inline) */}
<div
className={cn(
"hidden sm:inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold border transition-colors",
isOverallHealthy
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
: isDegraded
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30"
)}
>
<CheckCircle2 className="h-3 w-3" />
<span>
{isOverallHealthy
? t.health.systemReady
: isDegraded
? t.health.systemDegraded
: t.health.systemUnhealthy}
</span>
</div>
{/* Mobile Refresh Button: placed neatly at the top-right on mobile */}
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching || isLoading}
className="lg:hidden h-7 w-7 p-0 rounded-lg hover:bg-emerald-500/10 text-muted-foreground hover:text-emerald-500 transition-colors shrink-0"
title={t.health.refreshBtn}
>
<RefreshCw className={cn("h-3.5 w-3.5", (isFetching || isLoading) && "animate-spin text-emerald-500")} />
</Button>
</div>
{/* Mobile-only: Overall Status Pill */}
<div className="sm:hidden flex items-center">
<div
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold border transition-colors",
isOverallHealthy
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
: isDegraded
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30"
)}
>
<CheckCircle2 className="h-3 w-3" />
<span>
{isOverallHealthy
? t.health.systemReady
: isDegraded
? t.health.systemDegraded
: t.health.systemUnhealthy}
</span>
</div>
</div>
{/* Center/Right: Biophilic Badges for DB, Redis, BullMQ and Uptime */}
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2">
{/* PostgreSQL Badge */}
<div
className={cn(
"group flex-1 sm:flex-initial inline-flex items-center justify-center sm:justify-start gap-1.5 rounded-xl px-3 py-1.5 sm:py-1 text-xs font-medium border transition-all duration-200 hover:scale-[1.02]",
isDbUp
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25"
: "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25"
)}
title={dbCheck?.error || "PostgreSQL connection normal"}
>
<Database className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="font-semibold">PostgreSQL</span>
{isDbUp ? (
<span className="text-[11px] opacity-80 whitespace-nowrap">
{dbCheck?.latencyMs !== undefined ? `• ${dbCheck.latencyMs}ms` : "• Online"}
</span>
) : (
<span className="text-[11px] text-red-400 font-bold whitespace-nowrap">• Offline</span>
)}
</div>
{/* Redis / BullMQ Queue Badge */}
<div
className={cn(
"group flex-1 sm:flex-initial inline-flex items-center justify-center sm:justify-start gap-1.5 rounded-xl px-3 py-1.5 sm:py-1 text-xs font-medium border transition-all duration-200 hover:scale-[1.02]",
isRedisUp
? "bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/25"
: isDegraded
? "bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/25"
: "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25"
)}
title={redisCheck?.error || "BullMQ Queue active"}
>
<Layers className="h-3.5 w-3.5 text-teal-500 shrink-0" />
<span className="font-semibold">Redis/BullMQ</span>
<span className="text-[11px] opacity-80 whitespace-nowrap">
{redisCheck?.latencyMs !== undefined
? `• ${redisCheck.latencyMs}ms`
: isRedisUp
? "• Active"
: "• Degraded"}
</span>
{(activeJobs > 0 || waitingJobs > 0) && (
<span className="hidden md:inline-flex items-center gap-1 rounded bg-teal-500/20 px-1.5 py-0.2 text-[10px] font-bold text-teal-600 dark:text-teal-300">
<Zap className="h-2.5 w-2.5" />
{activeJobs} {t.health.queueActive} / {waitingJobs} {t.health.queueWaiting}
</span>
)}
</div>
{/* Eco-Uptime & Memory Metric */}
{uptimeSeconds > 0 && (
<div className="hidden xl:inline-flex items-center gap-1.5 rounded-xl bg-muted/40 border border-border/60 px-3 py-1 text-xs text-muted-foreground">
<Server className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>
{t.health.uptime}: <strong className="text-foreground font-semibold">{formatUptime(uptimeSeconds)}</strong>
</span>
{memoryRss > 0 && (
<span className="opacity-75">{memoryRss}MB RAM</span>
)}
</div>
)}
{/* Desktop Refresh Trigger */}
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching || isLoading}
className="hidden lg:flex h-7 w-7 p-0 rounded-lg hover:bg-emerald-500/10 text-muted-foreground hover:text-emerald-500 transition-colors shrink-0"
title={t.health.refreshBtn}
>
<RefreshCw className={cn("h-3.5 w-3.5", (isFetching || isLoading) && "animate-spin text-emerald-500")} />
</Button>
</div>
</div>
</div>
);
}
import { useQuery } from "@tanstack/react-query";
import { systemService } from "@/services/system.service";
export const SYSTEM_QUERY_KEYS = {
readiness: ["system", "readiness"] as const,
metrics: ["system", "metrics"] as const,
health: ["system", "health"] as const,
};
export function useServiceReadiness() {
return useQuery({
queryKey: SYSTEM_QUERY_KEYS.readiness,
queryFn: () => systemService.getReadiness(),
refetchInterval: 15000,
staleTime: 10000,
retry: 1,
});
}
export function useServiceMetrics() {
return useQuery({
queryKey: SYSTEM_QUERY_KEYS.metrics,
queryFn: () => systemService.getMetrics(),
refetchInterval: 15000,
staleTime: 10000,
retry: 1,
});
}
export function useServiceHealth() {
const readinessQuery = useServiceReadiness();
const metricsQuery = useServiceMetrics();
return {
readiness: readinessQuery.data,
metrics: metricsQuery.data,
isLoading: readinessQuery.isLoading || metricsQuery.isLoading,
isFetching: readinessQuery.isFetching || metricsQuery.isFetching,
isError: readinessQuery.isError || metricsQuery.isError,
error: readinessQuery.error || metricsQuery.error,
refetch: async () => {
await Promise.all([readinessQuery.refetch(), metricsQuery.refetch()]);
},
};
}
import { useQuery } from "@tanstack/react-query";
import { authService } from "@/services/auth.service";
export const USAGE_QUERY_KEYS = {
usage: ["auth", "me", "usage"] as const,
};
export function useUserUsage() {
return useQuery({
queryKey: USAGE_QUERY_KEYS.usage,
queryFn: () => authService.getUsage(),
refetchInterval: 15000,
staleTime: 10000,
retry: 1,
});
}
......@@ -9,16 +9,19 @@ export interface ApiErrorResponse {
export class ApiError extends Error {
statusCode?: number;
errors?: Record<string, string[]>;
data?: unknown;
constructor(
message: string,
statusCode?: number,
errors?: Record<string, string[]>
errors?: Record<string, string[]>,
data?: unknown
) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.errors = errors;
this.data = data;
}
}
......@@ -53,7 +56,7 @@ apiClient.interceptors.response.use(
"Đã có lỗi xảy ra khi kết nối tới máy chủ.";
const errors = error.response?.data?.errors;
return Promise.reject(new ApiError(message, statusCode, errors));
return Promise.reject(new ApiError(message, statusCode, errors, error.response?.data));
}
);
......
......@@ -26,10 +26,9 @@ export const translations = {
},
// Hero & Header
hero: {
badge: "Next.js 16 + TanStack Query + shadcn/ui",
title: "Quản Lý & Giám Sát Data Crawler",
title: "Quản Lý Data Crawler",
description:
"Hệ thống giám sát và bóc tách dữ liệu đa nguồn theo thời gian thực. Thiết kế chuẩn Organic Biophilic, tối ưu hiệu năng và thân thiện sinh thái.",
"Hệ thống giám sát và bóc tách dữ liệu đa nguồn theo thời gian thực.",
createTaskBtn: "Tạo Tác Vụ Mới",
},
// Dialog
......@@ -58,7 +57,65 @@ export const translations = {
failedTasks: "Gặp Lỗi",
failedTasksDesc: "Cần kiểm tra lại kết nối",
crawledPages: "Trang Đã Bóc Tách",
crawledPagesDesc: "Tổng số trang web đã tải về",
ecoScore: "Chỉ Số Sinh Thái (Eco)",
totalJobs: "Tổng Số Job",
totalJobsDesc: "Tổng tác vụ cào trong hệ thống",
runningJobs: "Job Đang Chạy",
runningJobsDesc: "Workers đang cào dữ liệu realtime",
extractedRecords: "Dữ Liệu Đã Trích Xuất",
extractedRecordsDesc: "Số bản ghi bóc tách thành công",
successRate: "Tỷ Lệ Thành Công",
successRateDesc: "Hiệu suất cào dữ liệu tối ưu",
realtime: "Thời gian thực",
},
// Quota & Usage Widget
quota: {
title: "Hạn Ngạch & Mức Độ Sử Dụng",
subtitle: "Theo dõi tài nguyên cào dữ liệu và giới hạn định mức của tài khoản",
pagesLimit: "Số trang tối đa",
pagesUsed: "Trang đã cào",
pagesProgress: "Tiến độ cào",
dailyJobs: "Tác vụ trong ngày",
jobsUsed: "Đã chạy hôm nay",
jobsRemaining: "Còn lại trong ngày",
concurrentSlots: "Tác vụ đồng thời",
concurrentAvailable: "Tác vụ đồng thời còn lại",
concurrentRunning: "Đang chạy song song",
resetAt: "Hạn ngạch làm mới lúc",
resetCountdown: "Thời gian làm mới",
unlimited: "Không giới hạn",
warningBadge: "Cảnh báo hạn ngạch",
warningTitle: "Tiệm cận ngưỡng tài nguyên (≥ 80%)",
warningDesc: "Bạn đã sử dụng gần hết hạn mức tác vụ cho phép trong ngày. Các tác vụ tiếp theo có thể bị giới hạn tốc độ.",
criticalBadge: "Chạm ngưỡng tối đa",
criticalTitle: "Đã đạt giới hạn tài nguyên (100%)",
criticalDesc: "Tài khoản của bạn đã đạt giới hạn tài nguyên tối đa cho phép. Vui lòng chờ đến mốc làm mới tiếp theo để tiếp tục chạy tác vụ.",
},
// Service Health Monitoring
health: {
title: "Giám Sát Sức Khỏe Dịch Vụ",
subtitle: "Trạng thái kết nối cơ sở dữ liệu, hàng đợi BullMQ và hệ sinh thái máy chủ",
systemReady: "Tất cả dịch vụ hoạt động tốt",
systemDegraded: "Hiệu năng một số dịch vụ bị giảm",
systemUnhealthy: "Phát hiện dịch vụ mất kết nối",
database: "Database (PostgreSQL)",
redis: "Hàng đợi (Redis/BullMQ)",
postgresReady: "PostgreSQL Kết Nối Tốt",
postgresDown: "PostgreSQL Mất Kết Nối",
redisReady: "Redis / BullMQ Sẵn Sàng",
redisDegraded: "Redis Bị Chậm / Degraded",
redisDown: "Redis Mất Kết Nối",
latency: "Độ trễ",
uptime: "Thời gian chạy (Uptime)",
memory: "Bộ nhớ RAM",
queueWaiting: "Hàng đợi",
queueActive: "Đang chạy",
queueCompleted: "Đã xong",
queueFailed: "Thất bại",
refreshBtn: "Làm mới trạng thái",
refreshing: "Đang kiểm tra...",
liveBadge: "Trực tiếp",
},
// Task Table
table: {
......@@ -217,10 +274,9 @@ export const translations = {
},
// Hero & Header
hero: {
badge: "Next.js 16 + TanStack Query + shadcn/ui",
title: "Data Crawler Management",
description:
"Real-time multi-source data scraping and extraction platform. Designed with Organic Biophilic principles, optimized for performance and eco-efficiency.",
"Real-time multi-source data scraping and extraction platform.",
createTaskBtn: "Create New Task",
},
// Dialog
......@@ -249,7 +305,65 @@ export const translations = {
failedTasks: "Failed Tasks",
failedTasksDesc: "Connection or proxy issues",
crawledPages: "Parsed Pages",
crawledPagesDesc: "Total web pages fetched",
ecoScore: "Eco Efficiency Score",
totalJobs: "Total Jobs",
totalJobsDesc: "Total crawl jobs configured",
runningJobs: "Running Jobs",
runningJobsDesc: "Workers actively scraping data",
extractedRecords: "Extracted Records",
extractedRecordsDesc: "Successfully parsed data items",
successRate: "Success Rate",
successRateDesc: "Optimal scraping efficiency",
realtime: "Realtime",
},
// Quota & Usage Widget
quota: {
title: "Resource Quota & Usage",
subtitle: "Monitor scraping capacity limits and account resource consumption",
pagesLimit: "Max Pages Limit",
pagesUsed: "Pages Crawled",
pagesProgress: "Crawl Progress",
dailyJobs: "Daily Jobs Quota",
jobsUsed: "Used Today",
jobsRemaining: "Remaining Today",
concurrentSlots: "Concurrent Tasks",
concurrentAvailable: "Available Concurrent Slots",
concurrentRunning: "Running in Parallel",
resetAt: "Quota resets at",
resetCountdown: "Reset Time",
unlimited: "Unlimited",
warningBadge: "Quota Warning",
warningTitle: "Approaching Resource Threshold (≥ 80%)",
warningDesc: "You have consumed almost all of your daily allocated jobs. Subsequent tasks may experience rate-limiting.",
criticalBadge: "Quota Exceeded",
criticalTitle: "Resource Limit Reached (100%)",
criticalDesc: "Your account has reached its maximum quota. New crawl jobs will remain pending until the quota reset window.",
},
// Service Health Monitoring
health: {
title: "Service Ecosystem Health",
subtitle: "Real-time connection monitoring for Database, BullMQ Queues and Server Uptime",
systemReady: "All Systems Operational",
systemDegraded: "Degraded Performance Detected",
systemUnhealthy: "Service Outage Detected",
database: "Database (PostgreSQL)",
redis: "Queues (Redis/BullMQ)",
postgresReady: "PostgreSQL Connected",
postgresDown: "PostgreSQL Disconnected",
redisReady: "Redis / BullMQ Ready",
redisDegraded: "Redis Degraded",
redisDown: "Redis Disconnected",
latency: "Latency",
uptime: "Uptime",
memory: "RAM Usage",
queueWaiting: "Waiting",
queueActive: "Active",
queueCompleted: "Completed",
queueFailed: "Failed",
refreshBtn: "Refresh Status",
refreshing: "Checking...",
liveBadge: "Live",
},
// Task Table
table: {
......
......@@ -23,3 +23,9 @@ export function formatBytes(bytes: number, decimals = 2): string {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function formatNumber(value: number | string): string {
const num = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(num)) return String(value);
return new Intl.NumberFormat("en-US").format(num);
}
......@@ -5,8 +5,9 @@ import {
ForgotPasswordInput,
ResetPasswordInput,
} from "@/schemas/auth.schema";
import { User, AuthUserSummary } from "@/types/auth";
import { User, AuthUserSummary, UserUsageDto } from "@/types/auth";
import { ApiResponse } from "@/types/api";
import apiClient from "@/lib/api-client";
const authClient = axios.create({
baseURL: "/api/auth",
......@@ -77,6 +78,15 @@ export class AuthService {
return response.data.data;
}
async getUsage(): Promise<UserUsageDto> {
try {
const response = await apiClient.get<ApiResponse<UserUsageDto>>("/auth/me/usage");
return response.data.data;
} catch (err) {
throw new Error(extractErrorMessage(err, "Không thể lấy thông tin hạn ngạch"));
}
}
async forgotPassword(input: ForgotPasswordInput): Promise<{ message: string }> {
try {
const response = await authClient.post<ApiResponse<null>>(
......
import apiClient from "@/lib/api-client";
import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import { CrawlerStats, CrawlerTask } from "@/types/crawler";
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[] = [
......@@ -48,14 +50,132 @@ const INITIAL_MOCK_TASKS: CrawlerTask[] = [
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,
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
export const crawlerService = {
// Lấy danh sách task (kết nối API, fallback mock)
async getTasks(): Promise<PaginatedResponse<CrawlerTask>> {
/**
* 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<PaginatedResponse<CrawlerTask>>>("/crawler/tasks");
return response.data.data;
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
return DEFAULT_FALLBACK_STATS;
} catch {
// Fallback local memory state cho demo
// 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,
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
}
},
/**
* Lấy danh sách task cào dữ liệu (kết nối endpoint thực /crawl-jobs hoặc fallback)
*/
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,
......@@ -63,33 +183,46 @@ export const crawlerService = {
pageSize: 10,
totalPages: 1,
};
}
},
// Lấy thống kê tổng quan
async getStats(): Promise<CrawlerStats> {
try {
const response = await apiClient.get<ApiResponse<CrawlerStats>>("/crawler/stats");
return response.data.data;
} catch {
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0);
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const active = localTasksState.filter((t) => t.status === "RUNNING").length;
// Fallback local memory state
return {
activeTasks: active,
totalTasks: localTasksState.length,
totalPagesCrawled: totalPages,
totalItemsExtracted: totalItems,
successRate: 98.4,
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
}
},
// Tạo mới một task
/**
* Tạo mới một task
*/
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try {
const response = await apiClient.post<ApiResponse<CrawlerTask>>("/crawler/tasks", input);
return response.data.data;
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()}`,
......@@ -109,24 +242,51 @@ export const crawlerService = {
}
},
// Dừng / chạy tiếp task
/**
* Dừng / chạy tiếp task
*/
async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try {
const response = await apiClient.patch<ApiResponse<CrawlerTask>>(`/crawler/tasks/${id}/toggle`);
return response.data.data;
// Thử gọi API cancel hoặc rerun tương ứng
const currentTask = localTasksState.find((t) => t.id === id);
if (currentTask?.status === "RUNNING") {
await apiClient.post(`/crawl-jobs/${id}/cancel`);
} else {
await apiClient.post(`/crawl-jobs/${id}/rerun`);
}
} catch {
const task = localTasksState.find((t) => t.id === id);
if (!task) throw new Error("Task not found");
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
// 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",
status: "PAUSED",
maxDepth: 1,
maxPages: 20,
pagesCrawled: 1,
itemsExtracted: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return fallbackTask;
}
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
},
// Xóa task
/**
* Xóa task
*/
async deleteTask(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawler/tasks/${id}`);
await apiClient.delete(`/crawl-jobs/${id}`);
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
} catch {
localTasksState = localTasksState.filter((t) => t.id !== id);
......
import apiClient, { ApiError } from "@/lib/api-client";
import { SystemMetricsResponse, SystemReadinessResponse } from "@/types/system";
import axios from "axios";
export const systemService = {
/**
* Lấy trạng thái sẵn sàng của hệ thống (Database PostgreSQL & Queue Redis/BullMQ)
* GET /health/readiness (qua BFF Proxy)
*/
async getReadiness(): Promise<SystemReadinessResponse> {
try {
const response = await apiClient.get<SystemReadinessResponse>("/health/readiness");
return response.data;
} catch (error: unknown) {
// Khi backend trả về HTTP 503 hoặc non-200, payload vẫn chứa chi tiết checks (database up, redis degraded)
if (error instanceof ApiError && error.data && typeof error.data === "object" && "checks" in error.data) {
return error.data as SystemReadinessResponse;
}
if (axios.isAxiosError(error) && error.response?.data && typeof error.response.data === "object" && "checks" in error.response.data) {
return error.response.data as SystemReadinessResponse;
}
return {
status: "unhealthy",
timestamp: new Date().toISOString(),
checks: {
database: {
status: "down",
error: error instanceof Error ? error.message : "Database connection unreachable",
},
redis: {
status: "degraded",
error: error instanceof Error ? error.message : "Redis queue service unreachable",
},
},
};
}
},
/**
* Lấy các chỉ số thời gian thực (Process, RAM Uptime, Queue BullMQ count)
* GET /health/metrics (qua BFF Proxy)
*/
async getMetrics(): Promise<SystemMetricsResponse> {
try {
const response = await apiClient.get<SystemMetricsResponse>("/health/metrics");
return response.data;
} catch (error: unknown) {
if (error instanceof ApiError && error.data && typeof error.data === "object" && "process" in error.data) {
return error.data as SystemMetricsResponse;
}
return {
timestamp: new Date().toISOString(),
process: {
uptimeSeconds: 0,
pid: 0,
memory: {
rssMb: 0,
heapTotalMb: 0,
heapUsedMb: 0,
},
},
queues: {
crawl: null,
webhook: null,
},
};
}
},
};
......@@ -6,3 +6,4 @@ export * from "./extraction-template";
export * from "./export";
export * from "./dashboard";
export * from "./crawler";
export * from "./system";
export type ServiceStatus = "up" | "down" | "degraded" | "skipped";
export interface DatabaseHealthCheck {
status: "up" | "down";
latencyMs?: number;
error?: string;
}
export interface RedisHealthCheck {
status: "up" | "down" | "degraded" | "skipped";
latencyMs?: number;
error?: string;
}
export interface SystemReadinessResponse {
status: "ready" | "unhealthy";
timestamp: string;
checks: {
database: DatabaseHealthCheck;
redis: RedisHealthCheck;
};
}
export interface QueueCountMetrics {
waiting: number;
active: number;
completed: number;
failed: number;
}
export type QueueMetricsResult = QueueCountMetrics | "unavailable" | null;
export interface SystemProcessMetrics {
uptimeSeconds: number;
pid: number;
memory: {
rssMb: number;
heapTotalMb: number;
heapUsedMb: number;
};
}
export interface SystemMetricsResponse {
timestamp: string;
process: SystemProcessMetrics;
queues: {
crawl: QueueMetricsResult;
webhook: QueueMetricsResult;
};
}
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