368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { agentsApi, type AgentTask, type TaskLog } from "@/lib/api/agents";
|
|
import { MetricCard } from "@/components/business/metric-card";
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableBody,
|
|
TableRow,
|
|
TableHead,
|
|
TableCell,
|
|
} from "@/components/ui/table";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { cn } from "@/lib/utils";
|
|
import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle } from "lucide-react";
|
|
|
|
type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
|
type FilterStatus = "all" | TaskStatus;
|
|
|
|
const STATUS_CONFIG: Record<TaskStatus, { label: string; icon: React.ReactNode; color: string }> = {
|
|
pending: { label: "等待中", icon: <Clock className="h-3 w-3" />, color: "bg-gray-100 text-gray-600" },
|
|
running: { label: "运行中", icon: <Loader2 className="h-3 w-3 animate-spin" />, color: "bg-blue-100 text-blue-600" },
|
|
completed: { label: "已完成", icon: <CheckCircle2 className="h-3 w-3" />, color: "bg-emerald-100 text-emerald-600" },
|
|
failed: { label: "失败", icon: <XCircle className="h-3 w-3" />, color: "bg-red-100 text-red-600" },
|
|
cancelled: { label: "已取消", icon: <AlertCircle className="h-3 w-3" />, color: "bg-yellow-100 text-yellow-600" },
|
|
};
|
|
|
|
function formatDuration(startedAt?: string, completedAt?: string): string {
|
|
if (!startedAt) return "-";
|
|
const start = new Date(startedAt).getTime();
|
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
const seconds = Math.round((end - start) / 1000);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
return `${minutes}m ${remainingSeconds}s`;
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString("zh-CN", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
interface ExecutionStats {
|
|
total: number;
|
|
successCount: number;
|
|
failedCount: number;
|
|
runningCount: number;
|
|
successRate: number;
|
|
avgDurationSeconds: number;
|
|
}
|
|
|
|
export default function AgentsPage() {
|
|
const { data: session } = useSession();
|
|
const token = (session as { accessToken?: string })?.accessToken;
|
|
|
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
|
|
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
|
|
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
|
|
const [loadingLogs, setLoadingLogs] = useState(false);
|
|
|
|
// 获取执行记录
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
|
|
async function fetchTasks() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params = filterStatus !== "all" ? { status: filterStatus, limit: 50 } : { limit: 50 };
|
|
const result = await agentsApi.listTasks(token!, params);
|
|
setTasks(result.items);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "获取执行记录失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
fetchTasks();
|
|
}, [token, filterStatus]);
|
|
|
|
// 获取任务日志
|
|
const fetchTaskLogs = async (taskId: string) => {
|
|
if (!token) return;
|
|
|
|
setLoadingLogs(true);
|
|
try {
|
|
const result = await agentsApi.getTaskLogs(token, taskId);
|
|
setTaskLogs(result.items);
|
|
} catch (err) {
|
|
console.error("获取日志失败:", err);
|
|
} finally {
|
|
setLoadingLogs(false);
|
|
}
|
|
};
|
|
|
|
// 计算统计摘要
|
|
const stats = useMemo<ExecutionStats>(() => {
|
|
const allTasks = tasks;
|
|
const successCount = allTasks.filter(t => t.status === "completed").length;
|
|
const failedCount = allTasks.filter(t => t.status === "failed").length;
|
|
const runningCount = allTasks.filter(t => t.status === "running" || t.status === "pending").length;
|
|
const total = allTasks.length;
|
|
|
|
const completedTasks = allTasks.filter(
|
|
t => t.status === "completed" && t.started_at && t.completed_at
|
|
);
|
|
const totalDuration = completedTasks.reduce((sum, t) => {
|
|
const start = new Date(t.started_at!).getTime();
|
|
const end = new Date(t.completed_at!).getTime();
|
|
return sum + (end - start) / 1000;
|
|
}, 0);
|
|
const avgDurationSeconds = completedTasks.length > 0 ? totalDuration / completedTasks.length : 0;
|
|
|
|
return {
|
|
total,
|
|
successCount,
|
|
failedCount,
|
|
runningCount,
|
|
successRate: total > 0 ? (successCount / total) * 100 : 0,
|
|
avgDurationSeconds,
|
|
};
|
|
}, [tasks]);
|
|
|
|
// 打开详情弹窗
|
|
const handleRowClick = async (task: AgentTask) => {
|
|
setSelectedTask(task);
|
|
await fetchTaskLogs(task.id);
|
|
};
|
|
|
|
const filterButtons: { status: FilterStatus; label: string }[] = [
|
|
{ status: "all", label: "全部" },
|
|
{ status: "running", label: "运行中" },
|
|
{ status: "completed", label: "已完成" },
|
|
{ status: "failed", label: "失败" },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 页面标题 */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
<Bot className="h-6 w-6" />
|
|
Agent监控
|
|
</h1>
|
|
<p className="text-muted-foreground">监控Agent执行状态和历史记录</p>
|
|
</div>
|
|
|
|
{/* 统计摘要卡片 */}
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<MetricCard
|
|
label="总执行次数"
|
|
value={stats.total}
|
|
icon={<Bot className="h-5 w-5 text-blue-500" />}
|
|
trend="neutral"
|
|
/>
|
|
<MetricCard
|
|
label="成功率"
|
|
value={`${stats.successRate.toFixed(1)}%`}
|
|
icon={<CheckCircle2 className="h-5 w-5 text-emerald-500" />}
|
|
trend={stats.successRate >= 80 ? "up" : "down"}
|
|
/>
|
|
<MetricCard
|
|
label="平均耗时"
|
|
value={stats.avgDurationSeconds > 0 ? `${stats.avgDurationSeconds.toFixed(1)}s` : "-"}
|
|
icon={<Clock className="h-5 w-5 text-purple-500" />}
|
|
trend="neutral"
|
|
/>
|
|
</div>
|
|
|
|
{/* 状态筛选 */}
|
|
<div className="flex gap-2">
|
|
{filterButtons.map(({ status, label }) => (
|
|
<button
|
|
key={status}
|
|
onClick={() => setFilterStatus(status)}
|
|
className={cn(
|
|
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
|
filterStatus === status
|
|
? "bg-blue-600 text-white"
|
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
|
)}
|
|
>
|
|
{label}
|
|
{status === "running" && stats.runningCount > 0 && (
|
|
<span className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-blue-200 text-blue-800">
|
|
{stats.runningCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 执行历史列表 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>执行历史</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{[1, 2, 3].map(i => (
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-8 text-red-500">
|
|
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
|
|
<p>{error}</p>
|
|
</div>
|
|
) : tasks.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">
|
|
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
<p>暂无执行记录</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>任务类型</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>耗时</TableHead>
|
|
<TableHead>创建时间</TableHead>
|
|
<TableHead>错误信息</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{tasks.map(task => {
|
|
const config = STATUS_CONFIG[task.status];
|
|
return (
|
|
<TableRow
|
|
key={task.id}
|
|
onClick={() => handleRowClick(task)}
|
|
className="cursor-pointer hover:bg-muted/40"
|
|
>
|
|
<TableCell className="font-medium">{task.task_type}</TableCell>
|
|
<TableCell>
|
|
<Badge className={cn("gap-1", config.color)} variant="secondary">
|
|
{config.icon}
|
|
{config.label}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{formatDuration(task.started_at, task.completed_at)}</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{formatDate(task.created_at)}
|
|
</TableCell>
|
|
<TableCell className="text-red-500 max-w-xs truncate">
|
|
{task.error_message || "-"}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 执行详情弹窗 */}
|
|
<Dialog open={!!selectedTask} onOpenChange={() => setSelectedTask(null)}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>执行详情</DialogTitle>
|
|
</DialogHeader>
|
|
{selectedTask && (
|
|
<div className="space-y-4">
|
|
{/* 基本信息 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">任务ID</p>
|
|
<p className="font-mono text-sm">{selectedTask.id}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">任务类型</p>
|
|
<p className="font-medium">{selectedTask.task_type}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">状态</p>
|
|
<Badge
|
|
className={cn("gap-1", STATUS_CONFIG[selectedTask.status].color)}
|
|
variant="secondary"
|
|
>
|
|
{STATUS_CONFIG[selectedTask.status].icon}
|
|
{STATUS_CONFIG[selectedTask.status].label}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">优先级</p>
|
|
<p>{selectedTask.priority}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">开始时间</p>
|
|
<p>{selectedTask.started_at ? formatDate(selectedTask.started_at) : "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">完成时间</p>
|
|
<p>{selectedTask.completed_at ? formatDate(selectedTask.completed_at) : "-"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 错误信息 */}
|
|
{selectedTask.error_message && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-sm font-medium text-red-600">错误信息</p>
|
|
<p className="text-sm text-red-500 mt-1">{selectedTask.error_message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 执行日志 */}
|
|
<div>
|
|
<p className="text-sm font-medium mb-2">执行日志</p>
|
|
{loadingLogs ? (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map(i => (
|
|
<Skeleton key={i} className="h-8 w-full" />
|
|
))}
|
|
</div>
|
|
) : taskLogs.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">暂无日志</p>
|
|
) : (
|
|
<div className="bg-gray-900 text-gray-100 rounded-lg p-3 font-mono text-xs max-h-64 overflow-y-auto">
|
|
{taskLogs.map(log => (
|
|
<div key={log.id} className="py-1">
|
|
<span className="text-gray-500">[{formatDate(log.created_at)}]</span>
|
|
<span
|
|
className={cn(
|
|
"ml-2",
|
|
log.log_level === "ERROR" && "text-red-400",
|
|
log.log_level === "WARNING" && "text-yellow-400",
|
|
log.log_level === "INFO" && "text-blue-400",
|
|
log.log_level === "DEBUG" && "text-gray-500"
|
|
)}
|
|
>
|
|
[{log.log_level}]
|
|
</span>
|
|
<span className="ml-2">{log.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|