From 2577d16af25948f5d456ac50140b6fad346635c7 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 18 Mar 2026 12:46:53 -0400 Subject: [PATCH] fix(site): use correct /api/experimental endpoint for PR insights (#23235) ## Problem The `/agents/settings/insights` page was broken because `InsightsContent` was calling `/api/v2/chats/insights/pull-requests`, but the backend route is registered under `/api/experimental/chats/insights/pull-requests` (the entire `/chats` route block lives under `r.Route("/api/experimental", ...)` in `coderd.go`). Every other chat endpoint in the frontend correctly uses `/api/experimental/chats/...`, but this one was missed. ## Fix - Added `getPRInsights` method to the API client (`api.ts`) pointing to `/api/experimental/chats/insights/pull-requests` - Added a `prInsights` react-query helper in `api/queries/chats.ts` (matching the pattern of `chatCostUsers`, etc.) - Updated `InsightsContent.tsx` to use the query helper instead of a raw `fetch()` with the wrong URL --- site/src/api/api.ts | 12 ++++++ site/src/api/queries/chats.ts | 12 ++++++ site/src/pages/AgentsPage/InsightsContent.tsx | 24 +---------- .../AgentsPage/PRInsightsView.stories.tsx | 41 +++++++++++++------ 4 files changed, 54 insertions(+), 35 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 1e1df13600..e933416f5d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3209,6 +3209,18 @@ class ApiMethods { return response.data; }; + getPRInsights = async (params?: { + start_date?: string; + end_date?: string; + }): Promise => { + const url = getURLWithSearchParams( + "/api/experimental/chats/insights/pull-requests", + params, + ); + const response = await this.axios.get(url); + return response.data; + }; + getChatUsageLimitConfig = async (): Promise => { const response = diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a8067348e4..e61dafb601 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -547,6 +547,18 @@ export const chatCostUsers = (params?: ChatCostUsersParams) => ({ staleTime: 60_000, }); +const prInsightsKey = (params?: { start_date?: string; end_date?: string }) => + [...chatsKey, "prInsights", params] as const; + +export const prInsights = (params?: { + start_date?: string; + end_date?: string; +}) => ({ + queryKey: prInsightsKey(params), + queryFn: () => API.getPRInsights(params), + staleTime: 60_000, +}); + const chatUsageLimitConfigKey = [...chatsKey, "usageLimitConfig"] as const; export const chatUsageLimitConfig = () => ({ diff --git a/site/src/pages/AgentsPage/InsightsContent.tsx b/site/src/pages/AgentsPage/InsightsContent.tsx index 8b72276375..522b563fd2 100644 --- a/site/src/pages/AgentsPage/InsightsContent.tsx +++ b/site/src/pages/AgentsPage/InsightsContent.tsx @@ -1,4 +1,4 @@ -import type { PRInsightsResponse } from "api/typesGenerated"; +import { prInsights } from "api/queries/chats"; import { Spinner } from "components/Spinner/Spinner"; import dayjs from "dayjs"; import { type FC, useCallback, useMemo, useState } from "react"; @@ -15,31 +15,11 @@ function timeRangeToDates(range: PRInsightsTimeRange) { }; } -async function fetchPRInsights( - startDate: string, - endDate: string, -): Promise { - 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("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 { data, isLoading, error } = useQuery(prInsights(dates)); const handleTimeRangeChange = useCallback( (range: PRInsightsTimeRange) => setTimeRange(range), diff --git a/site/src/pages/AgentsPage/PRInsightsView.stories.tsx b/site/src/pages/AgentsPage/PRInsightsView.stories.tsx index cc3a463907..a5a231249f 100644 --- a/site/src/pages/AgentsPage/PRInsightsView.stories.tsx +++ b/site/src/pages/AgentsPage/PRInsightsView.stories.tsx @@ -10,19 +10,30 @@ import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView"; const NOW = dayjs("2025-07-15"); +// Deterministic PRNG so story snapshots are stable across runs. +function seededRandom(seed: number) { + let s = seed; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + function generateTimeSeries( days: number, opts: { avgCreated: number; avgMerged: number; avgClosed?: number }, + seed = 42, ): TypesGen.PRInsightsTimeSeriesEntry[] { + const rand = seededRandom(seed); 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 jitter = () => Math.round((rand() - 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), + (opts.avgClosed ?? 0) + Math.round((rand() - 0.5) * 2), ); entries.push({ date, @@ -134,7 +145,11 @@ const AUTHORS = [ }, ]; -function generatePRs(count: number): TypesGen.PRInsightsPullRequest[] { +function generatePRs( + count: number, + seed = 100, +): TypesGen.PRInsightsPullRequest[] { + const rand = seededRandom(seed); const states: Array<"open" | "closed" | "merged"> = [ "merged", "merged", @@ -155,8 +170,8 @@ function generatePRs(count: number): TypesGen.PRInsightsPullRequest[] { 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); + const additions = Math.round(40 + rand() * 400); + const deletions = Math.round(10 + rand() * 150); return { chat_id: `chat-${i}`, @@ -167,22 +182,22 @@ function generatePRs(count: number): TypesGen.PRInsightsPullRequest[] { draft: state === "open" && i % 3 === 0, additions, deletions, - changed_files: Math.round(2 + Math.random() * 12), - commits: Math.round(1 + Math.random() * 6), + changed_files: Math.round(2 + rand() * 12), + commits: Math.round(1 + rand() * 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), + ? Math.round(1 + rand() * 2) + : Math.round(rand() * 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), + cost_micros: Math.round(1_500_000 + rand() * 8_000_000), created_at: NOW.subtract( - i * 4 + Math.round(Math.random() * 8), + i * 4 + Math.round(rand() * 8), "hour", ).toISOString(), }; @@ -262,11 +277,11 @@ const lowVolumeData: TypesGen.PRInsightsResponse = { 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) => ({ + by_model: MODELS.slice(0, 2).map((m, i) => ({ ...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, + merge_rate: 0.35 + i * 0.07, })), recent_prs: generatePRs(5), };