Browse Source

feat(工具调用日志): 添加统计功能并优化时间处理

- 新增工具调用日志统计接口及前端展示
- 修复时间戳处理逻辑,统一使用秒级时间戳
- 优化状态筛选逻辑,支持失败状态统计
- 改进分页和查询参数处理
max_liu 1 month ago
parent
commit
f308de4e01
4 changed files with 259 additions and 91 deletions
  1. 91 31
      server/routes/toolsCallLog.js
  2. 57 22
      src/pages/ToolsCallLogDetail.js
  3. 110 38
      src/pages/ToolsCallLogList.js
  4. 1 0
      src/services/api.js

+ 91 - 31
server/routes/toolsCallLog.js

@@ -1,12 +1,12 @@
 const express = require("express");
 const router = express.Router();
-const db = require("../config/database");
+const { executeQuery } = require("../config/database");
 
 // 获取工具调用日志列表
 router.get("/", async (req, res) => {
   try {
-    const { page = 1, pageSize = 10, user, mcp_tools_name, status, start_time, end_time } = req.query;
-    const offset = (page - 1) * pageSize;
+    const { page = 1, pageSize = 10, user, mcp_tools_name, status, startTime, endTime } = req.query;
+    const offset = (parseInt(page) - 1) * parseInt(pageSize);
 
     // 构建查询条件
     let whereConditions = [];
@@ -23,26 +23,31 @@ router.get("/", async (req, res) => {
     }
 
     if (status) {
-      whereConditions.push("status = ?");
-      queryParams.push(status);
+      if (status === "failure") {
+        // 将“失败”视为非成功的所有状态,避免仅限于字符串 'failure'
+        whereConditions.push("status <> 'success'");
+      } else {
+        whereConditions.push("status = ?");
+        queryParams.push(status);
+      }
     }
 
-    if (start_time) {
+    if (startTime) {
       whereConditions.push("call_timestamp >= ?");
-      queryParams.push(parseInt(start_time));
+      queryParams.push(parseInt(startTime));
     }
 
-    if (end_time) {
+    if (endTime) {
       whereConditions.push("call_timestamp <= ?");
-      queryParams.push(parseInt(end_time));
+      queryParams.push(parseInt(endTime));
     }
 
     const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
 
     // 获取总数
     const countQuery = `SELECT COUNT(*) as total FROM tools_call_log ${whereClause}`;
-    const countResult = await db.query(countQuery, queryParams);
-    const total = countResult[0].total;
+    const countResult = await executeQuery(countQuery, queryParams);
+    const total = countResult[0]?.total || 0;
 
     // 获取列表数据
     const listQuery = `
@@ -53,18 +58,15 @@ router.get("/", async (req, res) => {
       ORDER BY call_timestamp DESC 
       LIMIT ? OFFSET ?
     `;
-    
+
     const listParams = [...queryParams, parseInt(pageSize), offset];
-    const list = await db.query(listQuery, listParams);
+    const list = await executeQuery(listQuery, listParams);
 
     res.json({
-      success: true,
-      data: {
-        list,
-        total,
-        page: parseInt(page),
-        pageSize: parseInt(pageSize),
-      },
+      data: list,
+      total,
+      page: parseInt(page),
+      pageSize: parseInt(pageSize),
     });
   } catch (error) {
     console.error("获取工具调用日志列表失败:", error);
@@ -75,20 +77,20 @@ router.get("/", async (req, res) => {
   }
 });
 
-// 获取工具调用日志详情
-router.get("/:id", async (req, res) => {
+// 获取工具调用日志详情(仅匹配数字ID,避免与 /stats 等命名路由冲突)
+router.get("/:id(\\d+)", async (req, res) => {
   try {
     const { id } = req.params;
-    
+
     const query = `
       SELECT id, user, token, mcp_tools_name, request_params, status, 
              response, fail_msg, call_timestamp, finish_timestamp
       FROM tools_call_log 
       WHERE id = ?
     `;
-    
-    const result = await db.query(query, [id]);
-    
+
+    const result = await executeQuery(query, [id]);
+
     if (result.length === 0) {
       return res.status(404).json({
         success: false,
@@ -96,10 +98,7 @@ router.get("/:id", async (req, res) => {
       });
     }
 
-    res.json({
-      success: true,
-      data: result[0],
-    });
+    res.json(result[0]);
   } catch (error) {
     console.error("获取工具调用日志详情失败:", error);
     res.status(500).json({
@@ -109,4 +108,65 @@ router.get("/:id", async (req, res) => {
   }
 });
 
-module.exports = router;
+// 获取统计信息:支持与列表一致的筛选条件
+router.get("/stats", async (req, res) => {
+  try {
+    const { user, mcp_tools_name, status, startTime, endTime } = req.query;
+
+    let whereConditions = [];
+    let queryParams = [];
+
+    if (user) {
+      whereConditions.push("user LIKE ?");
+      queryParams.push(`%${user}%`);
+    }
+
+    if (mcp_tools_name) {
+      whereConditions.push("mcp_tools_name LIKE ?");
+      queryParams.push(`%${mcp_tools_name}%`);
+    }
+
+    if (status) {
+      if (status === "failure") {
+        whereConditions.push("status <> 'success'");
+      } else {
+        whereConditions.push("status = ?");
+        queryParams.push(status);
+      }
+    }
+
+    if (startTime) {
+      whereConditions.push("call_timestamp >= ?");
+      queryParams.push(parseInt(startTime));
+    }
+
+    if (endTime) {
+      whereConditions.push("call_timestamp <= ?");
+      queryParams.push(parseInt(endTime));
+    }
+
+    const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
+
+    const sql = `
+      SELECT
+        COUNT(*) AS total,
+        SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count
+      FROM tools_call_log
+      ${whereClause}
+    `;
+
+    const rows = await executeQuery(sql, queryParams);
+    const row = rows && rows[0] ? rows[0] : { total: 0, success_count: 0 };
+    const total = Number(row.total) || 0;
+    const successCount = Number(row.success_count) || 0;
+    const failureCount = total - successCount;
+    const successRate = total > 0 ? Number(((successCount / total) * 100).toFixed(2)) : 0;
+
+    res.json({ total, successCount, failureCount, successRate });
+  } catch (error) {
+    console.error("获取工具调用日志统计失败:", error);
+    res.status(500).json({ error: "获取工具调用日志统计失败" });
+  }
+});
+
+module.exports = router;

+ 57 - 22
src/pages/ToolsCallLogDetail.js

@@ -14,13 +14,13 @@ const ToolsCallLogDetail = () => {
   // 格式化时间戳为 YYYY-MM-DD HH:mm:ss
   const formatTimestamp = (timestamp) => {
     if (!timestamp) return "-";
-    return dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss");
+    return dayjs(timestamp * 1000).format("YYYY-MM-DD HH:mm:ss");
   };
 
   // 计算耗时(秒)
   const calculateDuration = (callTime, finishTime) => {
     if (!callTime || !finishTime) return "-";
-    return ((finishTime - callTime) / 1000).toFixed(2) + "s";
+    return (finishTime - callTime).toFixed(1) + "s";
   };
 
   // 复制到剪贴板
@@ -69,7 +69,10 @@ const ToolsCallLogDetail = () => {
   if (!data) {
     return (
       <div style={{ padding: "24px" }}>
-        <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
+        <Button
+          icon={<ArrowLeftOutlined />}
+          onClick={handleBack}
+        >
           返回列表
         </Button>
         <div style={{ textAlign: "center", marginTop: "50px" }}>
@@ -82,20 +85,35 @@ const ToolsCallLogDetail = () => {
   return (
     <div style={{ padding: "24px" }}>
       <div style={{ marginBottom: "16px" }}>
-        <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
+        <Button
+          icon={<ArrowLeftOutlined />}
+          onClick={handleBack}
+        >
           返回列表
         </Button>
       </div>
 
       <Card title="工具调用日志详情">
-        <Descriptions bordered column={2}>
-          <Descriptions.Item label="用户名" span={1}>
+        <Descriptions
+          bordered
+          column={2}
+        >
+          <Descriptions.Item
+            label="用户名"
+            span={1}
+          >
             {data.user || "-"}
           </Descriptions.Item>
-          <Descriptions.Item label="工具名称" span={1}>
+          <Descriptions.Item
+            label="工具名称"
+            span={1}
+          >
             {data.mcp_tools_name || "-"}
           </Descriptions.Item>
-          <Descriptions.Item label="Token" span={2}>
+          <Descriptions.Item
+            label="Token"
+            span={2}
+          >
             <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
               <span style={{ wordBreak: "break-all" }}>{data.token || "-"}</span>
               {data.token && (
@@ -109,32 +127,46 @@ const ToolsCallLogDetail = () => {
               )}
             </div>
           </Descriptions.Item>
-          <Descriptions.Item label="状态" span={1}>
-            <Tag color={data.status === "success" ? "green" : "red"}>
-              {data.status === "success" ? "成功" : "失败"}
-            </Tag>
+          <Descriptions.Item
+            label="状态"
+            span={1}
+          >
+            <Tag color={data.status === "success" ? "green" : "red"}>{data.status === "success" ? "成功" : "失败"}</Tag>
           </Descriptions.Item>
-          <Descriptions.Item label="耗时" span={1}>
+          <Descriptions.Item
+            label="耗时"
+            span={1}
+          >
             {calculateDuration(data.call_timestamp, data.finish_timestamp)}
           </Descriptions.Item>
-          <Descriptions.Item label="调用时间" span={1}>
+          <Descriptions.Item
+            label="调用时间"
+            span={1}
+          >
             {formatTimestamp(data.call_timestamp)}
           </Descriptions.Item>
-          <Descriptions.Item label="完成时间" span={1}>
+          <Descriptions.Item
+            label="完成时间"
+            span={1}
+          >
             {formatTimestamp(data.finish_timestamp)}
           </Descriptions.Item>
           {data.fail_msg && (
-            <Descriptions.Item label="失败原因" span={2}>
-              <div style={{ color: "#ff4d4f", wordBreak: "break-word" }}>
-                {data.fail_msg}
-              </div>
+            <Descriptions.Item
+              label="失败原因"
+              span={2}
+            >
+              <div style={{ color: "#ff4d4f", wordBreak: "break-word" }}>{data.fail_msg}</div>
             </Descriptions.Item>
           )}
         </Descriptions>
       </Card>
 
       {/* 请求参数 */}
-      <Card title="请求参数" style={{ marginTop: "16px" }}>
+      <Card
+        title="请求参数"
+        style={{ marginTop: "16px" }}
+      >
         <div style={{ position: "relative" }}>
           <Button
             type="text"
@@ -162,7 +194,10 @@ const ToolsCallLogDetail = () => {
       </Card>
 
       {/* 工具响应 */}
-      <Card title="工具响应" style={{ marginTop: "16px" }}>
+      <Card
+        title="工具响应"
+        style={{ marginTop: "16px" }}
+      >
         <div style={{ position: "relative" }}>
           <Button
             type="text"
@@ -192,4 +227,4 @@ const ToolsCallLogDetail = () => {
   );
 };
 
-export default ToolsCallLogDetail;
+export default ToolsCallLogDetail;

+ 110 - 38
src/pages/ToolsCallLogList.js

@@ -17,25 +17,26 @@ const ToolsCallLogList = () => {
     pageSize: 10,
     total: 0,
   });
-  
+  const [stats, setStats] = useState({ successCount: 0, failureCount: 0, successRate: 0 });
+
   // 搜索条件
   const [searchParams, setSearchParams] = useState({
     user: "",
     mcp_tools_name: "",
-    status: "",
+    status: null,
     timeRange: null,
   });
 
   // 格式化时间戳为 YYYY-MM-DD HH:mm:ss
   const formatTimestamp = (timestamp) => {
     if (!timestamp) return "-";
-    return dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss");
+    return dayjs(timestamp * 1000).format("YYYY-MM-DD HH:mm:ss");
   };
 
   // 计算耗时(秒)
   const calculateDuration = (callTime, finishTime) => {
     if (!callTime || !finishTime) return "-";
-    return ((finishTime - callTime) / 1000).toFixed(2) + "s";
+    return (finishTime - callTime).toFixed(1) + "s";
   };
 
   // 获取数据
@@ -49,17 +50,22 @@ const ToolsCallLogList = () => {
         ...params,
       };
 
-      // 处理时间范围
-      if (searchParams.timeRange && searchParams.timeRange.length === 2) {
-        queryParams.startTime = searchParams.timeRange[0].valueOf();
-        queryParams.endTime = searchParams.timeRange[1].valueOf();
+      // 处理时间范围(优先使用传入的覆盖参数;即便为 null 也要生效)
+      const hasTimeRangeInParams = Object.prototype.hasOwnProperty.call(params, "timeRange");
+      const appliedTimeRange = hasTimeRangeInParams ? params.timeRange : searchParams.timeRange;
+      if (appliedTimeRange && appliedTimeRange.length === 2) {
+        // 将 Dayjs 转为“秒”时间戳,并将结束时间设为当天 23:59:59 以便区间包含整天
+        const startSec = dayjs(appliedTimeRange[0]).startOf("day").unix();
+        const endSec = dayjs(appliedTimeRange[1]).endOf("day").unix();
+        queryParams.startTime = startSec;
+        queryParams.endTime = endSec;
       }
 
       const response = await toolsCallLogApi.getList(queryParams);
       const { data: list, total } = response.data;
 
       setData(list);
-      setPagination(prev => ({
+      setPagination((prev) => ({
         ...prev,
         total,
       }));
@@ -71,10 +77,32 @@ const ToolsCallLogList = () => {
     }
   };
 
+  // 获取统计数据(可带筛选参数)
+  const fetchStats = async (params = {}) => {
+    try {
+      const queryParams = { ...searchParams, ...params };
+      const hasTimeRangeInParams = Object.prototype.hasOwnProperty.call(params, "timeRange");
+      const appliedTimeRange = hasTimeRangeInParams ? params.timeRange : searchParams.timeRange;
+      if (appliedTimeRange && appliedTimeRange.length === 2) {
+        const startSec = dayjs(appliedTimeRange[0]).startOf("day").unix();
+        const endSec = dayjs(appliedTimeRange[1]).endOf("day").unix();
+        queryParams.startTime = startSec;
+        queryParams.endTime = endSec;
+      }
+
+      const res = await toolsCallLogApi.getStats(queryParams);
+      const { successCount = 0, failureCount = 0, successRate = 0 } = res.data || {};
+      setStats({ successCount, failureCount, successRate });
+    } catch (error) {
+      console.error("获取统计数据失败:", error);
+    }
+  };
+
   // 搜索
   const handleSearch = () => {
-    setPagination(prev => ({ ...prev, current: 1 }));
+    setPagination((prev) => ({ ...prev, current: 1 }));
     fetchData({ page: 1 });
+    fetchStats();
   };
 
   // 重置搜索
@@ -82,19 +110,19 @@ const ToolsCallLogList = () => {
     setSearchParams({
       user: "",
       mcp_tools_name: "",
-      status: "",
+      status: null,
       timeRange: null,
     });
-    setPagination(prev => ({ ...prev, current: 1 }));
-    setTimeout(() => {
-      fetchData({ 
-        page: 1,
-        user: "",
-        mcp_tools_name: "",
-        status: "",
-        timeRange: null,
-      });
-    }, 0);
+    setPagination((prev) => ({ ...prev, current: 1 }));
+    const params = {
+      page: 1,
+      user: "",
+      mcp_tools_name: "",
+      status: null,
+      timeRange: null,
+    };
+    fetchData(params);
+    fetchStats(params);
   };
 
   // 查看详情
@@ -122,9 +150,7 @@ const ToolsCallLogList = () => {
       key: "status",
       width: 100,
       render: (status) => (
-        <Tag color={status === "success" ? "green" : "red"}>
-          {status === "success" ? "成功" : "失败"}
-        </Tag>
+        <Tag color={status === "success" ? "green" : "red"}>{status === "success" ? "成功" : "失败"}</Tag>
       ),
     },
     {
@@ -160,7 +186,10 @@ const ToolsCallLogList = () => {
       key: "action",
       width: 100,
       render: (_, record) => (
-        <Button type="link" onClick={() => handleViewDetail(record)}>
+        <Button
+          type="link"
+          onClick={() => handleViewDetail(record)}
+        >
           详情
         </Button>
       ),
@@ -169,7 +198,7 @@ const ToolsCallLogList = () => {
 
   // 分页变化
   const handleTableChange = (paginationConfig) => {
-    setPagination(prev => ({
+    setPagination((prev) => ({
       ...prev,
       current: paginationConfig.current,
       pageSize: paginationConfig.pageSize,
@@ -182,31 +211,55 @@ const ToolsCallLogList = () => {
 
   useEffect(() => {
     fetchData();
+    fetchStats();
   }, []);
 
   return (
     <div style={{ padding: "24px" }}>
-      <h2>工具调用日志</h2>
-      
       {/* 搜索区域 */}
       <div style={{ marginBottom: "16px", padding: "16px", background: "#fafafa", borderRadius: "6px" }}>
+        {/* 统计信息 */}
+        <div className="mt-[12px] mb-[12px] flex gap-[24px]">
+          <div>
+            成功数: <span style={{ color: "#52c41a", fontWeight: 700 }}>{stats.successCount}</span>
+          </div>
+          <div>
+            失败数: <span style={{ color: "#ff4d4f", fontWeight: 700 }}>{stats.failureCount}</span>
+          </div>
+          <div>
+            成功率: <span style={{ color: "#52c41a", fontWeight: 700 }}>{stats.successRate}%</span>
+          </div>
+        </div>
         <Space wrap>
           <Input
             placeholder="用户名"
             value={searchParams.user}
-            onChange={(e) => setSearchParams(prev => ({ ...prev, user: e.target.value }))}
+            onChange={(e) => setSearchParams((prev) => ({ ...prev, user: e.target.value }))}
             style={{ width: 120 }}
           />
           <Input
             placeholder="工具名称"
             value={searchParams.mcp_tools_name}
-            onChange={(e) => setSearchParams(prev => ({ ...prev, mcp_tools_name: e.target.value }))}
+            onChange={(e) => setSearchParams((prev) => ({ ...prev, mcp_tools_name: e.target.value }))}
             style={{ width: 150 }}
           />
           <Select
-            placeholder="状态"
+            placeholder="全部"
             value={searchParams.status}
-            onChange={(value) => setSearchParams(prev => ({ ...prev, status: value }))}
+            onChange={(value) => {
+              const next = { ...searchParams, status: value };
+              setSearchParams(next);
+              setPagination((prev) => ({ ...prev, current: 1 }));
+              const params = {
+                page: 1,
+                status: value,
+                user: next.user,
+                mcp_tools_name: next.mcp_tools_name,
+                timeRange: next.timeRange,
+              };
+              fetchData(params);
+              fetchStats(params);
+            }}
             style={{ width: 120 }}
             allowClear
           >
@@ -216,14 +269,33 @@ const ToolsCallLogList = () => {
           <RangePicker
             placeholder={["开始时间", "结束时间"]}
             value={searchParams.timeRange}
-            onChange={(dates) => setSearchParams(prev => ({ ...prev, timeRange: dates }))}
-            showTime
-            format="YYYY-MM-DD HH:mm:ss"
+            onChange={(dates) => {
+              const next = { ...searchParams, timeRange: dates };
+              setSearchParams(next);
+              setPagination((prev) => ({ ...prev, current: 1 }));
+              const params = {
+                page: 1,
+                user: next.user,
+                mcp_tools_name: next.mcp_tools_name,
+                status: next.status,
+                timeRange: dates,
+              };
+              fetchData(params);
+              fetchStats(params);
+            }}
+            format="YYYY-MM-DD"
           />
-          <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
+          <Button
+            type="primary"
+            icon={<SearchOutlined />}
+            onClick={handleSearch}
+          >
             搜索
           </Button>
-          <Button icon={<ReloadOutlined />} onClick={handleReset}>
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={handleReset}
+          >
             重置
           </Button>
         </Space>
@@ -248,4 +320,4 @@ const ToolsCallLogList = () => {
   );
 };
 
-export default ToolsCallLogList;
+export default ToolsCallLogList;

+ 1 - 0
src/services/api.js

@@ -82,5 +82,6 @@ export const userToolsSetApi = {
 export const toolsCallLogApi = {
   getList: (params) => api.get("/tools-call-log", { params }),
   getDetail: (id) => api.get(`/tools-call-log/${id}`),
+  getStats: (params) => api.get("/tools-call-log/stats", { params }),
 };
 export default api;