Commit e204f321 authored by ThinhNC's avatar ThinhNC

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

parent 9151bfc2
This diff is collapsed.
import React from "react"; import React from "react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn, formatNumber } from "@/lib/utils";
interface StatCardProps { interface StatCardProps {
title: string; title: string;
...@@ -13,6 +13,9 @@ interface StatCardProps { ...@@ -13,6 +13,9 @@ interface StatCardProps {
isPositive: boolean; isPositive: boolean;
}; };
colorClassName?: string; colorClassName?: string;
isLoading?: boolean;
badge?: string;
className?: string;
} }
export function StatCard({ export function StatCard({
...@@ -22,53 +25,80 @@ export function StatCard({ ...@@ -22,53 +25,80 @@ export function StatCard({
icon: Icon, icon: Icon,
trend, trend,
colorClassName = "from-emerald-500/20 to-teal-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30", 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) { }: StatCardProps) {
return ( 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"> <Card
{/* Subtle Glow background */} 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 <div
className={cn( 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 colorClassName
)} )}
/> />
<CardContent className="p-5"> <CardContent className="p-3.5 sm:p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-1.5 sm:gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-1.5 sm:gap-2 min-w-0">
{title} <p className="text-[11px] sm:text-xs font-semibold uppercase tracking-wider text-muted-foreground truncate">
</p> {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 <div
className={cn( 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 colorClassName
)} )}
> >
<Icon className="h-5 w-5" /> <Icon className="h-3.5 w-3.5 sm:h-5 sm:w-5" />
</div> </div>
</div> </div>
<div className="mt-3 flex items-baseline gap-2"> <div className="mt-2 sm:mt-3 flex items-baseline gap-1.5 sm:gap-2">
<span className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground"> {isLoading ? (
{value} <div className="h-7 sm:h-8 w-20 sm:w-24 rounded-lg bg-muted/60 animate-pulse" />
</span> ) : (
{trend && ( <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 <span
className={cn( 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 trend.isPositive
? "text-emerald-600 dark:text-emerald-400" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-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} {trend.value}
</span> </span>
)} )}
</div> </div>
{description && ( {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> </CardContent>
</Card> </Card>
......
...@@ -16,7 +16,7 @@ import { ...@@ -16,7 +16,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/utils"; import { formatDate, formatNumber } from "@/lib/utils";
import { CrawlerStatus } from "@/types/crawler"; import { CrawlerStatus } from "@/types/crawler";
import { import {
ExternalLink, ExternalLink,
...@@ -161,8 +161,11 @@ export function CrawlerTaskTable() { ...@@ -161,8 +161,11 @@ export function CrawlerTaskTable() {
{/* Extracted Count */} {/* Extracted Count */}
<TableCell> <TableCell>
<span className="font-semibold text-emerald-400"> <span
{task.itemsExtracted.toLocaleString()} className="font-semibold text-emerald-400"
suppressHydrationWarning
>
{formatNumber(task.itemsExtracted)}
</span>{" "} </span>{" "}
<span className="text-xs text-slate-400">mục</span> <span className="text-xs text-slate-400">mục</span>
</TableCell> </TableCell>
......
This diff is collapsed.
"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 { ...@@ -9,16 +9,19 @@ export interface ApiErrorResponse {
export class ApiError extends Error { export class ApiError extends Error {
statusCode?: number; statusCode?: number;
errors?: Record<string, string[]>; errors?: Record<string, string[]>;
data?: unknown;
constructor( constructor(
message: string, message: string,
statusCode?: number, statusCode?: number,
errors?: Record<string, string[]> errors?: Record<string, string[]>,
data?: unknown
) { ) {
super(message); super(message);
this.name = "ApiError"; this.name = "ApiError";
this.statusCode = statusCode; this.statusCode = statusCode;
this.errors = errors; this.errors = errors;
this.data = data;
} }
} }
...@@ -53,7 +56,7 @@ apiClient.interceptors.response.use( ...@@ -53,7 +56,7 @@ apiClient.interceptors.response.use(
"Đã có lỗi xảy ra khi kết nối tới máy chủ."; "Đã có lỗi xảy ra khi kết nối tới máy chủ.";
const errors = error.response?.data?.errors; 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 = { ...@@ -26,10 +26,9 @@ export const translations = {
}, },
// Hero & Header // Hero & Header
hero: { hero: {
badge: "Next.js 16 + TanStack Query + shadcn/ui", title: "Quản Lý Data Crawler",
title: "Quản Lý & Giám Sát Data Crawler",
description: 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", createTaskBtn: "Tạo Tác Vụ Mới",
}, },
// Dialog // Dialog
...@@ -58,7 +57,65 @@ export const translations = { ...@@ -58,7 +57,65 @@ export const translations = {
failedTasks: "Gặp Lỗi", failedTasks: "Gặp Lỗi",
failedTasksDesc: "Cần kiểm tra lại kết nối", failedTasksDesc: "Cần kiểm tra lại kết nối",
crawledPages: "Trang Đã Bóc Tách", crawledPages: "Trang Đã Bóc Tách",
crawledPagesDesc: "Tổng số trang web đã tải về",
ecoScore: "Chỉ Số Sinh Thái (Eco)", 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 // Task Table
table: { table: {
...@@ -217,10 +274,9 @@ export const translations = { ...@@ -217,10 +274,9 @@ export const translations = {
}, },
// Hero & Header // Hero & Header
hero: { hero: {
badge: "Next.js 16 + TanStack Query + shadcn/ui",
title: "Data Crawler Management", title: "Data Crawler Management",
description: 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", createTaskBtn: "Create New Task",
}, },
// Dialog // Dialog
...@@ -249,7 +305,65 @@ export const translations = { ...@@ -249,7 +305,65 @@ export const translations = {
failedTasks: "Failed Tasks", failedTasks: "Failed Tasks",
failedTasksDesc: "Connection or proxy issues", failedTasksDesc: "Connection or proxy issues",
crawledPages: "Parsed Pages", crawledPages: "Parsed Pages",
crawledPagesDesc: "Total web pages fetched",
ecoScore: "Eco Efficiency Score", 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 // Task Table
table: { table: {
......
...@@ -23,3 +23,9 @@ export function formatBytes(bytes: number, decimals = 2): string { ...@@ -23,3 +23,9 @@ export function formatBytes(bytes: number, decimals = 2): string {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; 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 { ...@@ -5,8 +5,9 @@ import {
ForgotPasswordInput, ForgotPasswordInput,
ResetPasswordInput, ResetPasswordInput,
} from "@/schemas/auth.schema"; } from "@/schemas/auth.schema";
import { User, AuthUserSummary } from "@/types/auth"; import { User, AuthUserSummary, UserUsageDto } from "@/types/auth";
import { ApiResponse } from "@/types/api"; import { ApiResponse } from "@/types/api";
import apiClient from "@/lib/api-client";
const authClient = axios.create({ const authClient = axios.create({
baseURL: "/api/auth", baseURL: "/api/auth",
...@@ -77,6 +78,15 @@ export class AuthService { ...@@ -77,6 +78,15 @@ export class AuthService {
return response.data.data; 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 }> { async forgotPassword(input: ForgotPasswordInput): Promise<{ message: string }> {
try { try {
const response = await authClient.post<ApiResponse<null>>( const response = await authClient.post<ApiResponse<null>>(
......
import apiClient from "@/lib/api-client"; import apiClient from "@/lib/api-client";
import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema"; import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema";
import { ApiResponse, PaginatedResponse } from "@/types/api"; 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 // Dữ liệu mẫu khởi đầu khi Backend chưa kết nối
const INITIAL_MOCK_TASKS: CrawlerTask[] = [ const INITIAL_MOCK_TASKS: CrawlerTask[] = [
...@@ -48,14 +50,132 @@ const INITIAL_MOCK_TASKS: CrawlerTask[] = [ ...@@ -48,14 +50,132 @@ const INITIAL_MOCK_TASKS: CrawlerTask[] = [
let localTasksState = [...INITIAL_MOCK_TASKS]; 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 = { 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 { try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlerTask>>>("/crawler/tasks"); const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
return response.data.data; if (response.data?.data) {
return response.data.data;
}
return DEFAULT_FALLBACK_STATS;
} catch { } 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 { return {
items: localTasksState, items: localTasksState,
total: localTasksState.length, total: localTasksState.length,
...@@ -63,33 +183,46 @@ export const crawlerService = { ...@@ -63,33 +183,46 @@ export const crawlerService = {
pageSize: 10, pageSize: 10,
totalPages: 1, 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 { } catch {
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0); // Fallback local memory state
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const active = localTasksState.filter((t) => t.status === "RUNNING").length;
return { return {
activeTasks: active, items: localTasksState,
totalTasks: localTasksState.length, total: localTasksState.length,
totalPagesCrawled: totalPages, page: 1,
totalItemsExtracted: totalItems, pageSize: 10,
successRate: 98.4, totalPages: 1,
}; };
} }
}, },
// Tạo mới một task /**
* Tạo mới một task
*/
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> { async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try { try {
const response = await apiClient.post<ApiResponse<CrawlerTask>>("/crawler/tasks", input); const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", {
return response.data.data; 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 { } catch {
const newTask: CrawlerTask = { const newTask: CrawlerTask = {
id: `task-${Date.now()}`, id: `task-${Date.now()}`,
...@@ -109,24 +242,51 @@ export const crawlerService = { ...@@ -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> { async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try { try {
const response = await apiClient.patch<ApiResponse<CrawlerTask>>(`/crawler/tasks/${id}/toggle`); // Thử gọi API cancel hoặc rerun tương ứng
return response.data.data; 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 { } catch {
const task = localTasksState.find((t) => t.id === id); // Ignored for local fallback
if (!task) throw new Error("Task not found");
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
} }
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> { async deleteTask(id: string): Promise<boolean> {
try { try {
await apiClient.delete(`/crawler/tasks/${id}`); await apiClient.delete(`/crawl-jobs/${id}`);
localTasksState = localTasksState.filter((t) => t.id !== id);
return true; return true;
} catch { } catch {
localTasksState = localTasksState.filter((t) => t.id !== id); 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"; ...@@ -6,3 +6,4 @@ export * from "./extraction-template";
export * from "./export"; export * from "./export";
export * from "./dashboard"; export * from "./dashboard";
export * from "./crawler"; 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