Support multiple filters and reflect them in the chart (#194)

This commit is contained in:
Matt Rubens 2025-07-02 21:59:16 -04:00 committed by GitHub
parent ab04bf6ec7
commit 0111ee2193
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 253 additions and 99 deletions

View file

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

View file

@ -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<DeveloperUsage[]> => {
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<RepositoryUsage[]> => {
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<ModelUsage[]> => {
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<TasksResult> => {
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<HourlyUsageByUser[]> => {
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
`,

View file

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

View file

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

View file

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

View file

@ -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<Filter | null>(null);
// Reset pagination when filters change
const prevFilters = useRef<Filter[]>([]);
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 || [];

View file

@ -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<ViewMode>('tasks');
const [filter, setFilter] = useState<Filter | null>(null);
const [filters, setFilters] = useState<Filter[]>([]);
const [task, setTask] = useState<TaskWithUser | null>(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 <Loading />;
@ -51,7 +69,11 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
return (
<>
<div className="flex flex-col gap-3 sm:gap-4 lg:gap-6">
<UsageCard userRole={userRole} currentUserId={currentUserId} />
<UsageCard
userRole={userRole}
currentUserId={currentUserId}
filters={effectiveFilters}
/>
{!isMember && (
<div className="flex flex-wrap gap-2">
{availableViewModes.map((mode) => (
@ -67,36 +89,26 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
))}
</div>
)}
{filter && !isMember && (
<div className="flex flex-row">
<Badge variant="outline">
<span className="text-sm">
<strong>{filter.label}</strong>
</span>
<Button
variant="link"
size="icon"
onClick={() => setFilter(null)}
>
<X className="size-4" />
</Button>
</Badge>
</div>
{effectiveFilters.length > 0 && !isMember && (
<UsageFilters
filters={effectiveFilters}
onRemoveFilter={onRemoveFilter}
/>
)}
{viewMode === 'tasks' ? (
<Tasks
filter={effectiveFilter}
onFilter={isMember ? () => {} : onFilter}
filters={effectiveFilters}
onFilter={isMember ? () => {} : onAddFilter}
onTaskSelected={(task: TaskWithUser) => setTask(task)}
userRole={userRole}
currentUserId={currentUserId}
/>
) : viewMode === 'developers' ? (
<Developers onFilter={onFilter} />
<Developers onFilter={onAddFilter} filters={effectiveFilters} />
) : viewMode === 'repositories' ? (
<Repositories onFilter={onFilter} />
<Repositories onFilter={onAddFilter} filters={effectiveFilters} />
) : (
<Models onFilter={onFilter} />
<Models onFilter={onAddFilter} filters={effectiveFilters} />
)}
</div>
{task && (

View file

@ -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 (
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
{filters.map((filter) => (
<Badge
key={`${filter.type}-${filter.value}`}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
<span className="text-xs">{filter.label}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => onRemoveFilter(filter)}
title={`Remove ${filter.label} filter`}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
);
};

View file

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

View file

@ -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}
/>
)}
</div>

View file

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

View file

@ -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<FilterType, Filter[]>;
};
// Helper function to build SQL filter conditions
export const buildFilterConditions = (
filters: Filter[],
queryParams: Record<string, string | number | string[]>,
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<FilterType, Filter[]> => {
return filters.reduce(
(acc, filter) => {
if (!acc[filter.type]) {
acc[filter.type] = [];
}
acc[filter.type].push(filter);
return acc;
},
{} as Record<FilterType, Filter[]>,
);
};