diff --git a/apps/web/src/actions/analytics/__tests__/events.test.ts b/apps/web/src/actions/analytics/__tests__/events.test.ts index c9255c6b83..72793000fc 100644 --- a/apps/web/src/actions/analytics/__tests__/events.test.ts +++ b/apps/web/src/actions/analytics/__tests__/events.test.ts @@ -58,62 +58,63 @@ describe('getTasks with filters', () => { it('should include userId filter in query parameters', async () => { await getTasks({ orgId: 'test-org', - filterType: 'userId', - filterValue: 'filter-user-id', + filters: [ + { type: 'userId', value: 'filter-user-id', label: 'Test User' }, + ], limit: 20, }); expect(mockQuery).toHaveBeenCalledWith( expect.objectContaining({ query_params: expect.objectContaining({ - filterUserId: 'filter-user-id', + filter0: 'filter-user-id', }), }), ); const queryCall = mockQuery.mock.calls[0]?.[0]; - expect(queryCall?.query).toContain('AND e.userId = {filterUserId: String}'); + expect(queryCall?.query).toContain('AND e.userId = {filter0: String}'); }); it('should include model filter in query parameters', async () => { await getTasks({ orgId: 'test-org', - filterType: 'model', - filterValue: 'gpt-4', + filters: [{ type: 'model', value: 'gpt-4', label: 'GPT-4' }], limit: 20, }); expect(mockQuery).toHaveBeenCalledWith( expect.objectContaining({ query_params: expect.objectContaining({ - filterModel: 'gpt-4', + filter0: 'gpt-4', }), }), ); const queryCall = mockQuery.mock.calls[0]?.[0]; - expect(queryCall?.query).toContain('AND e.modelId = {filterModel: String}'); + expect(queryCall?.query).toContain('AND e.modelId = {filter0: String}'); }); it('should include repository filter in query parameters', async () => { await getTasks({ orgId: 'test-org', - filterType: 'repositoryName', - filterValue: 'test-repo', + filters: [ + { type: 'repositoryName', value: 'test-repo', label: 'test-repo' }, + ], limit: 20, }); expect(mockQuery).toHaveBeenCalledWith( expect.objectContaining({ query_params: expect.objectContaining({ - filterRepository: 'test-repo', + filter0: 'test-repo', }), }), ); const queryCall = mockQuery.mock.calls[0]?.[0]; expect(queryCall?.query).toContain( - 'AND e.repositoryName = {filterRepository: String}', + 'AND e.repositoryName = {filter0: String}', ); }); @@ -124,16 +125,15 @@ describe('getTasks with filters', () => { }); const queryCall = mockQuery.mock.calls[0]?.[0]; - expect(queryCall?.query).not.toContain('filterUserId'); - expect(queryCall?.query).not.toContain('filterModel'); - expect(queryCall?.query).not.toContain('filterRepository'); + expect(queryCall?.query).not.toContain('filter0'); + expect(queryCall?.query).not.toContain('filter1'); + expect(queryCall?.query).not.toContain('filter2'); }); it('should return properly formatted tasks with user data', async () => { const result = await getTasks({ orgId: 'test-org', - filterType: 'model', - filterValue: 'gpt-4', + filters: [{ type: 'model', value: 'gpt-4', label: 'GPT-4' }], limit: 20, }); diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index 98e77cc9eb..eba1b6e7eb 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -8,6 +8,8 @@ import { } from '@roo-code/types'; import type { AnyTimePeriod } from '@/types'; +import type { Filter } from '@/types/analytics'; +import { buildFilterConditions } from '@/types/analytics'; import { analytics } from '@/lib/server'; import { tokenSumSql } from '@/lib'; import { type User, getUsersById } from '@roo-code-cloud/db/server'; @@ -178,10 +180,12 @@ export const getDeveloperUsage = async ({ orgId, timePeriod = 90, userId, + filters = [], }: { orgId?: string | null; timePeriod?: AnyTimePeriod; userId?: string | null; + filters?: Filter[]; }): Promise => { await authorizeAnalytics({ requestedOrgId: orgId, @@ -207,6 +211,9 @@ export const getDeveloperUsage = async ({ queryParams.userId = userId; } + // Build filter conditions using shared helper + const filterClause = buildFilterConditions(filters, queryParams); + const results = await analytics.query({ query: ` SELECT @@ -221,6 +228,7 @@ export const getDeveloperUsage = async ({ AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) ${userFilter} + ${filterClause} GROUP BY 1 `, format: 'JSONEachRow', @@ -245,10 +253,12 @@ export const getRepositoryUsage = async ({ orgId, timePeriod = 90, userId, + filters = [], }: { orgId?: string | null; timePeriod?: AnyTimePeriod; userId?: string | null; + filters?: Filter[]; }): Promise => { await authorizeAnalytics({ requestedOrgId: orgId, @@ -274,6 +284,9 @@ export const getRepositoryUsage = async ({ queryParams.userId = userId; } + // Build filter conditions using shared helper + const filterClause = buildFilterConditions(filters, queryParams); + const results = await analytics.query({ query: ` SELECT @@ -291,6 +304,7 @@ export const getRepositoryUsage = async ({ AND type IN ({types: Array(String)}) AND repositoryName IS NOT NULL ${userFilter} + ${filterClause} GROUP BY repositoryName `, format: 'JSONEachRow', @@ -318,10 +332,12 @@ export const getModelUsage = async ({ orgId, timePeriod = 90, userId, + filters = [], }: { orgId?: string | null; timePeriod?: AnyTimePeriod; userId?: string | null; + filters?: Filter[]; }): Promise => { await authorizeAnalytics({ requestedOrgId: orgId, @@ -349,6 +365,9 @@ export const getModelUsage = async ({ queryParams.userId = userId; } + // Build filter conditions using shared helper + const filterClause = buildFilterConditions(filters, queryParams); + const results = await analytics.query({ query: ` SELECT @@ -364,6 +383,7 @@ export const getModelUsage = async ({ AND type IN ({types: Array(String)}) AND modelId IS NOT NULL ${userFilter} + ${filterClause} GROUP BY 1, 2 `, format: 'JSONEachRow', @@ -409,8 +429,7 @@ export const getTasks = async ({ skipAuth = false, limit = 20, cursor, - filterType, - filterValue, + filters = [], }: { orgId?: string | null; userId?: string | null; @@ -419,8 +438,7 @@ export const getTasks = async ({ skipAuth?: boolean; limit?: number; cursor?: number; - filterType?: 'userId' | 'model' | 'repositoryName'; - filterValue?: string; + filters?: Filter[]; }): Promise => { let effectiveUserId = userId; @@ -480,27 +498,8 @@ export const getTasks = async ({ queryParams.cursor = cursor; } - // Add filter parameters - if (filterType && filterValue) { - if (filterType === 'userId') { - queryParams.filterUserId = filterValue; - } else if (filterType === 'model') { - queryParams.filterModel = filterValue; - } else if (filterType === 'repositoryName') { - queryParams.filterRepository = filterValue; - } - } - - // Build filter conditions - const filterConditions = []; - if (filterType === 'userId' && filterValue) { - filterConditions.push('AND e.userId = {filterUserId: String}'); - } else if (filterType === 'model' && filterValue) { - filterConditions.push('AND e.modelId = {filterModel: String}'); - } else if (filterType === 'repositoryName' && filterValue) { - filterConditions.push('AND e.repositoryName = {filterRepository: String}'); - } - const filterClause = filterConditions.join(' '); + // Build filter conditions using shared helper + const filterClause = buildFilterConditions(filters, queryParams, 'e'); // TODO: Handle same-timestamp edge cases // Currently using only timestamp as cursor, but this can miss/duplicate tasks @@ -600,10 +599,12 @@ export const getHourlyUsageByUser = async ({ orgId, timePeriod = 90, userId, + filters = [], }: { orgId?: string | null; timePeriod?: AnyTimePeriod; userId?: string | null; + filters?: Filter[]; }): Promise => { const { effectiveUserId } = await authorizeAnalytics({ requestedOrgId: orgId, @@ -637,6 +638,9 @@ export const getHourlyUsageByUser = async ({ queryParams.userId = effectiveUserId; } + // Build filter conditions using shared helper + const filterClause = buildFilterConditions(filters, queryParams); + const results = await analytics.query({ query: ` SELECT @@ -651,6 +655,7 @@ export const getHourlyUsageByUser = async ({ AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) ${userFilter} + ${filterClause} GROUP BY 1, 2 ORDER BY hour_utc DESC, userId `, diff --git a/apps/web/src/app/(authenticated)/usage/Developers.tsx b/apps/web/src/app/(authenticated)/usage/Developers.tsx index 7e801de0c6..f4fa67ec22 100644 --- a/apps/web/src/app/(authenticated)/usage/Developers.tsx +++ b/apps/web/src/app/(authenticated)/usage/Developers.tsx @@ -17,14 +17,16 @@ import type { Filter } from './types'; export const Developers = ({ onFilter, + filters = [], }: { onFilter: (filter: Filter) => void; + filters?: Filter[]; }) => { const { orgId } = useAuth(); const { data = [], isPending } = useQuery({ - queryKey: ['getDeveloperUsage', orgId], - queryFn: () => getDeveloperUsage({ orgId }), + queryKey: ['getDeveloperUsage', orgId, filters], + queryFn: () => getDeveloperUsage({ orgId, filters }), enabled: !!orgId, }); diff --git a/apps/web/src/app/(authenticated)/usage/Models.tsx b/apps/web/src/app/(authenticated)/usage/Models.tsx index 9872e4a62a..2783ef45f1 100644 --- a/apps/web/src/app/(authenticated)/usage/Models.tsx +++ b/apps/web/src/app/(authenticated)/usage/Models.tsx @@ -12,14 +12,16 @@ import type { Filter } from './types'; export const Models = ({ onFilter, + filters = [], }: { onFilter: (filter: Filter) => void; + filters?: Filter[]; }) => { const { orgId } = useAuth(); const { data = [], isPending } = useQuery({ - queryKey: ['getModelUsage', orgId], - queryFn: () => getModelUsage({ orgId }), + queryKey: ['getModelUsage', orgId, filters], + queryFn: () => getModelUsage({ orgId, filters }), enabled: !!orgId, }); diff --git a/apps/web/src/app/(authenticated)/usage/Repositories.tsx b/apps/web/src/app/(authenticated)/usage/Repositories.tsx index c4548f7167..63602b69df 100644 --- a/apps/web/src/app/(authenticated)/usage/Repositories.tsx +++ b/apps/web/src/app/(authenticated)/usage/Repositories.tsx @@ -18,14 +18,16 @@ import type { Filter } from './types'; export const Repositories = ({ onFilter, + filters = [], }: { onFilter: (filter: Filter) => void; + filters?: Filter[]; }) => { const { orgId } = useAuth(); const { data = [], isPending } = useQuery({ - queryKey: ['getRepositoryUsage', orgId], - queryFn: () => getRepositoryUsage({ orgId }), + queryKey: ['getRepositoryUsage', orgId, filters], + queryFn: () => getRepositoryUsage({ orgId, filters }), enabled: !!orgId, }); diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index 50b31eab8b..1b15cca6d9 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -13,13 +13,13 @@ import { useCursorPagination } from '@/hooks/usePagination'; import type { Filter } from './types'; export const Tasks = ({ - filter, + filters, onFilter, onTaskSelected, userRole = 'admin', currentUserId, }: { - filter: Filter | null; + filters: Filter[]; onFilter: (filter: Filter) => void; onTaskSelected: (task: TaskWithUser) => void; userRole?: 'admin' | 'member'; @@ -40,8 +40,7 @@ export const Tasks = ({ !orgId, pagination.currentCursor, pagination.pageSize, - filter?.type, - filter?.value, + filters, // Include all filters in query key for proper cache invalidation ], queryFn: () => getTasks({ @@ -49,8 +48,7 @@ export const Tasks = ({ userId: userRole === 'member' ? currentUserId : undefined, limit: pagination.pageSize, cursor: pagination.currentCursor, - filterType: filter?.type, - filterValue: filter?.value, + filters, }), enabled: true, // Run for both personal and organization context ...polling, @@ -63,20 +61,23 @@ export const Tasks = ({ } }, [data?.nextCursor, pagination]); - // Reset pagination when filter changes - const prevFilter = useRef(null); + // Reset pagination when filters change + const prevFilters = useRef([]); useEffect(() => { - const currentFilter = filter; - const hasFilterChanged = - prevFilter.current?.type !== currentFilter?.type || - prevFilter.current?.value !== currentFilter?.value; + const hasFiltersChanged = + prevFilters.current.length !== filters.length || + prevFilters.current.some( + (prevFilter, index) => + prevFilter.type !== filters[index]?.type || + prevFilter.value !== filters[index]?.value, + ); - if (hasFilterChanged) { + if (hasFiltersChanged) { pagination.reset(); - prevFilter.current = currentFilter; + prevFilters.current = [...filters]; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filter]); + }, [filters]); const tasks = data?.tasks || []; diff --git a/apps/web/src/app/(authenticated)/usage/Usage.tsx b/apps/web/src/app/(authenticated)/usage/Usage.tsx index 02798b622e..6b886c077a 100644 --- a/apps/web/src/app/(authenticated)/usage/Usage.tsx +++ b/apps/web/src/app/(authenticated)/usage/Usage.tsx @@ -3,19 +3,19 @@ import { useState, useCallback } from 'react'; import { useTranslations } from 'next-intl'; import { useUser } from '@clerk/nextjs'; -import { X } from 'lucide-react'; import type { TaskWithUser } from '@/actions/analytics'; -import { Badge, Button } from '@/components/ui'; +import { Button } from '@/components/ui'; import { UsageCard } from '@/components/usage'; import { Loading } from '@/components/layout'; -import { type Filter, type ViewMode, viewModes } from './types'; +import { type Filter, type ViewMode, viewModes, filterExists } from './types'; import { Developers } from './Developers'; import { Models } from './Models'; import { Repositories } from './Repositories'; import { Tasks } from './Tasks'; import { TaskModal } from './TaskModal'; +import { UsageFilters } from './UsageFilters'; type UsageProps = { userRole?: 'admin' | 'member'; @@ -26,23 +26,41 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => { const { isSignedIn } = useUser(); const t = useTranslations('Analytics'); const [viewMode, setViewMode] = useState('tasks'); - const [filter, setFilter] = useState(null); + const [filters, setFilters] = useState([]); const [task, setTask] = useState(null); - const onFilter = useCallback((filter: Filter) => { - setFilter(filter); + const onAddFilter = useCallback((newFilter: Filter) => { + setFilters((currentFilters) => { + // Don't add if filter already exists + if (filterExists(currentFilters, newFilter)) { + return currentFilters; + } + return [...currentFilters, newFilter]; + }); setViewMode('tasks'); }, []); + const onRemoveFilter = useCallback((filterToRemove: Filter) => { + setFilters((currentFilters) => + currentFilters.filter( + (filter) => + !( + filter.type === filterToRemove.type && + filter.value === filterToRemove.value + ), + ), + ); + }, []); + // For members, automatically set filter to their user ID and hide other tabs. const isMember = userRole === 'member'; const availableViewModes = isMember ? (['tasks'] as const) : viewModes; // Auto-apply user filter for members. - const effectiveFilter = + const effectiveFilters = isMember && currentUserId - ? { type: 'userId' as const, value: currentUserId, label: 'Your Tasks' } - : filter; + ? [{ type: 'userId' as const, value: currentUserId, label: 'Your Tasks' }] + : filters; if (!isSignedIn) { return ; @@ -51,7 +69,11 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => { return ( <>
- + {!isMember && (
{availableViewModes.map((mode) => ( @@ -67,36 +89,26 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => { ))}
)} - {filter && !isMember && ( -
- - - {filter.label} - - - -
+ {effectiveFilters.length > 0 && !isMember && ( + )} {viewMode === 'tasks' ? ( {} : onFilter} + filters={effectiveFilters} + onFilter={isMember ? () => {} : onAddFilter} onTaskSelected={(task: TaskWithUser) => setTask(task)} userRole={userRole} currentUserId={currentUserId} /> ) : viewMode === 'developers' ? ( - + ) : viewMode === 'repositories' ? ( - + ) : ( - + )}
{task && ( diff --git a/apps/web/src/app/(authenticated)/usage/UsageFilters.tsx b/apps/web/src/app/(authenticated)/usage/UsageFilters.tsx new file mode 100644 index 0000000000..8c3c1b5c3a --- /dev/null +++ b/apps/web/src/app/(authenticated)/usage/UsageFilters.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { X } from 'lucide-react'; +import { Badge, Button } from '@/components/ui'; +import type { Filter } from './types'; + +type UsageFiltersProps = { + filters: Filter[]; + onRemoveFilter: (filter: Filter) => void; + className?: string; +}; + +export const UsageFilters = ({ + filters, + onRemoveFilter, + className = '', +}: UsageFiltersProps) => { + if (filters.length === 0) { + return null; + } + + return ( +
+ {filters.map((filter) => ( + + {filter.label} + + + ))} +
+ ); +}; diff --git a/apps/web/src/app/(authenticated)/usage/types.ts b/apps/web/src/app/(authenticated)/usage/types.ts index af4263aa0d..cda4aa3129 100644 --- a/apps/web/src/app/(authenticated)/usage/types.ts +++ b/apps/web/src/app/(authenticated)/usage/types.ts @@ -1,3 +1,8 @@ +// Re-export types and helpers from shared analytics types +export type { FilterType, Filter, FilterState } from '@/types/analytics'; + +export { filterExists, groupFiltersByType } from '@/types/analytics'; + export const viewModes = [ 'developers', 'models', @@ -6,9 +11,3 @@ export const viewModes = [ ] as const; export type ViewMode = (typeof viewModes)[number]; - -export type Filter = { - type: 'userId' | 'model' | 'repositoryName'; - value: string; - label: string; -}; diff --git a/apps/web/src/components/usage/UsageCard.tsx b/apps/web/src/components/usage/UsageCard.tsx index 8951e68b07..148e6cd29a 100644 --- a/apps/web/src/components/usage/UsageCard.tsx +++ b/apps/web/src/components/usage/UsageCard.tsx @@ -27,14 +27,22 @@ import { UsageChart } from './UsageChart'; type MetricType = 'tasks' | 'tokens' | 'cost'; +type Filter = { + type: 'userId' | 'model' | 'repositoryName'; + value: string; + label: string; +}; + type UsageCardProps = { userRole?: 'admin' | 'member'; currentUserId?: string | null; + filters?: Filter[]; }; export const UsageCard = ({ userRole = 'admin', currentUserId, + filters = [], }: UsageCardProps) => { const t = useTranslations('DashboardIndex'); const { orgId } = useAuth(); @@ -187,6 +195,7 @@ export const UsageCard = ({ selectedMetric={selectedMetric} userRole={userRole} currentUserId={currentUserId} + filters={filters} /> )} diff --git a/apps/web/src/components/usage/UsageChart.tsx b/apps/web/src/components/usage/UsageChart.tsx index 18919b3baf..d9d5c9cf8e 100644 --- a/apps/web/src/components/usage/UsageChart.tsx +++ b/apps/web/src/components/usage/UsageChart.tsx @@ -283,11 +283,18 @@ const processDailyDataForChart = ( }); }; +type Filter = { + type: 'userId' | 'model' | 'repositoryName'; + value: string; + label: string; +}; + interface UsageChartProps { timePeriodConfig: TimePeriodConfig; selectedMetric?: MetricType; userRole?: 'admin' | 'member'; currentUserId?: string | null; + filters?: Filter[]; } // Custom tick components for theme-aware labels @@ -463,6 +470,7 @@ export const UsageChart = ({ selectedMetric = 'tasks', userRole = 'admin', currentUserId, + filters = [], }: UsageChartProps) => { const { orgId } = useAuth(); const [isClient, setIsClient] = useState(false); @@ -480,12 +488,14 @@ export const UsageChart = ({ timePeriodConfig.value, timePeriodConfig.granularity, userRole === 'member' || !orgId ? currentUserId : null, + filters, ], queryFn: () => getHourlyUsageByUser({ orgId, timePeriod: timePeriodConfig.value, userId: userRole === 'member' || !orgId ? currentUserId : undefined, + filters, }), enabled: !!orgId || (!orgId && !!currentUserId), // Run for org context OR personal context with userId }); diff --git a/apps/web/src/types/analytics.ts b/apps/web/src/types/analytics.ts new file mode 100644 index 0000000000..915f6c4159 --- /dev/null +++ b/apps/web/src/types/analytics.ts @@ -0,0 +1,68 @@ +export type FilterType = 'userId' | 'model' | 'repositoryName'; + +export type Filter = { + type: FilterType; + value: string; + label: string; +}; + +export type FilterState = { + filters: Filter[]; + filtersByType: Record; +}; + +// Helper function to build SQL filter conditions +export const buildFilterConditions = ( + filters: Filter[], + queryParams: Record, + tablePrefix = '', +): string => { + const filterConditions: string[] = []; + const prefix = tablePrefix ? `${tablePrefix}.` : ''; + + // Mapping between filter types and their corresponding SQL conditions + const filterTypeToSqlCondition: Record< + FilterType, + (paramKey: string) => string + > = { + userId: (paramKey) => `AND ${prefix}userId = {${paramKey}: String}`, + model: (paramKey) => `AND ${prefix}modelId = {${paramKey}: String}`, + repositoryName: (paramKey) => + `AND ${prefix}repositoryName = {${paramKey}: String}`, + }; + + filters.forEach((filter, index) => { + const paramKey = `filter${index}`; + queryParams[paramKey] = filter.value; + + const conditionBuilder = filterTypeToSqlCondition[filter.type]; + if (conditionBuilder) { + filterConditions.push(conditionBuilder(paramKey)); + } + }); + + return filterConditions.join(' '); +}; + +// Helper functions for filter management +export const filterExists = (filters: Filter[], newFilter: Filter): boolean => { + return filters.some( + (filter) => + filter.type === newFilter.type && filter.value === newFilter.value, + ); +}; + +export const groupFiltersByType = ( + filters: Filter[], +): Record => { + return filters.reduce( + (acc, filter) => { + if (!acc[filter.type]) { + acc[filter.type] = []; + } + acc[filter.type].push(filter); + return acc; + }, + {} as Record, + ); +};