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:
João Salavisa
2026-06-07 13:24:32 +01:00
committed by GitHub
parent 607201be79
commit 31d4ba67fa
7 changed files with 704 additions and 362 deletions
@@ -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 {
+159
View File
@@ -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;
}
+208 -174
View File
@@ -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>
);
}
+8 -2
View File
@@ -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} />
+75 -4
View File
@@ -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);
}