mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
Support multiple filters and reflect them in the chart (#194)
This commit is contained in:
parent
ab04bf6ec7
commit
0111ee2193
12 changed files with 253 additions and 99 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
44
apps/web/src/app/(authenticated)/usage/UsageFilters.tsx
Normal file
44
apps/web/src/app/(authenticated)/usage/UsageFilters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
68
apps/web/src/types/analytics.ts
Normal file
68
apps/web/src/types/analytics.ts
Normal 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[]>,
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue