diff --git a/internal/app/app.go b/internal/app/app.go index e66759d9..8d8f1d7e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -448,6 +448,7 @@ func setupRoutes( protected.GET("/monitor", monitorHandler.Monitor) protected.GET("/monitor/execution/:id", monitorHandler.GetExecution) protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution) + protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions) protected.GET("/monitor/stats", monitorHandler.GetStats) // 配置管理 diff --git a/internal/database/monitor.go b/internal/database/monitor.go index 226ef117..bdfffb61 100644 --- a/internal/database/monitor.go +++ b/internal/database/monitor.go @@ -281,6 +281,117 @@ func (db *DB) DeleteToolExecution(id string) error { return nil } +// DeleteToolExecutions 批量删除工具执行记录 +func (db *DB) DeleteToolExecutions(ids []string) error { + if len(ids) == 0 { + return nil + } + + // 构建 IN 查询的占位符 + placeholders := make([]string, len(ids)) + args := make([]interface{}, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + + query := `DELETE FROM tool_executions WHERE id IN (` + strings.Join(placeholders, ",") + `)` + _, err := db.Exec(query, args...) + if err != nil { + db.logger.Error("批量删除工具执行记录失败", zap.Error(err), zap.Int("count", len(ids))) + return err + } + return nil +} + +// GetToolExecutionsByIds 根据ID列表获取工具执行记录(用于批量删除前获取统计信息) +func (db *DB) GetToolExecutionsByIds(ids []string) ([]*mcp.ToolExecution, error) { + if len(ids) == 0 { + return []*mcp.ToolExecution{}, nil + } + + // 构建 IN 查询的占位符 + placeholders := make([]string, len(ids)) + args := make([]interface{}, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + + query := ` + SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms + FROM tool_executions + WHERE id IN (` + strings.Join(placeholders, ",") + `) + ` + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var executions []*mcp.ToolExecution + for rows.Next() { + var exec mcp.ToolExecution + var argsJSON string + var resultJSON sql.NullString + var errorText sql.NullString + var endTime sql.NullTime + var durationMs sql.NullInt64 + + err := rows.Scan( + &exec.ID, + &exec.ToolName, + &argsJSON, + &exec.Status, + &resultJSON, + &errorText, + &exec.StartTime, + &endTime, + &durationMs, + ) + if err != nil { + db.logger.Warn("加载执行记录失败", zap.Error(err)) + continue + } + + // 解析参数 + if err := json.Unmarshal([]byte(argsJSON), &exec.Arguments); err != nil { + db.logger.Warn("解析执行参数失败", zap.Error(err)) + exec.Arguments = make(map[string]interface{}) + } + + // 解析结果 + if resultJSON.Valid && resultJSON.String != "" { + var result mcp.ToolResult + if err := json.Unmarshal([]byte(resultJSON.String), &result); err != nil { + db.logger.Warn("解析执行结果失败", zap.Error(err)) + } else { + exec.Result = &result + } + } + + // 设置错误 + if errorText.Valid { + exec.Error = errorText.String + } + + // 设置结束时间 + if endTime.Valid { + exec.EndTime = &endTime.Time + } + + // 设置持续时间 + if durationMs.Valid { + exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond + } + + executions = append(executions, &exec) + } + + return executions, nil +} + // SaveToolStats 保存工具统计信息 func (db *DB) SaveToolStats(toolName string, stats *mcp.ToolStats) error { var lastCallTime sql.NullTime diff --git a/internal/handler/monitor.go b/internal/handler/monitor.go index dab472ac..e2ebc456 100644 --- a/internal/handler/monitor.go +++ b/internal/handler/monitor.go @@ -307,4 +307,79 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"}) } +// DeleteExecutions 批量删除执行记录 +func (h *MonitorHandler) DeleteExecutions(c *gin.Context) { + var request struct { + IDs []string `json:"ids"` + } + + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()}) + return + } + + if len(request.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID列表不能为空"}) + return + } + + // 如果使用数据库,先获取执行记录信息,然后删除并更新统计 + if h.db != nil { + // 先获取执行记录信息(用于更新统计) + executions, err := h.db.GetToolExecutionsByIds(request.IDs) + if err != nil { + h.logger.Error("获取执行记录失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取执行记录失败: " + err.Error()}) + return + } + + // 按工具名称分组统计需要减少的数量 + toolStats := make(map[string]struct { + totalCalls int + successCalls int + failedCalls int + }) + + for _, exec := range executions { + if exec.ToolName == "" { + continue + } + + stats := toolStats[exec.ToolName] + stats.totalCalls++ + if exec.Status == "failed" { + stats.failedCalls++ + } else if exec.Status == "completed" { + stats.successCalls++ + } + toolStats[exec.ToolName] = stats + } + + // 批量删除执行记录 + err = h.db.DeleteToolExecutions(request.IDs) + if err != nil { + h.logger.Error("批量删除执行记录失败", zap.Error(err), zap.Int("count", len(request.IDs))) + c.JSON(http.StatusInternalServerError, gin.H{"error": "批量删除执行记录失败: " + err.Error()}) + return + } + + // 更新统计信息(减少相应的计数) + for toolName, stats := range toolStats { + if err := h.db.DecreaseToolStats(toolName, stats.totalCalls, stats.successCalls, stats.failedCalls); err != nil { + h.logger.Warn("更新统计信息失败", zap.Error(err), zap.String("toolName", toolName)) + // 不返回错误,因为记录已经删除成功 + } + } + + h.logger.Info("批量删除执行记录成功", zap.Int("count", len(request.IDs))) + c.JSON(http.StatusOK, gin.H{"message": "成功删除执行记录", "deleted": len(executions)}) + return + } + + // 如果不使用数据库,尝试从内存中删除(内部MCP服务器) + // 注意:内存中的记录可能已经被清理,所以这里只记录日志 + h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs))) + c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"}) +} + diff --git a/web/static/css/style.css b/web/static/css/style.css index e79d25cc..da794489 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3036,6 +3036,9 @@ header { .pagination-info { font-size: 0.875rem; color: var(--text-secondary); + display: flex; + align-items: center; + gap: 16px; } .pagination-controls { @@ -3056,6 +3059,36 @@ header { cursor: not-allowed; } +.pagination-page-size { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.pagination-page-size select { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + min-width: 60px; +} + +.pagination-page-size select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); +} + +.pagination-page-size select:hover { + border-color: var(--accent-color); +} + .pagination-btn { padding: 6px 12px; font-size: 0.875rem; @@ -3414,6 +3447,50 @@ header { color: #c82333; } +.monitor-batch-actions { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + margin-bottom: 16px; + gap: 16px; +} + +.monitor-batch-actions .batch-actions-info { + display: flex; + align-items: center; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; +} + +.monitor-batch-actions .batch-actions-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.monitor-batch-actions .batch-actions-buttons button { + padding: 6px 12px; + font-size: 0.75rem; +} + +.monitor-execution-checkbox { + cursor: pointer; + width: 18px; + height: 18px; + accent-color: var(--accent-color); +} + +.monitor-table th:first-child, +.monitor-table td:first-child { + text-align: center; + width: 40px; +} + .monitor-vuln-container { display: grid; gap: 16px; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index ca161278..cc90a9b0 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -1008,7 +1008,11 @@ const monitorState = { lastFetchedAt: null, pagination: { page: 1, - pageSize: 20, + pageSize: (() => { + // 从 localStorage 读取保存的每页显示数量,默认为 20 + const saved = localStorage.getItem('monitorPageSize'); + return saved ? parseInt(saved, 10) : 20; + })(), total: 0, totalPages: 0 } @@ -1019,6 +1023,39 @@ function openMonitorPanel() { if (typeof switchPage === 'function') { switchPage('mcp-monitor'); } + // 初始化每页显示数量选择器 + initializeMonitorPageSize(); +} + +// 初始化每页显示数量选择器 +function initializeMonitorPageSize() { + const pageSizeSelect = document.getElementById('monitor-page-size'); + if (pageSizeSelect) { + pageSizeSelect.value = monitorState.pagination.pageSize; + } +} + +// 改变每页显示数量 +function changeMonitorPageSize() { + const pageSizeSelect = document.getElementById('monitor-page-size'); + if (!pageSizeSelect) { + return; + } + + const newPageSize = parseInt(pageSizeSelect.value, 10); + if (isNaN(newPageSize) || newPageSize <= 0) { + return; + } + + // 保存到 localStorage + localStorage.setItem('monitorPageSize', newPageSize.toString()); + + // 更新状态 + monitorState.pagination.pageSize = newPageSize; + monitorState.pagination.page = 1; // 重置到第一页 + + // 刷新数据 + refreshMonitorPanel(1); } function closeMonitorPanel() { @@ -1076,6 +1113,9 @@ async function refreshMonitorPanel(page = null) { renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt); renderMonitorExecutions(monitorState.executions, currentStatusFilter); renderMonitorPagination(); + + // 初始化每页显示数量选择器 + initializeMonitorPageSize(); } catch (error) { console.error('刷新监控面板失败:', error); if (statsContainer) { @@ -1150,6 +1190,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter = renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt); renderMonitorExecutions(monitorState.executions, statusFilter); renderMonitorPagination(); + + // 初始化每页显示数量选择器 + initializeMonitorPageSize(); } catch (error) { console.error('刷新监控面板失败:', error); if (statsContainer) { @@ -1250,6 +1293,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { } else { container.innerHTML = '
| + + | 工具 | 状态 | 开始时间 | @@ -1317,6 +1371,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { } else { container.appendChild(tableContainer); } + + // 更新批量操作状态 + updateBatchActionsState(); } // 渲染监控面板分页控件 @@ -1342,7 +1399,16 @@ function renderMonitorPagination() { pagination.innerHTML = `
|---|