diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go index 77ba36ba70..46442bc533 100644 --- a/apps/backend/internal/analytics/handlers/stats_handlers.go +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -1,8 +1,6 @@ package handlers import ( - "context" - "fmt" "net/http" "time" @@ -13,12 +11,12 @@ import ( "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" - "golang.org/x/sync/errgroup" ) // allTimeActivityDays is the number of days shown in the daily activity heatmap for the "all" range. const allTimeActivityDays = 365 const taskStatsLimit = 200 +const agentUsageLimit = 5 type StatsHandlers struct { repo repository.Repository @@ -39,150 +37,154 @@ func RegisterStatsRoutes(router *gin.Engine, repo repository.Repository, log *lo func (h *StatsHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") - api.GET("/workspaces/:id/stats", h.httpGetStats) + api.GET("/workspaces/:id/stats/global", h.httpGetGlobalStats) + api.GET("/workspaces/:id/stats/tasks", h.httpGetTaskStats) + api.GET("/workspaces/:id/stats/daily-activity", h.httpGetDailyActivity) + api.GET("/workspaces/:id/stats/completed-activity", h.httpGetCompletedActivity) + api.GET("/workspaces/:id/stats/agent-usage", h.httpGetAgentUsage) + api.GET("/workspaces/:id/stats/repositories", h.httpGetRepositoryStats) + api.GET("/workspaces/:id/stats/git", h.httpGetGitStats) } -// rawStats holds all raw stats fetched from the repository before DTO conversion. -type rawStats struct { - globalStats *models.GlobalStats - taskStats []*models.TaskStats - dailyActivity []*models.DailyActivity - completedActivity []*models.CompletedTaskActivity - agentUsage []*models.AgentUsage - repoStats []*models.RepositoryStats - gitStats *models.GitStats +// taskStatsResponse pairs the workload list with a paging flag. +type taskStatsResponse struct { + TaskStats []dto.TaskStatsDTO `json:"task_stats"` + TaskStatsHasMore bool `json:"task_stats_has_more"` } -func (h *StatsHandlers) httpGetStats(c *gin.Context) { +func (h *StatsHandlers) httpGetGlobalStats(c *gin.Context) { + workspaceID, start, _, ok := h.parseRequest(c) + if !ok { + return + } + stats, err := h.repo.GetGlobalStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.fail(c, workspaceID, "global stats", err) + return + } + c.JSON(http.StatusOK, dto.GlobalStatsDTO{ + TotalTasks: stats.TotalTasks, + CompletedTasks: stats.CompletedTasks, + InProgressTasks: stats.InProgressTasks, + TotalSessions: stats.TotalSessions, + TotalTurns: stats.TotalTurns, + TotalMessages: stats.TotalMessages, + TotalUserMessages: stats.TotalUserMessages, + TotalToolCalls: stats.TotalToolCalls, + TotalDurationMs: stats.TotalDurationMs, + AvgTurnsPerTask: stats.AvgTurnsPerTask, + AvgMessagesPerTask: stats.AvgMessagesPerTask, + AvgDurationMsPerTask: stats.AvgDurationMsPerTask, + }) +} + +func (h *StatsHandlers) httpGetTaskStats(c *gin.Context) { + workspaceID, start, _, ok := h.parseRequest(c) + if !ok { + return + } + // Probe one row past the page size so we can distinguish "exactly N rows" + // from "N rows visible, more after" without a separate COUNT query. + stats, err := h.repo.GetTaskStats(c.Request.Context(), workspaceID, start, taskStatsLimit+1) + if err != nil { + h.fail(c, workspaceID, "task stats", err) + return + } + hasMore := len(stats) > taskStatsLimit + if hasMore { + stats = stats[:taskStatsLimit] + } + c.JSON(http.StatusOK, taskStatsResponse{ + TaskStats: taskStatsToDTOs(stats), + TaskStatsHasMore: hasMore, + }) +} + +func (h *StatsHandlers) httpGetDailyActivity(c *gin.Context) { + workspaceID, _, days, ok := h.parseRequest(c) + if !ok { + return + } + activity, err := h.repo.GetDailyActivity(c.Request.Context(), workspaceID, days) + if err != nil { + h.fail(c, workspaceID, "daily activity", err) + return + } + c.JSON(http.StatusOK, dailyActivityToDTOs(activity)) +} + +func (h *StatsHandlers) httpGetCompletedActivity(c *gin.Context) { + workspaceID, _, days, ok := h.parseRequest(c) + if !ok { + return + } + activity, err := h.repo.GetCompletedTaskActivity(c.Request.Context(), workspaceID, days) + if err != nil { + h.fail(c, workspaceID, "completed activity", err) + return + } + c.JSON(http.StatusOK, completedActivityToDTOs(activity)) +} + +func (h *StatsHandlers) httpGetAgentUsage(c *gin.Context) { + workspaceID, start, _, ok := h.parseRequest(c) + if !ok { + return + } + usage, err := h.repo.GetAgentUsage(c.Request.Context(), workspaceID, agentUsageLimit, start) + if err != nil { + h.fail(c, workspaceID, "agent usage", err) + return + } + c.JSON(http.StatusOK, agentUsageToDTOs(usage)) +} + +func (h *StatsHandlers) httpGetRepositoryStats(c *gin.Context) { + workspaceID, start, _, ok := h.parseRequest(c) + if !ok { + return + } + stats, err := h.repo.GetRepositoryStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.fail(c, workspaceID, "repository stats", err) + return + } + c.JSON(http.StatusOK, repositoryStatsToDTOs(stats)) +} + +func (h *StatsHandlers) httpGetGitStats(c *gin.Context) { + workspaceID, start, _, ok := h.parseRequest(c) + if !ok { + return + } + stats, err := h.repo.GetGitStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.fail(c, workspaceID, "git stats", err) + return + } + c.JSON(http.StatusOK, dto.GitStatsDTO{ + TotalCommits: stats.TotalCommits, + TotalFilesChanged: stats.TotalFilesChanged, + TotalInsertions: stats.TotalInsertions, + TotalDeletions: stats.TotalDeletions, + }) +} + +// parseRequest extracts the workspace ID and resolves the range query. +// Returns ok=false after writing a 400 response on a missing workspace id. +func (h *StatsHandlers) parseRequest(c *gin.Context) (string, *time.Time, int, bool) { workspaceID := c.Param("id") if workspaceID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) - return + return "", nil, 0, false } - - rangeKey := c.Query("range") - start, days := parseStatsRange(rangeKey) - - raw, err := h.fetchStats(c.Request.Context(), workspaceID, start, days) - if err != nil { - h.logger.Error("failed to get stats", zap.String("workspace_id", workspaceID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return - } - - response := dto.StatsResponse{ - Global: dto.GlobalStatsDTO{ - TotalTasks: raw.globalStats.TotalTasks, - CompletedTasks: raw.globalStats.CompletedTasks, - InProgressTasks: raw.globalStats.InProgressTasks, - TotalSessions: raw.globalStats.TotalSessions, - TotalTurns: raw.globalStats.TotalTurns, - TotalMessages: raw.globalStats.TotalMessages, - TotalUserMessages: raw.globalStats.TotalUserMessages, - TotalToolCalls: raw.globalStats.TotalToolCalls, - TotalDurationMs: raw.globalStats.TotalDurationMs, - AvgTurnsPerTask: raw.globalStats.AvgTurnsPerTask, - AvgMessagesPerTask: raw.globalStats.AvgMessagesPerTask, - AvgDurationMsPerTask: raw.globalStats.AvgDurationMsPerTask, - }, - TaskStats: taskStatsToDTOs(raw.taskStats), - TaskStatsHasMore: raw.globalStats.TotalTasks > len(raw.taskStats), - DailyActivity: dailyActivityToDTOs(raw.dailyActivity), - CompletedActivity: completedActivityToDTOs(raw.completedActivity), - AgentUsage: agentUsageToDTOs(raw.agentUsage), - RepositoryStats: repositoryStatsToDTOs(raw.repoStats), - GitStats: dto.GitStatsDTO{ - TotalCommits: raw.gitStats.TotalCommits, - TotalFilesChanged: raw.gitStats.TotalFilesChanged, - TotalInsertions: raw.gitStats.TotalInsertions, - TotalDeletions: raw.gitStats.TotalDeletions, - }, - } - - c.JSON(http.StatusOK, response) + start, days := parseStatsRange(c.Query("range")) + return workspaceID, start, days, true } -func (h *StatsHandlers) fetchStats(ctx context.Context, workspaceID string, start *time.Time, days int) (*rawStats, error) { - var ( - globalStats *models.GlobalStats - taskStats []*models.TaskStats - dailyActivity []*models.DailyActivity - completedActivity []*models.CompletedTaskActivity - agentUsage []*models.AgentUsage - repoStats []*models.RepositoryStats - gitStats *models.GitStats - ) - - group, groupCtx := errgroup.WithContext(ctx) - group.Go(func() error { - result, err := h.repo.GetGlobalStats(groupCtx, workspaceID, start) - if err != nil { - return fmt.Errorf("global stats: %w", err) - } - globalStats = result - return nil - }) - group.Go(func() error { - result, err := h.repo.GetTaskStats(groupCtx, workspaceID, start, taskStatsLimit) - if err != nil { - return fmt.Errorf("task stats: %w", err) - } - taskStats = result - return nil - }) - group.Go(func() error { - result, err := h.repo.GetDailyActivity(groupCtx, workspaceID, days) - if err != nil { - return fmt.Errorf("daily activity: %w", err) - } - dailyActivity = result - return nil - }) - group.Go(func() error { - result, err := h.repo.GetCompletedTaskActivity(groupCtx, workspaceID, days) - if err != nil { - return fmt.Errorf("completed activity: %w", err) - } - completedActivity = result - return nil - }) - group.Go(func() error { - result, err := h.repo.GetAgentUsage(groupCtx, workspaceID, 5, start) - if err != nil { - return fmt.Errorf("agent usage: %w", err) - } - agentUsage = result - return nil - }) - group.Go(func() error { - result, err := h.repo.GetRepositoryStats(groupCtx, workspaceID, start) - if err != nil { - return fmt.Errorf("repository stats: %w", err) - } - repoStats = result - return nil - }) - group.Go(func() error { - result, err := h.repo.GetGitStats(groupCtx, workspaceID, start) - if err != nil { - return fmt.Errorf("git stats: %w", err) - } - gitStats = result - return nil - }) - if err := group.Wait(); err != nil { - return nil, err - } - - return &rawStats{ - globalStats: globalStats, - taskStats: taskStats, - dailyActivity: dailyActivity, - completedActivity: completedActivity, - agentUsage: agentUsage, - repoStats: repoStats, - gitStats: gitStats, - }, nil +func (h *StatsHandlers) fail(c *gin.Context, workspaceID, section string, err error) { + h.logger.Error("failed to get "+section, zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get " + section}) } func taskStatsToDTOs(taskStats []*models.TaskStats) []dto.TaskStatsDTO { diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index 100f0afa0b..b1b615f3df 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -227,6 +227,60 @@ func TestGetRepositoryStats_ExcludesSoftDeletedRepos(t *testing.T) { } } +// TestGetGlobalStats_AggregatesAcrossTables exercises the CTE-based query path +// for every aggregated field — total/user/tool messages, turn count, and the +// computed total duration — to guard against regressions when the query is +// further consolidated. +func TestGetGlobalStats_AggregatesAcrossTables(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + // Two turns: 5m + 10m of active duration (= 900_000 ms). + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-1', 'sess-1', 'task-1', '2026-01-01T10:00:00Z', '2026-01-01T10:05:00Z', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-2', 'sess-1', 'task-1', '2026-01-01T10:10:00Z', '2026-01-01T10:20:00Z', ?, ?)`, nowStr, nowStr) + // Messages: 2 user, 1 tool_call, 1 tool_edit, 1 agent text. + execOrFatal(t, dbConn, `INSERT INTO task_session_messages (id, task_session_id, turn_id, author_type, type, content, created_at) VALUES ('m-1', 'sess-1', 'turn-1', 'user', 'message', 'hi', ?)`, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_messages (id, task_session_id, turn_id, author_type, type, content, created_at) VALUES ('m-2', 'sess-1', 'turn-1', 'user', 'message', 'hello', ?)`, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_messages (id, task_session_id, turn_id, author_type, type, content, created_at) VALUES ('m-3', 'sess-1', 'turn-1', 'agent', 'tool_call', 'bash', ?)`, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_messages (id, task_session_id, turn_id, author_type, type, content, created_at) VALUES ('m-4', 'sess-1', 'turn-1', 'agent', 'tool_edit', 'edit', ?)`, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_messages (id, task_session_id, turn_id, author_type, type, content, created_at) VALUES ('m-5', 'sess-1', 'turn-1', 'agent', 'message', 'reply', ?)`, nowStr) + + stats, err := repo.GetGlobalStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetGlobalStats failed: %v", err) + } + if stats.TotalTasks != 1 { + t.Errorf("TotalTasks: want 1, got %d", stats.TotalTasks) + } + if stats.TotalSessions != 1 { + t.Errorf("TotalSessions: want 1, got %d", stats.TotalSessions) + } + if stats.TotalTurns != 2 { + t.Errorf("TotalTurns: want 2, got %d", stats.TotalTurns) + } + if stats.TotalMessages != 5 { + t.Errorf("TotalMessages: want 5, got %d", stats.TotalMessages) + } + if stats.TotalUserMessages != 2 { + t.Errorf("TotalUserMessages: want 2, got %d", stats.TotalUserMessages) + } + if stats.TotalToolCalls != 2 { + t.Errorf("TotalToolCalls: want 2 (tool_call + tool_edit), got %d", stats.TotalToolCalls) + } + assertDurationAlmostEqual(t, stats.TotalDurationMs, int64(15*60*1000), "TotalDurationMs") +} + func TestGetGlobalStats_EmptyWorkspace(t *testing.T) { dbConn := createTestDB(t) repo, err := NewWithDB(dbConn, dbConn) diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 8f95228df4..588d1d714f 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -142,7 +142,10 @@ func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error) return results, rows.Err() } -// GetGlobalStats retrieves workspace-wide aggregated statistics +// GetGlobalStats retrieves workspace-wide aggregated statistics. +// Implemented as a single query over 4 CTEs (tasks, sessions, turns, messages) +// rather than 9 independent correlated subqueries: each underlying table is +// scanned at most once per request. func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) { var startArg any if start != nil { @@ -153,61 +156,74 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") query := fmt.Sprintf(` + WITH + task_agg AS ( + SELECT + COUNT(*) AS total_tasks, + SUM(CASE WHEN t.archived_at IS NOT NULL + OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) + THEN 1 ELSE 0 END) AS completed_tasks, + SUM(CASE WHEN t.state = 'IN_PROGRESS' AND t.archived_at IS NULL THEN 1 ELSE 0 END) AS in_progress_tasks + FROM tasks t + LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id + WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) + ), + session_agg AS ( + SELECT COUNT(*) AS total_sessions + FROM task_sessions s + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + ), + turn_agg AS ( + SELECT + COUNT(*) AS total_turns, + COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) AS total_duration_ms + FROM task_session_turns turn + JOIN task_sessions s ON s.id = turn.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + ), + message_agg AS ( + SELECT + COUNT(*) AS total_messages, + SUM(CASE WHEN msg.author_type = 'user' THEN 1 ELSE 0 END) AS total_user_messages, + SUM(CASE WHEN msg.type LIKE 'tool_%%' THEN 1 ELSE 0 END) AS total_tool_calls + FROM task_session_messages msg + JOIN task_sessions s ON s.id = msg.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + ) SELECT - (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0 AND (? IS NULL OR created_at >= ?)) as total_tasks, - (SELECT COUNT(*) FROM tasks t - LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id - WHERE t.workspace_id = ? - AND t.is_ephemeral = 0 - AND (t.archived_at IS NOT NULL - OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id)) - AND (? IS NULL OR t.created_at >= ?)) as completed_tasks, - (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0 AND state = 'IN_PROGRESS' AND archived_at IS NULL AND (? IS NULL OR created_at >= ?)) as in_progress_tasks, - (SELECT COUNT(*) FROM task_sessions s JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?)) as total_sessions, - (SELECT COUNT(*) FROM task_session_turns turn - JOIN task_sessions s ON s.id = turn.task_session_id - JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?)) as total_turns, - (SELECT COUNT(*) FROM task_session_messages msg - JOIN task_sessions s ON s.id = msg.task_session_id - JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?)) as total_messages, - (SELECT COUNT(*) FROM task_session_messages msg - JOIN task_sessions s ON s.id = msg.task_session_id - JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND msg.author_type = 'user' AND (? IS NULL OR s.started_at >= ?)) as total_user_messages, - (SELECT COUNT(*) FROM task_session_messages msg - JOIN task_sessions s ON s.id = msg.task_session_id - JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND msg.type LIKE 'tool_%%' AND (? IS NULL OR s.started_at >= ?)) as total_tool_calls, - (SELECT COALESCE(SUM( - CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END - ), 0) FROM task_session_turns turn - JOIN task_sessions s ON s.id = turn.task_session_id - JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?)) as total_duration_ms + task_agg.total_tasks, task_agg.completed_tasks, task_agg.in_progress_tasks, + session_agg.total_sessions, + turn_agg.total_turns, + message_agg.total_messages, message_agg.total_user_messages, message_agg.total_tool_calls, + turn_agg.total_duration_ms + FROM task_agg, session_agg, turn_agg, message_agg `, dur) var stats models.GlobalStats var totalDurationMs float64 + var completedTasks, inProgressTasks sql.NullInt64 + var userMessages, toolCalls sql.NullInt64 err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, - workspaceID, startArg, startArg, + workspaceID, startArg, startArg, // task_agg + workspaceID, startArg, startArg, // session_agg + workspaceID, startArg, startArg, // turn_agg + workspaceID, startArg, startArg, // message_agg ).Scan( - &stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks, - &stats.TotalSessions, &stats.TotalTurns, &stats.TotalMessages, - &stats.TotalUserMessages, &stats.TotalToolCalls, &totalDurationMs, + &stats.TotalTasks, &completedTasks, &inProgressTasks, + &stats.TotalSessions, &stats.TotalTurns, + &stats.TotalMessages, &userMessages, &toolCalls, + &totalDurationMs, ) if err != nil { return nil, err } + stats.CompletedTasks = int(completedTasks.Int64) + stats.InProgressTasks = int(inProgressTasks.Int64) + stats.TotalUserMessages = int(userMessages.Int64) + stats.TotalToolCalls = int(toolCalls.Int64) stats.TotalDurationMs = int64(totalDurationMs) if stats.TotalTasks > 0 { diff --git a/apps/web/app/stats/stats-data.tsx b/apps/web/app/stats/stats-data.tsx new file mode 100644 index 0000000000..b0e43d3b65 --- /dev/null +++ b/apps/web/app/stats/stats-data.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { + AgentUsageDTO, + CompletedTaskActivityDTO, + DailyActivityDTO, + GitStatsDTO, + GlobalStatsDTO, + RepositoryStatsDTO, + StatsResponse, + TaskStatsDTO, +} from "@/lib/types/http"; +import { + fetchAgentUsage, + fetchCompletedActivity, + fetchDailyActivity, + fetchGitStats, + fetchGlobalStats, + fetchRepositoryStats, + fetchTaskStats, + type TaskStatsResponse, +} from "@/lib/api/domains/stats-api"; +import type { RangeKey } from "./stats-utils"; + +export type SectionStatus = + | { kind: "loading" } + | { kind: "ready"; data: T } + | { kind: "error"; message: string }; + +export type StatsSections = { + global: SectionStatus; + tasks: SectionStatus; + daily: SectionStatus; + completed: SectionStatus; + agents: SectionStatus; + repos: SectionStatus; + git: SectionStatus; +}; + +const LOADING: SectionStatus = { kind: "loading" }; + +const INITIAL_SECTIONS: StatsSections = { + global: LOADING, + tasks: LOADING, + daily: LOADING, + completed: LOADING, + agents: LOADING, + repos: LOADING, + git: LOADING, +}; + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : "Failed to load section"; +} + +export function useStatsSections(workspaceId: string | undefined, range: RangeKey): StatsSections { + // Render-time reset: when (workspaceId, range) flips we wipe section state + // before the effect runs so panels show skeletons while new fetches are in + // flight, instead of stale data from the previous range. Matches the React + // docs "reset state when a prop changes" pattern (no setState-in-effect). + const fetchKey = workspaceId ? `${workspaceId}::${range}` : null; + const [trackedKey, setTrackedKey] = useState(null); + const [sections, setSections] = useState(INITIAL_SECTIONS); + if (fetchKey !== trackedKey) { + setTrackedKey(fetchKey); + setSections(INITIAL_SECTIONS); + } + + useEffect(() => { + if (!workspaceId) return; + const controller = new AbortController(); + + // cache: "no-store" matches the previous useStatsData behaviour — Gin emits + // no Cache-Control headers, so without this the browser is free to serve a + // heuristically-cached response when the user navigates back to the page. + const opts = { cache: "no-store" as const, init: { signal: controller.signal } }; + const apply = (key: K, next: StatsSections[K]) => { + if (controller.signal.aborted) return; + setSections((prev) => ({ ...prev, [key]: next })); + }; + + const run = ( + key: K, + promise: Promise ? U : never>, + ) => { + promise + .then((data) => apply(key, { kind: "ready", data } as StatsSections[K])) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + apply(key, { kind: "error", message: errorMessage(e) } as StatsSections[K]); + }); + }; + + run("global", fetchGlobalStats(workspaceId, opts, range)); + run("tasks", fetchTaskStats(workspaceId, opts, range)); + run("daily", fetchDailyActivity(workspaceId, opts, range)); + run("completed", fetchCompletedActivity(workspaceId, opts, range)); + run("agents", fetchAgentUsage(workspaceId, opts, range)); + run("repos", fetchRepositoryStats(workspaceId, opts, range)); + run("git", fetchGitStats(workspaceId, opts, range)); + + return () => controller.abort(); + }, [workspaceId, range]); + + return sections; +} + +export function readyGlobal(sections: StatsSections): GlobalStatsDTO | null { + return sections.global.kind === "ready" ? sections.global.data : null; +} + +// firstError returns the message of the first errored section in object-iteration +// order (insertion order: global → tasks → daily → completed → agents → repos → +// git). Callers use it only as a boolean "any section failed?" signal driving +// the header's "Failed to load stats" subtitle, so the deterministic ordering +// is fine — surface a specific section's error inside its own panel instead. +export function firstError(sections: StatsSections): string | null { + for (const key of Object.keys(sections) as (keyof StatsSections)[]) { + const s = sections[key]; + if (s.kind === "error") return s.message; + } + return null; +} + +// composeStatsResponse builds a full StatsResponse when every section is ready. +// Returns null otherwise — used to gate the Copy Stats button. +export function composeStatsResponse(sections: StatsSections): StatsResponse | null { + const { global, tasks, daily, completed, agents, repos, git } = sections; + if ( + global.kind !== "ready" || + tasks.kind !== "ready" || + daily.kind !== "ready" || + completed.kind !== "ready" || + agents.kind !== "ready" || + repos.kind !== "ready" || + git.kind !== "ready" + ) { + return null; + } + return { + global: global.data, + task_stats: tasks.data.task_stats, + daily_activity: daily.data, + completed_activity: completed.data, + agent_usage: agents.data, + repository_stats: repos.data, + git_stats: git.data, + }; +} + +export type TaskStatsSectionStatus = SectionStatus; + +// flattenTaskStats unwraps the {task_stats, has_more} envelope into the bare +// list expected by render helpers and the WorkloadSection component. +export function flattenTaskStats(status: SectionStatus): TaskStatsSectionStatus { + if (status.kind === "ready") return { kind: "ready", data: status.data.task_stats }; + return status; +} diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx index 61eb26c52b..9a14d74be2 100644 --- a/apps/web/app/stats/stats-page-client.tsx +++ b/apps/web/app/stats/stats-page-client.tsx @@ -4,12 +4,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { PageTopbar } from "@/components/page-topbar"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; -import type { StatsResponse } from "@/lib/types/http"; -import { useCallback, useEffect, useMemo, useReducer } from "react"; +import { useCallback, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { IconChartBar } from "@tabler/icons-react"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; -import { fetchStats } from "@/lib/api/domains/stats-api"; +import type { + AgentUsageDTO, + CompletedTaskActivityDTO, + DailyActivityDTO, + GitStatsDTO, + GlobalStatsDTO, + RepositoryStatsDTO, + TaskStatsDTO, +} from "@/lib/types/http"; import { OverviewCards, WorkloadSection, @@ -39,13 +46,18 @@ import { getRangeLabel, getSubtitle, isRangeKey, - type PanelState, RANGE_KEYS, type RangeKey, - type StatsState, - statsReducer, - toPanelState, } from "./stats-utils"; +import { + composeStatsResponse, + firstError, + flattenTaskStats, + readyGlobal, + type SectionStatus, + type StatsSections, + useStatsSections, +} from "./stats-data"; interface StatsPageClientProps { workspaceId?: string; @@ -65,7 +77,7 @@ function StatsEmptyState({ message }: { message: string }) { } type StatsHeaderProps = { - global: StatsResponse["global"] | null; + global: GlobalStatsDTO | null; range: RangeKey; copied: boolean; copyDisabled: boolean; @@ -147,34 +159,48 @@ function ErrorPanel({ title, message }: { title: string; message: string }) { ); } -function TelemetryPanels({ state, rangeLabel }: { state: PanelState; rangeLabel: string }) { - if (state.kind === "loading") { - return ( - <> -
- -
-
- -
- - ); - } - if (state.kind === "error") { - return ( - <> -
- -
-
- -
- - ); - } - const { stats } = state; - return ( - <> +// renderSection picks a skeleton, error panel, or ready render based on +// the section's status. Centralising the switch keeps each call site to one line. +function renderSection( + status: SectionStatus, + options: { + skeleton: React.ReactNode; + errorTitle: string; + ready: (data: T) => React.ReactNode; + }, +): React.ReactNode { + if (status.kind === "loading") return options.skeleton; + if (status.kind === "error") + return ; + return options.ready(status.data); +} + +function OverviewPanel({ + global, + git, +}: { + global: SectionStatus; + git: SectionStatus; +}) { + if (global.kind === "loading") return ; + if (global.kind === "error") return ; + // Render global cards as soon as `global` is ready; `git` is independent and + // its failure must not blank the tasks/sessions/turns summary the user can + // already see. OverviewCards.git_stats is optional → falls back to the + // averages card when git data is missing. + const gitData = git.kind === "ready" ? git.data : undefined; + return ; +} + +function CompletedPanel({ status }: { status: SectionStatus }) { + return renderSection(status, { + skeleton: ( +
+ +
+ ), + errorTitle: "Completed Tasks Over Time", + ready: (data) => (
@@ -184,7 +210,7 @@ function TelemetryPanels({ state, rangeLabel }: { state: PanelState; rangeLabel: - + @@ -194,149 +220,154 @@ function TelemetryPanels({ state, rangeLabel }: { state: PanelState; rangeLabel: - +
-
- - - - Activity ({rangeLabel.toLowerCase()}) - - - - - - - - - Top Agents - - - - - -
- - ); + ), + }); } -function renderOverviewPanel(state: PanelState) { - if (state.kind === "loading") return ; - if (state.kind === "error") return ; - return ; -} - -function renderRepositoryActivityPanel(state: PanelState) { - if (state.kind === "loading") return ; - if (state.kind === "error") - return ; - return ( - - - - Repository Activity - - - - - - - ); -} - -function renderTopRepositoriesPanel(state: PanelState) { - if (state.kind === "loading") return ; - if (state.kind === "error") - return ; - return ( - - - - Top Repositories - - - - - - - ); -} - -function renderRepoLeadersPanel(state: PanelState) { - if (state.kind === "loading") return ; - if (state.kind === "error") return ; - return ( - - - Repo Leaders - - - - - - ); -} - -function renderWorkloadPanel(state: PanelState) { - if (state.kind === "loading") return ; - if (state.kind === "error") return ; - return ; -} - -function StatsContent({ - state, +function ActivityPanel({ + daily, + agents, rangeLabel, - workspaceId, }: { - state: PanelState; + daily: SectionStatus; + agents: SectionStatus; rangeLabel: string; - workspaceId?: string; }) { return ( -
-
-
- {renderOverviewPanel(state)} - - - {renderRepositoryActivityPanel(state)} - {renderTopRepositoriesPanel(state)} - {renderRepoLeadersPanel(state)} - - - - {renderWorkloadPanel(state)} -
-
+
+ {renderSection(daily, { + skeleton: , + errorTitle: "Activity", + ready: (data) => ( + + + + Activity ({rangeLabel.toLowerCase()}) + + + + + + + ), + })} + {renderSection(agents, { + skeleton: , + errorTitle: "Top Agents", + ready: (data) => ( + + + + Top Agents + + + + + + + ), + })}
); } -function useStatsData(workspaceId: string | undefined, range: RangeKey): StatsState { - const [state, dispatch] = useReducer(statsReducer, { stats: null, error: null }); +function RepositoryActivityPanel({ status }: { status: SectionStatus }) { + return renderSection(status, { + skeleton: , + errorTitle: "Repository Activity", + ready: (data) => ( + + + + Repository Activity + + + + + + + ), + }); +} - useEffect(() => { - if (!workspaceId) return; - const controller = new AbortController(); - dispatch({ type: "fetch" }); - fetchStats(workspaceId, { cache: "no-store", init: { signal: controller.signal } }, range) - .then((data) => { - if (!controller.signal.aborted) dispatch({ type: "success", stats: data }); - }) - .catch((e: unknown) => { - if (controller.signal.aborted) return; - const message = e instanceof Error ? e.message : "Failed to fetch stats"; - dispatch({ type: "failure", error: message }); - }); - return () => { - controller.abort(); - }; - }, [workspaceId, range]); +function TopRepositoriesPanel({ status }: { status: SectionStatus }) { + return renderSection(status, { + skeleton: , + errorTitle: "Top Repositories", + ready: (data) => ( + + + + Top Repositories + + + + + + + ), + }); +} - return state; +function RepoLeadersPanel({ status }: { status: SectionStatus }) { + return renderSection(status, { + skeleton: , + errorTitle: "Repo Leaders", + ready: (data) => ( + + + Repo Leaders + + + + + + ), + }); +} + +function WorkloadPanel({ status }: { status: SectionStatus }) { + return renderSection(status, { + skeleton: , + errorTitle: "Workload", + ready: (data) => , + }); +} + +function StatsContent({ + sections, + rangeLabel, + workspaceId, +}: { + sections: StatsSections; + rangeLabel: string; + workspaceId?: string; +}) { + const taskStatus = flattenTaskStats(sections.tasks); + return ( +
+
+
+ + + + + + + + + + + +
+
+
+ ); } export function StatsPageClient({ workspaceId, activeRange, initialError }: StatsPageClientProps) { @@ -348,16 +379,19 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat const range: RangeKey = isRangeKey(rawRange) ? rawRange : DEFAULT_RANGE; const rangeLabel = getRangeLabel(range); - const { stats, error: fetchError } = useStatsData(workspaceId, range); - const panelState = toPanelState(stats, fetchError); + const sections = useStatsSections(workspaceId, range); + const fetchError = firstError(sections); + const globalReady = readyGlobal(sections); + const fullStats = composeStatsResponse(sections); + + const completedInRange = useMemo(() => { + if (sections.completed.kind !== "ready") return 0; + return sections.completed.data.reduce((sum, item) => sum + item.completed_tasks, 0); + }, [sections.completed]); - const completedInRange = useMemo( - () => (stats?.completed_activity ?? []).reduce((sum, item) => sum + item.completed_tasks, 0), - [stats?.completed_activity], - ); const statsSummary = useMemo( - () => (stats ? buildStatsSummary(stats, rangeLabel, completedInRange) : ""), - [stats, rangeLabel, completedInRange], + () => (fullStats ? buildStatsSummary(fullStats, rangeLabel, completedInRange) : ""), + [fullStats, rangeLabel, completedInRange], ); const handleCopyStats = useCallback(() => { @@ -387,15 +421,15 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat return (
- +
); } diff --git a/apps/web/app/stats/stats-sections.tsx b/apps/web/app/stats/stats-sections.tsx index 4bf31a2b4d..4995594b69 100644 --- a/apps/web/app/stats/stats-sections.tsx +++ b/apps/web/app/stats/stats-sections.tsx @@ -88,7 +88,7 @@ function TimeSpentCard({ global }: { global: GlobalStats }) { ); } -function GitOrAveragesCard({ global, git_stats }: { global: GlobalStats; git_stats: GitStats }) { +function GitOrAveragesCard({ global, git_stats }: { global: GlobalStats; git_stats?: GitStats }) { const hasGitStats = git_stats && (git_stats.total_commits > 0 || git_stats.total_files_changed > 0); @@ -199,7 +199,13 @@ function SignalCard({ global }: { global: GlobalStats }) { ); } -export function OverviewCards({ global, git_stats }: { global: GlobalStats; git_stats: GitStats }) { +export function OverviewCards({ + global, + git_stats, +}: { + global: GlobalStats; + git_stats?: GitStats; +}) { return (
diff --git a/apps/web/lib/api/domains/stats-api.ts b/apps/web/lib/api/domains/stats-api.ts index ef1df28497..ee892abe21 100644 --- a/apps/web/lib/api/domains/stats-api.ts +++ b/apps/web/lib/api/domains/stats-api.ts @@ -1,13 +1,84 @@ import { fetchJson, type ApiRequestOptions } from "../client"; -import type { StatsResponse } from "@/lib/types/http"; +import type { + AgentUsageDTO, + CompletedTaskActivityDTO, + DailyActivityDTO, + GitStatsDTO, + GlobalStatsDTO, + RepositoryStatsDTO, + TaskStatsDTO, +} from "@/lib/types/http"; export type StatsRange = "week" | "month" | "all"; -export async function fetchStats( +export type TaskStatsResponse = { + task_stats: TaskStatsDTO[]; + task_stats_has_more: boolean; +}; + +function rangeQuery(range?: StatsRange): string { + return range ? `?range=${encodeURIComponent(range)}` : ""; +} + +function statsUrl(workspaceId: string, section: string, range?: StatsRange): string { + return `/api/v1/workspaces/${workspaceId}/stats/${section}${rangeQuery(range)}`; +} + +export function fetchGlobalStats( workspaceId: string, options?: ApiRequestOptions, range?: StatsRange, ) { - const query = range ? `?range=${encodeURIComponent(range)}` : ""; - return fetchJson(`/api/v1/workspaces/${workspaceId}/stats${query}`, options); + return fetchJson(statsUrl(workspaceId, "global", range), options); +} + +export function fetchTaskStats( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange, +) { + return fetchJson(statsUrl(workspaceId, "tasks", range), options); +} + +export function fetchDailyActivity( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange, +) { + return fetchJson(statsUrl(workspaceId, "daily-activity", range), options); +} + +export function fetchCompletedActivity( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange, +) { + return fetchJson( + statsUrl(workspaceId, "completed-activity", range), + options, + ); +} + +export function fetchAgentUsage( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange, +) { + return fetchJson(statsUrl(workspaceId, "agent-usage", range), options); +} + +export function fetchRepositoryStats( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange, +) { + return fetchJson(statsUrl(workspaceId, "repositories", range), options); +} + +export function fetchGitStats( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange, +) { + return fetchJson(statsUrl(workspaceId, "git", range), options); }