Pull task data from ClickHouse (#33)

This commit is contained in:
Chris Estreich 2025-05-18 15:13:51 -07:00 committed by GitHub
parent 67c63b5b46
commit 3aec71ea16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 454 additions and 584 deletions

View file

@ -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<Record<TelemetryEventName, Usage>>;
export const getUsage = async ({
orgId,
timePeriod,
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod: TimePeriod;
timePeriod?: TimePeriod;
}): Promise<UsageRecord> => {
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<typeof developerUsageSchema> & {
export const getDeveloperUsage = async ({
orgId,
timePeriod,
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod: TimePeriod;
timePeriod?: TimePeriod;
}): Promise<DeveloperUsage[]> => {
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<typeof modelUsageSchema>;
export const getModelUsage = async ({
orgId,
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod?: TimePeriod;
}): Promise<ModelUsage[]> => {
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<typeof taskSchema> & {
user: User;
};
export const getTasks = async ({
orgId,
}: {
orgId?: string | null;
}): Promise<Task[]> => {
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);
};

View file

@ -24,7 +24,7 @@ export const AuditLogs = () => {
const [timePeriod, setTimePeriod] = useState<TimePeriod>(7);
const { data: logs = [], isPending } = useQuery({
queryKey: ['auditLogs', orgId, limit, timePeriod],
queryKey: ['getAuditLogs', orgId, limit, timePeriod],
queryFn: () => getAuditLogs({ orgId, limit, timePeriod }),
enabled: !!orgId,
});

View file

@ -809,7 +809,7 @@ export const DefaultParameters = () => {
const queryClient = useQueryClient();
const { data: orgSettings } = useQuery({
queryKey: ['organizationSettings'],
queryKey: ['getOrganizationSettings'],
queryFn: getOrganizationSettings,
});

View file

@ -383,7 +383,7 @@ export const ProviderWhitelist = () => {
const queryClient = useQueryClient();
const { data: orgSettings } = useQuery({
queryKey: ['organizationSettings'],
queryKey: ['getOrganizationSettings'],
queryFn: getOrganizationSettings,
});

View file

@ -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) => (
<div className="mb-4 flex items-center rounded-md bg-muted p-2">
<span className="mr-2 text-sm">
Filtered by {filter.type}: <strong>{filter.name}</strong>
</span>
<Button variant="ghost" size="sm" onClick={onClear} className="size-6 p-0">
<X className="size-4" />
</Button>
</div>
);

View file

@ -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<DeveloperUsage>[] = [
{
accessorKey: 'user.name',
header: 'Developer',
cell: ({ row }) => (
<button
onClick={() => onDeveloperSelected(row.original.user)}
className="text-left font-medium text-primary hover:underline"
<Button
variant="link"
onClick={() =>
onFilter({
type: 'userId',
value: row.original.userId,
label: row.original.user.name,
})
}
className="px-0"
>
{row.original.user.name}
</button>
</Button>
),
},
{
@ -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 <Loader />;
}
return <DataTable columns={columns} data={data} />;
};

View file

@ -0,0 +1,14 @@
import { LoaderCircle } from 'lucide-react';
import { Skeleton } from '@/components/ui';
export const Loader = () => (
<div className="flex flex-col bg-card border shadow rounded">
<div className="border-b p-2">
<Skeleton className="h-6 w-full" />
</div>
<div className="flex justify-center p-2">
<LoaderCircle className="size-5 animate-spin opacity-25" />
</div>
</div>
);

View file

@ -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<Model>[] = [
const { data = [], isPending } = useQuery({
queryKey: ['getModelUsage', orgId],
queryFn: () => getModelUsage({ orgId }),
enabled: !!orgId,
});
const columns: ColumnDef<ModelUsage>[] = [
{
accessorKey: 'name',
header: 'Model',
cell: ({ row }) => {
const model = row.original;
return (
<button
onClick={() => onClick(model)}
className="text-left font-medium text-primary hover:underline"
>
{model.name}
</button>
);
},
cell: ({ row: { original: model } }) => (
<Button
variant="link"
onClick={() =>
onFilter({
type: 'model',
value: model.model,
label: model.model,
})
}
className="px-0"
>
{model.model}
</Button>
),
},
{
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 <DataTable columns={modelColumns} data={mockModels} />;
if (isPending) {
return <Loader />;
}
return <DataTable columns={columns} data={data} />;
};

View file

@ -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 }) => (
<Badge variant="outline">
<Dot
className={cn('scale-200', completed ? 'text-chart-2' : 'text-chart-1')}
/>
{completed ? 'Complete' : 'Incomplete'}
</Badge>
);

View file

@ -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 ? (
<Drawer open={true} onOpenChange={onClose} direction="right">
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Task Details</DrawerTitle>
</DrawerHeader>
<div className="p-4">
<div className="mb-6 space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Task ID:</span>
<span className="font-medium">{task.id}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Date:</span>
<span className="font-medium">{task.date.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Developer:</span>
<span className="font-medium">{task.developerName}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Model:</span>
<span className="font-medium">{task.modelName}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Tokens:</span>
<span className="font-medium">
{task.tokensConsumed.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Cost:</span>
<span className="font-medium">${task.cost.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Status:</span>
<span
className={
task.status === 'completed'
? 'font-medium text-green-500'
: task.status === 'started'
? 'font-medium text-blue-500'
: 'font-medium text-red-500'
}
>
{task.status.charAt(0).toUpperCase() + task.status.slice(1)}
</span>
</div>
</div>
<h3 className="mb-2 text-lg font-medium">Conversation</h3>
<div className="space-y-4">
{task.conversation.map((message) => (
<div
key={message.id}
className={`rounded-lg p-3 ${
message.role === 'user'
? 'ml-4 bg-primary/10'
: message.role === 'assistant'
? 'mr-4 bg-secondary/10'
: 'bg-muted'
}`}
>
<div className="mb-1 text-xs font-medium text-muted-foreground">
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}{' '}
{message.timestamp.toLocaleTimeString()}
</div>
<div className="text-sm">{message.content}</div>
</div>
))}
</div>
</div>
</DrawerContent>
</Drawer>
) : null;

View file

@ -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) => (
<Drawer open={true} onOpenChange={onClose} direction="right">
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Task</DrawerTitle>
<DrawerDescription>{task.taskId}</DrawerDescription>
<div className="absolute top-2 right-2">
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
</DrawerHeader>
<div className="p-4">
<div className="mb-6 space-y-2">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Developer</span>
<span className="max-w-64 truncate">{task.user.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Provider</span>
<span className="max-w-64 truncate font-mono">{task.provider}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Model</span>
<span className="max-w-64 truncate font-mono">{task.model}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tokens</span>
<span className="max-w-64 truncate font-mono">
{formatNumber(task.tokens)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Cost</span>
<span className="max-w-64 truncate font-mono">
{formatCurrency(task.cost)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Date</span>
<span className="max-w-64 truncate">
{new Date(task.timestamp * 1000).toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Status</span>
<Status completed={task.completed} />
</div>
</div>
</div>
</DrawerContent>
</Drawer>
);

View file

@ -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<string, ConversationMessage[]> = {
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<Task>[] = [
const columns: ColumnDef<Task>[] = [
{
accessorKey: 'id',
header: 'Task ID',
cell: ({ row }) => {
const task = row.original;
return (
<button
onClick={() => onClick(task)}
className="text-left font-medium text-primary hover:underline"
>
{task.id}
</button>
);
},
cell: ({ row: { original: task } }) => (
<Button
variant="link"
onClick={() => onTaskSelected(task)}
className="px-0"
>
{task.taskId.slice(0, 8)}
</Button>
),
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
const date = row.getValue('date') as Date;
return date.toLocaleString();
},
},
{
accessorKey: 'developerName',
header: 'Developer',
cell: ({ row }) => (
<Button
variant="link"
onClick={() =>
onFilter({
type: 'userId',
value: row.original.userId,
label: row.original.user.name,
})
}
className="px-0"
>
{row.original.user.name}
</Button>
),
},
{
accessorKey: 'modelName',
header: 'Model',
cell: ({ row }) => (
<Button
variant="link"
onClick={() =>
onFilter({
type: 'model',
value: row.original.model,
label: row.original.model,
})
}
className="px-0"
>
{row.original.model}
</Button>
),
},
{
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 (
<span
className={
status === 'completed'
? 'text-green-500'
: status === 'started'
? 'text-blue-500'
: 'text-red-500'
}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
cell: ({ row }) => <Status completed={row.original.completed} />,
},
];
return <DataTable columns={taskColumns} data={filteredTasks} />;
if (isPending) {
return <Loader />;
}
return <DataTable columns={columns} data={tasks} />;
};

View file

@ -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<ViewMode>('tasks');
const [filter, setFilter] = useState<Filter | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [task, setTask] = useState<Task | null>(null);
const onFilter = useCallback((filter: Filter) => {
setFilter(filter);
setViewMode('tasks');
}, []);
return (
<>
<div className="flex flex-col gap-4">
<UsageCard />
<ViewModeToggle viewMode={viewMode} setViewMode={setViewMode} />
<div className="flex gap-2">
{viewModes.map((mode) => (
<Button
key={mode}
variant={viewMode === mode ? 'default' : 'secondary'}
size="sm"
onClick={() => setViewMode(mode)}
>
{t(`view_mode_${mode}`)}
</Button>
))}
</div>
{filter && (
<ActiveFilter filter={filter} onClear={() => setFilter(null)} />
<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>
)}
{viewMode === 'tasks' ? (
<Tasks
filter={filter}
onClick={(task: Task) => setSelectedTask(task)}
onFilter={onFilter}
onTaskSelected={(task: Task) => setTask(task)}
/>
) : viewMode === 'developers' ? (
<Developers
onDeveloperSelected={({ id, name }: User) => {
setFilter({ type: 'developer', id, name });
setViewMode('tasks');
}}
/>
<Developers onFilter={onFilter} />
) : (
<Models
onClick={({ id, name }: Model) => {
setFilter({ type: 'model', id, name });
setViewMode('tasks');
}}
/>
<Models onFilter={onFilter} />
)}
</div>
<TaskDetails task={selectedTask} onClose={() => setSelectedTask(null)} />
{task && <TaskDrawer task={task} onClose={() => setTask(null)} />}
</>
);
};

View file

@ -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 (
<div className="flex gap-2">
{viewModes.map((mode) => (
<Button
key={mode}
variant={viewMode === mode ? 'default' : 'secondary'}
size="sm"
onClick={() => setViewMode(mode)}
>
{t(`view_mode_${mode}`)}
</Button>
))}
</div>
);
};

View file

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

View file

@ -32,7 +32,7 @@ export function DataTable<TData, TValue>({
});
return (
<div className="bg-card border shadow-md rounded-md">
<div className="bg-card border shadow rounded">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View file

@ -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: {

View file

@ -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: {

View file

@ -80,22 +80,22 @@ export const UsageCard = () => {
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<Metric
label={t('analytics_active_developers')}
value={usage[TelemetryEventName.TASK_CREATED]?.userCount}
value={usage[TelemetryEventName.TASK_CREATED]?.users}
isPending={isPending}
/>
<Metric
label={t('analytics_tasks_started')}
value={usage[TelemetryEventName.TASK_CREATED]?.eventCount}
value={usage[TelemetryEventName.TASK_CREATED]?.events}
isPending={isPending}
/>
<Metric
label={t('analytics_tasks_completed')}
value={usage[TelemetryEventName.TASK_COMPLETED]?.eventCount}
value={usage[TelemetryEventName.TASK_COMPLETED]?.events}
isPending={isPending}
/>
<Metric
label={t('analytics_tokens_consumed')}
value={usage[TelemetryEventName.LLM_COMPLETION]?.inputTokens}
value={usage[TelemetryEventName.LLM_COMPLETION]?.tokens}
isPending={isPending}
/>
<Metric