mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: add PR insights analytics dashboard (#23215)
## What Adds a new admin-only **PR Insights** page for the `/agents` analytics view — a dashboard for engineering leaders to understand code shipped by AI agents. ### Backend - `GET /api/v2/chats/insights/pull-requests` — admin-only endpoint - 4 SQL queries in `chatinsights.sql` aggregating `chat_diff_statuses` joined with chat cost data (via root chat tree rollup) - Runs 5 parallel DB queries: current summary, previous summary (for trends), time series, per-model breakdown, recent PRs - SDK types auto-generate to TypeScript ### Frontend (`PRInsightsView`) - **Stat cards**: PRs created, Merged, Merge rate, Lines shipped, Cost/merged PR — with trend badges comparing to previous period - **Activity chart**: Stacked area chart (created/merged/closed) using git color tokens (`git-added-bright`, `git-merged-bright`, `git-deleted-bright`) - **Model performance table**: Per-model PR counts, inline merge rate bars, diff stats, cost breakdown - **Recent PRs table**: Status badges, review state icons, author info, external links - **Time range filter**: 7d/14d/30d/90d button group - **4 Storybook stories**: Default, HighPerformance, LowVolume, NoPRs ### Data source All PR data comes from the existing `chat_diff_statuses` table (populated by the `gitsync.Worker` background job that polls GitHub every 120s). No new data collection required. ### Screenshot View in Storybook: `pages/AgentsPage/PRInsightsView`
This commit is contained in:
Generated
+229
@@ -481,6 +481,50 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/chats/insights/pull-requests": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Chats"
|
||||
],
|
||||
"summary": "Get PR insights",
|
||||
"operationId": "get-pr-insights",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Start date (RFC3339)",
|
||||
"name": "start_date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "End date (RFC3339)",
|
||||
"name": "end_date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/connectionlog": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -17108,6 +17152,191 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsModelBreakdown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cost_per_merged_pr_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"merge_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"merged_prs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"model_config_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_additions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_cost_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_deletions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_prs": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsPullRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"author_avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"author_login": {
|
||||
"type": "string"
|
||||
},
|
||||
"base_branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"changed_files": {
|
||||
"type": "integer"
|
||||
},
|
||||
"changes_requested": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"commits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cost_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"deletions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"draft": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"model_display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pr_number": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pr_title": {
|
||||
"type": "string"
|
||||
},
|
||||
"pr_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"reviewer_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"by_model": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsModelBreakdown"
|
||||
}
|
||||
},
|
||||
"recent_prs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsPullRequest"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsSummary"
|
||||
},
|
||||
"time_series": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsTimeSeriesEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"approval_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"cost_per_merged_pr_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"merge_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"prev_cost_per_merged_pr_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prev_merge_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"prev_total_prs_created": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prev_total_prs_merged": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_additions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_cost_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_deletions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_prs_created": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_prs_merged": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsTimeSeriesEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"prs_closed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prs_created": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prs_merged": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PaginatedMembersResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+225
@@ -410,6 +410,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/chats/insights/pull-requests": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Chats"],
|
||||
"summary": "Get PR insights",
|
||||
"operationId": "get-pr-insights",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Start date (RFC3339)",
|
||||
"name": "start_date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "End date (RFC3339)",
|
||||
"name": "end_date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/connectionlog": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -15553,6 +15593,191 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsModelBreakdown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cost_per_merged_pr_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"merge_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"merged_prs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"model_config_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_additions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_cost_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_deletions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_prs": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsPullRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"author_avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"author_login": {
|
||||
"type": "string"
|
||||
},
|
||||
"base_branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"changed_files": {
|
||||
"type": "integer"
|
||||
},
|
||||
"changes_requested": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"commits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cost_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"deletions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"draft": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"model_display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pr_number": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pr_title": {
|
||||
"type": "string"
|
||||
},
|
||||
"pr_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"reviewer_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"by_model": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsModelBreakdown"
|
||||
}
|
||||
},
|
||||
"recent_prs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsPullRequest"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsSummary"
|
||||
},
|
||||
"time_series": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PRInsightsTimeSeriesEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"approval_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"cost_per_merged_pr_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"merge_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"prev_cost_per_merged_pr_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prev_merge_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"prev_total_prs_created": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prev_total_prs_merged": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_additions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_cost_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_deletions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_prs_created": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_prs_merged": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PRInsightsTimeSeriesEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"prs_closed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prs_created": {
|
||||
"type": "integer"
|
||||
},
|
||||
"prs_merged": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PaginatedMembersResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+219
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
@@ -4179,3 +4180,221 @@ func (api *API) hasEffectiveProviderAPIKey(ctx context.Context, provider databas
|
||||
)
|
||||
return effectiveKeys.APIKey(provider.Provider) != ""
|
||||
}
|
||||
|
||||
// @Summary Get PR insights
|
||||
// @ID get-pr-insights
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Chats
|
||||
// @Produce json
|
||||
// @Param start_date query string true "Start date (RFC3339)"
|
||||
// @Param end_date query string true "End date (RFC3339)"
|
||||
// @Success 200 {object} codersdk.PRInsightsResponse
|
||||
// @Router /chats/insights/pull-requests [get]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) prInsights(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Admin-only endpoint.
|
||||
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse date range.
|
||||
now := time.Now()
|
||||
defaultStart := now.AddDate(0, 0, -30)
|
||||
|
||||
qp := r.URL.Query()
|
||||
p := httpapi.NewQueryParamParser()
|
||||
startDate := p.Time(qp, defaultStart, "start_date", time.RFC3339)
|
||||
endDate := p.Time(qp, now, "end_date", time.RFC3339)
|
||||
p.ErrorExcessParams(qp)
|
||||
if len(p.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid query parameters.",
|
||||
Validations: p.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate previous period of equal length for trend comparison.
|
||||
duration := endDate.Sub(startDate)
|
||||
prevStart := startDate.Add(-duration)
|
||||
|
||||
// No owner filter — admin sees all data.
|
||||
ownerID := uuid.NullUUID{}
|
||||
|
||||
// Run all queries in parallel.
|
||||
var (
|
||||
currentSummary database.GetPRInsightsSummaryRow
|
||||
previousSummary database.GetPRInsightsSummaryRow
|
||||
timeSeries []database.GetPRInsightsTimeSeriesRow
|
||||
byModel []database.GetPRInsightsPerModelRow
|
||||
recentPRs []database.GetPRInsightsRecentPRsRow
|
||||
)
|
||||
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(5)
|
||||
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
currentSummary, err = api.Database.GetPRInsightsSummary(egCtx, database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
previousSummary, err = api.Database.GetPRInsightsSummary(egCtx, database.GetPRInsightsSummaryParams{
|
||||
StartDate: prevStart,
|
||||
EndDate: startDate,
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
timeSeries, err = api.Database.GetPRInsightsTimeSeries(egCtx, database.GetPRInsightsTimeSeriesParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
byModel, err = api.Database.GetPRInsightsPerModel(egCtx, database.GetPRInsightsPerModelParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
recentPRs, err = api.Database.GetPRInsightsRecentPRs(egCtx, database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: ownerID,
|
||||
LimitVal: 20,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build summary with computed fields.
|
||||
summary := codersdk.PRInsightsSummary{
|
||||
TotalPRsCreated: currentSummary.TotalPrsCreated,
|
||||
TotalPRsMerged: currentSummary.TotalPrsMerged,
|
||||
TotalAdditions: currentSummary.TotalAdditions,
|
||||
TotalDeletions: currentSummary.TotalDeletions,
|
||||
TotalCostMicros: currentSummary.TotalCostMicros,
|
||||
PrevTotalPRsCreated: previousSummary.TotalPrsCreated,
|
||||
PrevTotalPRsMerged: previousSummary.TotalPrsMerged,
|
||||
}
|
||||
if summary.TotalPRsCreated > 0 {
|
||||
summary.MergeRate = float64(summary.TotalPRsMerged) / float64(summary.TotalPRsCreated)
|
||||
}
|
||||
if summary.TotalPRsMerged > 0 {
|
||||
summary.CostPerMergedPRMicros = currentSummary.MergedCostMicros / summary.TotalPRsMerged
|
||||
}
|
||||
if summary.PrevTotalPRsCreated > 0 {
|
||||
summary.PrevMergeRate = float64(summary.PrevTotalPRsMerged) / float64(summary.PrevTotalPRsCreated)
|
||||
}
|
||||
if summary.PrevTotalPRsMerged > 0 {
|
||||
summary.PrevCostPerMergedPRMicros = previousSummary.MergedCostMicros / summary.PrevTotalPRsMerged
|
||||
}
|
||||
|
||||
// Convert time series.
|
||||
tsEntries := make([]codersdk.PRInsightsTimeSeriesEntry, 0, len(timeSeries))
|
||||
for _, ts := range timeSeries {
|
||||
tsEntries = append(tsEntries, codersdk.PRInsightsTimeSeriesEntry{
|
||||
Date: ts.Date,
|
||||
PRsCreated: ts.PrsCreated,
|
||||
PRsMerged: ts.PrsMerged,
|
||||
PRsClosed: ts.PrsClosed,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert model breakdown.
|
||||
modelEntries := make([]codersdk.PRInsightsModelBreakdown, 0, len(byModel))
|
||||
for _, m := range byModel {
|
||||
entry := codersdk.PRInsightsModelBreakdown{
|
||||
ModelConfigID: m.ModelConfigID,
|
||||
DisplayName: m.DisplayName,
|
||||
Provider: m.Provider,
|
||||
TotalPRs: m.TotalPrs,
|
||||
MergedPRs: m.MergedPrs,
|
||||
TotalAdditions: m.TotalAdditions,
|
||||
TotalDeletions: m.TotalDeletions,
|
||||
TotalCostMicros: m.TotalCostMicros,
|
||||
}
|
||||
if entry.TotalPRs > 0 {
|
||||
entry.MergeRate = float64(entry.MergedPRs) / float64(entry.TotalPRs)
|
||||
}
|
||||
if entry.MergedPRs > 0 {
|
||||
entry.CostPerMergedPRMicros = m.MergedCostMicros / entry.MergedPRs
|
||||
}
|
||||
modelEntries = append(modelEntries, entry)
|
||||
}
|
||||
|
||||
// Convert recent PRs.
|
||||
prEntries := make([]codersdk.PRInsightsPullRequest, 0, len(recentPRs))
|
||||
for _, pr := range recentPRs {
|
||||
entry := codersdk.PRInsightsPullRequest{
|
||||
ChatID: pr.ChatID,
|
||||
PRTitle: pr.PrTitle,
|
||||
Draft: pr.Draft,
|
||||
Additions: pr.Additions,
|
||||
Deletions: pr.Deletions,
|
||||
ChangedFiles: pr.ChangedFiles,
|
||||
ChangesRequested: pr.ChangesRequested,
|
||||
BaseBranch: pr.BaseBranch,
|
||||
ModelDisplayName: pr.ModelDisplayName,
|
||||
CostMicros: pr.CostMicros,
|
||||
CreatedAt: pr.CreatedAt,
|
||||
}
|
||||
if pr.PrUrl.Valid {
|
||||
entry.PRURL = &pr.PrUrl.String
|
||||
}
|
||||
if pr.PrNumber.Valid {
|
||||
entry.PRNumber = &pr.PrNumber.Int32
|
||||
}
|
||||
if pr.State.Valid {
|
||||
entry.State = pr.State.String
|
||||
}
|
||||
if pr.Commits.Valid {
|
||||
entry.Commits = &pr.Commits.Int32
|
||||
}
|
||||
if pr.Approved.Valid {
|
||||
entry.Approved = &pr.Approved.Bool
|
||||
}
|
||||
if pr.ReviewerCount.Valid {
|
||||
entry.ReviewerCount = &pr.ReviewerCount.Int32
|
||||
}
|
||||
if pr.AuthorLogin.Valid {
|
||||
entry.AuthorLogin = &pr.AuthorLogin.String
|
||||
}
|
||||
if pr.AuthorAvatarUrl.Valid {
|
||||
entry.AuthorAvatarURL = &pr.AuthorAvatarUrl.String
|
||||
}
|
||||
prEntries = append(prEntries, entry)
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.PRInsightsResponse{
|
||||
Summary: summary,
|
||||
TimeSeries: tsEntries,
|
||||
ByModel: modelEntries,
|
||||
RecentPRs: prEntries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1159,6 +1159,9 @@ func New(options *Options) *API {
|
||||
r.Get("/summary", api.chatCostSummary)
|
||||
})
|
||||
})
|
||||
r.Route("/insights", func(r chi.Router) {
|
||||
r.Get("/pull-requests", api.prInsights)
|
||||
})
|
||||
r.Route("/files", func(r chi.Router) {
|
||||
r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute))
|
||||
r.Post("/", api.postChatFile)
|
||||
|
||||
@@ -3159,6 +3159,34 @@ func (q *querier) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg da
|
||||
return q.db.GetOrganizationsWithPrebuildStatus(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetPRInsightsPerModel(ctx context.Context, arg database.GetPRInsightsPerModelParams) ([]database.GetPRInsightsPerModelRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetPRInsightsPerModel(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetPRInsightsRecentPRs(ctx context.Context, arg database.GetPRInsightsRecentPRsParams) ([]database.GetPRInsightsRecentPRsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetPRInsightsRecentPRs(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetPRInsightsSummary(ctx context.Context, arg database.GetPRInsightsSummaryParams) (database.GetPRInsightsSummaryRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return database.GetPRInsightsSummaryRow{}, err
|
||||
}
|
||||
return q.db.GetPRInsightsSummary(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetPRInsightsTimeSeries(ctx context.Context, arg database.GetPRInsightsTimeSeriesParams) ([]database.GetPRInsightsTimeSeriesRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetPRInsightsTimeSeries(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
|
||||
version, err := q.db.GetTemplateVersionByJobID(ctx, jobID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1924,6 +1924,26 @@ func (s *MethodTestSuite) TestTemplate() {
|
||||
dbm.EXPECT().GetTemplateInsightsByTemplate(gomock.Any(), arg).Return([]database.GetTemplateInsightsByTemplateRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights)
|
||||
}))
|
||||
s.Run("GetPRInsightsSummary", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetPRInsightsSummaryParams{}
|
||||
dbm.EXPECT().GetPRInsightsSummary(gomock.Any(), arg).Return(database.GetPRInsightsSummaryRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetPRInsightsTimeSeries", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetPRInsightsTimeSeriesParams{}
|
||||
dbm.EXPECT().GetPRInsightsTimeSeries(gomock.Any(), arg).Return([]database.GetPRInsightsTimeSeriesRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetPRInsightsPerModel", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetPRInsightsPerModelParams{}
|
||||
dbm.EXPECT().GetPRInsightsPerModel(gomock.Any(), arg).Return([]database.GetPRInsightsPerModelRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetPRInsightsRecentPRs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetPRInsightsRecentPRsParams{}
|
||||
dbm.EXPECT().GetPRInsightsRecentPRs(gomock.Any(), arg).Return([]database.GetPRInsightsRecentPRsRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetTelemetryTaskEvents", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetTelemetryTaskEventsParams{}
|
||||
dbm.EXPECT().GetTelemetryTaskEvents(gomock.Any(), arg).Return([]database.GetTelemetryTaskEventsRow{}, nil).AnyTimes()
|
||||
|
||||
@@ -1744,6 +1744,38 @@ func (m queryMetricsStore) GetOrganizationsWithPrebuildStatus(ctx context.Contex
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetPRInsightsPerModel(ctx context.Context, arg database.GetPRInsightsPerModelParams) ([]database.GetPRInsightsPerModelRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetPRInsightsPerModel(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetPRInsightsPerModel").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsPerModel").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetPRInsightsRecentPRs(ctx context.Context, arg database.GetPRInsightsRecentPRsParams) ([]database.GetPRInsightsRecentPRsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetPRInsightsRecentPRs(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetPRInsightsRecentPRs").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsRecentPRs").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetPRInsightsSummary(ctx context.Context, arg database.GetPRInsightsSummaryParams) (database.GetPRInsightsSummaryRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetPRInsightsSummary(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetPRInsightsSummary").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsSummary").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetPRInsightsTimeSeries(ctx context.Context, arg database.GetPRInsightsTimeSeriesParams) ([]database.GetPRInsightsTimeSeriesRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetPRInsightsTimeSeries(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetPRInsightsTimeSeries").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsTimeSeries").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetParameterSchemasByJobID(ctx, jobID)
|
||||
|
||||
@@ -3216,6 +3216,66 @@ func (mr *MockStoreMockRecorder) GetOrganizationsWithPrebuildStatus(ctx, arg any
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsWithPrebuildStatus", reflect.TypeOf((*MockStore)(nil).GetOrganizationsWithPrebuildStatus), ctx, arg)
|
||||
}
|
||||
|
||||
// GetPRInsightsPerModel mocks base method.
|
||||
func (m *MockStore) GetPRInsightsPerModel(ctx context.Context, arg database.GetPRInsightsPerModelParams) ([]database.GetPRInsightsPerModelRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPRInsightsPerModel", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetPRInsightsPerModelRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPRInsightsPerModel indicates an expected call of GetPRInsightsPerModel.
|
||||
func (mr *MockStoreMockRecorder) GetPRInsightsPerModel(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsPerModel", reflect.TypeOf((*MockStore)(nil).GetPRInsightsPerModel), ctx, arg)
|
||||
}
|
||||
|
||||
// GetPRInsightsRecentPRs mocks base method.
|
||||
func (m *MockStore) GetPRInsightsRecentPRs(ctx context.Context, arg database.GetPRInsightsRecentPRsParams) ([]database.GetPRInsightsRecentPRsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPRInsightsRecentPRs", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetPRInsightsRecentPRsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPRInsightsRecentPRs indicates an expected call of GetPRInsightsRecentPRs.
|
||||
func (mr *MockStoreMockRecorder) GetPRInsightsRecentPRs(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsRecentPRs", reflect.TypeOf((*MockStore)(nil).GetPRInsightsRecentPRs), ctx, arg)
|
||||
}
|
||||
|
||||
// GetPRInsightsSummary mocks base method.
|
||||
func (m *MockStore) GetPRInsightsSummary(ctx context.Context, arg database.GetPRInsightsSummaryParams) (database.GetPRInsightsSummaryRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPRInsightsSummary", ctx, arg)
|
||||
ret0, _ := ret[0].(database.GetPRInsightsSummaryRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPRInsightsSummary indicates an expected call of GetPRInsightsSummary.
|
||||
func (mr *MockStoreMockRecorder) GetPRInsightsSummary(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsSummary", reflect.TypeOf((*MockStore)(nil).GetPRInsightsSummary), ctx, arg)
|
||||
}
|
||||
|
||||
// GetPRInsightsTimeSeries mocks base method.
|
||||
func (m *MockStore) GetPRInsightsTimeSeries(ctx context.Context, arg database.GetPRInsightsTimeSeriesParams) ([]database.GetPRInsightsTimeSeriesRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPRInsightsTimeSeries", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetPRInsightsTimeSeriesRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPRInsightsTimeSeries indicates an expected call of GetPRInsightsTimeSeries.
|
||||
func (mr *MockStoreMockRecorder) GetPRInsightsTimeSeries(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsTimeSeries", reflect.TypeOf((*MockStore)(nil).GetPRInsightsTimeSeries), ctx, arg)
|
||||
}
|
||||
|
||||
// GetParameterSchemasByJobID mocks base method.
|
||||
func (m *MockStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -341,6 +341,18 @@ type sqlcQuerier interface {
|
||||
// GetOrganizationsWithPrebuildStatus returns organizations with prebuilds configured and their
|
||||
// membership status for the prebuilds system user (org membership, group existence, group membership).
|
||||
GetOrganizationsWithPrebuildStatus(ctx context.Context, arg GetOrganizationsWithPrebuildStatusParams) ([]GetOrganizationsWithPrebuildStatusRow, error)
|
||||
// Returns PR metrics grouped by the model used for each chat.
|
||||
GetPRInsightsPerModel(ctx context.Context, arg GetPRInsightsPerModelParams) ([]GetPRInsightsPerModelRow, error)
|
||||
// Returns individual PR rows with cost for the recent PRs table.
|
||||
GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsightsRecentPRsParams) ([]GetPRInsightsRecentPRsRow, error)
|
||||
// PR Insights queries for the /agents analytics dashboard.
|
||||
// These aggregate data from chat_diff_statuses (PR metadata) joined
|
||||
// with chats and chat_messages (cost) to power the PR Insights view.
|
||||
// Returns aggregate PR metrics for the given date range.
|
||||
// The handler calls this twice (current + previous period) for trends.
|
||||
GetPRInsightsSummary(ctx context.Context, arg GetPRInsightsSummaryParams) (GetPRInsightsSummaryRow, error)
|
||||
// Returns daily PR counts grouped by state for the chart.
|
||||
GetPRInsightsTimeSeries(ctx context.Context, arg GetPRInsightsTimeSeriesParams) ([]GetPRInsightsTimeSeriesRow, error)
|
||||
GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error)
|
||||
GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error)
|
||||
GetPrebuildsSettings(ctx context.Context) (string, error)
|
||||
|
||||
@@ -2414,6 +2414,329 @@ func (q *sqlQuerier) InsertChatFile(ctx context.Context, arg InsertChatFileParam
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPRInsightsPerModel = `-- name: GetPRInsightsPerModel :many
|
||||
SELECT
|
||||
cmc.id AS model_config_id,
|
||||
cmc.display_name,
|
||||
cmc.provider,
|
||||
COUNT(*)::bigint AS total_prs,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS merged_prs,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
GROUP BY cmc.id, cmc.display_name, cmc.provider
|
||||
ORDER BY total_prs DESC
|
||||
`
|
||||
|
||||
type GetPRInsightsPerModelParams struct {
|
||||
StartDate time.Time `db:"start_date" json:"start_date"`
|
||||
EndDate time.Time `db:"end_date" json:"end_date"`
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
}
|
||||
|
||||
type GetPRInsightsPerModelRow struct {
|
||||
ModelConfigID uuid.UUID `db:"model_config_id" json:"model_config_id"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Provider string `db:"provider" json:"provider"`
|
||||
TotalPrs int64 `db:"total_prs" json:"total_prs"`
|
||||
MergedPrs int64 `db:"merged_prs" json:"merged_prs"`
|
||||
TotalAdditions int64 `db:"total_additions" json:"total_additions"`
|
||||
TotalDeletions int64 `db:"total_deletions" json:"total_deletions"`
|
||||
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
|
||||
MergedCostMicros int64 `db:"merged_cost_micros" json:"merged_cost_micros"`
|
||||
}
|
||||
|
||||
// Returns PR metrics grouped by the model used for each chat.
|
||||
func (q *sqlQuerier) GetPRInsightsPerModel(ctx context.Context, arg GetPRInsightsPerModelParams) ([]GetPRInsightsPerModelRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getPRInsightsPerModel, arg.StartDate, arg.EndDate, arg.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetPRInsightsPerModelRow
|
||||
for rows.Next() {
|
||||
var i GetPRInsightsPerModelRow
|
||||
if err := rows.Scan(
|
||||
&i.ModelConfigID,
|
||||
&i.DisplayName,
|
||||
&i.Provider,
|
||||
&i.TotalPrs,
|
||||
&i.MergedPrs,
|
||||
&i.TotalAdditions,
|
||||
&i.TotalDeletions,
|
||||
&i.TotalCostMicros,
|
||||
&i.MergedCostMicros,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getPRInsightsRecentPRs = `-- name: GetPRInsightsRecentPRs :many
|
||||
SELECT
|
||||
c.id AS chat_id,
|
||||
cds.pull_request_title AS pr_title,
|
||||
cds.url AS pr_url,
|
||||
cds.pr_number,
|
||||
cds.pull_request_state AS state,
|
||||
cds.pull_request_draft AS draft,
|
||||
cds.additions,
|
||||
cds.deletions,
|
||||
cds.changed_files,
|
||||
cds.commits,
|
||||
cds.approved,
|
||||
cds.changes_requested,
|
||||
cds.reviewer_count,
|
||||
cds.author_login,
|
||||
cds.author_avatar_url,
|
||||
COALESCE(cds.base_branch, '')::text AS base_branch,
|
||||
COALESCE(cmc.display_name, cmc.model)::text AS model_display_name,
|
||||
COALESCE(cc.cost_micros, 0)::bigint AS cost_micros,
|
||||
c.created_at
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $4::int
|
||||
`
|
||||
|
||||
type GetPRInsightsRecentPRsParams struct {
|
||||
StartDate time.Time `db:"start_date" json:"start_date"`
|
||||
EndDate time.Time `db:"end_date" json:"end_date"`
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
LimitVal int32 `db:"limit_val" json:"limit_val"`
|
||||
}
|
||||
|
||||
type GetPRInsightsRecentPRsRow struct {
|
||||
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
|
||||
PrTitle string `db:"pr_title" json:"pr_title"`
|
||||
PrUrl sql.NullString `db:"pr_url" json:"pr_url"`
|
||||
PrNumber sql.NullInt32 `db:"pr_number" json:"pr_number"`
|
||||
State sql.NullString `db:"state" json:"state"`
|
||||
Draft bool `db:"draft" json:"draft"`
|
||||
Additions int32 `db:"additions" json:"additions"`
|
||||
Deletions int32 `db:"deletions" json:"deletions"`
|
||||
ChangedFiles int32 `db:"changed_files" json:"changed_files"`
|
||||
Commits sql.NullInt32 `db:"commits" json:"commits"`
|
||||
Approved sql.NullBool `db:"approved" json:"approved"`
|
||||
ChangesRequested bool `db:"changes_requested" json:"changes_requested"`
|
||||
ReviewerCount sql.NullInt32 `db:"reviewer_count" json:"reviewer_count"`
|
||||
AuthorLogin sql.NullString `db:"author_login" json:"author_login"`
|
||||
AuthorAvatarUrl sql.NullString `db:"author_avatar_url" json:"author_avatar_url"`
|
||||
BaseBranch string `db:"base_branch" json:"base_branch"`
|
||||
ModelDisplayName string `db:"model_display_name" json:"model_display_name"`
|
||||
CostMicros int64 `db:"cost_micros" json:"cost_micros"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Returns individual PR rows with cost for the recent PRs table.
|
||||
func (q *sqlQuerier) GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsightsRecentPRsParams) ([]GetPRInsightsRecentPRsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getPRInsightsRecentPRs,
|
||||
arg.StartDate,
|
||||
arg.EndDate,
|
||||
arg.OwnerID,
|
||||
arg.LimitVal,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetPRInsightsRecentPRsRow
|
||||
for rows.Next() {
|
||||
var i GetPRInsightsRecentPRsRow
|
||||
if err := rows.Scan(
|
||||
&i.ChatID,
|
||||
&i.PrTitle,
|
||||
&i.PrUrl,
|
||||
&i.PrNumber,
|
||||
&i.State,
|
||||
&i.Draft,
|
||||
&i.Additions,
|
||||
&i.Deletions,
|
||||
&i.ChangedFiles,
|
||||
&i.Commits,
|
||||
&i.Approved,
|
||||
&i.ChangesRequested,
|
||||
&i.ReviewerCount,
|
||||
&i.AuthorLogin,
|
||||
&i.AuthorAvatarUrl,
|
||||
&i.BaseBranch,
|
||||
&i.ModelDisplayName,
|
||||
&i.CostMicros,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getPRInsightsSummary = `-- name: GetPRInsightsSummary :one
|
||||
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_prs_created,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS total_prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS total_prs_closed,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
`
|
||||
|
||||
type GetPRInsightsSummaryParams struct {
|
||||
StartDate time.Time `db:"start_date" json:"start_date"`
|
||||
EndDate time.Time `db:"end_date" json:"end_date"`
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
}
|
||||
|
||||
type GetPRInsightsSummaryRow struct {
|
||||
TotalPrsCreated int64 `db:"total_prs_created" json:"total_prs_created"`
|
||||
TotalPrsMerged int64 `db:"total_prs_merged" json:"total_prs_merged"`
|
||||
TotalPrsClosed int64 `db:"total_prs_closed" json:"total_prs_closed"`
|
||||
TotalAdditions int64 `db:"total_additions" json:"total_additions"`
|
||||
TotalDeletions int64 `db:"total_deletions" json:"total_deletions"`
|
||||
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
|
||||
MergedCostMicros int64 `db:"merged_cost_micros" json:"merged_cost_micros"`
|
||||
}
|
||||
|
||||
// PR Insights queries for the /agents analytics dashboard.
|
||||
// These aggregate data from chat_diff_statuses (PR metadata) joined
|
||||
// with chats and chat_messages (cost) to power the PR Insights view.
|
||||
// Returns aggregate PR metrics for the given date range.
|
||||
// The handler calls this twice (current + previous period) for trends.
|
||||
func (q *sqlQuerier) GetPRInsightsSummary(ctx context.Context, arg GetPRInsightsSummaryParams) (GetPRInsightsSummaryRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getPRInsightsSummary, arg.StartDate, arg.EndDate, arg.OwnerID)
|
||||
var i GetPRInsightsSummaryRow
|
||||
err := row.Scan(
|
||||
&i.TotalPrsCreated,
|
||||
&i.TotalPrsMerged,
|
||||
&i.TotalPrsClosed,
|
||||
&i.TotalAdditions,
|
||||
&i.TotalDeletions,
|
||||
&i.TotalCostMicros,
|
||||
&i.MergedCostMicros,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPRInsightsTimeSeries = `-- name: GetPRInsightsTimeSeries :many
|
||||
SELECT
|
||||
date_trunc('day', c.created_at)::timestamptz AS date,
|
||||
COUNT(*)::bigint AS prs_created,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS prs_closed
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
GROUP BY date_trunc('day', c.created_at)
|
||||
ORDER BY date_trunc('day', c.created_at)
|
||||
`
|
||||
|
||||
type GetPRInsightsTimeSeriesParams struct {
|
||||
StartDate time.Time `db:"start_date" json:"start_date"`
|
||||
EndDate time.Time `db:"end_date" json:"end_date"`
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
}
|
||||
|
||||
type GetPRInsightsTimeSeriesRow struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
PrsCreated int64 `db:"prs_created" json:"prs_created"`
|
||||
PrsMerged int64 `db:"prs_merged" json:"prs_merged"`
|
||||
PrsClosed int64 `db:"prs_closed" json:"prs_closed"`
|
||||
}
|
||||
|
||||
// Returns daily PR counts grouped by state for the chart.
|
||||
func (q *sqlQuerier) GetPRInsightsTimeSeries(ctx context.Context, arg GetPRInsightsTimeSeriesParams) ([]GetPRInsightsTimeSeriesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getPRInsightsTimeSeries, arg.StartDate, arg.EndDate, arg.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetPRInsightsTimeSeriesRow
|
||||
for rows.Next() {
|
||||
var i GetPRInsightsTimeSeriesRow
|
||||
if err := rows.Scan(
|
||||
&i.Date,
|
||||
&i.PrsCreated,
|
||||
&i.PrsMerged,
|
||||
&i.PrsClosed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const deleteChatModelConfigByID = `-- name: DeleteChatModelConfigByID :exec
|
||||
UPDATE
|
||||
chat_model_configs
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- PR Insights queries for the /agents analytics dashboard.
|
||||
-- These aggregate data from chat_diff_statuses (PR metadata) joined
|
||||
-- with chats and chat_messages (cost) to power the PR Insights view.
|
||||
|
||||
-- name: GetPRInsightsSummary :one
|
||||
-- Returns aggregate PR metrics for the given date range.
|
||||
-- The handler calls this twice (current + previous period) for trends.
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_prs_created,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS total_prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS total_prs_closed,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid);
|
||||
|
||||
-- name: GetPRInsightsTimeSeries :many
|
||||
-- Returns daily PR counts grouped by state for the chart.
|
||||
SELECT
|
||||
date_trunc('day', c.created_at)::timestamptz AS date,
|
||||
COUNT(*)::bigint AS prs_created,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS prs_closed
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid)
|
||||
GROUP BY date_trunc('day', c.created_at)
|
||||
ORDER BY date_trunc('day', c.created_at);
|
||||
|
||||
-- name: GetPRInsightsPerModel :many
|
||||
-- Returns PR metrics grouped by the model used for each chat.
|
||||
SELECT
|
||||
cmc.id AS model_config_id,
|
||||
cmc.display_name,
|
||||
cmc.provider,
|
||||
COUNT(*)::bigint AS total_prs,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS merged_prs,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid)
|
||||
GROUP BY cmc.id, cmc.display_name, cmc.provider
|
||||
ORDER BY total_prs DESC;
|
||||
|
||||
-- name: GetPRInsightsRecentPRs :many
|
||||
-- Returns individual PR rows with cost for the recent PRs table.
|
||||
SELECT
|
||||
c.id AS chat_id,
|
||||
cds.pull_request_title AS pr_title,
|
||||
cds.url AS pr_url,
|
||||
cds.pr_number,
|
||||
cds.pull_request_state AS state,
|
||||
cds.pull_request_draft AS draft,
|
||||
cds.additions,
|
||||
cds.deletions,
|
||||
cds.changed_files,
|
||||
cds.commits,
|
||||
cds.approved,
|
||||
cds.changes_requested,
|
||||
cds.reviewer_count,
|
||||
cds.author_login,
|
||||
cds.author_avatar_url,
|
||||
COALESCE(cds.base_branch, '')::text AS base_branch,
|
||||
COALESCE(cmc.display_name, cmc.model)::text AS model_display_name,
|
||||
COALESCE(cc.cost_micros, 0)::bigint AS cost_micros,
|
||||
c.created_at
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT @limit_val::int;
|
||||
@@ -1794,3 +1794,75 @@ func formatChatStreamResponseError(response Response) string {
|
||||
return fmt.Sprintf("%s: %s", message, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// PRInsightsResponse is the response from the PR insights endpoint.
|
||||
type PRInsightsResponse struct {
|
||||
Summary PRInsightsSummary `json:"summary"`
|
||||
TimeSeries []PRInsightsTimeSeriesEntry `json:"time_series"`
|
||||
ByModel []PRInsightsModelBreakdown `json:"by_model"`
|
||||
RecentPRs []PRInsightsPullRequest `json:"recent_prs"`
|
||||
}
|
||||
|
||||
// PRInsightsSummary contains aggregate PR metrics for a time period,
|
||||
// plus the previous period's metrics for trend calculation.
|
||||
type PRInsightsSummary struct {
|
||||
TotalPRsCreated int64 `json:"total_prs_created"`
|
||||
TotalPRsMerged int64 `json:"total_prs_merged"`
|
||||
MergeRate float64 `json:"merge_rate"`
|
||||
TotalAdditions int64 `json:"total_additions"`
|
||||
TotalDeletions int64 `json:"total_deletions"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
CostPerMergedPRMicros int64 `json:"cost_per_merged_pr_micros"`
|
||||
ApprovalRate float64 `json:"approval_rate"`
|
||||
PrevTotalPRsCreated int64 `json:"prev_total_prs_created"`
|
||||
PrevTotalPRsMerged int64 `json:"prev_total_prs_merged"`
|
||||
PrevMergeRate float64 `json:"prev_merge_rate"`
|
||||
PrevCostPerMergedPRMicros int64 `json:"prev_cost_per_merged_pr_micros"`
|
||||
}
|
||||
|
||||
// PRInsightsTimeSeriesEntry is a single data point in the PR
|
||||
// activity time series chart.
|
||||
type PRInsightsTimeSeriesEntry struct {
|
||||
Date time.Time `json:"date" format:"date-time"`
|
||||
PRsCreated int64 `json:"prs_created"`
|
||||
PRsMerged int64 `json:"prs_merged"`
|
||||
PRsClosed int64 `json:"prs_closed"`
|
||||
}
|
||||
|
||||
// PRInsightsModelBreakdown contains PR metrics for a single model.
|
||||
type PRInsightsModelBreakdown struct {
|
||||
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Provider string `json:"provider"`
|
||||
TotalPRs int64 `json:"total_prs"`
|
||||
MergedPRs int64 `json:"merged_prs"`
|
||||
MergeRate float64 `json:"merge_rate"`
|
||||
TotalAdditions int64 `json:"total_additions"`
|
||||
TotalDeletions int64 `json:"total_deletions"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
CostPerMergedPRMicros int64 `json:"cost_per_merged_pr_micros"`
|
||||
}
|
||||
|
||||
// PRInsightsPullRequest represents a single PR in the recent PRs
|
||||
// table.
|
||||
type PRInsightsPullRequest struct {
|
||||
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
PRURL *string `json:"pr_url,omitempty"`
|
||||
PRNumber *int32 `json:"pr_number,omitempty"`
|
||||
State string `json:"state"`
|
||||
Draft bool `json:"draft"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
Commits *int32 `json:"commits,omitempty"`
|
||||
Approved *bool `json:"approved,omitempty"`
|
||||
ChangesRequested bool `json:"changes_requested"`
|
||||
ReviewerCount *int32 `json:"reviewer_count,omitempty"`
|
||||
AuthorLogin *string `json:"author_login,omitempty"`
|
||||
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
ModelDisplayName string `json:"model_display_name"`
|
||||
CostMicros int64 `json:"cost_micros"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
}
|
||||
|
||||
@@ -1429,6 +1429,10 @@
|
||||
"title": "Builds",
|
||||
"path": "./reference/api/builds.md"
|
||||
},
|
||||
{
|
||||
"title": "Chats",
|
||||
"path": "./reference/api/chats.md"
|
||||
},
|
||||
{
|
||||
"title": "Debug",
|
||||
"path": "./reference/api/debug.md"
|
||||
|
||||
Generated
-44
@@ -1,45 +1 @@
|
||||
# Chats
|
||||
|
||||
## Archive a chat
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/chats/{chat}/archive
|
||||
|
||||
```
|
||||
|
||||
`POST /chats/{chat}/archive`
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-----------------------------------------------------------------|-------------|--------|
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
|
||||
|
||||
## Watch chat desktop
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/chats/{chat}/desktop \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /chats/{chat}/desktop`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------|
|
||||
| `chat` | path | string(uuid) | true | Chat ID |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------------------|---------------------|--------|
|
||||
| 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
Generated
+213
@@ -6014,6 +6014,219 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
| » `[any property]` | array of string | false | | |
|
||||
| `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. |
|
||||
|
||||
## codersdk.PRInsightsModelBreakdown
|
||||
|
||||
```json
|
||||
{
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"display_name": "string",
|
||||
"merge_rate": 0,
|
||||
"merged_prs": 0,
|
||||
"model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205",
|
||||
"provider": "string",
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------------------|---------|----------|--------------|-------------|
|
||||
| `cost_per_merged_pr_micros` | integer | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `merge_rate` | number | false | | |
|
||||
| `merged_prs` | integer | false | | |
|
||||
| `model_config_id` | string | false | | |
|
||||
| `provider` | string | false | | |
|
||||
| `total_additions` | integer | false | | |
|
||||
| `total_cost_micros` | integer | false | | |
|
||||
| `total_deletions` | integer | false | | |
|
||||
| `total_prs` | integer | false | | |
|
||||
|
||||
## codersdk.PRInsightsPullRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"cost_micros": 0,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"deletions": 0,
|
||||
"draft": true,
|
||||
"model_display_name": "string",
|
||||
"pr_number": 0,
|
||||
"pr_title": "string",
|
||||
"pr_url": "string",
|
||||
"reviewer_count": 0,
|
||||
"state": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------|---------|----------|--------------|-------------|
|
||||
| `additions` | integer | false | | |
|
||||
| `approved` | boolean | false | | |
|
||||
| `author_avatar_url` | string | false | | |
|
||||
| `author_login` | string | false | | |
|
||||
| `base_branch` | string | false | | |
|
||||
| `changed_files` | integer | false | | |
|
||||
| `changes_requested` | boolean | false | | |
|
||||
| `chat_id` | string | false | | |
|
||||
| `commits` | integer | false | | |
|
||||
| `cost_micros` | integer | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `deletions` | integer | false | | |
|
||||
| `draft` | boolean | false | | |
|
||||
| `model_display_name` | string | false | | |
|
||||
| `pr_number` | integer | false | | |
|
||||
| `pr_title` | string | false | | |
|
||||
| `pr_url` | string | false | | |
|
||||
| `reviewer_count` | integer | false | | |
|
||||
| `state` | string | false | | |
|
||||
|
||||
## codersdk.PRInsightsResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"by_model": [
|
||||
{
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"display_name": "string",
|
||||
"merge_rate": 0,
|
||||
"merged_prs": 0,
|
||||
"model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205",
|
||||
"provider": "string",
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs": 0
|
||||
}
|
||||
],
|
||||
"recent_prs": [
|
||||
{
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"cost_micros": 0,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"deletions": 0,
|
||||
"draft": true,
|
||||
"model_display_name": "string",
|
||||
"pr_number": 0,
|
||||
"pr_title": "string",
|
||||
"pr_url": "string",
|
||||
"reviewer_count": 0,
|
||||
"state": "string"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"approval_rate": 0,
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"merge_rate": 0,
|
||||
"prev_cost_per_merged_pr_micros": 0,
|
||||
"prev_merge_rate": 0,
|
||||
"prev_total_prs_created": 0,
|
||||
"prev_total_prs_merged": 0,
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs_created": 0,
|
||||
"total_prs_merged": 0
|
||||
},
|
||||
"time_series": [
|
||||
{
|
||||
"date": "2019-08-24T14:15:22Z",
|
||||
"prs_closed": 0,
|
||||
"prs_created": 0,
|
||||
"prs_merged": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|-----------------------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `by_model` | array of [codersdk.PRInsightsModelBreakdown](#codersdkprinsightsmodelbreakdown) | false | | |
|
||||
| `recent_prs` | array of [codersdk.PRInsightsPullRequest](#codersdkprinsightspullrequest) | false | | |
|
||||
| `summary` | [codersdk.PRInsightsSummary](#codersdkprinsightssummary) | false | | |
|
||||
| `time_series` | array of [codersdk.PRInsightsTimeSeriesEntry](#codersdkprinsightstimeseriesentry) | false | | |
|
||||
|
||||
## codersdk.PRInsightsSummary
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_rate": 0,
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"merge_rate": 0,
|
||||
"prev_cost_per_merged_pr_micros": 0,
|
||||
"prev_merge_rate": 0,
|
||||
"prev_total_prs_created": 0,
|
||||
"prev_total_prs_merged": 0,
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs_created": 0,
|
||||
"total_prs_merged": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------------------|---------|----------|--------------|-------------|
|
||||
| `approval_rate` | number | false | | |
|
||||
| `cost_per_merged_pr_micros` | integer | false | | |
|
||||
| `merge_rate` | number | false | | |
|
||||
| `prev_cost_per_merged_pr_micros` | integer | false | | |
|
||||
| `prev_merge_rate` | number | false | | |
|
||||
| `prev_total_prs_created` | integer | false | | |
|
||||
| `prev_total_prs_merged` | integer | false | | |
|
||||
| `total_additions` | integer | false | | |
|
||||
| `total_cost_micros` | integer | false | | |
|
||||
| `total_deletions` | integer | false | | |
|
||||
| `total_prs_created` | integer | false | | |
|
||||
| `total_prs_merged` | integer | false | | |
|
||||
|
||||
## codersdk.PRInsightsTimeSeriesEntry
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2019-08-24T14:15:22Z",
|
||||
"prs_closed": 0,
|
||||
"prs_created": 0,
|
||||
"prs_merged": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|---------|----------|--------------|-------------|
|
||||
| `date` | string | false | | |
|
||||
| `prs_closed` | integer | false | | |
|
||||
| `prs_created` | integer | false | | |
|
||||
| `prs_merged` | integer | false | | |
|
||||
|
||||
## codersdk.PaginatedMembersResponse
|
||||
|
||||
```json
|
||||
|
||||
Generated
+87
@@ -4470,6 +4470,93 @@ export interface OrganizationSyncSettings {
|
||||
readonly organization_assign_default: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsModelBreakdown contains PR metrics for a single model.
|
||||
*/
|
||||
export interface PRInsightsModelBreakdown {
|
||||
readonly model_config_id: string;
|
||||
readonly display_name: string;
|
||||
readonly provider: string;
|
||||
readonly total_prs: number;
|
||||
readonly merged_prs: number;
|
||||
readonly merge_rate: number;
|
||||
readonly total_additions: number;
|
||||
readonly total_deletions: number;
|
||||
readonly total_cost_micros: number;
|
||||
readonly cost_per_merged_pr_micros: number;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsPullRequest represents a single PR in the recent PRs
|
||||
* table.
|
||||
*/
|
||||
export interface PRInsightsPullRequest {
|
||||
readonly chat_id: string;
|
||||
readonly pr_title: string;
|
||||
readonly pr_url?: string;
|
||||
readonly pr_number?: number;
|
||||
readonly state: string;
|
||||
readonly draft: boolean;
|
||||
readonly additions: number;
|
||||
readonly deletions: number;
|
||||
readonly changed_files: number;
|
||||
readonly commits?: number;
|
||||
readonly approved?: boolean;
|
||||
readonly changes_requested: boolean;
|
||||
readonly reviewer_count?: number;
|
||||
readonly author_login?: string;
|
||||
readonly author_avatar_url?: string;
|
||||
readonly base_branch: string;
|
||||
readonly model_display_name: string;
|
||||
readonly cost_micros: number;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsResponse is the response from the PR insights endpoint.
|
||||
*/
|
||||
export interface PRInsightsResponse {
|
||||
readonly summary: PRInsightsSummary;
|
||||
readonly time_series: readonly PRInsightsTimeSeriesEntry[];
|
||||
readonly by_model: readonly PRInsightsModelBreakdown[];
|
||||
readonly recent_prs: readonly PRInsightsPullRequest[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsSummary contains aggregate PR metrics for a time period,
|
||||
* plus the previous period's metrics for trend calculation.
|
||||
*/
|
||||
export interface PRInsightsSummary {
|
||||
readonly total_prs_created: number;
|
||||
readonly total_prs_merged: number;
|
||||
readonly merge_rate: number;
|
||||
readonly total_additions: number;
|
||||
readonly total_deletions: number;
|
||||
readonly total_cost_micros: number;
|
||||
readonly cost_per_merged_pr_micros: number;
|
||||
readonly approval_rate: number;
|
||||
readonly prev_total_prs_created: number;
|
||||
readonly prev_total_prs_merged: number;
|
||||
readonly prev_merge_rate: number;
|
||||
readonly prev_cost_per_merged_pr_micros: number;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsTimeSeriesEntry is a single data point in the PR
|
||||
* activity time series chart.
|
||||
*/
|
||||
export interface PRInsightsTimeSeriesEntry {
|
||||
readonly date: string;
|
||||
readonly prs_created: number;
|
||||
readonly prs_merged: number;
|
||||
readonly prs_closed: number;
|
||||
}
|
||||
|
||||
// From codersdk/organizations.go
|
||||
export interface PaginatedMembersRequest {
|
||||
readonly limit?: number;
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
UserIcon,
|
||||
WandSparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { UserDropdownContent } from "modules/dashboard/Navbar/UserDropdown/UserDropdownContent";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
@@ -1002,6 +1003,15 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
state={location.state}
|
||||
adminOnly
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={WandSparklesIcon}
|
||||
label="Analytics"
|
||||
active={sidebarView.section === "insights"}
|
||||
to="/agents/settings/insights"
|
||||
replace
|
||||
state={location.state}
|
||||
adminOnly
|
||||
/>{" "}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -409,7 +409,7 @@ const RepoHeader: FC<{
|
||||
// PR state icon (compact, for the tab bar)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const PrStateIcon: FC<{
|
||||
export const PrStateIcon: FC<{
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
className?: string;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { PRInsightsResponse } from "api/typesGenerated";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import dayjs from "dayjs";
|
||||
import { type FC, useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
|
||||
|
||||
function timeRangeToDates(range: PRInsightsTimeRange) {
|
||||
const end = dayjs();
|
||||
const days = Number.parseInt(range, 10);
|
||||
const start = end.subtract(days, "day");
|
||||
return {
|
||||
start_date: start.toISOString(),
|
||||
end_date: end.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPRInsights(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): Promise<PRInsightsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
});
|
||||
const resp = await fetch(
|
||||
`/api/v2/chats/insights/pull-requests?${params.toString()}`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch PR insights: ${resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export const InsightsContent: FC = () => {
|
||||
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>("30d");
|
||||
const dates = useMemo(() => timeRangeToDates(timeRange), [timeRange]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["prInsights", dates.start_date, dates.end_date],
|
||||
queryFn: () => fetchPRInsights(dates.start_date, dates.end_date),
|
||||
});
|
||||
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(range: PRInsightsTimeRange) => setTimeRange(range),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Spinner size="lg" loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<p className="text-sm text-content-secondary">
|
||||
Failed to load analytics data.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PRInsightsView
|
||||
data={data}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOW = dayjs("2025-07-15");
|
||||
|
||||
function generateTimeSeries(
|
||||
days: number,
|
||||
opts: { avgCreated: number; avgMerged: number; avgClosed?: number },
|
||||
): TypesGen.PRInsightsTimeSeriesEntry[] {
|
||||
const entries: TypesGen.PRInsightsTimeSeriesEntry[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = NOW.subtract(i, "day").format("YYYY-MM-DD");
|
||||
const jitter = () => Math.round((Math.random() - 0.3) * 3);
|
||||
const created = Math.max(0, opts.avgCreated + jitter());
|
||||
const merged = Math.min(created, Math.max(0, opts.avgMerged + jitter()));
|
||||
const closed = Math.max(
|
||||
0,
|
||||
(opts.avgClosed ?? 0) + Math.round((Math.random() - 0.5) * 2),
|
||||
);
|
||||
entries.push({
|
||||
date,
|
||||
prs_created: created,
|
||||
prs_merged: merged,
|
||||
prs_closed: closed,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
const MODELS: TypesGen.PRInsightsModelBreakdown[] = [
|
||||
{
|
||||
model_config_id: "m1",
|
||||
display_name: "Claude Sonnet 4",
|
||||
provider: "Anthropic",
|
||||
total_prs: 48,
|
||||
merged_prs: 35,
|
||||
merge_rate: 0.73,
|
||||
total_additions: 8420,
|
||||
total_deletions: 2130,
|
||||
total_cost_micros: 142_000_000,
|
||||
cost_per_merged_pr_micros: 4_057_143,
|
||||
},
|
||||
{
|
||||
model_config_id: "m2",
|
||||
display_name: "GPT-4.1",
|
||||
provider: "OpenAI",
|
||||
total_prs: 31,
|
||||
merged_prs: 20,
|
||||
merge_rate: 0.645,
|
||||
total_additions: 5100,
|
||||
total_deletions: 1340,
|
||||
total_cost_micros: 98_400_000,
|
||||
cost_per_merged_pr_micros: 4_920_000,
|
||||
},
|
||||
{
|
||||
model_config_id: "m3",
|
||||
display_name: "Gemini 2.5 Pro",
|
||||
provider: "Google",
|
||||
total_prs: 18,
|
||||
merged_prs: 14,
|
||||
merge_rate: 0.778,
|
||||
total_additions: 3200,
|
||||
total_deletions: 890,
|
||||
total_cost_micros: 41_300_000,
|
||||
cost_per_merged_pr_micros: 2_950_000,
|
||||
},
|
||||
{
|
||||
model_config_id: "m4",
|
||||
display_name: "Claude Opus 4",
|
||||
provider: "Anthropic",
|
||||
total_prs: 8,
|
||||
merged_prs: 7,
|
||||
merge_rate: 0.875,
|
||||
total_additions: 2100,
|
||||
total_deletions: 480,
|
||||
total_cost_micros: 64_200_000,
|
||||
cost_per_merged_pr_micros: 9_171_429,
|
||||
},
|
||||
];
|
||||
|
||||
const PR_TITLES = [
|
||||
"fix: resolve race condition in workspace agent reconnect",
|
||||
"feat: add OAuth2 PKCE support for external apps",
|
||||
"refactor: extract provisioner job queue into separate package",
|
||||
"fix: correct RBAC check for template version imports",
|
||||
"feat: add workspace build timeline visualization",
|
||||
"chore: upgrade Go to 1.24 and update dependencies",
|
||||
"fix: handle nil pointer in DERP mesh coordinator",
|
||||
"feat: implement workspace dormancy auto-deletion policy",
|
||||
"fix: prevent duplicate agent stats insertion on restart",
|
||||
"feat: add audit log entries for SSH connections",
|
||||
"refactor: simplify template parameter validation logic",
|
||||
"fix: correct timezone handling in usage stats rollup",
|
||||
"feat: add support for workspace agent environment variables",
|
||||
"fix: resolve flaky TestWorkspaceBuild integration test",
|
||||
"feat: implement organization-scoped template policies",
|
||||
];
|
||||
|
||||
const AUTHORS = [
|
||||
{
|
||||
login: "kylecarbs",
|
||||
avatar: "https://avatars.githubusercontent.com/u/7122116",
|
||||
},
|
||||
{
|
||||
login: "ammario",
|
||||
avatar: "https://avatars.githubusercontent.com/u/9078713",
|
||||
},
|
||||
{
|
||||
login: "mafredri",
|
||||
avatar: "https://avatars.githubusercontent.com/u/147409",
|
||||
},
|
||||
{
|
||||
login: "aslilac",
|
||||
avatar: "https://avatars.githubusercontent.com/u/23068824",
|
||||
},
|
||||
{
|
||||
login: "sreya",
|
||||
avatar: "https://avatars.githubusercontent.com/u/67369800",
|
||||
},
|
||||
{
|
||||
login: "mtojek",
|
||||
avatar: "https://avatars.githubusercontent.com/u/14044910",
|
||||
},
|
||||
{
|
||||
login: "deansheather",
|
||||
avatar: "https://avatars.githubusercontent.com/u/11241812",
|
||||
},
|
||||
];
|
||||
|
||||
function generatePRs(count: number): TypesGen.PRInsightsPullRequest[] {
|
||||
const states: Array<"open" | "closed" | "merged"> = [
|
||||
"merged",
|
||||
"merged",
|
||||
"merged",
|
||||
"merged",
|
||||
"merged",
|
||||
"open",
|
||||
"open",
|
||||
"closed",
|
||||
];
|
||||
const models = [
|
||||
"Claude Sonnet 4",
|
||||
"GPT-4.1",
|
||||
"Gemini 2.5 Pro",
|
||||
"Claude Opus 4",
|
||||
];
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const state = states[i % states.length];
|
||||
const author = AUTHORS[i % AUTHORS.length];
|
||||
const additions = Math.round(40 + Math.random() * 400);
|
||||
const deletions = Math.round(10 + Math.random() * 150);
|
||||
|
||||
return {
|
||||
chat_id: `chat-${i}`,
|
||||
pr_title: PR_TITLES[i % PR_TITLES.length],
|
||||
pr_url: `https://github.com/coder/coder/pull/${1200 + i}`,
|
||||
pr_number: 1200 + i,
|
||||
state,
|
||||
draft: state === "open" && i % 3 === 0,
|
||||
additions,
|
||||
deletions,
|
||||
changed_files: Math.round(2 + Math.random() * 12),
|
||||
commits: Math.round(1 + Math.random() * 6),
|
||||
approved:
|
||||
state === "merged" ? true : state === "open" ? undefined : false,
|
||||
changes_requested: state === "closed" && i % 2 === 0,
|
||||
reviewer_count:
|
||||
state === "merged"
|
||||
? Math.round(1 + Math.random() * 2)
|
||||
: Math.round(Math.random() * 2),
|
||||
author_login: author.login,
|
||||
author_avatar_url: author.avatar,
|
||||
base_branch: "main",
|
||||
model_display_name: models[i % models.length],
|
||||
cost_micros: Math.round(1_500_000 + Math.random() * 8_000_000),
|
||||
created_at: NOW.subtract(
|
||||
i * 4 + Math.round(Math.random() * 8),
|
||||
"hour",
|
||||
).toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assembled mock datasets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultData: TypesGen.PRInsightsResponse = {
|
||||
summary: {
|
||||
total_prs_created: 105,
|
||||
total_prs_merged: 76,
|
||||
merge_rate: 0.724,
|
||||
total_additions: 18820,
|
||||
total_deletions: 4840,
|
||||
total_cost_micros: 346_000_000,
|
||||
cost_per_merged_pr_micros: 4_552_632,
|
||||
approval_rate: 0.88,
|
||||
prev_total_prs_created: 82,
|
||||
prev_total_prs_merged: 55,
|
||||
prev_merge_rate: 0.671,
|
||||
prev_cost_per_merged_pr_micros: 5_120_000,
|
||||
},
|
||||
time_series: generateTimeSeries(30, {
|
||||
avgCreated: 4,
|
||||
avgMerged: 3,
|
||||
avgClosed: 1,
|
||||
}),
|
||||
by_model: MODELS,
|
||||
recent_prs: generatePRs(12),
|
||||
};
|
||||
|
||||
const highPerformanceData: TypesGen.PRInsightsResponse = {
|
||||
summary: {
|
||||
total_prs_created: 210,
|
||||
total_prs_merged: 189,
|
||||
merge_rate: 0.9,
|
||||
total_additions: 42_600,
|
||||
total_deletions: 11_200,
|
||||
total_cost_micros: 520_000_000,
|
||||
cost_per_merged_pr_micros: 2_751_323,
|
||||
approval_rate: 0.95,
|
||||
prev_total_prs_created: 140,
|
||||
prev_total_prs_merged: 112,
|
||||
prev_merge_rate: 0.8,
|
||||
prev_cost_per_merged_pr_micros: 3_400_000,
|
||||
},
|
||||
time_series: generateTimeSeries(30, {
|
||||
avgCreated: 7,
|
||||
avgMerged: 6,
|
||||
avgClosed: 1,
|
||||
}),
|
||||
by_model: MODELS.map((m) => ({
|
||||
...m,
|
||||
merge_rate: Math.min(m.merge_rate + 0.12, 0.98),
|
||||
total_prs: m.total_prs * 2,
|
||||
merged_prs: Math.round(m.merged_prs * 2.4),
|
||||
})),
|
||||
recent_prs: generatePRs(15),
|
||||
};
|
||||
|
||||
const lowVolumeData: TypesGen.PRInsightsResponse = {
|
||||
summary: {
|
||||
total_prs_created: 8,
|
||||
total_prs_merged: 3,
|
||||
merge_rate: 0.375,
|
||||
total_additions: 620,
|
||||
total_deletions: 180,
|
||||
total_cost_micros: 18_000_000,
|
||||
cost_per_merged_pr_micros: 6_000_000,
|
||||
approval_rate: 0.67,
|
||||
prev_total_prs_created: 12,
|
||||
prev_total_prs_merged: 7,
|
||||
prev_merge_rate: 0.583,
|
||||
prev_cost_per_merged_pr_micros: 4_200_000,
|
||||
},
|
||||
time_series: generateTimeSeries(30, { avgCreated: 0, avgMerged: 0 }),
|
||||
by_model: MODELS.slice(0, 2).map((m) => ({
|
||||
...m,
|
||||
total_prs: Math.round(m.total_prs / 6),
|
||||
merged_prs: Math.round(m.merged_prs / 8),
|
||||
merge_rate: 0.35 + Math.random() * 0.15,
|
||||
})),
|
||||
recent_prs: generatePRs(5),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta<typeof PRInsightsView> = {
|
||||
title: "pages/AgentsPage/PRInsightsView",
|
||||
component: PRInsightsView,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="bg-surface-primary p-6" style={{ maxWidth: 1100 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: function Render(args) {
|
||||
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>(
|
||||
args.timeRange,
|
||||
);
|
||||
return (
|
||||
<PRInsightsView
|
||||
{...args}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PRInsightsView>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
data: defaultData,
|
||||
timeRange: "30d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const HighPerformance: Story = {
|
||||
args: {
|
||||
data: highPerformanceData,
|
||||
timeRange: "30d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const LowVolume: Story = {
|
||||
args: {
|
||||
data: lowVolumeData,
|
||||
timeRange: "14d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPRs: Story = {
|
||||
args: {
|
||||
data: {
|
||||
summary: {
|
||||
total_prs_created: 0,
|
||||
total_prs_merged: 0,
|
||||
merge_rate: 0,
|
||||
total_additions: 0,
|
||||
total_deletions: 0,
|
||||
total_cost_micros: 0,
|
||||
cost_per_merged_pr_micros: 0,
|
||||
approval_rate: 0,
|
||||
prev_total_prs_created: 0,
|
||||
prev_total_prs_merged: 0,
|
||||
prev_merge_rate: 0,
|
||||
prev_cost_per_merged_pr_micros: 0,
|
||||
},
|
||||
time_series: generateTimeSeries(30, {
|
||||
avgCreated: 0,
|
||||
avgMerged: 0,
|
||||
avgClosed: 0,
|
||||
}),
|
||||
by_model: [],
|
||||
recent_prs: [],
|
||||
},
|
||||
timeRange: "30d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,679 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "components/Chart/Chart";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import {
|
||||
ArrowDownRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleDotIcon,
|
||||
CodeIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageSquareTextIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { PrStateIcon } from "./GitPanel";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PRInsightsTimeRange = "7d" | "14d" | "30d" | "90d";
|
||||
|
||||
interface PRInsightsViewProps {
|
||||
data: TypesGen.PRInsightsResponse;
|
||||
timeRange: PRInsightsTimeRange;
|
||||
onTimeRangeChange: (range: PRInsightsTimeRange) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pctChange(current: number, previous: number): number | null {
|
||||
if (previous === 0) return current > 0 ? 100 : null;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
function formatPct(value: number): string {
|
||||
return `${value >= 0 ? "+" : ""}${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
function formatMergeRate(rate: number): string {
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
function formatLinesShipped(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TrendBadge: FC<{
|
||||
current: number;
|
||||
previous: number;
|
||||
invert?: boolean;
|
||||
}> = ({ current, previous, invert = false }) => {
|
||||
const change = pctChange(current, previous);
|
||||
if (change === null) return null;
|
||||
|
||||
const isPositive = invert ? change < 0 : change > 0;
|
||||
const isNegative = invert ? change > 0 : change < 0;
|
||||
|
||||
if (isPositive) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-surface-green px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-success">
|
||||
<ArrowUpRightIcon className="size-3" />
|
||||
{formatPct(change)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isNegative) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-surface-red px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-destructive">
|
||||
<ArrowDownRightIcon className="size-3" />
|
||||
{formatPct(change)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-secondary">
|
||||
0%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard: FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: React.ReactNode;
|
||||
detail?: string;
|
||||
}> = ({ label, value, trend, detail }) => (
|
||||
<div className="flex flex-col justify-between rounded-lg border border-border-default bg-surface-primary p-5">
|
||||
<p className="m-0 text-[13px] text-content-secondary">{label}</p>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="m-0 text-[28px] font-semibold leading-none tracking-tight text-content-primary">
|
||||
{value}
|
||||
</p>
|
||||
{trend}
|
||||
</div>
|
||||
{detail && (
|
||||
<p className="m-0 mt-1.5 text-xs text-content-disabled">{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const prStateBadgeStyles: Record<string, string> = {
|
||||
merged: "text-git-merged-bright ring-current/20",
|
||||
closed: "text-git-deleted-bright ring-current/20",
|
||||
open: "text-git-added-bright ring-current/20",
|
||||
draft: "text-content-secondary ring-border-default",
|
||||
};
|
||||
|
||||
const prStateLabels: Record<string, string> = {
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
open: "Open",
|
||||
draft: "Draft",
|
||||
};
|
||||
|
||||
function prStateKey(state: string, draft: boolean): string {
|
||||
if (state === "merged" || state === "closed") return state;
|
||||
return draft ? "draft" : "open";
|
||||
}
|
||||
|
||||
const PRStateBadge: FC<{ state: string; draft: boolean }> = ({
|
||||
state,
|
||||
draft,
|
||||
}) => {
|
||||
const key = prStateKey(state, draft);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
prStateBadgeStyles[key] ?? prStateBadgeStyles.open,
|
||||
)}
|
||||
>
|
||||
<PrStateIcon state={state} draft={draft} className="size-3" />
|
||||
{prStateLabels[key] ?? "Open"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineMergeBar: FC<{ rate: number }> = ({ rate }) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-[6px] w-20 overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-git-merged-bright transition-all"
|
||||
style={{ width: `${Math.round(rate * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs tabular-nums text-content-primary">
|
||||
{formatMergeRate(rate)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activityChartConfig = {
|
||||
prs_created: {
|
||||
label: "Created",
|
||||
color: "hsl(var(--git-added-bright))",
|
||||
},
|
||||
prs_merged: {
|
||||
label: "Merged",
|
||||
color: "hsl(var(--git-merged-bright))",
|
||||
},
|
||||
prs_closed: {
|
||||
label: "Closed",
|
||||
color: "hsl(var(--git-deleted-bright))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function formatChartDate(dateStr: string): string {
|
||||
return dayjs(dateStr).format("MMM D");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity chart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ActivityChart: FC<{
|
||||
data: readonly TypesGen.PRInsightsTimeSeriesEntry[];
|
||||
}> = ({ data }) => (
|
||||
<ChartContainer config={activityChartConfig} className="aspect-auto h-full">
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[...data]}
|
||||
margin={{ top: 8, left: -8, right: 8, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillCreated" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-prs_created)"
|
||||
stopOpacity={0.35}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-prs_created)"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMerged" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-prs_merged)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-prs_merged)"
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillClosed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-prs_closed)"
|
||||
stopOpacity={0.35}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-prs_closed)"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
tickMargin={12}
|
||||
minTickGap={40}
|
||||
tickFormatter={formatChartDate}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={12}
|
||||
allowDecimals={false}
|
||||
tickFormatter={(v: number) => (v === 0 ? "" : String(v))}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(v: string) => dayjs(v).format("ddd, MMM D")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="prs_created"
|
||||
fill="url(#fillCreated)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_created)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="prs_merged"
|
||||
fill="url(#fillMerged)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_merged)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="prs_closed"
|
||||
fill="url(#fillClosed)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_closed)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EmptyState: FC = () => (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<CodeIcon className="size-5 text-content-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="m-0 text-sm font-medium text-content-primary">
|
||||
No pull requests yet
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-sm text-content-secondary">
|
||||
Pull request data will appear here once agents start shipping code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SectionTitle: FC<{ children: string }> = ({ children }) => (
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">{children}</h3>
|
||||
);
|
||||
|
||||
const timeRangeOptions: { value: PRInsightsTimeRange; label: string }[] = [
|
||||
{ value: "7d", label: "7d" },
|
||||
{ value: "14d", label: "14d" },
|
||||
{ value: "30d", label: "30d" },
|
||||
{ value: "90d", label: "90d" },
|
||||
];
|
||||
|
||||
const TimeRangeFilter: FC<{
|
||||
value: PRInsightsTimeRange;
|
||||
onChange: (range: PRInsightsTimeRange) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<div className="inline-flex -space-x-px">
|
||||
{timeRangeOptions.map((opt, i) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
variant={value === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"min-w-0 rounded-none px-3",
|
||||
i === 0 && "rounded-l-md",
|
||||
i === timeRangeOptions.length - 1 && "rounded-r-md",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReviewBadge: FC<{
|
||||
approved: boolean | undefined;
|
||||
changes_requested: boolean;
|
||||
reviewer_count: number | undefined;
|
||||
}> = ({ approved, changes_requested, reviewer_count }) => {
|
||||
if (!reviewer_count) {
|
||||
return <span className="text-xs text-content-disabled">No reviews</span>;
|
||||
}
|
||||
|
||||
if (approved === true && !changes_requested) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-content-success">
|
||||
<CheckCircle2Icon className="size-3.5" />
|
||||
{reviewer_count} approved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (changes_requested) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-content-warning">
|
||||
<MessageSquareTextIcon className="size-3.5" />
|
||||
Changes requested
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-content-secondary">
|
||||
<CircleDotIcon className="size-3.5" />
|
||||
{reviewer_count} reviewing
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PRInsightsView: FC<PRInsightsViewProps> = ({
|
||||
data,
|
||||
timeRange,
|
||||
onTimeRangeChange,
|
||||
}) => {
|
||||
const { summary, time_series, by_model, recent_prs } = data;
|
||||
const isEmpty = summary.total_prs_created === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="m-0 text-xl font-semibold tracking-tight text-content-primary">
|
||||
Pull Request Insights
|
||||
</h2>
|
||||
<p className="m-0 mt-1 text-[13px] text-content-secondary">
|
||||
Code shipped by AI agents across your organization.
|
||||
</p>
|
||||
</div>
|
||||
<TimeRangeFilter value={timeRange} onChange={onTimeRangeChange} />
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* ── Stat cards ── */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
label="PRs created"
|
||||
value={summary.total_prs_created.toLocaleString()}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.total_prs_created}
|
||||
previous={summary.prev_total_prs_created}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Merged"
|
||||
value={summary.total_prs_merged.toLocaleString()}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.total_prs_merged}
|
||||
previous={summary.prev_total_prs_merged}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Merge rate"
|
||||
value={formatMergeRate(summary.merge_rate)}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.merge_rate}
|
||||
previous={summary.prev_merge_rate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Lines shipped"
|
||||
value={formatLinesShipped(summary.total_additions)}
|
||||
detail={`${formatLinesShipped(summary.total_deletions)} removed`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cost / merged PR"
|
||||
value={formatCostMicros(summary.cost_per_merged_pr_micros)}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.cost_per_merged_pr_micros}
|
||||
previous={summary.prev_cost_per_merged_pr_micros}
|
||||
invert
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Activity chart ── */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<SectionTitle>Activity</SectionTitle>
|
||||
<div className="flex items-center gap-5">
|
||||
{Object.entries(activityChartConfig).map(([key, cfg]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: cfg.color }}
|
||||
/>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[260px] rounded-lg border border-border-default p-4 pt-2">
|
||||
<ActivityChart data={time_series} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Model performance ── */}
|
||||
{by_model.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>Performance by model</SectionTitle>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
|
||||
<TableHead className="px-4 py-3">Model</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
PRs
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Merged
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3">Merge rate</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Changes
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Total cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cost / merge
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{by_model.map((m) => (
|
||||
<TableRow
|
||||
key={m.model_config_id}
|
||||
className="border-t border-border-default"
|
||||
>
|
||||
<TableCell className="px-4 py-3">
|
||||
<span className="font-medium text-content-primary">
|
||||
{m.display_name}
|
||||
</span>
|
||||
<span className="ml-1.5 text-xs text-content-disabled">
|
||||
{m.provider}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.total_prs}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.merged_prs}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<InlineMergeBar rate={m.merge_rate} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DiffStatBadge
|
||||
additions={m.total_additions}
|
||||
deletions={m.total_deletions}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
|
||||
{formatCostMicros(m.total_cost_micros)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.merged_prs > 0
|
||||
? formatCostMicros(m.cost_per_merged_pr_micros)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Recent pull requests ── */}
|
||||
{recent_prs.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>Recent pull requests</SectionTitle>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
|
||||
<TableHead className="px-4 py-3">Pull request</TableHead>
|
||||
<TableHead className="px-4 py-3">Status</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Changes
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Reviews
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3">Model</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Created
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recent_prs.map((pr) => (
|
||||
<TableRow
|
||||
key={pr.chat_id}
|
||||
className="border-t border-border-default transition-colors hover:bg-surface-secondary/50"
|
||||
>
|
||||
<TableCell className="max-w-[320px] px-4 py-3">
|
||||
<a
|
||||
href={pr.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1 text-sm font-medium text-content-primary no-underline hover:text-content-link"
|
||||
>
|
||||
<span className="truncate">{pr.pr_title}</span>
|
||||
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-content-disabled opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</a>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-content-disabled">
|
||||
<img
|
||||
src={pr.author_avatar_url}
|
||||
alt=""
|
||||
className="size-3.5 rounded-full"
|
||||
/>
|
||||
<span>{pr.author_login}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">#{pr.pr_number}</span>
|
||||
<span>→</span>
|
||||
<span className="font-mono">{pr.base_branch}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<PRStateBadge state={pr.state} draft={pr.draft} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DiffStatBadge
|
||||
additions={pr.additions}
|
||||
deletions={pr.deletions}
|
||||
/>
|
||||
<p className="m-0 mt-1 text-xs text-content-disabled">
|
||||
{pr.changed_files} file
|
||||
{pr.changed_files !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<ReviewBadge
|
||||
approved={pr.approved}
|
||||
changes_requested={pr.changes_requested}
|
||||
reviewer_count={pr.reviewer_count}
|
||||
/>
|
||||
</TableCell>{" "}
|
||||
<TableCell className="px-4 py-3 text-xs text-content-secondary">
|
||||
{pr.model_display_name}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
|
||||
{formatCostMicros(pr.cost_micros)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap px-4 py-3 text-right text-xs text-content-disabled">
|
||||
{dayjs(pr.created_at).format("MMM D, h:mm A")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,6 +48,7 @@ import { formatTokenCount } from "utils/analytics";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { ChatCostSummaryView } from "./ChatCostSummaryView";
|
||||
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
import { InsightsContent } from "./InsightsContent";
|
||||
import { LimitsTab } from "./LimitsTab";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
||||
@@ -576,6 +577,9 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
||||
{activeSection === "usage" && canManageChatModelConfigs && (
|
||||
<UsageContent now={now} />
|
||||
)}
|
||||
{activeSection === "insights" && canManageChatModelConfigs && (
|
||||
<InsightsContent />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user