From 3aec71ea16ed8d7964d579d34087872df7e4abd7 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Sun, 18 May 2025 15:13:51 -0700 Subject: [PATCH] Pull task data from ClickHouse (#33) --- src/actions/analytics.ts | 169 ++++++++- .../(authenticated)/audit-logs/AuditLogs.tsx | 2 +- .../DefaultParameters.tsx | 2 +- .../ProviderWhitelist.tsx | 2 +- .../(authenticated)/usage/ActiveFilter.tsx | 21 -- src/app/(authenticated)/usage/Developers.tsx | 39 +- src/app/(authenticated)/usage/Loader.tsx | 14 + src/app/(authenticated)/usage/Models.tsx | 115 +++--- src/app/(authenticated)/usage/Status.tsx | 13 + src/app/(authenticated)/usage/TaskDetails.tsx | 90 ----- src/app/(authenticated)/usage/TaskDrawer.tsx | 73 ++++ src/app/(authenticated)/usage/Tasks.tsx | 341 ++++-------------- src/app/(authenticated)/usage/Usage.tsx | 70 ++-- .../(authenticated)/usage/ViewModeToggle.tsx | 30 -- src/app/(authenticated)/usage/types.ts | 43 +-- src/components/layout/DataTable.tsx | 2 +- src/components/ui/badge.tsx | 2 +- src/components/ui/button.tsx | 2 +- src/components/usage/UsageCard.tsx | 8 +- 19 files changed, 454 insertions(+), 584 deletions(-) delete mode 100644 src/app/(authenticated)/usage/ActiveFilter.tsx create mode 100644 src/app/(authenticated)/usage/Loader.tsx create mode 100644 src/app/(authenticated)/usage/Status.tsx delete mode 100644 src/app/(authenticated)/usage/TaskDetails.tsx create mode 100644 src/app/(authenticated)/usage/TaskDrawer.tsx delete mode 100644 src/app/(authenticated)/usage/ViewModeToggle.tsx diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts index 728fed9249..651f7d7477 100644 --- a/src/actions/analytics.ts +++ b/src/actions/analytics.ts @@ -44,10 +44,9 @@ export const captureEvent = async ({ const usageSchema = z.object({ type: z.nativeEnum(TelemetryEventName), - userCount: z.coerce.number(), - eventCount: z.coerce.number(), - inputTokens: z.coerce.number(), - outputTokens: z.coerce.number(), + users: z.coerce.number(), + events: z.coerce.number(), + tokens: z.coerce.number(), cost: z.number(), }); @@ -57,26 +56,27 @@ type UsageRecord = Partial>; export const getUsage = async ({ orgId, - timePeriod, + timePeriod = 90, }: { orgId?: string | null; - timePeriod: TimePeriod; + timePeriod?: TimePeriod; }): Promise => { if (!orgId) { return {}; } - const resultSet = await analytics.query({ + const results = await analytics.query({ query: ` SELECT type, - COUNT(distinct userId) as userCount, - COUNT(1) as eventCount, - SUM(COALESCE(inputTokens, 0)) AS inputTokens, - SUM(COALESCE(outputTokens, 0)) AS outputTokens, + COUNT(1) as events, + COUNT(distinct userId) as users, + SUM(COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0)) AS tokens, SUM(COALESCE(cost, 0)) AS cost FROM events - WHERE orgId = {orgId: String} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + WHERE + orgId = {orgId: String} + AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) GROUP BY 1 `, format: 'JSONEachRow', @@ -85,7 +85,7 @@ export const getUsage = async ({ return z .array(usageSchema) - .parse(await resultSet.json()) + .parse(await results.json()) .reduce( (collect, usage) => ({ ...collect, [usage.type]: usage }), {} as UsageRecord, @@ -110,16 +110,16 @@ export type DeveloperUsage = z.infer & { export const getDeveloperUsage = async ({ orgId, - timePeriod, + timePeriod = 90, }: { orgId?: string | null; - timePeriod: TimePeriod; + timePeriod?: TimePeriod; }): Promise => { if (!orgId) { return []; } - const resultSet = await analytics.query({ + const results = await analytics.query({ query: ` SELECT userId, @@ -128,16 +128,26 @@ export const getDeveloperUsage = async ({ SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens, SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost FROM events - WHERE orgId = {orgId: String} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + WHERE orgId = {orgId: String} + AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + AND type IN ({types: Array(String)}) GROUP BY 1 `, format: 'JSONEachRow', - query_params: { orgId, timePeriod }, + query_params: { + orgId, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }, }); const developerUsages = z .array(developerUsageSchema) - .parse(await resultSet.json()); + .parse(await results.json()); const users = await getUsersById(developerUsages.map(({ userId }) => userId)); @@ -145,3 +155,124 @@ export const getDeveloperUsage = async ({ .map((usage) => ({ ...usage, user: users[usage.userId] })) .filter((usage): usage is DeveloperUsage => !!usage.user); }; + +/** + * getModelUsage + */ + +const modelUsageSchema = z.object({ + provider: z.string(), + model: z.string(), + tasks: z.coerce.number(), + tokens: z.coerce.number(), + cost: z.coerce.number(), +}); + +export type ModelUsage = z.infer; + +export const getModelUsage = async ({ + orgId, + timePeriod = 90, +}: { + orgId?: string | null; + timePeriod?: TimePeriod; +}): Promise => { + if (!orgId) { + return []; + } + + const results = await analytics.query({ + query: ` + SELECT + apiProvider as provider, + modelId as model, + SUM(CASE WHEN type = '${TelemetryEventName.TASK_CREATED}' THEN 1 ELSE 0 END) AS tasks, + SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens, + SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost + FROM events + WHERE + orgId = {orgId: String} + AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + AND type IN ({types: Array(String)}) + GROUP BY 1, 2 + `, + format: 'JSONEachRow', + query_params: { + orgId, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }, + }); + + return z.array(modelUsageSchema).parse(await results.json()); +}; + +/** + * getTasks + */ + +const taskSchema = z.object({ + taskId: z.string(), + userId: z.string(), + provider: z.string(), + model: z.string(), + completed: z.coerce.boolean(), + tokens: z.coerce.number(), + cost: z.coerce.number(), + timestamp: z.coerce.number(), +}); + +export type Task = z.infer & { + user: User; +}; + +export const getTasks = async ({ + orgId, +}: { + orgId?: string | null; +}): Promise => { + if (!orgId) { + return []; + } + + const results = await analytics.query({ + query: ` + SELECT + taskId, + userId, + apiProvider AS provider, + modelId as model, + MAX(CASE WHEN type = 'Task Completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN type = 'LLM Completion' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens, + SUM(CASE WHEN type = 'LLM Completion' THEN COALESCE(cost, 0) ELSE 0 END) AS cost, + MIN(timestamp) AS timestamp + FROM events + WHERE + orgId = {orgId: String} + AND type IN ({types: Array(String)}) + GROUP BY 1, 2, 3, 4 + ORDER BY timestamp DESC + `, + format: 'JSONEachRow', + query_params: { + orgId, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }, + }); + + const tasks = z.array(taskSchema).parse(await results.json()); + + const users = await getUsersById(tasks.map(({ userId }) => userId)); + + return tasks + .map((usage) => ({ ...usage, user: users[usage.userId] })) + .filter((usage): usage is Task => !!usage.user); +}; diff --git a/src/app/(authenticated)/audit-logs/AuditLogs.tsx b/src/app/(authenticated)/audit-logs/AuditLogs.tsx index 277c0dfaf4..ab34709052 100644 --- a/src/app/(authenticated)/audit-logs/AuditLogs.tsx +++ b/src/app/(authenticated)/audit-logs/AuditLogs.tsx @@ -24,7 +24,7 @@ export const AuditLogs = () => { const [timePeriod, setTimePeriod] = useState(7); const { data: logs = [], isPending } = useQuery({ - queryKey: ['auditLogs', orgId, limit, timePeriod], + queryKey: ['getAuditLogs', orgId, limit, timePeriod], queryFn: () => getAuditLogs({ orgId, limit, timePeriod }), enabled: !!orgId, }); diff --git a/src/app/(authenticated)/org/[[...organization-profile]]/DefaultParameters.tsx b/src/app/(authenticated)/org/[[...organization-profile]]/DefaultParameters.tsx index bafd606a64..64e33090ed 100644 --- a/src/app/(authenticated)/org/[[...organization-profile]]/DefaultParameters.tsx +++ b/src/app/(authenticated)/org/[[...organization-profile]]/DefaultParameters.tsx @@ -809,7 +809,7 @@ export const DefaultParameters = () => { const queryClient = useQueryClient(); const { data: orgSettings } = useQuery({ - queryKey: ['organizationSettings'], + queryKey: ['getOrganizationSettings'], queryFn: getOrganizationSettings, }); diff --git a/src/app/(authenticated)/org/[[...organization-profile]]/ProviderWhitelist.tsx b/src/app/(authenticated)/org/[[...organization-profile]]/ProviderWhitelist.tsx index 7c410c729d..7a8f739023 100644 --- a/src/app/(authenticated)/org/[[...organization-profile]]/ProviderWhitelist.tsx +++ b/src/app/(authenticated)/org/[[...organization-profile]]/ProviderWhitelist.tsx @@ -383,7 +383,7 @@ export const ProviderWhitelist = () => { const queryClient = useQueryClient(); const { data: orgSettings } = useQuery({ - queryKey: ['organizationSettings'], + queryKey: ['getOrganizationSettings'], queryFn: getOrganizationSettings, }); diff --git a/src/app/(authenticated)/usage/ActiveFilter.tsx b/src/app/(authenticated)/usage/ActiveFilter.tsx deleted file mode 100644 index 4a7922dd66..0000000000 --- a/src/app/(authenticated)/usage/ActiveFilter.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { X } from 'lucide-react'; - -import { Button } from '@/components/ui'; - -import type { Filter } from './types'; - -type ActiveFilterProps = { - filter: Filter; - onClear: () => void; -}; - -export const ActiveFilter = ({ filter, onClear }: ActiveFilterProps) => ( -
- - Filtered by {filter.type}: {filter.name} - - -
-); diff --git a/src/app/(authenticated)/usage/Developers.tsx b/src/app/(authenticated)/usage/Developers.tsx index 9dd383275b..c9ba07cbe1 100644 --- a/src/app/(authenticated)/usage/Developers.tsx +++ b/src/app/(authenticated)/usage/Developers.tsx @@ -2,35 +2,44 @@ import { useQuery } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; import { useAuth } from '@clerk/nextjs'; -import { type User } from '@/db'; import { type DeveloperUsage, getDeveloperUsage } from '@/actions/analytics'; -import { DataTable } from '@/components/layout/DataTable'; import { formatCurrency, formatNumber } from '@/lib/formatters'; +import { Button } from '@/components/ui'; +import { DataTable } from '@/components/layout/DataTable'; + +import type { Filter } from './types'; +import { Loader } from './Loader'; export const Developers = ({ - onDeveloperSelected, + onFilter, }: { - onDeveloperSelected: (user: User) => void; + onFilter: (filter: Filter) => void; }) => { const { orgId } = useAuth(); - const { data = [] } = useQuery({ - queryKey: ['developers'], - queryFn: () => getDeveloperUsage({ orgId, timePeriod: 30 }), + const { data = [], isPending } = useQuery({ + queryKey: ['getDeveloperUsage', orgId], + queryFn: () => getDeveloperUsage({ orgId }), enabled: !!orgId, }); const columns: ColumnDef[] = [ { - accessorKey: 'user.name', header: 'Developer', cell: ({ row }) => ( - + ), }, { @@ -46,16 +55,18 @@ export const Developers = ({ header: 'Tasks Completed', }, { - accessorKey: 'tokens', header: 'Tokens', cell: ({ row }) => formatNumber(row.original.tokens), }, { - accessorKey: 'cost', header: 'Cost (USD)', cell: ({ row }) => formatCurrency(row.original.cost), }, ]; + if (isPending) { + return ; + } + return ; }; diff --git a/src/app/(authenticated)/usage/Loader.tsx b/src/app/(authenticated)/usage/Loader.tsx new file mode 100644 index 0000000000..8e946ecee2 --- /dev/null +++ b/src/app/(authenticated)/usage/Loader.tsx @@ -0,0 +1,14 @@ +import { LoaderCircle } from 'lucide-react'; + +import { Skeleton } from '@/components/ui'; + +export const Loader = () => ( +
+
+ +
+
+ +
+
+); diff --git a/src/app/(authenticated)/usage/Models.tsx b/src/app/(authenticated)/usage/Models.tsx index eefdb1d2e9..30825e083b 100644 --- a/src/app/(authenticated)/usage/Models.tsx +++ b/src/app/(authenticated)/usage/Models.tsx @@ -1,69 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; +import { useAuth } from '@clerk/nextjs'; +import { type ModelUsage, getModelUsage } from '@/actions/analytics'; +import { formatCurrency, formatNumber } from '@/lib/formatters'; +import { Button } from '@/components/ui'; import { DataTable } from '@/components/layout/DataTable'; -import type { Model } from './types'; +import type { Filter } from './types'; +import { Loader } from './Loader'; -const mockModels: Model[] = [ - { - id: 'model1', - name: 'GPT-4', - provider: 'OpenAI', - tasks: 45, - tokensConsumed: 1500000, - cost: 30.0, - }, - { - id: 'model2', - name: 'Claude 3 Opus', - provider: 'Anthropic', - tasks: 35, - tokensConsumed: 1000000, - cost: 20.0, - }, - { - id: 'model3', - name: 'Mistral Large', - provider: 'Mistral AI', - tasks: 25, - tokensConsumed: 430000, - cost: 9.1, - }, - { - id: 'model4', - name: 'GPT-3.5 Turbo', - provider: 'OpenAI', - tasks: 38, - tokensConsumed: 850000, - cost: 8.5, - }, - { - id: 'model5', - name: 'Claude 3 Sonnet', - provider: 'Anthropic', - tasks: 30, - tokensConsumed: 720000, - cost: 14.4, - }, -]; +export const Models = ({ + onFilter, +}: { + onFilter: (filter: Filter) => void; +}) => { + const { orgId } = useAuth(); -export const Models = ({ onClick }: { onClick: (model: Model) => void }) => { - const modelColumns: ColumnDef[] = [ + const { data = [], isPending } = useQuery({ + queryKey: ['getModelUsage', orgId], + queryFn: () => getModelUsage({ orgId }), + enabled: !!orgId, + }); + + const columns: ColumnDef[] = [ { - accessorKey: 'name', header: 'Model', - cell: ({ row }) => { - const model = row.original; - - return ( - - ); - }, + cell: ({ row: { original: model } }) => ( + + ), }, { accessorKey: 'provider', @@ -74,26 +51,18 @@ export const Models = ({ onClick }: { onClick: (model: Model) => void }) => { header: 'Tasks', }, { - accessorKey: 'tokensConsumed', header: 'Tokens', - cell: ({ row }) => { - const tokens = row.getValue('tokensConsumed') as number; - return tokens >= 1000000 - ? `${(tokens / 1000000).toFixed(1)}M` - : tokens >= 1000 - ? `${(tokens / 1000).toFixed(1)}K` - : tokens; - }, + cell: ({ row }) => formatNumber(row.original.tokens), }, { - accessorKey: 'cost', header: 'Cost (USD)', - cell: ({ row }) => { - const cost = row.getValue('cost') as number; - return `$${cost.toFixed(2)}`; - }, + cell: ({ row }) => formatCurrency(row.original.cost), }, ]; - return ; + if (isPending) { + return ; + } + + return ; }; diff --git a/src/app/(authenticated)/usage/Status.tsx b/src/app/(authenticated)/usage/Status.tsx new file mode 100644 index 0000000000..870da8b96b --- /dev/null +++ b/src/app/(authenticated)/usage/Status.tsx @@ -0,0 +1,13 @@ +import { Dot } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui'; + +export const Status = ({ completed }: { completed: boolean }) => ( + + + {completed ? 'Complete' : 'Incomplete'} + +); diff --git a/src/app/(authenticated)/usage/TaskDetails.tsx b/src/app/(authenticated)/usage/TaskDetails.tsx deleted file mode 100644 index 799dabafd3..0000000000 --- a/src/app/(authenticated)/usage/TaskDetails.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from '@/components/ui'; - -import type { Task } from './types'; - -export const TaskDetails = ({ - task, - onClose, -}: { - task: Task | null; - onClose: () => void; -}) => - task ? ( - - - - Task Details - -
-
-
- Task ID: - {task.id} -
-
- Date: - {task.date.toLocaleString()} -
-
- Developer: - {task.developerName} -
-
- Model: - {task.modelName} -
-
- Tokens: - - {task.tokensConsumed.toLocaleString()} - -
-
- Cost: - ${task.cost.toFixed(2)} -
-
- Status: - - {task.status.charAt(0).toUpperCase() + task.status.slice(1)} - -
-
-

Conversation

-
- {task.conversation.map((message) => ( -
-
- {message.role.charAt(0).toUpperCase() + message.role.slice(1)}{' '} - •{message.timestamp.toLocaleTimeString()} -
-
{message.content}
-
- ))} -
-
-
-
- ) : null; diff --git a/src/app/(authenticated)/usage/TaskDrawer.tsx b/src/app/(authenticated)/usage/TaskDrawer.tsx new file mode 100644 index 0000000000..ea07a0df23 --- /dev/null +++ b/src/app/(authenticated)/usage/TaskDrawer.tsx @@ -0,0 +1,73 @@ +import { X } from 'lucide-react'; + +import type { Task } from '@/actions/analytics'; +import { formatCurrency, formatNumber } from '@/lib/formatters'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + Button, +} from '@/components/ui'; + +import { Status } from './Status'; + +type TaskDrawerProps = { + task: Task; + onClose: () => void; +}; + +export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => ( + + + + Task + {task.taskId} +
+ +
+
+
+
+
+ Developer + {task.user.name} +
+
+ Provider + {task.provider} +
+
+ Model + {task.model} +
+
+ Tokens + + {formatNumber(task.tokens)} + +
+
+ Cost + + {formatCurrency(task.cost)} + +
+
+ Date + + {new Date(task.timestamp * 1000).toLocaleString()} + +
+
+ Status + +
+
+
+
+
+); diff --git a/src/app/(authenticated)/usage/Tasks.tsx b/src/app/(authenticated)/usage/Tasks.tsx index ba5498db07..f79aa061eb 100644 --- a/src/app/(authenticated)/usage/Tasks.tsx +++ b/src/app/(authenticated)/usage/Tasks.tsx @@ -1,298 +1,117 @@ import { useMemo } from 'react'; import type { ColumnDef } from '@tanstack/react-table'; +import { useAuth } from '@clerk/nextjs'; +import { useQuery } from '@tanstack/react-query'; +import { type Task, getTasks } from '@/actions/analytics'; +import { formatNumber, formatCurrency } from '@/lib/formatters'; +import { Button } from '@/components/ui'; import { DataTable } from '@/components/layout/DataTable'; -import type { Filter, Task, ConversationMessage } from './types'; - -const generateMockConversation = (taskId: string): ConversationMessage[] => { - const baseConversations: Record = { - task1: [ - { - id: 'msg1', - role: 'user', - content: - 'Create a React component that displays a list of items with pagination.', - timestamp: new Date('2025-05-01T10:00:00'), - }, - { - id: 'msg2', - role: 'assistant', - content: - "I'll create a React component for displaying a paginated list. Here's how we can implement it...", - timestamp: new Date('2025-05-01T10:00:30'), - }, - { - id: 'msg3', - role: 'user', - content: 'Can you add sorting functionality as well?', - timestamp: new Date('2025-05-01T10:02:00'), - }, - { - id: 'msg4', - role: 'assistant', - content: - "Certainly! I'll add sorting functionality to the component. Here's the updated implementation...", - timestamp: new Date('2025-05-01T10:02:30'), - }, - ], - task2: [ - { - id: 'msg1', - role: 'user', - content: - 'Write a function to calculate the Fibonacci sequence recursively.', - timestamp: new Date('2025-05-02T11:30:00'), - }, - { - id: 'msg2', - role: 'assistant', - content: - "Here's a recursive function to calculate the Fibonacci sequence in JavaScript...", - timestamp: new Date('2025-05-02T11:30:30'), - }, - { - id: 'msg3', - role: 'user', - content: 'Can you optimize it to avoid redundant calculations?', - timestamp: new Date('2025-05-02T11:32:00'), - }, - { - id: 'msg4', - role: 'assistant', - content: - "Yes, we can optimize it using memoization to avoid redundant calculations. Here's the optimized version...", - timestamp: new Date('2025-05-02T11:32:30'), - }, - ], - }; - - if (baseConversations[taskId]) { - return baseConversations[taskId]; - } - - return [ - { - id: `${taskId}-msg1`, - role: 'user', - content: 'I need help with a coding task.', - timestamp: new Date(), - }, - { - id: `${taskId}-msg2`, - role: 'assistant', - content: - "I'd be happy to help! What kind of coding task are you working on?", - timestamp: new Date(Date.now() + 30000), - }, - { - id: `${taskId}-msg3`, - role: 'user', - content: "I'm trying to implement a feature in my application.", - timestamp: new Date(Date.now() + 60000), - }, - { - id: `${taskId}-msg4`, - role: 'assistant', - content: - 'I can help with that. Let me provide some guidance on implementing that feature...', - timestamp: new Date(Date.now() + 90000), - }, - ]; -}; - -const mockTasks: Task[] = [ - { - id: 'task1', - date: new Date('2025-05-01T10:00:00'), - developerId: 'dev1', - developerName: 'John Doe', - modelId: 'model1', - modelName: 'GPT-4', - tokensConsumed: 15000, - cost: 0.3, - status: 'completed', - conversation: generateMockConversation('task1'), - }, - { - id: 'task2', - date: new Date('2025-05-02T11:30:00'), - developerId: 'dev2', - developerName: 'Jane Smith', - modelId: 'model2', - modelName: 'Claude 3 Opus', - tokensConsumed: 12000, - cost: 0.24, - status: 'completed', - conversation: generateMockConversation('task2'), - }, - { - id: 'task3', - date: new Date('2025-05-03T14:15:00'), - developerId: 'dev3', - developerName: 'Bob Johnson', - modelId: 'model3', - modelName: 'Mistral Large', - tokensConsumed: 8000, - cost: 0.16, - status: 'completed', - conversation: generateMockConversation('task3'), - }, - { - id: 'task4', - date: new Date('2025-05-04T09:45:00'), - developerId: 'dev1', - developerName: 'John Doe', - modelId: 'model2', - modelName: 'Claude 3 Opus', - tokensConsumed: 10000, - cost: 0.2, - status: 'completed', - conversation: generateMockConversation('task4'), - }, - { - id: 'task5', - date: new Date('2025-05-05T16:30:00'), - developerId: 'dev2', - developerName: 'Jane Smith', - modelId: 'model1', - modelName: 'GPT-4', - tokensConsumed: 18000, - cost: 0.36, - status: 'started', - conversation: generateMockConversation('task5'), - }, - { - id: 'task6', - date: new Date('2025-05-06T13:20:00'), - developerId: 'dev4', - developerName: 'Alice Williams', - modelId: 'model4', - modelName: 'GPT-3.5 Turbo', - tokensConsumed: 7500, - cost: 0.08, - status: 'completed', - conversation: generateMockConversation('task6'), - }, - { - id: 'task7', - date: new Date('2025-05-07T10:10:00'), - developerId: 'dev5', - developerName: 'Charlie Brown', - modelId: 'model5', - modelName: 'Claude 3 Sonnet', - tokensConsumed: 9200, - cost: 0.18, - status: 'failed', - conversation: generateMockConversation('task7'), - }, - { - id: 'task8', - date: new Date('2025-05-07T15:45:00'), - developerId: 'dev3', - developerName: 'Bob Johnson', - modelId: 'model1', - modelName: 'GPT-4', - tokensConsumed: 14000, - cost: 0.28, - status: 'completed', - conversation: generateMockConversation('task8'), - }, -]; +import type { Filter } from './types'; +import { Loader } from './Loader'; +import { Status } from './Status'; export const Tasks = ({ filter, - onClick, + onFilter, + onTaskSelected, }: { filter: Filter | null; - onClick: (task: Task) => void; + onFilter: (filter: Filter) => void; + onTaskSelected: (task: Task) => void; }) => { - const filteredTasks = useMemo(() => { + const { orgId } = useAuth(); + + const { data = [], isPending } = useQuery({ + queryKey: ['getTasks', orgId], + queryFn: () => getTasks({ orgId }), + enabled: !!orgId, + }); + + const tasks = useMemo(() => { if (!filter) { - return mockTasks; + return data; } - return mockTasks.filter((task) => - filter.type === 'developer' - ? task.developerId === filter.id - : task.modelId === filter.id, + return data.filter((task) => + filter.type === 'userId' + ? task.userId === filter.value + : task.model === filter.value, ); - }, [filter]); + }, [filter, data]); - const taskColumns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { - accessorKey: 'id', header: 'Task ID', - cell: ({ row }) => { - const task = row.original; - - return ( - - ); - }, + cell: ({ row: { original: task } }) => ( + + ), }, { - accessorKey: 'date', - header: 'Date', - cell: ({ row }) => { - const date = row.getValue('date') as Date; - return date.toLocaleString(); - }, - }, - { - accessorKey: 'developerName', header: 'Developer', + cell: ({ row }) => ( + + ), }, { - accessorKey: 'modelName', header: 'Model', + cell: ({ row }) => ( + + ), }, { - accessorKey: 'tokensConsumed', header: 'Tokens', - cell: ({ row }) => { - const tokens = row.getValue('tokensConsumed') as number; - return tokens >= 1000000 - ? `${(tokens / 1000000).toFixed(1)}M` - : tokens >= 1000 - ? `${(tokens / 1000).toFixed(1)}K` - : tokens; - }, + cell: ({ row }) => formatNumber(row.original.tokens), }, { - accessorKey: 'cost', header: 'Cost (USD)', - cell: ({ row }) => { - const cost = row.getValue('cost') as number; - return `$${cost.toFixed(2)}`; - }, + cell: ({ row }) => formatCurrency(row.original.cost), + }, + { + header: 'Date', + cell: ({ row }) => + new Date(1000 * row.original.timestamp).toLocaleString(), }, { - accessorKey: 'status', header: 'Status', - cell: ({ row }) => { - const status = row.getValue('status') as string; - return ( - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ); - }, + cell: ({ row }) => , }, ]; - return ; + if (isPending) { + return ; + } + + return ; }; diff --git a/src/app/(authenticated)/usage/Usage.tsx b/src/app/(authenticated)/usage/Usage.tsx index 1845d3d0df..c597b5ff54 100644 --- a/src/app/(authenticated)/usage/Usage.tsx +++ b/src/app/(authenticated)/usage/Usage.tsx @@ -1,53 +1,75 @@ 'use client'; -import React, { useState } from 'react'; +import { useState, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { X } from 'lucide-react'; -import { type User } from '@/db'; -import { UsageCard } from '@/components/usage/UsageCard'; +import type { Task } from '@/actions/analytics'; +import { Badge, Button } from '@/components/ui'; +import { UsageCard } from '@/components/usage'; -import type { Filter, Model, Task, ViewMode } from './types'; -import { ViewModeToggle } from './ViewModeToggle'; -import { ActiveFilter } from './ActiveFilter'; +import { type Filter, type ViewMode, viewModes } from './types'; import { Developers } from './Developers'; import { Models } from './Models'; import { Tasks } from './Tasks'; -import { TaskDetails } from './TaskDetails'; +import { TaskDrawer } from './TaskDrawer'; export const Usage = () => { + const t = useTranslations('Analytics'); const [viewMode, setViewMode] = useState('tasks'); const [filter, setFilter] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); + const [task, setTask] = useState(null); + + const onFilter = useCallback((filter: Filter) => { + setFilter(filter); + setViewMode('tasks'); + }, []); return ( <>
- +
+ {viewModes.map((mode) => ( + + ))} +
{filter && ( - setFilter(null)} /> +
+ + + {filter.label} + + + +
)} {viewMode === 'tasks' ? ( setSelectedTask(task)} + onFilter={onFilter} + onTaskSelected={(task: Task) => setTask(task)} /> ) : viewMode === 'developers' ? ( - { - setFilter({ type: 'developer', id, name }); - setViewMode('tasks'); - }} - /> + ) : ( - { - setFilter({ type: 'model', id, name }); - setViewMode('tasks'); - }} - /> + )}
- setSelectedTask(null)} /> + {task && setTask(null)} />} ); }; diff --git a/src/app/(authenticated)/usage/ViewModeToggle.tsx b/src/app/(authenticated)/usage/ViewModeToggle.tsx deleted file mode 100644 index 8bbf8ab370..0000000000 --- a/src/app/(authenticated)/usage/ViewModeToggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useTranslations } from 'next-intl'; - -import { Button } from '@/components/ui'; - -import { type ViewMode, viewModes } from './types'; - -export const ViewModeToggle = ({ - viewMode, - setViewMode, -}: { - viewMode: ViewMode; - setViewMode: (mode: ViewMode) => void; -}) => { - const t = useTranslations('Analytics'); - - return ( -
- {viewModes.map((mode) => ( - - ))} -
- ); -}; diff --git a/src/app/(authenticated)/usage/types.ts b/src/app/(authenticated)/usage/types.ts index 38d3602a88..4df7ba00df 100644 --- a/src/app/(authenticated)/usage/types.ts +++ b/src/app/(authenticated)/usage/types.ts @@ -2,45 +2,4 @@ export const viewModes = ['developers', 'models', 'tasks'] as const; export type ViewMode = (typeof viewModes)[number]; -export type Model = { - id: string; - name: string; - provider: string; - tasks: number; - tokensConsumed: number; - cost: number; -}; - -export type ConversationMessage = { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: Date; -}; - -export type Task = { - id: string; - date: Date; - developerId: string; - developerName: string; - modelId: string; - modelName: string; - tokensConsumed: number; - cost: number; - status: 'completed' | 'started' | 'failed'; - conversation: ConversationMessage[]; -}; - -export type SummaryData = { - activeDevelopers: number; - tasksStarted: number; - tasksCompleted: number; - tokensConsumed: string; - costs: number; -}; - -export type Filter = { - type: 'developer' | 'model'; - id: string; - name: string; -}; +export type Filter = { type: 'userId' | 'model'; value: string; label: string }; diff --git a/src/components/layout/DataTable.tsx b/src/components/layout/DataTable.tsx index f0add0d9da..50f6e959a9 100644 --- a/src/components/layout/DataTable.tsx +++ b/src/components/layout/DataTable.tsx @@ -32,7 +32,7 @@ export function DataTable({ }); return ( -
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index b0c1fc9338..e0dc4d69a5 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const badgeVariants = cva( - 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', { variants: { variant: { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 29f98ef9ee..cf691161e2 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -25,7 +25,7 @@ const buttonVariants = cva( default: 'h-9 px-4 py-2 has-[>svg]:px-3', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', - icon: 'size-9', + icon: 'size-6', }, }, defaultVariants: { diff --git a/src/components/usage/UsageCard.tsx b/src/components/usage/UsageCard.tsx index b0459b3fd1..cef4c07d00 100644 --- a/src/components/usage/UsageCard.tsx +++ b/src/components/usage/UsageCard.tsx @@ -80,22 +80,22 @@ export const UsageCard = () => {