feat: improved stats (#227)
This commit is contained in:
@@ -31,6 +31,13 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo
|
||||
}
|
||||
cleanups = append(cleanups, cleanup)
|
||||
|
||||
// Workflow repo must be initialized before analytics repo because
|
||||
// analytics creates indexes on the workflow_steps table.
|
||||
workflowRepo, err := workflowrepository.NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
analyticsRepo, cleanup, err := analyticsrepository.Provide(dbConn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
@@ -67,11 +74,6 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo
|
||||
}
|
||||
cleanups = append(cleanups, cleanup)
|
||||
|
||||
workflowRepo, err := workflowrepository.NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
repos := &Repositories{
|
||||
Task: taskRepoImpl,
|
||||
Analytics: analyticsRepo,
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// allTimeActivityDays is the number of days shown in the daily activity heatmap for the "all" range.
|
||||
const allTimeActivityDays = 365
|
||||
|
||||
type StatsHandlers struct {
|
||||
repo repository.Repository
|
||||
logger *logger.Logger
|
||||
@@ -213,6 +216,8 @@ func parseStatsRange(rangeKey string) (*time.Time, int) {
|
||||
case "month":
|
||||
start := now.AddDate(0, 0, -30)
|
||||
return &start, 30
|
||||
case "all":
|
||||
return nil, allTimeActivityDays
|
||||
default:
|
||||
start := now.AddDate(0, 0, -30)
|
||||
return &start, 30
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseStatsRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rangeKey string
|
||||
wantNilStart bool
|
||||
wantDays int
|
||||
}{
|
||||
{
|
||||
name: "week returns 7 days",
|
||||
rangeKey: "week",
|
||||
wantNilStart: false,
|
||||
wantDays: 7,
|
||||
},
|
||||
{
|
||||
name: "month returns 30 days",
|
||||
rangeKey: "month",
|
||||
wantNilStart: false,
|
||||
wantDays: 30,
|
||||
},
|
||||
{
|
||||
name: "all returns nil start and allTimeActivityDays",
|
||||
rangeKey: "all",
|
||||
wantNilStart: true,
|
||||
wantDays: allTimeActivityDays,
|
||||
},
|
||||
{
|
||||
name: "empty string defaults to month",
|
||||
rangeKey: "",
|
||||
wantNilStart: false,
|
||||
wantDays: 30,
|
||||
},
|
||||
{
|
||||
name: "unknown value defaults to month",
|
||||
rangeKey: "year",
|
||||
wantNilStart: false,
|
||||
wantDays: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, days := parseStatsRange(tt.rangeKey)
|
||||
|
||||
if tt.wantNilStart {
|
||||
if start != nil {
|
||||
t.Errorf("expected nil start, got %v", start)
|
||||
}
|
||||
} else {
|
||||
if start == nil {
|
||||
t.Fatal("expected non-nil start, got nil")
|
||||
}
|
||||
// Verify start is approximately the right number of days ago
|
||||
expectedStart := time.Now().UTC().AddDate(0, 0, -tt.wantDays)
|
||||
diff := start.Sub(expectedStart)
|
||||
if diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("expected start ~%v, got %v (diff: %v)", expectedStart, start, diff)
|
||||
}
|
||||
}
|
||||
|
||||
if days != tt.wantDays {
|
||||
t.Errorf("expected days=%d, got %d", tt.wantDays, days)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,88 @@ type Repository struct {
|
||||
}
|
||||
|
||||
// NewWithDB creates a new analytics repository with an existing database connection.
|
||||
// It automatically creates performance indexes for stats queries.
|
||||
func NewWithDB(dbConn *sql.DB) (*Repository, error) {
|
||||
return &Repository{db: dbConn}, nil
|
||||
repo := &Repository{db: dbConn}
|
||||
if err := repo.ensureStatsIndexes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// Close is a no-op because this repository does not own the database connection.
|
||||
func (r *Repository) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureStatsIndexes creates performance indexes for stats queries if they don't already exist.
|
||||
// Query planner statistics are maintained by PRAGMA optimize on connection close (see persistence/provider.go).
|
||||
func (r *Repository) ensureStatsIndexes() error {
|
||||
indexes := []string{
|
||||
// Tasks table indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_created
|
||||
ON tasks(workspace_id, created_at)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_state
|
||||
ON tasks(workflow_step_id, state)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_tasks_updated
|
||||
ON tasks(workspace_id, updated_at)`,
|
||||
|
||||
// Task sessions indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_task_started
|
||||
ON task_sessions(task_id, started_at)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_agent_started
|
||||
ON task_sessions(agent_profile_id, started_at)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_repo
|
||||
ON task_sessions(repository_id)`,
|
||||
|
||||
// Task session turns indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_turns_session_times
|
||||
ON task_session_turns(task_session_id, started_at, completed_at)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_turns_task
|
||||
ON task_session_turns(task_id)`,
|
||||
|
||||
// Task session messages indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_session_created
|
||||
ON task_session_messages(task_session_id, created_at)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_author_type
|
||||
ON task_session_messages(task_session_id, author_type, type)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_turn
|
||||
ON task_session_messages(turn_id)`,
|
||||
|
||||
// Task session commits indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_commits_session_time
|
||||
ON task_session_commits(session_id, committed_at)`,
|
||||
|
||||
// Task repositories indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_task_repos_task
|
||||
ON task_repositories(task_id, repository_id)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_task_repos_repo
|
||||
ON task_repositories(repository_id, task_id)`,
|
||||
|
||||
// Repositories indexes (with soft delete filter)
|
||||
`CREATE INDEX IF NOT EXISTS idx_repos_workspace
|
||||
ON repositories(workspace_id)
|
||||
WHERE deleted_at IS NULL`,
|
||||
|
||||
// Workflow steps indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_workflow_steps_type
|
||||
ON workflow_steps(id, step_type)`,
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
if _, err := r.db.Exec(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
)
|
||||
|
||||
// createTestDB sets up a SQLite database with all required schemas for analytics queries.
|
||||
func createTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dbConn.Close() })
|
||||
|
||||
// Create tables that analytics queries depend on
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
step_type TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
board_id TEXT NOT NULL,
|
||||
workflow_step_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
state TEXT DEFAULT 'TODO',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'local',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
deleted_at DATETIME
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_repositories (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE(task_id, repository_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
agent_profile_id TEXT NOT NULL,
|
||||
agent_profile_snapshot TEXT DEFAULT '{}',
|
||||
repository_id TEXT DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT 'CREATED',
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_session_turns (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_session_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_session_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_session_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
author_type TEXT NOT NULL DEFAULT 'user',
|
||||
type TEXT NOT NULL DEFAULT 'message',
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_session_commits (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
commit_sha TEXT NOT NULL,
|
||||
committed_at DATETIME NOT NULL,
|
||||
files_changed INTEGER DEFAULT 0,
|
||||
insertions INTEGER DEFAULT 0,
|
||||
deletions INTEGER DEFAULT 0,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
if _, err := dbConn.Exec(schema); err != nil {
|
||||
t.Fatalf("failed to create schema: %v", err)
|
||||
}
|
||||
return dbConn
|
||||
}
|
||||
|
||||
func execOrFatal(t *testing.T, dbConn *sql.DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := dbConn.Exec(query, args...); err != nil {
|
||||
t.Fatalf("exec failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsIndexes_CreatesIndexes(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
|
||||
repo, err := NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithDB failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify indexes were created
|
||||
var count int
|
||||
err = dbConn.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count indexes: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Error("expected indexes to be created, got 0")
|
||||
}
|
||||
|
||||
_ = repo // keep reference
|
||||
}
|
||||
|
||||
func TestEnsureStatsIndexes_Idempotent(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
|
||||
repo := &Repository{db: dbConn}
|
||||
if err := repo.ensureStatsIndexes(); err != nil {
|
||||
t.Fatalf("first ensureStatsIndexes failed: %v", err)
|
||||
}
|
||||
|
||||
// Calling again should not error (CREATE INDEX IF NOT EXISTS is idempotent)
|
||||
if err := repo.ensureStatsIndexes(); err != nil {
|
||||
t.Fatalf("second ensureStatsIndexes failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRepositoryStats_ExcludesSoftDeletedRepos(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
repo, err := NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithDB failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Insert workspace
|
||||
if _, err := dbConn.Exec(
|
||||
`INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
"ws-1", "Test Workspace", now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to insert workspace: %v", err)
|
||||
}
|
||||
|
||||
// Insert an active repo
|
||||
if _, err := dbConn.Exec(
|
||||
`INSERT INTO repositories (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"repo-active", "ws-1", "active-repo", now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to insert active repo: %v", err)
|
||||
}
|
||||
|
||||
// Insert a soft-deleted repo
|
||||
if _, err := dbConn.Exec(
|
||||
`INSERT INTO repositories (id, workspace_id, name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"repo-deleted", "ws-1", "deleted-repo", now, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to insert deleted repo: %v", err)
|
||||
}
|
||||
|
||||
results, err := repo.GetRepositoryStats(ctx, "ws-1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepositoryStats failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 repository, got %d", len(results))
|
||||
}
|
||||
if results[0].RepositoryID != "repo-active" {
|
||||
t.Errorf("expected repo-active, got %s", results[0].RepositoryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGlobalStats_EmptyWorkspace(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
repo, err := NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithDB failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
stats, err := repo.GetGlobalStats(ctx, "nonexistent", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGlobalStats failed: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalTasks != 0 {
|
||||
t.Errorf("expected 0 total tasks, got %d", stats.TotalTasks)
|
||||
}
|
||||
if stats.TotalSessions != 0 {
|
||||
t.Errorf("expected 0 total sessions, got %d", stats.TotalSessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGlobalStats_WithTimeFilter(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
repo, err := NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithDB failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
nowStr := now.Format(time.RFC3339)
|
||||
oldStr := now.AddDate(0, 0, -60).Format(time.RFC3339)
|
||||
|
||||
// Insert workspace + board + workflow step
|
||||
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 workflow_steps (id, board_id, name, step_type, position, created_at, updated_at) VALUES ('step-todo', 'board-1', 'To Do', 'todo', 0, ?, ?)`, nowStr, nowStr)
|
||||
|
||||
// Insert a recent task and an old task
|
||||
execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-recent', 'ws-1', 'board-1', 'step-todo', 'Recent', ?, ?)`, nowStr, nowStr)
|
||||
execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-old', 'ws-1', 'board-1', 'step-todo', 'Old', ?, ?)`, oldStr, oldStr)
|
||||
|
||||
// With no time filter — should see both
|
||||
stats, err := repo.GetGlobalStats(ctx, "ws-1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGlobalStats (no filter) failed: %v", err)
|
||||
}
|
||||
if stats.TotalTasks != 2 {
|
||||
t.Errorf("expected 2 total tasks (no filter), got %d", stats.TotalTasks)
|
||||
}
|
||||
|
||||
// With time filter for last 7 days — should see only the recent one
|
||||
weekAgo := now.AddDate(0, 0, -7)
|
||||
stats, err = repo.GetGlobalStats(ctx, "ws-1", &weekAgo)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGlobalStats (week filter) failed: %v", err)
|
||||
}
|
||||
if stats.TotalTasks != 1 {
|
||||
t.Errorf("expected 1 total task (week filter), got %d", stats.TotalTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitStats_EmptyWorkspace(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
repo, err := NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithDB failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
stats, err := repo.GetGitStats(ctx, "nonexistent", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGitStats failed: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalCommits != 0 {
|
||||
t.Errorf("expected 0 commits, got %d", stats.TotalCommits)
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
|
||||
GROUP BY s.repository_id
|
||||
) git_stats ON git_stats.repository_id = r.id
|
||||
WHERE r.workspace_id = ?
|
||||
AND r.deleted_at IS NULL
|
||||
ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC
|
||||
`
|
||||
|
||||
|
||||
@@ -35,7 +35,14 @@ func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, err
|
||||
if log != nil {
|
||||
log.Info("Database initialized", zap.String("db_path", dbPath), zap.String("db_driver", driver))
|
||||
}
|
||||
return dbConn, func() error { return dbConn.Close() }, nil
|
||||
cleanup := func() error {
|
||||
// Run PRAGMA optimize before closing to update query planner
|
||||
// statistics for tables that need it. This is the SQLite-recommended
|
||||
// way to maintain stats — lightweight and safe to call on every close.
|
||||
_, _ = dbConn.Exec("PRAGMA optimize")
|
||||
return dbConn.Close()
|
||||
}
|
||||
return dbConn, cleanup, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported database driver: %s", driver)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Card, CardContent, CardHeader } from '@kandev/ui/card';
|
||||
import { Skeleton } from '@kandev/ui/skeleton';
|
||||
|
||||
export default function StatsLoading() {
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-3 p-4 pb-3 shrink-0">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-7 w-24" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="space-y-5">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-9 w-16 mb-2" />
|
||||
<Skeleton className="h-4 w-32 mb-3" />
|
||||
<Skeleton className="h-1.5 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Telemetry Section */}
|
||||
<Skeleton className="h-4 w-32" />
|
||||
|
||||
{/* Completed Tasks and Productivity */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="rounded-sm lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-16" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Activity and Agents */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48 mb-2" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Skeleton className="h-3 w-8" />
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-2 w-2" />
|
||||
))}
|
||||
<Skeleton className="h-3 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between mb-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
<Skeleton className="h-1.5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Repositories */}
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="rounded-sm border bg-muted/20 p-3">
|
||||
<div className="flex justify-between mb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="flex gap-3 mb-3">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full mb-2" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function formatPercent(value: number): string {
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
type RangeKey = 'week' | 'month';
|
||||
type RangeKey = 'week' | 'month' | 'all';
|
||||
|
||||
function getRangeLabel(range: RangeKey): string {
|
||||
switch (range) {
|
||||
@@ -78,6 +78,8 @@ function getRangeLabel(range: RangeKey): string {
|
||||
return 'Last Week';
|
||||
case 'month':
|
||||
return 'Last Month';
|
||||
case 'all':
|
||||
return 'All Time';
|
||||
default:
|
||||
return 'Last Month';
|
||||
}
|
||||
@@ -830,7 +832,7 @@ export function StatsPageClient({ stats, error, workspaceId, activeRange }: Stat
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
>
|
||||
{(['week', 'month'] as RangeKey[]).map((key) => (
|
||||
{(['week', 'month', 'all'] as RangeKey[]).map((key) => (
|
||||
<ToggleGroupItem
|
||||
key={key}
|
||||
value={key}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
IconCode,
|
||||
IconCpu,
|
||||
IconMessageCircle,
|
||||
IconChartBar,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -123,7 +122,6 @@ export function SettingsAppSidebar() {
|
||||
const workspacePath = `/settings/workspace/${workspace.id}`;
|
||||
const boardsPath = `${workspacePath}/boards`;
|
||||
const repositoriesPath = `${workspacePath}/repositories`;
|
||||
const statsPath = `${workspacePath}/stats`;
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem key={workspace.id}>
|
||||
@@ -158,18 +156,6 @@ export function SettingsAppSidebar() {
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
size="sm"
|
||||
isActive={pathname === statsPath}
|
||||
>
|
||||
<Link href={statsPath}>
|
||||
<IconChartBar className="h-3 w-3 mr-1" />
|
||||
<span>Stats</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchJson, type ApiRequestOptions } from '../client';
|
||||
import type { StatsResponse } from '@/lib/types/http';
|
||||
|
||||
export type StatsRange = 'week' | 'month';
|
||||
export type StatsRange = 'week' | 'month' | 'all';
|
||||
|
||||
export async function fetchStats(
|
||||
workspaceId: string,
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Statistics System
|
||||
|
||||
## Overview
|
||||
|
||||
The stats system provides workspace-level analytics about tasks, agent sessions, repository activity, and git commits. It computes everything on-demand from existing tables — there are no dedicated stats tables.
|
||||
|
||||
**Route**: `/stats` (with `?range=week|month|all`)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Frontend Backend Database
|
||||
─────── ─────── ────────
|
||||
/stats (server component) GET /api/v1/workspaces/:id/ SQLite
|
||||
└─ fetchStats() stats?range=week
|
||||
└─ stats-page-client.tsx stats_handlers.go Source tables:
|
||||
├─ Overview cards └─ httpGetStats() ├─ tasks
|
||||
├─ Heatmap ├─ GetGlobalStats() ├─ task_sessions
|
||||
├─ Completed chart ├─ GetTaskStats() ├─ task_session_turns
|
||||
├─ Agent usage ├─ GetDailyActivity() ├─ task_session_messages
|
||||
├─ Repo stats ├─ GetCompletedTaskActivity()├─ task_session_commits
|
||||
└─ Workload ├─ GetAgentUsage() ├─ task_repositories
|
||||
├─ GetRepositoryStats() ├─ repositories
|
||||
└─ GetGitStats() └─ workflow_steps
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Write path (automatic during task execution)
|
||||
|
||||
| Event | Table | Key columns |
|
||||
|-------|-------|-------------|
|
||||
| Session start | `task_sessions` | task_id, agent_profile_id, started_at |
|
||||
| Turn start | `task_session_turns` | task_session_id, started_at |
|
||||
| Message sent | `task_session_messages` | task_session_id, author_type, type |
|
||||
| Git commit | `task_session_commits` | session_id, files_changed, insertions, deletions |
|
||||
| Turn end | `task_session_turns` | completed_at (UPDATE) |
|
||||
| Session end | `task_sessions` | completed_at, state (UPDATE) |
|
||||
|
||||
Write code lives in `apps/backend/internal/task/repository/sqlite/` (session.go, message.go, git_snapshots.go).
|
||||
|
||||
### Read path (stats page load)
|
||||
|
||||
The handler at `apps/backend/internal/analytics/handlers/stats_handlers.go` calls 7 repository methods sequentially, each running a SQL aggregation query against the source tables. Results are assembled into a single `StatsResponse` JSON.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/backend/internal/analytics/handlers/stats_handlers.go` | HTTP handler, DTO conversion |
|
||||
| `apps/backend/internal/analytics/repository/sqlite/stats.go` | All SQL queries |
|
||||
| `apps/backend/internal/analytics/repository/sqlite/repository.go` | Init + auto-created indexes |
|
||||
| `apps/backend/internal/analytics/models/models.go` | Go data models |
|
||||
| `apps/web/app/stats/page.tsx` | Server component (data fetching) |
|
||||
| `apps/web/app/stats/stats-page-client.tsx` | Client component (all UI) |
|
||||
| `apps/web/app/stats/loading.tsx` | Loading skeleton |
|
||||
| `apps/web/lib/api/domains/stats-api.ts` | Frontend API client |
|
||||
|
||||
## Metrics
|
||||
|
||||
### How key metrics are computed
|
||||
|
||||
| Metric | Method |
|
||||
|--------|--------|
|
||||
| Completed tasks | Tasks whose workflow step has `step_type = 'done'` |
|
||||
| In-progress tasks | Tasks with `state = 'IN_PROGRESS'` |
|
||||
| Duration | `SUM((julianday(completed_at) - julianday(started_at)) * 86400000)` on turns |
|
||||
| Tool calls | Messages with `type LIKE 'tool_%'` |
|
||||
| User messages | Messages with `author_type = 'user'` |
|
||||
| Agent name/model | `json_extract()` from `agent_profile_snapshot` on sessions |
|
||||
| Git stats | Aggregated from `task_session_commits` (only in-session commits) |
|
||||
|
||||
### Time ranges
|
||||
|
||||
| Range | Backend behavior |
|
||||
|-------|-----------------|
|
||||
| `week` | `start = now - 7 days`, daily activity = 7 days |
|
||||
| `month` | `start = now - 30 days`, daily activity = 30 days |
|
||||
| `all` | `start = nil` (no filter), daily activity = 365 days |
|
||||
|
||||
Range is parsed in `parseStatsRange()`. The `start` value is passed to each query as `WHERE (? IS NULL OR timestamp >= ?)`.
|
||||
|
||||
## Performance
|
||||
|
||||
### Indexes
|
||||
|
||||
Performance indexes are **automatically created** on startup in `repository.go` via `ensureStatsIndexes()`. Uses `CREATE INDEX IF NOT EXISTS` so it's idempotent.
|
||||
|
||||
Key indexes: tasks(workspace_id, created_at), task_sessions(task_id, started_at), task_session_turns(task_session_id, started_at, completed_at), task_session_messages(task_session_id, author_type, type), task_session_commits(session_id, committed_at).
|
||||
|
||||
### Query planner statistics
|
||||
|
||||
`PRAGMA optimize` runs automatically on database connection close (see `persistence/provider.go`). This keeps SQLite's query planner statistics up to date by only re-analyzing tables whose stats are stale. No manual `ANALYZE` is needed.
|
||||
|
||||
### Query patterns
|
||||
|
||||
- Separate subqueries to avoid row multiplication from JOINs
|
||||
- `COUNT(DISTINCT id)` to prevent duplicate counting across LEFT JOINs
|
||||
- `COALESCE(..., 0)` for NULL safety
|
||||
- Recursive CTE for date series (heatmap/charts fill in zero-activity days)
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No caching** — every page load runs all 7 queries
|
||||
2. **No real-time updates** — single fetch on page load, no WebSocket/polling
|
||||
3. **Git tracking gaps** — only commits made during task sessions are tracked
|
||||
4. **Duration precision** — `julianday()` returns float, cast to int64 (minor precision loss)
|
||||
5. **Incomplete turns** — turns with NULL `completed_at` count in totals but contribute 0ms to duration
|
||||
6. **"Month" = 30 days** — not a calendar month
|
||||
7. **Sequential queries** — the 7 repo calls in the handler run sequentially, not in parallel
|
||||
Reference in New Issue
Block a user