Browse Source

feat: 添加工具使用统计功能

新增工具使用统计 API,支持按工具/工作空间统计调用次数、成功率、
平均耗时等指标,前端新增统计面板组件。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip 6 ngày trước cách đây
mục cha
commit
cf434e3280

+ 1 - 1
apps/tools/urls.py

@@ -36,5 +36,5 @@ urlpatterns = [
     path('workspace/<str:workspace_id>/tool/<str:tool_id>/tool_version/<int:current_page>/<int:page_size>', views.ToolWorkflowVersionView.Page.as_view()),
     path('workspace/<str:workspace_id>/tool/<str:tool_id>/tool_version/<str:tool_version_id>', views.ToolWorkflowVersionView.Operate.as_view()),
     path('workspace/<str:workspace_id>/tool/<str:tool_id>/mcp_tools', views.McpServers.as_view()),
-
+    path('workspace/<str:workspace_id>/tool/statistics', views.ToolView.Statistics.as_view()),
 ]

+ 51 - 2
apps/tools/views/tool.py

@@ -1,4 +1,4 @@
-from django.db.models import QuerySet
+from django.db.models import QuerySet, Count, Avg, Min, Max, Q
 from django.utils.translation import gettext_lazy as _
 from drf_spectacular.utils import extend_schema
 from rest_framework.parsers import MultiPartParser
@@ -13,7 +13,7 @@ from common.log.log import log
 from tools.api.tool import ToolCreateAPI, ToolEditAPI, ToolReadAPI, ToolDeleteAPI, ToolTreeReadAPI, ToolDebugApi, \
     ToolExportAPI, ToolImportAPI, ToolPageAPI, PylintAPI, EditIconAPI, GetInternalToolAPI, AddInternalToolAPI, \
     ToolBatchOperateAPI
-from tools.models import ToolScope, Tool
+from tools.models import ToolScope, Tool, ToolRecord
 from tools.serializers.tool import ToolSerializer, ToolTreeSerializer, ToolBatchOperateSerializer
 
 
@@ -717,3 +717,52 @@ class ToolView(APIView):
                 'workspace_id': workspace_id,
                 **request.data
             }).generate_code()
+
+    class Statistics(APIView):
+        """工具使用统计"""
+        authentication_classes = [TokenAuth]
+
+        @extend_schema(
+            methods=['GET'],
+            description=_("Get tool usage statistics"),
+            summary=_("Get tool usage statistics"),
+            operation_id=_("Get tool usage statistics"),
+            tags=[_('Tool')]
+        )
+        @has_permissions(
+            PermissionConstants.TOOL_READ.get_workspace_permission(),
+            PermissionConstants.TOOL_READ.get_workspace_permission_workspace_manage_role(),
+            RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+            RoleConstants.USER.get_workspace_role()
+        )
+        def get(self, request: Request, workspace_id: str):
+            tool_id = request.query_params.get('tool_id')
+            days = int(request.query_params.get('days', 7))
+
+            query = QuerySet(ToolRecord).filter(workspace_id=workspace_id)
+
+            if tool_id:
+                query = query.filter(tool_id=tool_id)
+
+            stats = query.aggregate(
+                total_count=Count('id'),
+                success_count=Count('id', filter=Q(state='SUCCESS')),
+                failure_count=Count('id', filter=Q(state='FAILURE')),
+                avg_run_time=Avg('run_time'),
+                min_run_time=Min('run_time'),
+                max_run_time=Max('run_time')
+            )
+
+            total = stats['total_count'] or 0
+            success = stats['success_count'] or 0
+            success_rate = (success / total * 100) if total > 0 else 0
+
+            return result.success({
+                'total_count': total,
+                'success_count': success,
+                'failure_count': stats['failure_count'] or 0,
+                'success_rate': round(success_rate, 2),
+                'avg_run_time': round(stats['avg_run_time'] or 0, 3),
+                'min_run_time': stats['min_run_time'] or 0,
+                'max_run_time': stats['max_run_time'] or 0
+            })

+ 30 - 1
ui/src/api/tool/tool.ts

@@ -336,6 +336,34 @@ const putMulMoveTool: (data: any, loading?: Ref<boolean>) => Promise<Result<bool
 ) => {
   return put(`${prefix.value}/batch_move`, data, undefined, loading)
 }
+
+/**
+ * 获取工具使用统计
+ * @param tool_id 工具id(可选)
+ * @param days 统计天数
+ * @param loading 加载器
+ * @returns 统计数据
+ */
+const getToolStatistics: (
+  tool_id?: string,
+  days?: number,
+  loading?: Ref<boolean>,
+) => Promise<Result<{
+  total_count: number
+  success_count: number
+  failure_count: number
+  success_rate: number
+  avg_run_time: number
+  min_run_time: number
+  max_run_time: number
+}>> = (tool_id, days = 7, loading) => {
+  const params: any = { days }
+  if (tool_id) {
+    params.tool_id = tool_id
+  }
+  return get(`${prefix.value}/statistics`, params, loading)
+}
+
 export default {
   getToolList,
   getAllToolList,
@@ -366,5 +394,6 @@ export default {
   generateCode,
   getMcpTools,
   delMulTool,
-  putMulMoveTool
+  putMulMoveTool,
+  getToolStatistics
 }

+ 12 - 0
ui/src/views/tool/component/ToolListContainer.vue

@@ -262,6 +262,11 @@
                         class="mr-4"
                         v-if="permissionPrecise.switch(item.id)"
                       />
+                      <el-tooltip effect="dark" :content="$t('views.tool.statistics.title')" placement="top">
+                        <el-button text @click.stop="openStatistics(item)" size="small">
+                          <AppIcon iconName="app-operate-log" class="color-secondary"></AppIcon>
+                        </el-button>
+                      </el-tooltip>
                       <el-divider direction="vertical"/>
                       <el-dropdown trigger="click">
                         <el-button text @click.stop>
@@ -484,6 +489,7 @@
       :source="SourceTypeEnum.TOOL"
     ></ResourceTriggerDrawer>
     <ExecutionRecordDrawer ref="toolRecordDrawerRef"/>
+    <ToolStatisticsDrawer ref="ToolStatisticsDrawerRef"/>
     <WorkflowFormDialog
       ref="workflowFormDialogRef"
       @refresh="refresh"
@@ -513,6 +519,7 @@ import ResourceTriggerDrawer from '@/views/trigger/ResourceTriggerDrawer.vue'
 import ToolStoreDescDrawer from '@/views/tool/component/ToolStoreDescDrawer.vue'
 import ResourceMappingDrawer from '@/components/resource_mapping/index.vue'
 import WorkflowFormDialog from '../WorkflowFormDialog.vue'
+import ToolStatisticsDrawer from '@/views/tool/component/ToolStatisticsDrawer.vue'
 import ExecutionRecordDrawer from '@/views/tool-workflow/execution-record/ExecutionRecordDrawer.vue'
 import ToolStoreApi from '@/api/tool/store.ts'
 import {resetUrl, i18n_name} from '@/utils/common'
@@ -586,6 +593,7 @@ function openAuthorization(item: any) {
 }
 
 const toolRecordDrawerRef = ref<InstanceType<typeof ExecutionRecordDrawer>>()
+const ToolStatisticsDrawerRef = ref<InstanceType<typeof ToolStatisticsDrawer>>()
 const openToolRecordDrawer = (data: any) => {
   toolRecordDrawerRef.value?.open(data)
 }
@@ -681,6 +689,10 @@ function deleteMulTool() {
     })
 }
 
+function openStatistics(data: any) {
+  ToolStatisticsDrawerRef.value?.open({ id: data.id, name: data.name })
+}
+
 function openEditDialog(data?: any) {
   if (isBatch.value) {
     const index = multipleSelection.value.indexOf(data?.id)

+ 140 - 0
ui/src/views/tool/component/ToolStatisticsDrawer.vue

@@ -0,0 +1,140 @@
+<template>
+  <el-drawer v-model="visible" :title="toolName + ' - ' + $t('views.tool.statistics.title')" size="400px">
+    <div class="p-16" v-loading="loading">
+      <el-form label-position="top">
+        <el-form-item :label="$t('views.tool.statistics.timeRange')">
+          <el-select v-model="days" @change="fetchStatistics" style="width: 100%">
+            <el-option :label="$t('views.tool.statistics.past7Days')" :value="7" />
+            <el-option :label="$t('views.tool.statistics.past30Days')" :value="30" />
+            <el-option :label="$t('views.tool.statistics.past90Days')" :value="90" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+
+      <el-row :gutter="16" class="mb-16">
+        <el-col :span="12">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-value">{{ stats.total_count }}</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.totalCalls') }}</div>
+          </el-card>
+        </el-col>
+        <el-col :span="12">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-value color-success">{{ stats.success_rate }}%</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.successRate') }}</div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="16" class="mb-16">
+        <el-col :span="12">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-value color-success">{{ stats.success_count }}</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.successCount') }}</div>
+          </el-card>
+        </el-col>
+        <el-col :span="12">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-value" style="color: var(--el-color-danger)">{{ stats.failure_count }}</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.failureCount') }}</div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <el-divider />
+
+      <h4 class="mb-12">{{ $t('views.tool.statistics.runTime') }}</h4>
+      <el-row :gutter="16">
+        <el-col :span="8">
+          <div class="run-time-item">
+            <div class="run-time-value">{{ stats.avg_run_time }}ms</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.avgTime') }}</div>
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="run-time-item">
+            <div class="run-time-value">{{ stats.min_run_time }}ms</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.minTime') }}</div>
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="run-time-item">
+            <div class="run-time-value">{{ stats.max_run_time }}ms</div>
+            <div class="stat-label">{{ $t('views.tool.statistics.maxTime') }}</div>
+          </div>
+        </el-col>
+      </el-row>
+    </div>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import toolApi from '@/api/tool/tool'
+
+const visible = ref(false)
+const loading = ref(false)
+const toolId = ref('')
+const toolName = ref('')
+const days = ref(7)
+
+const stats = reactive({
+  total_count: 0,
+  success_count: 0,
+  failure_count: 0,
+  success_rate: 0,
+  avg_run_time: 0,
+  min_run_time: 0,
+  max_run_time: 0,
+})
+
+function open(data: { id: string; name: string }) {
+  toolId.value = data.id
+  toolName.value = data.name
+  visible.value = true
+  fetchStatistics()
+}
+
+function fetchStatistics() {
+  toolApi.getToolStatistics(toolId.value, days.value, loading).then((res: any) => {
+    Object.assign(stats, res.data)
+  })
+}
+
+defineExpose({ open })
+</script>
+
+<style lang="scss" scoped>
+.stat-card {
+  text-align: center;
+  padding: 8px 0;
+
+  .stat-value {
+    font-size: 24px;
+    font-weight: 600;
+    color: var(--el-text-color-primary);
+  }
+
+  .stat-label {
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    margin-top: 4px;
+  }
+}
+
+.run-time-item {
+  text-align: center;
+
+  .run-time-value {
+    font-size: 16px;
+    font-weight: 500;
+    color: var(--el-text-color-primary);
+  }
+
+  .stat-label {
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    margin-top: 4px;
+  }
+}
+</style>