perf(stats): split stats endpoint per-section and rewrite GetGlobalStats (#1289)
* perf(stats): split stats endpoint per-section and rewrite GetGlobalStats Stats page used to wait on a single endpoint that ran 7 SQL queries in parallel and rendered nothing until the slowest one returned. Cold first-paint was the bottleneck users actually hit. - Backend: split /api/v1/workspaces/:id/stats into 7 per-section endpoints (global, tasks, daily-activity, completed-activity, agent-usage, repositories, git). Each handler runs one repo call and returns its DTO. - Backend: rewrote GetGlobalStats from 9 independent correlated subqueries into 4 CTEs (task / session / turn / message). Each underlying table is now scanned at most once per request instead of 1-3 times. - Frontend: replaced the monolithic fetchStats + single PanelState with useStatsSections hook (7 parallel fetches, per-section SectionStatus). Each panel renders skeleton -> data -> error independently; light queries (overview, agent usage, git) paint immediately, heavy ones stream in. - Tests: added TestGetGlobalStats_AggregatesAcrossTables to lock the CTE-based query against regressions for turns, user/tool messages, and total duration. * fix(stats): address PR review feedback - Restore cache: "no-store" on section fetches; Gin emits no Cache-Control headers so the browser could otherwise serve stale stats on revisit (Greptile P1, Cubic). - Stop blanking the Overview panel when /git fails: render OverviewCards as soon as `global` is ready and treat `git` as optional, falling back to the Averages card. Updates GitOrAveragesCard / OverviewCards types (Claude). - Fix TaskStatsHasMore off-by-one at exactly taskStatsLimit rows: probe taskStatsLimit+1, slice back to the page size, only flag hasMore on a confirmed extra row (Claude, Cubic). - Rename anyError → firstError with a comment documenting the deterministic insertion-order iteration; clearer signal that callers should use it as "any section failed?" and surface per-section errors in their own panels (Claude). --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T> =
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; data: T }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export type StatsSections = {
|
||||
global: SectionStatus<GlobalStatsDTO>;
|
||||
tasks: SectionStatus<TaskStatsResponse>;
|
||||
daily: SectionStatus<DailyActivityDTO[]>;
|
||||
completed: SectionStatus<CompletedTaskActivityDTO[]>;
|
||||
agents: SectionStatus<AgentUsageDTO[]>;
|
||||
repos: SectionStatus<RepositoryStatsDTO[]>;
|
||||
git: SectionStatus<GitStatsDTO>;
|
||||
};
|
||||
|
||||
const LOADING: SectionStatus<never> = { 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<string | null>(null);
|
||||
const [sections, setSections] = useState<StatsSections>(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 = <K extends keyof StatsSections>(key: K, next: StatsSections[K]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSections((prev) => ({ ...prev, [key]: next }));
|
||||
};
|
||||
|
||||
const run = <K extends keyof StatsSections>(
|
||||
key: K,
|
||||
promise: Promise<StatsSections[K] extends SectionStatus<infer U> ? 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<TaskStatsDTO[]>;
|
||||
|
||||
// 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<TaskStatsResponse>): TaskStatsSectionStatus {
|
||||
if (status.kind === "ready") return { kind: "ready", data: status.data.task_stats };
|
||||
return status;
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div id="completed" className="scroll-mt-24">
|
||||
<ChartsSkeleton />
|
||||
</div>
|
||||
<div id="activity" className="scroll-mt-24">
|
||||
<ActivitySkeleton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (state.kind === "error") {
|
||||
return (
|
||||
<>
|
||||
<div id="completed" className="scroll-mt-24">
|
||||
<ErrorPanel title="Completed Tasks Over Time" message={state.message} />
|
||||
</div>
|
||||
<div id="activity" className="scroll-mt-24">
|
||||
<ErrorPanel title="Activity" message={state.message} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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<T>(
|
||||
status: SectionStatus<T>,
|
||||
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 <ErrorPanel title={options.errorTitle} message={status.message} />;
|
||||
return options.ready(status.data);
|
||||
}
|
||||
|
||||
function OverviewPanel({
|
||||
global,
|
||||
git,
|
||||
}: {
|
||||
global: SectionStatus<GlobalStatsDTO>;
|
||||
git: SectionStatus<GitStatsDTO>;
|
||||
}) {
|
||||
if (global.kind === "loading") return <OverviewCardsSkeleton />;
|
||||
if (global.kind === "error") return <ErrorPanel title="Overview" message={global.message} />;
|
||||
// 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 <OverviewCards global={global.data} git_stats={gitData} />;
|
||||
}
|
||||
|
||||
function CompletedPanel({ status }: { status: SectionStatus<CompletedTaskActivityDTO[]> }) {
|
||||
return renderSection(status, {
|
||||
skeleton: (
|
||||
<div id="completed" className="scroll-mt-24">
|
||||
<ChartsSkeleton />
|
||||
</div>
|
||||
),
|
||||
errorTitle: "Completed Tasks Over Time",
|
||||
ready: (data) => (
|
||||
<div id="completed" className="scroll-mt-24">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="rounded-sm lg:col-span-2">
|
||||
@@ -184,7 +210,7 @@ function TelemetryPanels({ state, rangeLabel }: { state: PanelState; rangeLabel:
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletedTasksChart completedActivity={stats.completed_activity} />
|
||||
<CompletedTasksChart completedActivity={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-sm">
|
||||
@@ -194,149 +220,154 @@ function TelemetryPanels({ state, rangeLabel }: { state: PanelState; rangeLabel:
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MostProductiveSummary completedActivity={stats.completed_activity} />
|
||||
<MostProductiveSummary completedActivity={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div id="activity" className="grid gap-4 lg:grid-cols-2 scroll-mt-24">
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Activity ({rangeLabel.toLowerCase()})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityHeatmap dailyActivity={stats.daily_activity} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Top Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentUsageList agentUsage={stats.agent_usage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function renderOverviewPanel(state: PanelState) {
|
||||
if (state.kind === "loading") return <OverviewCardsSkeleton />;
|
||||
if (state.kind === "error") return <ErrorPanel title="Overview" message={state.message} />;
|
||||
return <OverviewCards global={state.stats.global} git_stats={state.stats.git_stats} />;
|
||||
}
|
||||
|
||||
function renderRepositoryActivityPanel(state: PanelState) {
|
||||
if (state.kind === "loading") return <RepositoriesSkeleton />;
|
||||
if (state.kind === "error")
|
||||
return <ErrorPanel title="Repository Activity" message={state.message} />;
|
||||
return (
|
||||
<Card id="repositories" className="rounded-sm scroll-mt-24">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Repository Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RepositoryStatsGrid repositoryStats={state.stats.repository_stats} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTopRepositoriesPanel(state: PanelState) {
|
||||
if (state.kind === "loading") return <TopRepositoriesSkeleton />;
|
||||
if (state.kind === "error")
|
||||
return <ErrorPanel title="Top Repositories" message={state.message} />;
|
||||
return (
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Top Repositories
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopRepositories repositoryStats={state.stats.repository_stats} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRepoLeadersPanel(state: PanelState) {
|
||||
if (state.kind === "loading") return <RepoLeadersSkeleton />;
|
||||
if (state.kind === "error") return <ErrorPanel title="Repo Leaders" message={state.message} />;
|
||||
return (
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Repo Leaders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RepoLeaders repositoryStats={state.stats.repository_stats} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWorkloadPanel(state: PanelState) {
|
||||
if (state.kind === "loading") return <WorkloadSkeleton />;
|
||||
if (state.kind === "error") return <ErrorPanel title="Workload" message={state.message} />;
|
||||
return <WorkloadSection task_stats={state.stats.task_stats} />;
|
||||
}
|
||||
|
||||
function StatsContent({
|
||||
state,
|
||||
function ActivityPanel({
|
||||
daily,
|
||||
agents,
|
||||
rangeLabel,
|
||||
workspaceId,
|
||||
}: {
|
||||
state: PanelState;
|
||||
daily: SectionStatus<DailyActivityDTO[]>;
|
||||
agents: SectionStatus<AgentUsageDTO[]>;
|
||||
rangeLabel: string;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="space-y-5">
|
||||
{renderOverviewPanel(state)}
|
||||
<SectionDivider id="telemetry" label="Telemetry" />
|
||||
<TelemetryPanels state={state} rangeLabel={rangeLabel} />
|
||||
{renderRepositoryActivityPanel(state)}
|
||||
{renderTopRepositoriesPanel(state)}
|
||||
{renderRepoLeadersPanel(state)}
|
||||
<SectionDivider id="github" label="GitHub" />
|
||||
<PRStatsPanel workspaceId={workspaceId ?? null} />
|
||||
<SectionDivider id="workload" label="Workload" />
|
||||
{renderWorkloadPanel(state)}
|
||||
</div>
|
||||
</div>
|
||||
<div id="activity" className="grid gap-4 lg:grid-cols-2 scroll-mt-24">
|
||||
{renderSection(daily, {
|
||||
skeleton: <ActivitySkeleton />,
|
||||
errorTitle: "Activity",
|
||||
ready: (data) => (
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Activity ({rangeLabel.toLowerCase()})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityHeatmap dailyActivity={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
})}
|
||||
{renderSection(agents, {
|
||||
skeleton: <ActivitySkeleton />,
|
||||
errorTitle: "Top Agents",
|
||||
ready: (data) => (
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Top Agents
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentUsageList agentUsage={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useStatsData(workspaceId: string | undefined, range: RangeKey): StatsState {
|
||||
const [state, dispatch] = useReducer(statsReducer, { stats: null, error: null });
|
||||
function RepositoryActivityPanel({ status }: { status: SectionStatus<RepositoryStatsDTO[]> }) {
|
||||
return renderSection(status, {
|
||||
skeleton: <RepositoriesSkeleton />,
|
||||
errorTitle: "Repository Activity",
|
||||
ready: (data) => (
|
||||
<Card id="repositories" className="rounded-sm scroll-mt-24">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Repository Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RepositoryStatsGrid repositoryStats={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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<RepositoryStatsDTO[]> }) {
|
||||
return renderSection(status, {
|
||||
skeleton: <TopRepositoriesSkeleton />,
|
||||
errorTitle: "Top Repositories",
|
||||
ready: (data) => (
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Top Repositories
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopRepositories repositoryStats={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
function RepoLeadersPanel({ status }: { status: SectionStatus<RepositoryStatsDTO[]> }) {
|
||||
return renderSection(status, {
|
||||
skeleton: <RepoLeadersSkeleton />,
|
||||
errorTitle: "Repo Leaders",
|
||||
ready: (data) => (
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Repo Leaders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RepoLeaders repositoryStats={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function WorkloadPanel({ status }: { status: SectionStatus<TaskStatsDTO[]> }) {
|
||||
return renderSection(status, {
|
||||
skeleton: <WorkloadSkeleton />,
|
||||
errorTitle: "Workload",
|
||||
ready: (data) => <WorkloadSection task_stats={data} />,
|
||||
});
|
||||
}
|
||||
|
||||
function StatsContent({
|
||||
sections,
|
||||
rangeLabel,
|
||||
workspaceId,
|
||||
}: {
|
||||
sections: StatsSections;
|
||||
rangeLabel: string;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
const taskStatus = flattenTaskStats(sections.tasks);
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="space-y-5">
|
||||
<OverviewPanel global={sections.global} git={sections.git} />
|
||||
<SectionDivider id="telemetry" label="Telemetry" />
|
||||
<CompletedPanel status={sections.completed} />
|
||||
<ActivityPanel daily={sections.daily} agents={sections.agents} rangeLabel={rangeLabel} />
|
||||
<RepositoryActivityPanel status={sections.repos} />
|
||||
<TopRepositoriesPanel status={sections.repos} />
|
||||
<RepoLeadersPanel status={sections.repos} />
|
||||
<SectionDivider id="github" label="GitHub" />
|
||||
<PRStatsPanel workspaceId={workspaceId ?? null} />
|
||||
<SectionDivider id="workload" label="Workload" />
|
||||
<WorkloadPanel status={taskStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<StatsHeader
|
||||
global={stats?.global ?? null}
|
||||
global={globalReady}
|
||||
range={range}
|
||||
copied={copied}
|
||||
copyDisabled={!stats}
|
||||
copyDisabled={!fullStats}
|
||||
hasError={Boolean(fetchError)}
|
||||
onRangeChange={handleRangeChange}
|
||||
onCopy={handleCopyStats}
|
||||
/>
|
||||
<StatsContent state={panelState} rangeLabel={rangeLabel} workspaceId={workspaceId} />
|
||||
<StatsContent sections={sections} rangeLabel={rangeLabel} workspaceId={workspaceId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div id="overview" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 scroll-mt-24">
|
||||
<TasksCard global={global} />
|
||||
|
||||
@@ -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<StatsResponse>(`/api/v1/workspaces/${workspaceId}/stats${query}`, options);
|
||||
return fetchJson<GlobalStatsDTO>(statsUrl(workspaceId, "global", range), options);
|
||||
}
|
||||
|
||||
export function fetchTaskStats(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
range?: StatsRange,
|
||||
) {
|
||||
return fetchJson<TaskStatsResponse>(statsUrl(workspaceId, "tasks", range), options);
|
||||
}
|
||||
|
||||
export function fetchDailyActivity(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
range?: StatsRange,
|
||||
) {
|
||||
return fetchJson<DailyActivityDTO[]>(statsUrl(workspaceId, "daily-activity", range), options);
|
||||
}
|
||||
|
||||
export function fetchCompletedActivity(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
range?: StatsRange,
|
||||
) {
|
||||
return fetchJson<CompletedTaskActivityDTO[]>(
|
||||
statsUrl(workspaceId, "completed-activity", range),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAgentUsage(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
range?: StatsRange,
|
||||
) {
|
||||
return fetchJson<AgentUsageDTO[]>(statsUrl(workspaceId, "agent-usage", range), options);
|
||||
}
|
||||
|
||||
export function fetchRepositoryStats(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
range?: StatsRange,
|
||||
) {
|
||||
return fetchJson<RepositoryStatsDTO[]>(statsUrl(workspaceId, "repositories", range), options);
|
||||
}
|
||||
|
||||
export function fetchGitStats(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
range?: StatsRange,
|
||||
) {
|
||||
return fetchJson<GitStatsDTO>(statsUrl(workspaceId, "git", range), options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user