mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
c069bae97d
commit
6571f5a1e8
8 changed files with 253 additions and 7 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
125
apps/web/src/app/(authenticated)/usage/Repositories.tsx
Normal file
125
apps/web/src/app/(authenticated)/usage/Repositories.tsx
Normal 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} />;
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue