mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Pull task data from ClickHouse (#33)
This commit is contained in:
parent
67c63b5b46
commit
3aec71ea16
19 changed files with 454 additions and 584 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -809,7 +809,7 @@ export const DefaultParameters = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: orgSettings } = useQuery({
|
||||
queryKey: ['organizationSettings'],
|
||||
queryKey: ['getOrganizationSettings'],
|
||||
queryFn: getOrganizationSettings,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ export const ProviderWhitelist = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: orgSettings } = useQuery({
|
||||
queryKey: ['organizationSettings'],
|
||||
queryKey: ['getOrganizationSettings'],
|
||||
queryFn: getOrganizationSettings,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
14
src/app/(authenticated)/usage/Loader.tsx
Normal file
14
src/app/(authenticated)/usage/Loader.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
13
src/app/(authenticated)/usage/Status.tsx
Normal file
13
src/app/(authenticated)/usage/Status.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
73
src/app/(authenticated)/usage/TaskDrawer.tsx
Normal file
73
src/app/(authenticated)/usage/TaskDrawer.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue