add git repo aggregation (#138)

Context
Currently, our analytics system provides data aggregation by developer. This PR extends this functionality to also provide analytics aggregated by repository name, allowing organizations to better understand which repositories are consuming the most resources.

Changes
Added a new getRepositoryUsage function in events.ts to aggregate data by repository
Created a new Repositories.tsx component to display repository analytics
Updated types.ts to add 'repositories' to viewModes and 'repositoryName' as a filter type
Enhanced Tasks.tsx to handle filtering by repository name
Updated Usage.tsx to render the Repositories component
Enhanced TaskCard.tsx to display repository information
Added translations for "repositories" in locale files
Added empty state handling for when no repository data is available
Testing
Navigate to Usage page and switch to "Repositories" tab
Click on repository names to filter tasks
Verify repository information appears in task cards
Test with accounts that have no repository data
This commit is contained in:
Will Li 2025-06-27 11:10:46 -07:00 committed by GitHub
parent c069bae97d
commit 6571f5a1e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 253 additions and 7 deletions

View file

@ -159,6 +159,21 @@ export type DeveloperUsage = z.infer<typeof developerUsageSchema> & {
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<typeof repositoryUsageSchema>;
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<RepositoryUsage[]> => {
await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
requireAdmin: true,
});
if (!orgId) {
return [];
}
const userFilter = userId ? 'AND userId = {userId: String}' : '';
const queryParams: Record<string, string | number | string[]> = {
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
*/

View file

@ -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<RepositoryUsage>[] = useMemo(
() => [
{
header: 'Repository',
cell: ({ row }) => (
<div className="flex items-center">
<Button
variant="link"
onClick={() =>
onFilter({
type: 'repositoryName',
value: row.original.repositoryName,
label: row.original.repositoryName,
})
}
className="px-0"
>
{row.original.repositoryName}
</Button>
{row.original.repositoryUrl && (
<a
href={row.original.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="ml-2 text-muted-foreground hover:text-foreground"
>
<ExternalLink size={14} />
</a>
)}
</div>
),
},
{
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 (
<span title={absoluteTime} className="cursor-help">
{relativeTime}
</span>
);
},
},
],
[onFilter],
);
const columns = useMemo(
() =>
isPending
? cols.map((col) => ({
...col,
cell: () => <Skeleton className="h-9 w-full" />,
}))
: cols,
[isPending, cols],
);
if (data.length === 0 && !isPending) {
return (
<div className="text-center py-8 text-muted-foreground">
No Git repository data available. Repository information will appear
here once tasks with Git repository context are created.
</div>
);
}
return <DataTable columns={columns} data={data} />;
};

View file

@ -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) {

View file

@ -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' ? (
<Developers onFilter={onFilter} />
) : viewMode === 'repositories' ? (
<Repositories onFilter={onFilter} />
) : (
<Models onFilter={onFilter} />
)}

View file

@ -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;
};

View file

@ -106,6 +106,32 @@ export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => {
</span>
</>
)}
{/* Repository (if present) */}
{task.repositoryName && (
<>
<div className="mx-2 text-muted-foreground/30"></div>
<Button
variant="link"
size="sm"
onClick={(e) => {
e.stopPropagation();
// Only allow filtering if onFilter is provided (disabled for members)
if (onFilter && task.repositoryName) {
onFilter({
type: 'repositoryName',
value: task.repositoryName,
label: task.repositoryName,
});
}
}}
className="px-0 h-auto text-xs font-normal text-muted-foreground hover:text-foreground"
disabled={!onFilter}
>
{task.repositoryName}
</Button>
</>
)}
</div>
{/* Right side: Tokens and Cost with bullet separator */}

View file

@ -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",

View file

@ -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"
}
}