geo/frontend/app/(dashboard)/dashboard/agents/page.tsx

477 lines
18 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { useSession } from "next-auth/react";
import { agentsApi, type AgentTask, type TaskLog, type Agent } 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle, Settings2, Save } 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);
const [agents, setAgents] = useState<Agent[]>([]);
const [configAgent, setConfigAgent] = useState<Agent | null>(null);
const [configJson, setConfigJson] = useState("");
const [savingConfig, setSavingConfig] = 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]);
useEffect(() => {
if (!token) return;
agentsApi.list(token).then(setAgents).catch(() => {});
}, [token]);
// 获取任务日志
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 openConfig = (agent: Agent) => {
setConfigAgent(agent);
setConfigJson(JSON.stringify(agent.config ?? {}, null, 2));
};
const saveConfig = async () => {
if (!token || !configAgent) return;
try {
setSavingConfig(true);
const parsed = JSON.parse(configJson);
await agentsApi.updateConfig(token, configAgent.id, parsed);
setConfigAgent(null);
const updated = await agentsApi.list(token);
setAgents(updated);
} catch (err) {
console.error("保存配置失败:", err);
} finally {
setSavingConfig(false);
}
};
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>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4" />
Agent
</CardTitle>
</CardHeader>
<CardContent>
{agents.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> Agent</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{agent.name}</p>
<p className="text-xs text-muted-foreground">{agent.type}</p>
</div>
<Badge
variant="secondary"
className={cn(
"ml-2 shrink-0",
agent.status === "running" && "bg-emerald-100 text-emerald-700",
agent.status === "error" && "bg-red-100 text-red-700",
agent.status === "disabled" && "bg-gray-100 text-gray-600",
agent.status === "idle" && "bg-blue-100 text-blue-700"
)}
>
{agent.status}
</Badge>
<Button variant="ghost" size="sm" className="ml-2 shrink-0" onClick={() => openConfig(agent)}>
<Settings2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 状态筛选 */}
<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>
<Dialog open={!!configAgent} onOpenChange={() => setConfigAgent(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{configAgent?.name}
</DialogTitle>
</DialogHeader>
{configAgent && (
<div className="space-y-4">
<div className="space-y-2">
<Label> (JSON)</Label>
<textarea
className="w-full min-h-[200px] rounded-md border bg-gray-50 p-3 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={configJson}
onChange={(e) => setConfigJson(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setConfigAgent(null)}>
</Button>
<Button onClick={saveConfig} disabled={savingConfig}>
{savingConfig ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}