diff --git a/internal/database/monitor.go b/internal/database/monitor.go index c9f27f08..102980f6 100644 --- a/internal/database/monitor.go +++ b/internal/database/monitor.go @@ -267,7 +267,8 @@ type ToolStatsSummaryResult struct { TopTools []*mcp.ToolStats } -// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输) +// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)。 +// 监控页的失败口径只包含真实失败/异常终止;用户主动取消的 cancelled 保留在总调用中,不计入失败。 func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { if topN <= 0 { topN = 6 @@ -282,19 +283,19 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { summaryQuery := ` SELECT COUNT(*), - COALESCE(SUM(total_calls), 0), - COALESCE(SUM(success_calls), 0), - COALESCE(SUM(failed_calls), 0), - MAX(last_call_time) - FROM tool_stats + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0), + MAX(start_time), + COUNT(DISTINCT tool_name) + FROM tool_executions ` var lastCallRaw sql.NullString err := db.QueryRow(summaryQuery).Scan( - &result.Summary.ToolCount, &result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls, &lastCallRaw, + &result.Summary.ToolCount, ) if err != nil { return nil, err @@ -310,9 +311,13 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { } topQuery := ` - SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time - FROM tool_stats - WHERE total_calls > 0 + SELECT tool_name, + COUNT(*) AS total_calls, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS success_calls, + SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed_calls, + MAX(start_time) AS last_call_time + FROM tool_executions + GROUP BY tool_name ORDER BY total_calls DESC, tool_name ASC LIMIT ? ` @@ -324,7 +329,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { for rows.Next() { var stat mcp.ToolStats - var lastCallTime sql.NullTime + var lastCallTime sql.NullString if err := rows.Scan( &stat.ToolName, &stat.TotalCalls, @@ -336,7 +341,8 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { continue } if lastCallTime.Valid { - stat.LastCallTime = &lastCallTime.Time + parsed := parseDBTime(lastCallTime.String) + stat.LastCallTime = &parsed } result.TopTools = append(result.TopTools, &stat) } @@ -359,7 +365,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T var lastCall sql.NullString err := db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0), MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan( &result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls, &lastCall, &result.Summary.ToolCount, @@ -373,7 +379,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T } rows, err := db.Query(`SELECT tool_name, COUNT(*), SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), - SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+ + SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+ fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...) if err != nil { return nil, err @@ -815,7 +821,7 @@ func (db *DB) PurgeToolExecutionsBefore(cutoff time.Time) (int64, error) { } delta.totalCalls += count switch status { - case "failed", "cancelled", "hard_timeout", "orphaned": + case "failed", "hard_timeout", "orphaned": delta.failedCalls += count case "completed": delta.successCalls += count @@ -971,7 +977,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime query = ` SELECT date(start_time, 'localtime') AS bucket, COUNT(*) AS total, - SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed + SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed FROM tool_executions WHERE start_time >= ? GROUP BY bucket @@ -981,7 +987,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime query = ` SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket, COUNT(*) AS total, - SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed + SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed FROM tool_executions WHERE start_time >= ? GROUP BY bucket diff --git a/internal/database/monitor_summary_test.go b/internal/database/monitor_summary_test.go index 7c162aed..f7fcbf4d 100644 --- a/internal/database/monitor_summary_test.go +++ b/internal/database/monitor_summary_test.go @@ -84,3 +84,49 @@ func TestLoadToolStatsSummaryAndListPage(t *testing.T) { } } } + +func TestLoadToolStatsSummaryDoesNotCountCancelledAsFailed(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "monitor-cancelled-summary.db") + db, err := NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + defer db.Close() + + now := time.Now() + for i, status := range []string{"completed", "cancelled", "failed"} { + exec := &mcp.ToolExecution{ + ID: fmt.Sprintf("exec-%d", i), + ToolName: "exec", + Arguments: map[string]interface{}{}, + Status: status, + StartTime: now.Add(time.Duration(i) * time.Second), + } + end := exec.StartTime.Add(time.Second) + exec.EndTime = &end + exec.Duration = time.Second + if err := db.SaveToolExecution(exec); err != nil { + t.Fatalf("SaveToolExecution(%s): %v", status, err) + } + } + + summary, err := db.LoadToolStatsSummary(1) + if err != nil { + t.Fatalf("LoadToolStatsSummary: %v", err) + } + if summary.Summary.TotalCalls != 3 { + t.Fatalf("totalCalls = %d, want 3", summary.Summary.TotalCalls) + } + if summary.Summary.SuccessCalls != 1 { + t.Fatalf("successCalls = %d, want 1", summary.Summary.SuccessCalls) + } + if summary.Summary.FailedCalls != 1 { + t.Fatalf("failedCalls = %d, want 1", summary.Summary.FailedCalls) + } + if len(summary.TopTools) != 1 { + t.Fatalf("top tools = %d, want 1", len(summary.TopTools)) + } + if summary.TopTools[0].FailedCalls != 1 { + t.Fatalf("top tool failedCalls = %d, want 1", summary.TopTools[0].FailedCalls) + } +} diff --git a/internal/mcp/external_manager.go b/internal/mcp/external_manager.go index ca9913fd..c6bd9b53 100644 --- a/internal/mcp/external_manager.go +++ b/internal/mcp/external_manager.go @@ -745,7 +745,7 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args return result, callErr }, OnDone: func(exec *ToolExecution) { - failed := exec != nil && exec.Status != ToolExecutionStatusCompleted + failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled m.recordExternalMCPResult(mcpName, failed) m.updateStats(toolName, failed) }, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d2838db5..2bf2c8ec 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -602,13 +602,13 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa st, msg := executionStatusAndMessage(err) execution.Status = st execution.Error = msg - failed = true + failed = st != "cancelled" } else if result != nil && result.IsError { if cancelledWithUserNote { execution.Status = "cancelled" execution.Error = "" execution.Result = result - failed = true + failed = false } else { execution.Status = "failed" if len(result.Content) > 0 { @@ -930,7 +930,7 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string] return handler(runCtx, args) }, OnDone: func(exec *ToolExecution) { - failed := exec != nil && exec.Status != ToolExecutionStatusCompleted + failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled s.updateStats(toolName, failed) }, })