diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index 6c01b65713..84ededc4b6 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -159,6 +159,21 @@ export type DeveloperUsage = z.infer & { user: User; }; +/** + * Repository usage schema + */ +const repositoryUsageSchema = z.object({ + repositoryName: z.string(), + repositoryUrl: z.string().nullable(), + tasksStarted: z.coerce.number(), + tasksCompleted: z.coerce.number(), + tokens: z.coerce.number(), + cost: z.coerce.number(), + lastEventTimestamp: z.coerce.number(), +}); + +export type RepositoryUsage = z.infer; + export const getDeveloperUsage = async ({ orgId, timePeriod = 90, @@ -223,6 +238,67 @@ export const getDeveloperUsage = async ({ .filter((usage): usage is DeveloperUsage => !!usage.user); }; +/** + * getRepositoryUsage + */ +export const getRepositoryUsage = async ({ + orgId, + timePeriod = 90, + userId, +}: { + orgId?: string | null; + timePeriod?: AnyTimePeriod; + userId?: string | null; +}): Promise => { + await authorizeAnalytics({ + requestedOrgId: orgId, + requestedUserId: userId, + requireAdmin: true, + }); + + if (!orgId) { + return []; + } + + const userFilter = userId ? 'AND userId = {userId: String}' : ''; + const queryParams: Record = { + orgId: orgId!, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }; + if (userId) { + queryParams.userId = userId; + } + + const results = await analytics.query({ + query: ` + SELECT + repositoryName, + any(repositoryUrl) as repositoryUrl, + SUM(CASE WHEN type = '${TelemetryEventName.TASK_CREATED}' THEN 1 ELSE 0 END) AS tasksStarted, + SUM(CASE WHEN type = '${TelemetryEventName.TASK_COMPLETED}' THEN 1 ELSE 0 END) AS tasksCompleted, + SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN ${tokenSumSql()} ELSE 0 END) AS tokens, + SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost, + MAX(timestamp) AS lastEventTimestamp + FROM events + WHERE orgId = {orgId: String} + AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + AND type IN ({types: Array(String)}) + AND repositoryName IS NOT NULL + ${userFilter} + GROUP BY repositoryName + `, + format: 'JSONEachRow', + query_params: queryParams, + }); + + return z.array(repositoryUsageSchema).parse(await results.json()); +}; + /** * getModelUsage */ diff --git a/apps/web/src/app/(authenticated)/usage/Repositories.tsx b/apps/web/src/app/(authenticated)/usage/Repositories.tsx new file mode 100644 index 0000000000..c4548f7167 --- /dev/null +++ b/apps/web/src/app/(authenticated)/usage/Repositories.tsx @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useAuth } from '@clerk/nextjs'; +import { formatDistance } from 'date-fns'; +import { ExternalLink } from 'lucide-react'; + +import { type RepositoryUsage, getRepositoryUsage } from '@/actions/analytics'; +import { + formatCurrency, + formatNumber, + formatTimestamp, +} from '@/lib/formatters'; +import { Button, Skeleton } from '@/components/ui'; +import { DataTable } from '@/components/layout'; + +import type { Filter } from './types'; + +export const Repositories = ({ + onFilter, +}: { + onFilter: (filter: Filter) => void; +}) => { + const { orgId } = useAuth(); + + const { data = [], isPending } = useQuery({ + queryKey: ['getRepositoryUsage', orgId], + queryFn: () => getRepositoryUsage({ orgId }), + enabled: !!orgId, + }); + + const cols: ColumnDef[] = useMemo( + () => [ + { + header: 'Repository', + cell: ({ row }) => ( +
+ + {row.original.repositoryUrl && ( + e.stopPropagation()} + className="ml-2 text-muted-foreground hover:text-foreground" + > + + + )} +
+ ), + }, + { + accessorKey: 'tasksStarted', + header: 'Tasks Started', + }, + { + accessorKey: 'tasksCompleted', + header: 'Tasks Completed', + }, + { + header: 'Tokens', + cell: ({ row }) => formatNumber(row.original.tokens), + }, + { + header: 'Cost (USD)', + cell: ({ row }) => formatCurrency(row.original.cost), + }, + { + header: 'Last Event', + cell: ({ row }) => { + const timestamp = row.original.lastEventTimestamp; + if (!timestamp) return 'No activity'; + + const date = new Date(timestamp * 1000); + const relativeTime = formatDistance(date, new Date(), { + addSuffix: true, + }); + const absoluteTime = formatTimestamp(timestamp); + + return ( + + {relativeTime} + + ); + }, + }, + ], + [onFilter], + ); + + const columns = useMemo( + () => + isPending + ? cols.map((col) => ({ + ...col, + cell: () => , + })) + : cols, + [isPending, cols], + ); + + if (data.length === 0 && !isPending) { + return ( +
+ No Git repository data available. Repository information will appear + here once tasks with Git repository context are created. +
+ ); + } + + return ; +}; diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index bb5f406f05..e35e47b5aa 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -44,11 +44,16 @@ export const Tasks = ({ return data; } - return data.filter((task) => - filter.type === 'userId' - ? task.userId === filter.value - : task.model === filter.value, - ); + return data.filter((task) => { + if (filter.type === 'userId') { + return task.userId === filter.value; + } else if (filter.type === 'model') { + return task.model === filter.value; + } else if (filter.type === 'repositoryName') { + return task.repositoryName === filter.value; + } + return false; + }); }, [filter, data]); if (isPending) { diff --git a/apps/web/src/app/(authenticated)/usage/Usage.tsx b/apps/web/src/app/(authenticated)/usage/Usage.tsx index 76c9e70470..02798b622e 100644 --- a/apps/web/src/app/(authenticated)/usage/Usage.tsx +++ b/apps/web/src/app/(authenticated)/usage/Usage.tsx @@ -13,6 +13,7 @@ import { Loading } from '@/components/layout'; import { type Filter, type ViewMode, viewModes } from './types'; import { Developers } from './Developers'; import { Models } from './Models'; +import { Repositories } from './Repositories'; import { Tasks } from './Tasks'; import { TaskModal } from './TaskModal'; @@ -92,6 +93,8 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => { /> ) : viewMode === 'developers' ? ( + ) : viewMode === 'repositories' ? ( + ) : ( )} diff --git a/apps/web/src/app/(authenticated)/usage/types.ts b/apps/web/src/app/(authenticated)/usage/types.ts index 4df7ba00df..af4263aa0d 100644 --- a/apps/web/src/app/(authenticated)/usage/types.ts +++ b/apps/web/src/app/(authenticated)/usage/types.ts @@ -1,5 +1,14 @@ -export const viewModes = ['developers', 'models', 'tasks'] as const; +export const viewModes = [ + 'developers', + 'models', + 'repositories', + 'tasks', +] as const; export type ViewMode = (typeof viewModes)[number]; -export type Filter = { type: 'userId' | 'model'; value: string; label: string }; +export type Filter = { + type: 'userId' | 'model' | 'repositoryName'; + value: string; + label: string; +}; diff --git a/apps/web/src/components/usage/TaskCard.tsx b/apps/web/src/components/usage/TaskCard.tsx index dcd10bdcd7..f64fde13c2 100644 --- a/apps/web/src/components/usage/TaskCard.tsx +++ b/apps/web/src/components/usage/TaskCard.tsx @@ -106,6 +106,32 @@ export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => { )} + + {/* Repository (if present) */} + {task.repositoryName && ( + <> +
+ + + )} {/* Right side: Tokens and Cost with bullet separator */} diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 34506f966e..dd7aee3565 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -36,6 +36,7 @@ "view_mode_title": "View By", "view_mode_developers": "Developers", "view_mode_models": "Models", + "view_mode_repositories": "Repositories", "view_mode_tasks": "Tasks", "chart_metric_tasks": "Tasks", "chart_metric_tokens": "Tokens", diff --git a/apps/web/src/i18n/locales/fr.json b/apps/web/src/i18n/locales/fr.json index e20ec9e6ce..34386f4df0 100644 --- a/apps/web/src/i18n/locales/fr.json +++ b/apps/web/src/i18n/locales/fr.json @@ -36,6 +36,7 @@ "view_mode_title": "Afficher Par", "view_mode_developers": "Développeurs", "view_mode_models": "Modèles", + "view_mode_repositories": "Dépôts", "view_mode_tasks": "Tâches" } }