diff --git a/apps/backend/src/queries/project.queries.ts b/apps/backend/src/queries/project.queries.ts index cd8237776..b332870bc 100644 --- a/apps/backend/src/queries/project.queries.ts +++ b/apps/backend/src/queries/project.queries.ts @@ -392,6 +392,15 @@ export const listProjectChats = async ( const toolErrorCountExpr = countToolState('output-error'); const toolAvailableCountExpr = countToolState('output-available'); + const sourceExpr = sql`( + select source_message.source + from ${s.chatMessage} as source_message + where source_message.chat_id = ${s.chat.id} + and source_message.role = 'user' + and source_message.superseded_at is null + order by source_message.created_at desc + limit 1 + )`; const baseWhereClauses = [eq(s.chat.projectId, projectId)]; @@ -444,6 +453,11 @@ export const listProjectChats = async ( if (expr) { filterWhereClauses.push(expr); } + } else if (filter.id === 'source') { + const expr = or(...filter.values.map((source) => eq(sourceExpr, source))); + if (expr) { + filterWhereClauses.push(expr); + } } else if (filter.id === 'toolState') { const exprs: SQL[] = []; for (const v of filter.values) { @@ -465,6 +479,24 @@ export const listProjectChats = async ( if (expr) { filterWhereClauses.push(expr); } + } else if (filter.id === 'feedback') { + const exprs: SQL[] = []; + for (const v of filter.values) { + if (v === 'noVotes') { + const e = and(eq(upvotesExpr, 0), eq(downvotesExpr, 0)); + if (e) { + exprs.push(e); + } + } else if (v === 'upvotes') { + exprs.push(gt(upvotesExpr, 0)); + } else if (v === 'downvotes') { + exprs.push(gt(downvotesExpr, 0)); + } + } + const expr = or(...exprs); + if (expr) { + filterWhereClauses.push(expr); + } } } @@ -490,6 +522,7 @@ export const listProjectChats = async ( userName: s.user.name, userRole: sql`coalesce(${s.projectMember.role}, 'Former member')`.as('userRole'), title: s.chat.title, + source: sourceExpr.as('source'), numberOfMessages: numberOfMessagesExpr.as('numberOfMessages'), totalTokens: totalTokensExpr.as('totalTokens'), feedbackText: feedbackTextExpr.as('feedbackText'), @@ -530,6 +563,7 @@ export const listProjectChats = async ( userName: row.userName, userRole: row.userRole, title: row.title, + source: row.source, numberOfMessages: Number(row.numberOfMessages ?? 0), totalTokens: Number(row.totalTokens ?? 0), feedbackText: row.feedbackText ?? '', diff --git a/apps/backend/src/queries/usage.queries.ts b/apps/backend/src/queries/usage.queries.ts index d135f05d2..cb5b000e0 100644 --- a/apps/backend/src/queries/usage.queries.ts +++ b/apps/backend/src/queries/usage.queries.ts @@ -1,11 +1,11 @@ import type { LlmProvider } from '@nao/shared/types'; -import { and, eq, isNotNull, SQL, sql, SQLWrapper, sum } from 'drizzle-orm'; +import { and, eq, isNotNull, or, SQL, sql, SQLWrapper, sum } from 'drizzle-orm'; import { LLM_PROVIDERS } from '../agents/providers'; import s from '../db/abstractSchema'; import { db } from '../db/db'; import dbConfig, { Dialect } from '../db/dbConfig'; -import type { Granularity, UsageFilter, UsageRecord } from '../types/usage'; +import type { Granularity, TotalUsageRecord, UsageFilter, UsageRecord, UsageSource } from '../types/usage'; import { fillMissingDates, getLookbackTimestamp } from '../utils/date'; import * as projectLlmConfigQueries from './project-llm-config.queries'; @@ -54,6 +54,41 @@ export async function createCostLookup(projectId: string) { return { table, joinCondition }; } +const MESSAGE_USAGE_PROVIDER_EXPR = sql`case + when ${s.chatMessage.role} = 'user' then ( + select next_message.llm_provider + from chat_message as next_message + where next_message.chat_id = ${s.chatMessage.chatId} + and next_message.role = 'assistant' + and next_message.llm_provider is not null + and next_message.created_at > ${s.chatMessage.createdAt} + and not exists ( + select 1 + from chat_message as next_user_message + where next_user_message.chat_id = ${s.chatMessage.chatId} + and next_user_message.role = 'user' + and next_user_message.created_at > ${s.chatMessage.createdAt} + and next_user_message.created_at < next_message.created_at + ) + order by next_message.created_at asc + limit 1 + ) + else ${s.chatMessage.llmProvider} +end`; + +const MESSAGE_USAGE_SOURCE_EXPR = sql`case + when ${s.chatMessage.role} = 'assistant' then ( + select source_message.source + from chat_message as source_message + where source_message.chat_id = ${s.chatMessage.chatId} + and source_message.role = 'user' + and source_message.created_at <= ${s.chatMessage.createdAt} + order by source_message.created_at desc + limit 1 + ) + else ${s.chatMessage.source} +end`; + export const getMessagesUsage = async (projectId: string, filter: UsageFilter): Promise => { const { granularity, provider } = filter; const dateExpr = getDateExpr(s.chatMessage.createdAt, granularity); @@ -65,8 +100,10 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): const whereConditions = [eq(s.chat.projectId, projectId), lookbackFilter]; if (provider) { - whereConditions.push(eq(s.chatMessage.llmProvider, provider)); + whereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`); } + addUserNameFilter(whereConditions, filter.userNames); + addSourceFilter(whereConditions, filter.sources); const costLookup = await createCostLookup(projectId); @@ -79,6 +116,9 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): teamsMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'teams' then ${s.chatMessage.id} end)`, telegramMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'telegram' then ${s.chatMessage.id} end)`, whatsappMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'whatsapp' then ${s.chatMessage.id} end)`, + adminMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'admin' then ${s.chatMessage.id} end)`, + mcpMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'mcp' then ${s.chatMessage.id} end)`, + contextRecommendationsMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'contextRecommendations' then ${s.chatMessage.id} end)`, inputNoCacheTokens: sum(s.chatMessage.inputNoCacheTokens), inputCacheReadTokens: sum(s.chatMessage.inputCacheReadTokens), inputCacheWriteTokens: sum(s.chatMessage.inputCacheWriteTokens), @@ -91,6 +131,7 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): }) .from(s.chatMessage) .innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id)) + .innerJoin(s.user, eq(s.chat.userId, s.user.id)) .leftJoin(costLookup.table, costLookup.joinCondition) .where(and(...whereConditions)) .groupBy(dateExpr); @@ -98,12 +139,15 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): return fillMissingDates( rows.map((row) => ({ date: row.date, - messageCount: row.messageCount, - webMessageCount: row.webMessageCount, - slackMessageCount: row.slackMessageCount, - teamsMessageCount: row.teamsMessageCount, - telegramMessageCount: row.telegramMessageCount, - whatsappMessageCount: row.whatsappMessageCount, + messageCount: Number(row.messageCount ?? 0), + webMessageCount: Number(row.webMessageCount ?? 0), + slackMessageCount: Number(row.slackMessageCount ?? 0), + teamsMessageCount: Number(row.teamsMessageCount ?? 0), + telegramMessageCount: Number(row.telegramMessageCount ?? 0), + whatsappMessageCount: Number(row.whatsappMessageCount ?? 0), + adminMessageCount: Number(row.adminMessageCount ?? 0), + mcpMessageCount: Number(row.mcpMessageCount ?? 0), + contextRecommendationsMessageCount: Number(row.contextRecommendationsMessageCount ?? 0), inputNoCacheTokens: Number(row.inputNoCacheTokens ?? 0), inputCacheReadTokens: Number(row.inputCacheReadTokens ?? 0), inputCacheWriteTokens: Number(row.inputCacheWriteTokens ?? 0), @@ -123,6 +167,37 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): ); }; +export const getTotalUsage = async (projectId: string, filter: UsageFilter): Promise => { + const { granularity, provider } = filter; + const lookbackTs = getLookbackTimestamp(granularity); + const lookbackFilter = + dbConfig.dialect === Dialect.Postgres + ? sql`${s.chatMessage.createdAt} >= ${new Date(lookbackTs).toISOString()}` + : sql`${s.chatMessage.createdAt} >= ${lookbackTs}`; + + const whereConditions = [eq(s.chat.projectId, projectId), lookbackFilter]; + if (provider) { + whereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`); + } + addUserNameFilter(whereConditions, filter.userNames); + addSourceFilter(whereConditions, filter.sources); + + const rows = await db + .select({ + totalMessages: sql`count(distinct case when ${s.chatMessage.role} = 'user' then ${s.chatMessage.id} end)`, + uniqueUsers: sql`count(distinct ${s.chat.userId})`, + }) + .from(s.chatMessage) + .innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id)) + .innerJoin(s.user, eq(s.chat.userId, s.user.id)) + .where(and(...whereConditions)); + + return { + totalMessages: Number(rows[0]?.totalMessages ?? 0), + uniqueUsers: Number(rows[0]?.uniqueUsers ?? 0), + }; +}; + export const getUsedProviders = async (projectId: string): Promise => { const rows = await db .selectDistinct({ provider: s.chatMessage.llmProvider }) @@ -134,6 +209,29 @@ export const getUsedProviders = async (projectId: string): Promise row.provider).filter((p): p is LlmProvider => p !== null); }; +function addUserNameFilter(whereConditions: SQL[], userNames: string[] | undefined) { + const names = userNames?.filter(Boolean) ?? []; + if (names.length === 0) { + return; + } + + const expr = or(...names.map((name) => eq(s.user.name, name))); + if (expr) { + whereConditions.push(expr); + } +} + +function addSourceFilter(whereConditions: SQL[], sources: UsageSource[] | undefined) { + if (!sources?.length) { + return; + } + + const expr = or(...sources.map((source) => eq(MESSAGE_USAGE_SOURCE_EXPR, source))); + if (expr) { + whereConditions.push(expr); + } +} + function getDateExpr(field: SQLWrapper, granularity: Granularity): SQL { if (dbConfig.dialect === Dialect.Postgres) { const format = sql.raw(`'${pgFormats[granularity]}'`); diff --git a/apps/backend/src/trpc/project.routes.ts b/apps/backend/src/trpc/project.routes.ts index e591b2222..efbeabaa4 100644 --- a/apps/backend/src/trpc/project.routes.ts +++ b/apps/backend/src/trpc/project.routes.ts @@ -837,7 +837,7 @@ export const projectRoutes = { filters: z .array( z.object({ - id: z.enum(['userName', 'userRole', 'toolState']), + id: z.enum(['userName', 'userRole', 'toolState', 'feedback', 'source']), values: z.array(z.string()).default([]), }), ) @@ -876,7 +876,12 @@ export const projectRoutes = { } const ownerName = ownerId ? await userQueries.getUserName(ownerId) : null; - return { ...chat, ownerId: ownerId ?? null, ownerName }; + return { + ...chat, + ownerId: ownerId ?? null, + ownerName, + chatOwnerId: ownerId ?? null, + }; }), getEnvVars: adminProtectedProcedure.query(async ({ ctx }) => { diff --git a/apps/backend/src/trpc/usage.routes.ts b/apps/backend/src/trpc/usage.routes.ts index 30e1f9501..60a046bac 100644 --- a/apps/backend/src/trpc/usage.routes.ts +++ b/apps/backend/src/trpc/usage.routes.ts @@ -7,6 +7,10 @@ export const usageRoutes = { return usageQueries.getMessagesUsage(ctx.project.id, input); }), + getTotalUsage: adminProtectedProcedure.input(usageFilterSchema).query(async ({ ctx, input }) => { + return usageQueries.getTotalUsage(ctx.project.id, input); + }), + getUsedProviders: adminProtectedProcedure.query(async ({ ctx }) => { return usageQueries.getUsedProviders(ctx.project.id); }), diff --git a/apps/backend/src/types/project.ts b/apps/backend/src/types/project.ts index b2f28efdc..deabe4d18 100644 --- a/apps/backend/src/types/project.ts +++ b/apps/backend/src/types/project.ts @@ -8,7 +8,7 @@ export interface UserWithRole { messagingProviderCode: string | null; } -export type ProjectChatsFacetKey = 'userName' | 'userRole' | 'toolState'; +export type ProjectChatsFacetKey = 'userName' | 'userRole' | 'toolState' | 'feedback' | 'source'; export interface ListProjectChatsResponse { chats: ProjectChatListItem[]; diff --git a/apps/backend/src/types/usage.ts b/apps/backend/src/types/usage.ts index d1627af9e..d9e765664 100644 --- a/apps/backend/src/types/usage.ts +++ b/apps/backend/src/types/usage.ts @@ -1,13 +1,19 @@ import { z } from 'zod/v4'; +import { MESSAGE_SOURCES } from './chat'; import { llmProviderSchema } from './llm'; export const granularitySchema = z.enum(['hour', 'day', 'month']); export type Granularity = z.infer; +export const USAGE_SOURCES = MESSAGE_SOURCES; +export type UsageSource = (typeof USAGE_SOURCES)[number]; + export const usageFilterSchema = z.object({ granularity: granularitySchema.default('day'), provider: llmProviderSchema.optional(), + userNames: z.array(z.string()).optional(), + sources: z.array(z.enum(USAGE_SOURCES)).optional(), }); export type UsageFilter = z.infer; @@ -19,6 +25,9 @@ export interface UsageRecord { teamsMessageCount: number; telegramMessageCount: number; whatsappMessageCount: number; + adminMessageCount: number; + mcpMessageCount: number; + contextRecommendationsMessageCount: number; inputNoCacheTokens: number; inputCacheReadTokens: number; inputCacheWriteTokens: number; @@ -31,3 +40,8 @@ export interface UsageRecord { outputCost: number; totalCost: number; } + +export interface TotalUsageRecord { + totalMessages: number; + uniqueUsers: number; +} diff --git a/apps/backend/src/utils/date.ts b/apps/backend/src/utils/date.ts index 091b5f651..a5eead1cb 100644 --- a/apps/backend/src/utils/date.ts +++ b/apps/backend/src/utils/date.ts @@ -11,8 +11,8 @@ export function isValidIsoDateString(s: string): boolean { export const lookbackPeriods = { hour: 24, - day: 30, - month: 12, + day: 15, + month: 6, }; export function getLookbackTimestamp(granularity: Granularity): number { @@ -111,6 +111,9 @@ export function fillMissingDates(records: UsageRecord[], granularity: Granularit teamsMessageCount: 0, telegramMessageCount: 0, whatsappMessageCount: 0, + adminMessageCount: 0, + mcpMessageCount: 0, + contextRecommendationsMessageCount: 0, inputNoCacheTokens: 0, inputCacheReadTokens: 0, inputCacheWriteTokens: 0, diff --git a/apps/backend/tests/usage-queries.test.ts b/apps/backend/tests/usage-queries.test.ts new file mode 100644 index 000000000..d61b3a103 --- /dev/null +++ b/apps/backend/tests/usage-queries.test.ts @@ -0,0 +1,78 @@ +import '../src/env'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getMessagesUsage, getTotalUsage } from '../src/queries/usage.queries'; +import { formatDate } from '../src/utils/date'; + +const queryRows = vi.hoisted(() => ({ value: [] as Record[] })); + +vi.mock('../src/db/db', () => { + const query = { + from: () => query, + innerJoin: () => query, + leftJoin: () => query, + where: () => query, + groupBy: async () => queryRows.value, + then: (resolve: (rows: Record[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(queryRows.value).then(resolve, reject), + }; + + return { + db: { + select: () => query, + }, + }; +}); + +vi.mock('../src/queries/project-llm-config.queries', () => ({ + getProjectLlmConfigs: async () => [], +})); + +describe('usage query results', () => { + beforeEach(() => { + queryRows.value = []; + }); + + it('normalizes total usage aggregates to numbers', async () => { + queryRows.value = [{ totalMessages: '12', uniqueUsers: '4' }]; + + await expect(getTotalUsage('project-1', { granularity: 'day' })).resolves.toEqual({ + totalMessages: 12, + uniqueUsers: 4, + }); + }); + + it('normalizes message aggregates and includes context recommendations', async () => { + const date = formatDate(new Date(), 'day'); + queryRows.value = [ + { + date, + messageCount: '8', + webMessageCount: '1', + slackMessageCount: '1', + teamsMessageCount: '1', + telegramMessageCount: '1', + whatsappMessageCount: '1', + adminMessageCount: '1', + mcpMessageCount: '1', + contextRecommendationsMessageCount: '1', + }, + ]; + + const records = await getMessagesUsage('project-1', { granularity: 'day' }); + const record = records.find((item) => item.date === date); + + expect(record).toMatchObject({ + messageCount: 8, + webMessageCount: 1, + slackMessageCount: 1, + teamsMessageCount: 1, + telegramMessageCount: 1, + whatsappMessageCount: 1, + adminMessageCount: 1, + mcpMessageCount: 1, + contextRecommendationsMessageCount: 1, + }); + }); +}); diff --git a/apps/frontend/src/components/recommendation-card.tsx b/apps/frontend/src/components/recommendation-card.tsx index 4b2629922..6ff7b0cbe 100644 --- a/apps/frontend/src/components/recommendation-card.tsx +++ b/apps/frontend/src/components/recommendation-card.tsx @@ -19,6 +19,7 @@ import type { inferRouterOutputs } from '@trpc/server'; import type { TrpcRouter } from '@nao/backend/trpc'; import { RecommendationDiffPanel } from '@/components/side-panel/recommendation-diff-panel'; import { RecommendationManualFixPanel } from '@/components/side-panel/recommendation-manual-fix-panel'; +import { DEFAULT_USAGE_SEARCH } from '@/components/settings/usage-route-search'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -226,8 +227,9 @@ export function RecommendationCard({ {chatIds.slice(0, 5).map((chatId) => ( {chatId.slice(0, 8)} diff --git a/apps/frontend/src/components/settings-search-index.ts b/apps/frontend/src/components/settings-search-index.ts index 40b0d209c..759f766a3 100644 --- a/apps/frontend/src/components/settings-search-index.ts +++ b/apps/frontend/src/components/settings-search-index.ts @@ -458,10 +458,10 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [ adminOnly: true, }, - // ── Chats Replay ───────────────────────────────────────── + // ── Usage & Costs > Chats Replay ───────────────────────── { - page: '/settings/chats-replay', - pageLabel: 'Chats Replay', + page: '/settings/usage', + pageLabel: 'Usage & Costs', title: 'Chats Replay', description: 'Replay and review past chat conversations.', keywords: ['history', 'conversation', 'replay', 'review'], diff --git a/apps/frontend/src/components/settings/chats-replay-columns.tsx b/apps/frontend/src/components/settings/chats-replay-columns.tsx index 772ca7054..d4b268fae 100644 --- a/apps/frontend/src/components/settings/chats-replay-columns.tsx +++ b/apps/frontend/src/components/settings/chats-replay-columns.tsx @@ -1,22 +1,30 @@ -import { CircleAlert, Eye, ThumbsDown, ThumbsUp } from 'lucide-react'; +import { CircleAlert, CircleHelp, Globe2, Lightbulb, Shield, ThumbsDown, ThumbsUp } from 'lucide-react'; import { differenceInDays, format, isToday, isYesterday } from 'date-fns'; -import { USER_ROLE_LABELS } from '@nao/shared/types'; import type { ColumnDef } from '@tanstack/react-table'; -import type { ProjectChatListItem, UserRole } from '@nao/shared/types'; +import type { ProjectChatListItem } from '@nao/shared/types'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; +import McpIcon from '@/components/icons/model-context-protocol.svg'; +import TeamsIcon from '@/components/icons/microsoft-teams.svg'; +import SlackIcon from '@/components/icons/slack.svg'; +import TelegramIcon from '@/components/icons/telegram.svg'; +import WhatsAppIcon from '@/components/icons/whatsapp.svg'; -/** Render a project member role for display, leaving non-role values (e.g. "Former member") untouched. */ -export function formatUserRole(value: string): string { - return USER_ROLE_LABELS[value as UserRole] ?? value; -} - -export function getChatsReplayColumns(args: { - onOpenChat: (chat: ProjectChatListItem) => void; -}): ColumnDef[] { - const { onOpenChat } = args; +const sourceConfig = { + web: { label: 'Web', icon: }, + slack: { label: 'Slack', icon: }, + teams: { label: 'Teams', icon: }, + telegram: { label: 'Telegram', icon: }, + whatsapp: { label: 'WhatsApp', icon: }, + admin: { label: 'Admin mode', icon: }, + mcp: { label: 'MCP', icon: }, + contextRecommendations: { + label: 'Context recommendations', + icon: , + }, +} as const; +export function getChatsReplayColumns(): ColumnDef[] { return [ { accessorKey: 'updatedAt', @@ -27,30 +35,41 @@ export function getChatsReplayColumns(args: { return {formatted}; }, }, - { - accessorKey: 'userName', - header: 'User', - }, - { - accessorKey: 'userRole', - header: 'Role', - cell: ({ getValue }) => { - const value = getValue(); - return value ? formatUserRole(value) : '—'; - }, - }, { accessorKey: 'title', header: 'Title', cell: ({ getValue }) => { const value = getValue() ?? ''; return ( - + {value} ); }, }, + { + accessorKey: 'userName', + header: 'User', + }, + { + accessorKey: 'source', + header: 'Source', + enableSorting: false, + cell: ({ getValue }) => { + const source = getValue(); + if (!source) { + return ; + } + + const config = sourceConfig[source as keyof typeof sourceConfig]; + return ( + + {config?.icon ?? } + {config?.label ?? source} + + ); + }, + }, { accessorKey: 'numberOfMessages', header: 'Messages' }, { accessorKey: 'totalTokens', header: 'Tokens' }, { @@ -107,19 +126,6 @@ export function getChatsReplayColumns(args: { ); }, }, - { - id: 'actions', - header: '', - enableHiding: false, - cell: ({ row }) => { - const chat = row.original; - return ( - - ); - }, - }, ]; } diff --git a/apps/frontend/src/components/settings/chats-replay-date-filter.tsx b/apps/frontend/src/components/settings/chats-replay-date-filter.tsx deleted file mode 100644 index aa7a47b6b..000000000 --- a/apps/frontend/src/components/settings/chats-replay-date-filter.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useState } from 'react'; -import DatePicker from 'react-datepicker'; -import { Calendar } from 'lucide-react'; - -import type { UpdatedAtFilter } from '@nao/shared/types'; -import { cn, toLocalDateString } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; - -import 'react-datepicker/dist/react-datepicker.css'; - -type ChatsReplayDateFilterProps = { - value: UpdatedAtFilter | undefined; - onChange: (value: UpdatedAtFilter | undefined) => void; -}; - -export function ChatsReplayDateFilter({ value, onChange }: ChatsReplayDateFilterProps) { - const [mode, setMode] = useState<'single' | 'range'>(value?.mode ?? 'single'); - const [singleDate, setSingleDate] = useState( - value?.mode === 'single' && value.value ? new Date(value.value + 'T12:00:00') : null, - ); - const [startDate, setStartDate] = useState( - value?.mode === 'range' && value.start ? new Date(value.start + 'T12:00:00') : null, - ); - const [endDate, setEndDate] = useState( - value?.mode === 'range' && value.end ? new Date(value.end + 'T12:00:00') : null, - ); - const [open, setOpen] = useState(false); - - const applyFilter = ( - single: Date | null, - start: Date | null, - end: Date | null, - currentMode: 'single' | 'range', - ) => { - if (currentMode === 'single') { - if (single) { - onChange({ mode: 'single', value: toLocalDateString(single) }); - } else { - onChange(undefined); - } - return; - } - if (start && end) { - onChange({ mode: 'range', start: toLocalDateString(start), end: toLocalDateString(end) }); - } else if (!start && !end) { - onChange(undefined); - } - }; - - const handleSingleChange = (date: Date | null) => { - setSingleDate(date); - applyFilter(date, null, null, 'single'); - }; - - const handleModeToggle = () => { - const nextMode = mode === 'single' ? 'range' : 'single'; - setMode(nextMode); - if (nextMode === 'single') { - applyFilter(singleDate, null, null, 'single'); - } else { - applyFilter(null, startDate, endDate, 'range'); - } - }; - - const clearFilter = () => { - setSingleDate(null); - setStartDate(null); - setEndDate(null); - onChange(undefined); - setOpen(false); - }; - - const hasActiveFilter = - (value?.mode === 'single' && value.value) || (value?.mode === 'range' && value.start && value.end); - - const label = - hasActiveFilter && value - ? value.mode === 'single' - ? value.value - : `${value.start} – ${value.end}` - : 'Last update'; - - return ( - - - - - -
- Last update -
-
- {mode === 'single' ? ( - - ) : ( - { - const [start, end] = range ?? [null, null]; - setStartDate(start); - setEndDate(end); - if (start && end) { - applyFilter(null, start, end, 'range'); - } else if (!start && !end) { - onChange(undefined); - } - }} - inline - popperProps={{ strategy: 'fixed' }} - calendarClassName='react-datepicker--no-shadow chats-replay-datepicker' - /> - )} -
-
- - -
-
-
- ); -} diff --git a/apps/frontend/src/components/settings/chats-replay-page.tsx b/apps/frontend/src/components/settings/chats-replay-page.tsx index b4f60e8a2..61044c5c5 100644 --- a/apps/frontend/src/components/settings/chats-replay-page.tsx +++ b/apps/frontend/src/components/settings/chats-replay-page.tsx @@ -1,79 +1,76 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { getRouteApi } from '@tanstack/react-router'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import type { ColumnFiltersState, PaginationState, SortingState, VisibilityState } from '@tanstack/react-table'; +import type { PaginationState, SortingState } from '@tanstack/react-table'; -import type { ProjectChatListItem, UpdatedAtFilter } from '@nao/shared/types'; +import type { UsageSource } from '@nao/backend/usage'; +import type { ChatReplayFeedbackState, ChatReplayToolState } from '@nao/shared/types'; import { getChatsReplayColumns } from '@/components/settings/chats-replay-columns'; -import { ChatsReplayPanel } from '@/components/settings/chats-replay-panel'; import { ChatsReplayTable } from '@/components/settings/chats-replay-table'; -import { ChatsReplayToolbar } from '@/components/settings/chats-replay-toolbar'; -import { SettingsCard } from '@/components/ui/settings-card'; -import { cn } from '@/lib/utils'; import { trpc } from '@/main'; -const routeApi = getRouteApi('/_sidebar-layout/settings/chats-replay'); +type ChatsReplayPageProps = { + selectedUserNames: string[] | undefined; + selectedFeedbackStates: ChatReplayFeedbackState[] | undefined; + selectedToolStates: ChatReplayToolState[] | undefined; + selectedSources: UsageSource[] | undefined; + onOpenChat: (chatId: string) => void; +}; -export function ChatsReplayPage() { +type ProjectChatsFilter = { + id: 'userName' | 'feedback' | 'toolState' | 'source'; + values: string[]; +}; + +export function ChatsReplayPage({ + selectedUserNames, + selectedFeedbackStates, + selectedToolStates, + selectedSources, + onOpenChat, +}: ChatsReplayPageProps) { const [sorting, setSorting] = useState([]); - const [globalFilter, setGlobalFilter] = useState(''); - const [columnFilters, setColumnFilters] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({ - userRole: false, - title: false, - }); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 30, }); - const { chatId: deepLinkChatId } = routeApi.useSearch(); - const navigate = routeApi.useNavigate(); - const [selectedChat, setSelectedChat] = useState(null); - const [isPanelOpen, setIsPanelOpen] = useState(false); - - const deepLinkChatQuery = useQuery({ - ...trpc.project.getChatReplay.queryOptions({ chatId: deepLinkChatId ?? '' }), - enabled: !!deepLinkChatId, - }); + const columns = useMemo(() => getChatsReplayColumns(), []); - const openChatPanel = useCallback((chat: ProjectChatListItem) => { - setSelectedChat(chat); - setIsPanelOpen(true); - }, []); + useEffect(() => { + setPagination((current) => ({ ...current, pageIndex: 0 })); + }, [selectedFeedbackStates, selectedSources, selectedToolStates, selectedUserNames]); - const closeChatPanel = useCallback(() => { - setIsPanelOpen(false); - setSelectedChat(null); - if (deepLinkChatId) { - navigate({ search: (prev) => ({ ...prev, chatId: undefined }) }); + const queryInput = useMemo(() => { + const filters: ProjectChatsFilter[] = []; + if (selectedUserNames?.length) { + filters.push({ id: 'userName', values: selectedUserNames }); + } + if (selectedFeedbackStates?.length) { + filters.push({ id: 'feedback', values: selectedFeedbackStates }); + } + if (selectedToolStates?.length) { + filters.push({ id: 'toolState', values: selectedToolStates }); + } + if (selectedSources?.length) { + filters.push({ id: 'source', values: selectedSources }); } - }, [deepLinkChatId, navigate]); - - const columns = useMemo(() => getChatsReplayColumns({ onOpenChat: openChatPanel }), [openChatPanel]); - const queryInput = useMemo(() => { - const filters = columnFilters - .map((f) => ({ id: f.id, values: (f.value as string[]) ?? [] })) - .filter( - (f): f is { id: 'userName' | 'userRole' | 'toolState'; values: string[] } => - (f.id === 'userName' || f.id === 'userRole' || f.id === 'toolState') && f.values.length > 0, - ); - const updatedAtFilter = columnFilters.find((f) => f.id === 'updatedAt')?.value as UpdatedAtFilter | undefined; - const hasValidDateFilter = - updatedAtFilter && - ((updatedAtFilter.mode === 'single' && updatedAtFilter.value) || - (updatedAtFilter.mode === 'range' && updatedAtFilter.start && updatedAtFilter.end)); return { page: pagination.pageIndex, pageSize: pagination.pageSize, - search: globalFilter || undefined, filters: filters.length ? filters : undefined, - updatedAtFilter: hasValidDateFilter ? updatedAtFilter : undefined, sorting: sorting.length ? sorting : undefined, }; - }, [columnFilters, globalFilter, pagination.pageIndex, pagination.pageSize, sorting]); + }, [ + pagination.pageIndex, + pagination.pageSize, + selectedFeedbackStates, + selectedSources, + selectedToolStates, + selectedUserNames, + sorting, + ]); const projectChatsQuery = useQuery({ ...trpc.project.getProjectChats.queryOptions(queryInput), @@ -81,85 +78,25 @@ export function ChatsReplayPage() { }); const chats = projectChatsQuery.data?.chats ?? []; const total = projectChatsQuery.data?.total ?? 0; - const defaultToolStateFacet = { noToolsUsed: 0, toolsNoErrors: 0, toolsWithErrors: 0 }; - const facets = projectChatsQuery.data?.facets ?? { - userNames: [], - userNameCounts: {}, - userRoles: [], - userRoleCounts: {}, - toolState: defaultToolStateFacet, - }; const table = useReactTable({ data: chats, columns, - state: { sorting, columnFilters, globalFilter, pagination, columnVisibility }, + state: { sorting, pagination }, onSortingChange: setSorting, - onGlobalFilterChange: setGlobalFilter, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), manualPagination: true, manualSorting: true, - manualFiltering: true, rowCount: total, pageCount: Math.ceil(total / pagination.pageSize), }); - const selectedChatInfo = selectedChat - ? { - chatId: selectedChat.id, - chatOwnerId: selectedChat.userId, - userName: selectedChat.userName, - updatedAt: selectedChat.updatedAt, - feedbackCount: selectedChat.upvotes + selectedChat.downvotes, - feedbackText: selectedChat.feedbackText, - toolErrorCount: selectedChat.toolErrorCount, - } - : null; - const deepLinkChatInfo = deepLinkChatId - ? { - chatId: deepLinkChatId, - chatOwnerId: deepLinkChatQuery.data?.ownerId ?? '', - userName: deepLinkChatQuery.data?.ownerName ?? '—', - updatedAt: deepLinkChatQuery.data?.updatedAt ?? Date.now(), - feedbackCount: 0, - feedbackText: '', - toolErrorCount: 0, - } - : null; - const panelChatInfo = selectedChatInfo ?? deepLinkChatInfo; - const isPanelVisible = isPanelOpen || !!deepLinkChatId; - return ( -
- {!isPanelVisible ? ( -
-
-

Chats Replay

-

- Browse chats across the organization and replay them. -

-
- -
- - - -
-
-
- ) : ( - - )} +
+
+ onOpenChat(chat.id)} /> +
); } diff --git a/apps/frontend/src/components/settings/chats-replay-panel.tsx b/apps/frontend/src/components/settings/chats-replay-panel.tsx index fff3f3253..4b44ad7d0 100644 --- a/apps/frontend/src/components/settings/chats-replay-panel.tsx +++ b/apps/frontend/src/components/settings/chats-replay-panel.tsx @@ -1,7 +1,8 @@ import { useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { X } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { formatDate } from 'date-fns'; +import type { ReactNode } from 'react'; import { SidePanelProvider } from '@/contexts/side-panel'; import { SidePanel } from '@/components/side-panel/side-panel'; @@ -18,25 +19,18 @@ import { trpc } from '@/main'; import { useSession } from '@/lib/auth-client'; type ChatsReplayPanelProps = { - chatInfo: { - chatId: string; - chatOwnerId: string; - userName: string; - updatedAt: number; - feedbackCount: number; - feedbackText: string; - toolErrorCount: number; - } | null; - onClose: () => void; + chatId: string; + onBack: () => void; + metadataAction?: ReactNode; }; -export function ChatsReplayPanel({ chatInfo, onClose }: ChatsReplayPanelProps) { +export function ChatsReplayPanel({ chatId, onBack, metadataAction }: ChatsReplayPanelProps) { const scrollContainerRef = useRef(null); const chatReplayQuery = useQuery( trpc.project.getChatReplay.queryOptions( - { chatId: chatInfo?.chatId ?? '' }, + { chatId }, { - enabled: !!chatInfo?.chatId, + enabled: !!chatId, }, ), ); @@ -63,19 +57,30 @@ export function ChatsReplayPanel({ chatInfo, onClose }: ChatsReplayPanelProps) { shouldCollapseSidebar: false, }); const { data: session } = useSession(); - const isOwner = session?.user?.id === chatInfo?.chatOwnerId; + const isOwner = session?.user?.id === chatReplayQuery.data?.chatOwnerId; + const title = chatReplayQuery.data?.title ?? 'Chat replay'; + const updatedAt = chatReplayQuery.data?.updatedAt; return (
-
-

Chat by {chatInfo?.userName ?? '—'}

- - {chatInfo?.updatedAt != null ? formatDate(new Date(chatInfo.updatedAt), 'yyyy-MM-dd') : '—'} - +
+ +
+

{title}

+
+ + {updatedAt != null ? formatDate(new Date(updatedAt), 'yyyy-MM-dd') : '—'} + + {metadataAction} +
+
- {chatInfo?.chatId && chatReplayQuery.data && ( + {chatReplayQuery.data && ( )} -
@@ -98,28 +100,21 @@ export function ChatsReplayPanel({ chatInfo, onClose }: ChatsReplayPanelProps) { rootClassName='flex-1 min-h-0' className='flex-1 min-h-0 overflow-hidden bg-background border p-0' > - {!chatInfo?.chatId ? ( -
- Select a chat to preview. -
- ) : chatReplayQuery.isLoading ? ( + {chatReplayQuery.isLoading ? (
Loading chat…
) : chatReplayQuery.isError ? (
Failed to load chat.
) : chatReplayQuery.data ? ( - - + + [] = [{ accessorKey: 'title', header: 'Title' }]; + +describe('ChatsReplayTable', () => { + afterEach(cleanup); + + it('opens a focused row with Enter or Space', () => { + const onRowClick = vi.fn(); + render(); + const row = screen.getByLabelText('Open chat: Quarterly report'); + + fireEvent.keyDown(row, { key: 'Enter' }); + fireEvent.keyDown(row, { key: ' ' }); + + expect(onRowClick).toHaveBeenCalledTimes(2); + expect(onRowClick).toHaveBeenCalledWith(chat); + expect(row.getAttribute('tabindex')).toBe('0'); + }); +}); + +function TestTable({ onRowClick }: { onRowClick: (chat: ProjectChatListItem) => void }) { + const table = useReactTable({ + data: [chat], + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ; +} diff --git a/apps/frontend/src/components/settings/chats-replay-table.tsx b/apps/frontend/src/components/settings/chats-replay-table.tsx index 0a6ef92a2..376532029 100644 --- a/apps/frontend/src/components/settings/chats-replay-table.tsx +++ b/apps/frontend/src/components/settings/chats-replay-table.tsx @@ -1,50 +1,52 @@ import { ChevronDown } from 'lucide-react'; import { flexRender } from '@tanstack/react-table'; import type { Table } from '@tanstack/react-table'; +import type { KeyboardEvent } from 'react'; +import type { ProjectChatListItem } from '@nao/shared/types'; import { TablePagination } from '@/components/ui/table-pagination'; import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { cn } from '@/lib/utils'; -export function ChatsReplayTable({ table }: { table: Table }) { +type ChatsReplayTableProps = { + table: Table; + onRowClick: (chat: ProjectChatListItem) => void; +}; + +export function ChatsReplayTable({ table, onRowClick }: ChatsReplayTableProps) { const colSpan = table.getVisibleLeafColumns().length; + const edgeCellClassName = 'first:pl-4 first:md:pl-8 last:pr-4 last:md:pr-8'; return ( -
-
- - +
+
+
+ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( -
- {header.id === 'actions' ? ( - Open chat - ) : ( - <> - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - - - )} +
+ + {flexRender(header.column.columnDef.header, header.getContext())} + +
))} @@ -64,9 +66,16 @@ export function ChatsReplayTable({ table }: { table: Table }) { ) : ( table.getRowModel().rows.map((row) => ( - + onRowClick(row.original)} + onKeyDown={(event) => handleRowKeyDown(event, row.original, onRowClick)} + tabIndex={0} + aria-label={`Open chat: ${row.original.title || 'Untitled'}`} + className='!border-0 cursor-pointer hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + > {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} @@ -77,14 +86,29 @@ export function ChatsReplayTable({ table }: { table: Table }) {
- table.setPageIndex(p)} - onPageSizeChange={(s) => table.setPageSize(s)} - /> +
+ table.setPageIndex(p)} + onPageSizeChange={(s) => table.setPageSize(s)} + /> +
); } + +function handleRowKeyDown( + event: KeyboardEvent, + chat: ProjectChatListItem, + onRowClick: (chat: ProjectChatListItem) => void, +) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + onRowClick(chat); +} diff --git a/apps/frontend/src/components/settings/chats-replay-toolbar.tsx b/apps/frontend/src/components/settings/chats-replay-toolbar.tsx deleted file mode 100644 index 0b45aad39..000000000 --- a/apps/frontend/src/components/settings/chats-replay-toolbar.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { Columns2, Filter, Users, X } from 'lucide-react'; -import { ChatsReplayDateFilter } from './chats-replay-date-filter'; -import { formatUserRole } from './chats-replay-columns'; -import type { ColumnFiltersState, Table } from '@tanstack/react-table'; - -import type { ProjectChatReplayFacets, UpdatedAtFilter } from '@nao/shared/types'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Input } from '@/components/ui/input'; -import { cn } from '@/lib/utils'; - -type ChatsReplayToolbarProps = { - globalFilter: string; - onGlobalFilterChange: (value: string) => void; - columnFilters: ColumnFiltersState; - onColumnFiltersChange: (next: ColumnFiltersState) => void; - facets: ProjectChatReplayFacets; - table: Table; -}; - -export function ChatsReplayToolbar({ - globalFilter, - onGlobalFilterChange, - columnFilters, - onColumnFiltersChange, - facets, - table, -}: ChatsReplayToolbarProps) { - const selectedUsers = - (columnFilters.find((f) => f.id === 'userName')?.value as string[] | undefined) ?? facets.userNames; - const someUsersUnchecked = selectedUsers.length < facets.userNames.length; - - const updatedAtFilter = columnFilters.find((f) => f.id === 'updatedAt')?.value as UpdatedAtFilter | undefined; - - const setUpdatedAtFilter = (value: UpdatedAtFilter | undefined) => { - const next = value - ? [...columnFilters.filter((f) => f.id !== 'updatedAt'), { id: 'updatedAt', value }] - : columnFilters.filter((f) => f.id !== 'updatedAt'); - onColumnFiltersChange(next); - }; - - const toolStateValues = [ - { value: 'noToolsUsed', label: 'No tools used', count: facets.toolState.noToolsUsed }, - { value: 'toolsNoErrors', label: 'Tools, no errors', count: facets.toolState.toolsNoErrors }, - { value: 'toolsWithErrors', label: 'Tools with errors', count: facets.toolState.toolsWithErrors }, - ].filter((o) => o.count > 0); - - const getAllValuesForFilterId = (id: string): string[] => { - if (id === 'userName') { - return facets.userNames; - } - if (id === 'userRole') { - return facets.userRoles; - } - if (id === 'toolState') { - return toolStateValues.map((o) => o.value); - } - return []; - }; - - const activeFilters = columnFilters.filter((f) => { - if (f.id === 'updatedAt') { - const v = f.value as UpdatedAtFilter | undefined; - return !!(v && ((v.mode === 'single' && v.value) || (v.mode === 'range' && v.start && v.end))); - } - const v = (f.value as string[]) ?? []; - const all = getAllValuesForFilterId(f.id); - return v.length > 0 && v.length < all.length; - }); - - const setSelectedUsers = (next: string[]) => { - const nextFilters = columnFilters.filter((f) => f.id !== 'userName'); - if (next.length > 0 && next.length < facets.userNames.length) { - nextFilters.push({ id: 'userName', value: next }); - } - onColumnFiltersChange(nextFilters); - }; - - const removeFilterValue = (columnId: string, value: string) => { - onColumnFiltersChange( - columnFilters - .map((f) => { - if (f.id !== columnId) { - return f; - } - const current = (f.value as string[]) ?? []; - const next = current.filter((v) => v !== value); - return { ...f, value: next }; - }) - .filter((f) => { - const v = f.value as string[]; - const all = getAllValuesForFilterId(f.id); - return v.length > 0 && v.length < all.length; - }), - ); - }; - - const clearAllFilters = () => { - onColumnFiltersChange([]); - onGlobalFilterChange(''); - }; - - const removeUpdatedAtFilter = () => { - onColumnFiltersChange(columnFilters.filter((f) => f.id !== 'updatedAt')); - }; - - const toggleableColumns = table.getAllLeafColumns().filter((col) => col.getCanHide()); - const visibleCount = toggleableColumns.filter((col) => col.getIsVisible()).length; - const hiddenCount = toggleableColumns.length - visibleCount; - - const filterConfigs = [ - { - id: 'userRole', - label: 'Role', - values: facets.userRoles, - valueLabels: Object.fromEntries(facets.userRoles.map((role) => [role, formatUserRole(role)])), - valueCounts: facets.userRoles.reduce>((acc, role) => { - acc[role] = facets.userRoleCounts?.[role] ?? 0; - return acc; - }, {}), - }, - { - id: 'toolState', - label: 'Tool State', - values: toolStateValues.map((o) => o.value), - valueLabels: Object.fromEntries(toolStateValues.map((o) => [o.value, o.label])), - valueCounts: Object.fromEntries(toolStateValues.map((o) => [o.value, o.count])), - }, - ] as const; - - const totalFilterSelectedCount = filterConfigs.reduce((acc, cfg) => { - const selected = (columnFilters.find((f) => f.id === cfg.id)?.value as string[] | undefined) ?? cfg.values; - return acc + selected.length; - }, 0); - - return ( -
-
- onGlobalFilterChange(e.target.value)} - placeholder='Search chats...' - className='h-8 text-sm max-w-sm' - /> - - - - - - - - -
- Users -
- - {facets.userNames.length === 0 ? ( -
No users
- ) : ( - <> - {facets.userNames.map((name) => ( - { - const next = checked - ? [...selectedUsers, name] - : selectedUsers.filter((v) => v !== name); - setSelectedUsers(next); - }} - > -
- {name} - {typeof facets.userNameCounts?.[name] === 'number' && - facets.userNameCounts[name] > 0 && ( - - {facets.userNameCounts[name]} - - )} -
-
- ))} - {someUsersUnchecked && ( - <> - - - - )} - - )} -
-
- - - - - - -
- Visible columns -
- - {toggleableColumns.map((column) => ( - column.toggleVisibility(!!value)} - > - - {typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id} - - - ))} - {hiddenCount > 0 && ( - <> - - - - )} -
-
- - - - - - - -
- Filter by -
- - - {filterConfigs.map((cfg) => { - const selected = - (columnFilters.find((f) => f.id === cfg.id)?.value as string[] | undefined) ?? - cfg.values; - const someUnchecked = selected.length < cfg.values.length; - - const toggleValue = (value: string, checked: boolean) => { - const next = checked ? [...selected, value] : selected.filter((v) => v !== value); - const nextFilters = columnFilters.filter((f) => f.id !== cfg.id); - if (next.length > 0 && next.length < cfg.values.length) { - nextFilters.push({ id: cfg.id, value: next }); - } - onColumnFiltersChange(nextFilters); - }; - - const showAll = () => { - onColumnFiltersChange(columnFilters.filter((f) => f.id !== cfg.id)); - }; - - return ( - - - {cfg.label} - {someUnchecked && ( - - {selected.length} - - )} - - - -
- {cfg.label} -
- - {cfg.values.length === 0 ? ( -
- No values -
- ) : ( - <> - {cfg.values.map((value) => ( - toggleValue(value, checked)} - > -
- - {cfg.valueLabels?.[value] ?? value} - - {typeof cfg.valueCounts?.[value] === 'number' && - cfg.valueCounts[value] > 0 && ( - - {cfg.valueCounts[value]} - - )} -
-
- ))} - {someUnchecked && ( - <> - - - - )} - - )} -
-
- ); - })} - - {(activeFilters.length > 0 || globalFilter || updatedAtFilter) && ( - <> - - - - )} -
-
-
- - {(activeFilters.length > 0 || globalFilter || updatedAtFilter) && ( -
- Active: - - {updatedAtFilter && - ((updatedAtFilter.mode === 'single' && updatedAtFilter.value) || - (updatedAtFilter.mode === 'range' && updatedAtFilter.start && updatedAtFilter.end)) && ( - - Last update: - - {updatedAtFilter.mode === 'single' - ? updatedAtFilter.value - : `${updatedAtFilter.start} – ${updatedAtFilter.end}`} - - - - )} - - {globalFilter && ( - - search: - {globalFilter} - - - )} - - {activeFilters - .filter((f) => f.id !== 'updatedAt') - .flatMap((filter) => { - const cfg = filterConfigs.find((c) => c.id === filter.id); - const getLabel = (val: string) => cfg?.valueLabels?.[val] ?? val; - return (filter.value as string[]).map((val) => ( - - {filter.id}: - {getLabel(val)} - - - )); - })} - - -
- )} -
- ); -} diff --git a/apps/frontend/src/components/settings/usage-chart-card.tsx b/apps/frontend/src/components/settings/usage-chart-card.tsx index ac7d633d5..9eaa5802b 100644 --- a/apps/frontend/src/components/settings/usage-chart-card.tsx +++ b/apps/frontend/src/components/settings/usage-chart-card.tsx @@ -1,23 +1,23 @@ -import type { UsageRecord } from '@nao/backend/usage'; +import type { ReactNode } from 'react'; +import type { TotalUsageRecord, UsageRecord } from '@nao/backend/usage'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; -import { SettingsCard } from '@/components/ui/settings-card'; export interface UsageChartCardProps { title: string; - description: string; isLoading: boolean; isFetching: boolean; isError: boolean; - data: UsageRecord[]; - chartType: 'bar' | 'stacked_bar'; + data: UsageRecord[] | TotalUsageRecord[]; + chartType: 'bar' | 'stacked_bar' | 'kpi_card'; series: { data_key: string; color: string; label: string }[]; - xAxisLabelFormatter: (value: string) => string; - filters: React.ReactNode; + xAxisLabelFormatter?: (value: string) => string; + valueFormatter?: (value: number) => string; + titleAccessory?: ReactNode; + showLegend?: boolean; } export function UsageChartCard({ title, - description, isLoading, isFetching, isError, @@ -25,10 +25,12 @@ export function UsageChartCard({ chartType, series, xAxisLabelFormatter, - filters, + valueFormatter, + titleAccessory, + showLegend, }: UsageChartCardProps) { return ( - +
{isError ? (

Error loading usage data.

@@ -44,16 +46,29 @@ export function UsageChartCard({ ) : (
[]} chartType={chartType} xAxisKey='date' xAxisType='category' xAxisLabelFormatter={xAxisLabelFormatter} + valueFormatter={valueFormatter} series={series} + titleAccessory={titleAccessory} + showLegend={showLegend} showGrid={true} + chartContainerClassName={ + chartType === 'kpi_card' + ? undefined + : 'max-lg:h-[200px] max-lg:max-h-[200px] h-[320px] max-h-[320px]' + } + chartContentClassName={ + chartType === 'kpi_card' ? undefined : 'max-lg:min-h-0 max-lg:flex-1 max-lg:aspect-auto' + } />
)} - +
); } diff --git a/apps/frontend/src/components/settings/usage-filters.tsx b/apps/frontend/src/components/settings/usage-filters.tsx index c880c5543..126babf71 100644 --- a/apps/frontend/src/components/settings/usage-filters.tsx +++ b/apps/frontend/src/components/settings/usage-filters.tsx @@ -1,21 +1,39 @@ -import { providerLabels } from '@nao/shared/types'; -import type { Granularity } from '@nao/backend/usage'; -import type { LlmProvider } from '@nao/shared/types'; +import { Radio, ThumbsUp, Users, Wrench } from 'lucide-react'; +import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabels } from '@nao/shared/types'; +import { USAGE_SOURCES } from '@nao/backend/usage'; +import type { Granularity, UsageSource } from '@nao/backend/usage'; +import type { + ChatReplayFeedbackState, + ChatReplayToolState, + LlmProvider, + ProjectChatReplayFacets, +} from '@nao/shared/types'; +import type { LucideIcon } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; -export type ChartView = 'messages' | 'tokens' | 'cost'; +type UsagePeriod = '24h' | '15d' | '6m'; -const granularityOptions: { value: Granularity; label: string }[] = [ - { value: 'hour', label: 'Hour' }, - { value: 'day', label: 'Day' }, - { value: 'month', label: 'Month' }, +const periodOptions: { value: UsagePeriod; label: string; granularity: Granularity }[] = [ + { value: '24h', label: 'Last 24 hours', granularity: 'hour' }, + { value: '15d', label: 'Last 15 days', granularity: 'day' }, + { value: '6m', label: 'Last 6 months', granularity: 'month' }, ]; -const chartViewOptions: { value: ChartView; label: string }[] = [ - { value: 'messages', label: 'Messages' }, - { value: 'tokens', label: 'Tokens' }, - { value: 'cost', label: 'Cost' }, -]; +const periodByGranularity: Record = { + hour: '24h', + day: '15d', + month: '6m', +}; export const dateFormats: Record = { hour: 'MMM d, HH:00', @@ -24,63 +42,261 @@ export const dateFormats: Record = { }; interface UsageFiltersProps { - chartView: ChartView; - onChartViewChange: (value: ChartView) => void; + showUsageControls?: boolean; provider: LlmProvider | 'all'; onProviderChange: (value: LlmProvider | 'all') => void; granularity: Granularity; onGranularityChange: (value: Granularity) => void; availableProviders: LlmProvider[] | undefined; + chatFacets: ProjectChatReplayFacets | undefined; + selectedUserNames: string[] | undefined; + onSelectedUserNamesChange: (value: string[] | undefined) => void; + selectedSources: UsageSource[] | undefined; + onSelectedSourcesChange: (value: UsageSource[] | undefined) => void; } export function UsageFilters({ - chartView, - onChartViewChange, + showUsageControls = true, provider, onProviderChange, granularity, onGranularityChange, availableProviders, + chatFacets, + selectedUserNames, + onSelectedUserNamesChange, + selectedSources, + onSelectedSourcesChange, }: UsageFiltersProps) { + const period = periodByGranularity[granularity]; + const userOptions = (chatFacets?.userNames ?? []).map((name) => ({ + value: name, + label: name, + count: chatFacets?.userNameCounts[name], + })); + const sourceOptions = USAGE_SOURCES.map((value) => ({ + value, + label: sourceLabels[value], + })); + return ( -
- - - +
+ {showUsageControls && ( + <> + + + + )} + + +
); } + +type ReplayFiltersProps = { + chatFacets: ProjectChatReplayFacets | undefined; + selectedFeedbackStates: ChatReplayFeedbackState[] | undefined; + onSelectedFeedbackStatesChange: (value: ChatReplayFeedbackState[] | undefined) => void; + selectedToolStates: ChatReplayToolState[] | undefined; + onSelectedToolStatesChange: (value: ChatReplayToolState[] | undefined) => void; +}; + +export function ReplayFilters({ + chatFacets, + selectedFeedbackStates, + onSelectedFeedbackStatesChange, + selectedToolStates, + onSelectedToolStatesChange, +}: ReplayFiltersProps) { + const toolStateOptions = CHAT_REPLAY_TOOL_STATES.map((value) => ({ + value, + label: toolStateLabels[value], + count: chatFacets?.toolState[value] ?? 0, + })).filter((option) => option.count > 0); + const feedbackOptions = CHAT_REPLAY_FEEDBACK_STATES.map((value) => ({ + value, + label: feedbackStateLabels[value], + })); + + return ( +
+ + +
+ ); +} + +type FilterOption = { + value: T; + label: string; + count?: number; +}; + +type MultiSelectFilterProps = { + label: string; + icon: LucideIcon; + options: FilterOption[]; + selectedValues: T[] | undefined; + onChange: (value: T[] | undefined) => void; +}; + +const toolStateLabels: Record = { + noToolsUsed: 'No tools used', + toolsNoErrors: 'Tools, no errors', + toolsWithErrors: 'Tools with errors', +}; + +const feedbackStateLabels: Record = { + noVotes: 'No votes', + upvotes: 'Upvotes', + downvotes: 'Downvotes', +}; + +const sourceLabels: Record = { + web: 'Web', + slack: 'Slack', + teams: 'Teams', + telegram: 'Telegram', + whatsapp: 'WhatsApp', + admin: 'Admin mode', + mcp: 'MCP', + contextRecommendations: 'Context recommendations', +}; + +function MultiSelectFilter({ + label, + icon: Icon, + options, + selectedValues, + onChange, +}: MultiSelectFilterProps) { + const allValues = options.map((option) => option.value); + const currentValues = selectedValues ?? allValues; + const hasPartialSelection = selectedValues !== undefined && selectedValues.length < allValues.length; + + const updateSelection = (next: T[]) => { + onChange(next.length === 0 || next.length === allValues.length ? undefined : next); + }; + + return ( + + + + + +
+ {label} +
+ + {options.map((option) => ( + event.preventDefault()} + onCheckedChange={(checked) => { + if (!selectedValues) { + updateSelection([option.value]); + return; + } + + const next = checked + ? Array.from(new Set([...currentValues, option.value])) + : currentValues.filter((value) => value !== option.value); + updateSelection(next); + }} + > +
+ {option.label} + {typeof option.count === 'number' && ( + + {option.count} + + )} +
+
+ ))} + {hasPartialSelection && ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/frontend/src/components/settings/usage-route-search.test.ts b/apps/frontend/src/components/settings/usage-route-search.test.ts new file mode 100644 index 000000000..8f92ccea0 --- /dev/null +++ b/apps/frontend/src/components/settings/usage-route-search.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { validateUsageSearch } from './usage-route-search'; + +describe('validateUsageSearch', () => { + it('accepts supported providers', () => { + expect(validateUsageSearch({ provider: 'openai' }).provider).toBe('openai'); + expect(validateUsageSearch({ provider: 'all' }).provider).toBe('all'); + }); + + it('rejects inherited provider label properties', () => { + expect(validateUsageSearch({ provider: 'constructor' }).provider).toBe('all'); + expect(validateUsageSearch({ provider: 'toString' }).provider).toBe('all'); + }); + + it('accepts context recommendations as a source', () => { + expect(validateUsageSearch({ sources: ['contextRecommendations'] }).sources).toEqual([ + 'contextRecommendations', + ]); + }); +}); diff --git a/apps/frontend/src/components/settings/usage-route-search.ts b/apps/frontend/src/components/settings/usage-route-search.ts new file mode 100644 index 000000000..a1432de74 --- /dev/null +++ b/apps/frontend/src/components/settings/usage-route-search.ts @@ -0,0 +1,108 @@ +import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabels } from '@nao/shared/types'; +import { USAGE_SOURCES } from '@nao/backend/usage'; +import type { Granularity, UsageSource } from '@nao/backend/usage'; +import type { ChatReplayFeedbackState, ChatReplayToolState, LlmProvider } from '@nao/shared/types'; +import { getActiveProjectId } from '@/lib/active-project'; + +export type TokenChartDisplayMode = 'tokens' | 'dollars'; + +export type UsageRouteSearch = { + provider: LlmProvider | 'all'; + granularity: Granularity; + users: string[] | undefined; + feedback: ChatReplayFeedbackState[] | undefined; + tools: ChatReplayToolState[] | undefined; + sources: UsageSource[] | undefined; + tokenView: TokenChartDisplayMode; +}; + +export const DEFAULT_USAGE_SEARCH: UsageRouteSearch = { + provider: 'all', + granularity: 'day', + users: undefined, + feedback: undefined, + tools: undefined, + sources: undefined, + tokenView: 'tokens', +}; + +const granularities = ['hour', 'day', 'month'] as const satisfies readonly Granularity[]; +const tokenViews = ['tokens', 'dollars'] as const satisfies readonly TokenChartDisplayMode[]; +const filterSearchKeys = ['provider', 'granularity', 'users', 'feedback', 'tools', 'sources'] as const; +const usageFiltersStorageKey = 'nao.usage-filters'; + +export function validateUsageSearchWithStoredFilters(search: Record): UsageRouteSearch { + const hasSearchFilters = filterSearchKeys.some((key) => search[key] !== undefined); + const storedFilters = hasSearchFilters ? {} : readStoredUsageFilters(); + + return validateUsageSearch({ ...storedFilters, ...search }); +} + +export function saveUsageFilters(search: UsageRouteSearch): void { + if (typeof window === 'undefined') { + return; + } + + const filters = Object.fromEntries(filterSearchKeys.map((key) => [key, search[key]])); + + try { + localStorage.setItem(getUsageFiltersStorageKey(), JSON.stringify(filters)); + } catch { + return; + } +} + +export function validateUsageSearch(search: Record): UsageRouteSearch { + return { + provider: parseProvider(search.provider), + granularity: parseOneOf(search.granularity, granularities) ?? 'day', + users: parseStringArray(search.users), + feedback: parseArrayOf(search.feedback, CHAT_REPLAY_FEEDBACK_STATES), + tools: parseArrayOf(search.tools, CHAT_REPLAY_TOOL_STATES), + sources: parseArrayOf(search.sources, USAGE_SOURCES), + tokenView: parseOneOf(search.tokenView, tokenViews) ?? 'tokens', + }; +} + +function readStoredUsageFilters(): Record { + if (typeof window === 'undefined') { + return {}; + } + + try { + const stored = localStorage.getItem(getUsageFiltersStorageKey()); + const parsed = stored ? JSON.parse(stored) : null; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function getUsageFiltersStorageKey(): string { + return `${usageFiltersStorageKey}.${getActiveProjectId() ?? 'default'}`; +} + +function parseProvider(value: unknown): LlmProvider | 'all' { + if (value === 'all' || (typeof value === 'string' && Object.hasOwn(providerLabels, value))) { + return value as LlmProvider | 'all'; + } + return 'all'; +} + +function parseStringArray(value: unknown): string[] | undefined { + const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []; + const parsed = values.filter((item): item is string => typeof item === 'string' && item.length > 0); + return parsed.length ? parsed : undefined; +} + +function parseArrayOf(value: unknown, allowedValues: readonly T[]): T[] | undefined { + const allowed = new Set(allowedValues); + const parsed = parseStringArray(value)?.filter((item): item is T => allowed.has(item)) ?? []; + return parsed.length ? parsed : undefined; +} + +function parseOneOf(value: unknown, allowedValues: readonly T[]): T | undefined { + return typeof value === 'string' && allowedValues.includes(value as T) ? (value as T) : undefined; +} diff --git a/apps/frontend/src/components/sidebar-settings-nav.tsx b/apps/frontend/src/components/sidebar-settings-nav.tsx index 04f155460..5cd6c54a4 100644 --- a/apps/frontend/src/components/sidebar-settings-nav.tsx +++ b/apps/frontend/src/components/sidebar-settings-nav.tsx @@ -68,14 +68,24 @@ const settingsNavItems: NavItem[] = [ visible: ({ isAdmin }) => isAdmin, }, { - label: 'Usage & costs', + label: 'Usage, costs & replay', to: '/settings/usage', visible: ({ isAdmin }) => isAdmin, }, { - label: 'Chats Replay', - to: '/settings/chats-replay', - visible: ({ isAdmin, isContextAdmin }) => isAdmin || isContextAdmin, + label: 'Chats replay', + to: '/settings/usage', + visible: ({ isAdmin, isContextAdmin }) => !isAdmin && isContextAdmin, + }, + { + label: 'Server logs', + to: '/settings/logs', + visible: ({ isAdmin, isCloud }) => isAdmin && !isCloud, + }, + { + label: 'Context', + type: 'divider', + visible: ({ isViewer }) => !isViewer, }, { label: 'Recommendations', @@ -85,9 +95,14 @@ const settingsNavItems: NavItem[] = [ badgeVariant: 'new', }, { - label: 'Logs', - to: '/settings/logs', - visible: ({ isAdmin, isCloud }) => isAdmin && !isCloud, + label: 'File Explorer', + to: '/settings/context-explorer', + visible: ({ isAdmin }) => isAdmin, + }, + { + label: 'Memory', + to: '/settings/memory', + visible: ({ isViewer }) => !isViewer, }, { label: 'Enterprise', @@ -104,21 +119,6 @@ const settingsNavItems: NavItem[] = [ to: '/settings/white-label', visible: ({ isAdmin, isCloud }) => isAdmin && !isCloud, }, - { - label: 'Context', - type: 'divider', - visible: ({ isViewer }) => !isViewer, - }, - { - label: 'Memory', - to: '/settings/memory', - visible: ({ isViewer }) => !isViewer, - }, - { - label: 'File Explorer', - to: '/settings/context-explorer', - visible: ({ isAdmin }) => isAdmin, - }, ]; interface SidebarSettingsNavProps { diff --git a/apps/frontend/src/components/tool-calls/display-chart.test.tsx b/apps/frontend/src/components/tool-calls/display-chart.test.tsx new file mode 100644 index 000000000..9f43b0dd4 --- /dev/null +++ b/apps/frontend/src/components/tool-calls/display-chart.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ChartDisplay } from './display-chart'; + +vi.mock('@/hooks/use-date-format', () => ({ + useDateFormat: () => ({ preset: 'european' }), +})); + +vi.mock('@/hooks/use-resize-observer', () => ({ + useResizeObserver: () => undefined, +})); + +vi.mock('@/main', () => ({ + trpc: {}, +})); + +describe('ChartDisplay', () => { + afterEach(cleanup); + + it('renders a left-aligned title for KPI cards', () => { + render( + , + ); + + expect(screen.getByText('Messages')).toBeDefined(); + expect(screen.getByText('Total messages')).toBeDefined(); + expect(screen.getByText('Unique users')).toBeDefined(); + }); +}); diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 5b1e76df1..36ea4bfcb 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -2,8 +2,17 @@ import { buildChart, bucketPieData, buildStoryChartBlock, labelize } from '@nao/ import { appendBlockToStoryCode } from '@nao/shared/story-tabs'; import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ChartNoAxesColumn, Code, Download, FilePlus, Pencil, Table as TableIcon } from 'lucide-react'; -import { memo, useCallback, useId, useMemo, useRef, useState } from 'react'; +import { + ChartNoAxesColumn, + ChevronLeft, + ChevronRight, + Code, + Download, + FilePlus, + Pencil, + Table as TableIcon, +} from 'lucide-react'; +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; import GraphLoaderAnimated from '../icons/graph-loader-animated'; @@ -43,6 +52,7 @@ import { ExportDataMenu } from '@/components/export-data-menu'; const Colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']; const EMPTY_MESSAGES: UIMessage[] = []; +const LEGEND_SCROLL_OFFSET = 120; const PIE_LEGEND_BREAKPOINT = 280; const COMPACT_XAXIS_BREAKPOINT = 360; const CHAR_WIDTH_RATIO = 0.6; @@ -401,8 +411,12 @@ export interface ChartDisplayProps { xAxisKey: string; xAxisType: 'number' | 'category'; xAxisLabelFormatter?: (value: string) => string; + valueFormatter?: (value: number) => string; series: displayChart.SeriesConfig[]; title?: string; + titleStyle?: 'default' | 'left'; + titleAccessory?: React.ReactNode; + showLegend?: boolean; showGrid?: boolean; yAxisMin?: number; yAxisMax?: number; @@ -411,6 +425,9 @@ export interface ChartDisplayProps { yAxisRightMax?: number; yAxisRightLabel?: string; showDataLabels?: boolean; + className?: string; + chartContainerClassName?: string; + chartContentClassName?: string; normalSize?: boolean; hideTotal?: boolean; } @@ -421,8 +438,12 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisKey: xAxisKeyProp, xAxisType, xAxisLabelFormatter, + valueFormatter, series: seriesProp, title, + titleStyle = 'default', + titleAccessory, + showLegend = true, showGrid = true, yAxisMin, yAxisMax, @@ -431,6 +452,9 @@ export const ChartDisplay = memo(function ChartDisplay({ yAxisRightMax, yAxisRightLabel, showDataLabels, + className, + chartContainerClassName, + chartContentClassName, normalSize = false, hideTotal, }: ChartDisplayProps) { @@ -460,6 +484,9 @@ export const ChartDisplay = memo(function ChartDisplay({ () => (isPie ? bucketPieData(data, xAxisKey, pieValueKey) : data), [isPie, data, xAxisKey, pieValueKey], ); + const useInlineHeader = titleStyle === 'left' && Boolean(title) && !isPie; + const showInlineLegend = showLegend && chartType !== 'kpi_card'; + const { scrollRef, canScrollLeft, canScrollRight, scrollLegend } = useHorizontalScrollControls(); const chartConfig = useMemo((): ChartConfig => { if (isPie) { @@ -559,6 +586,7 @@ export const ChartDisplay = memo(function ChartDisplay({ series: visibleSeries, colorFor, labelFormatter, + valueFormatter, compactXAxis, xAxisTickFontSize, xAxisMaxLabelChars, @@ -584,10 +612,11 @@ export const ChartDisplay = memo(function ChartDisplay({ isDualAxis={isDualAxis} hideTotal={hideTotal} labelFormatter={tooltipLabelFormatter} + valueFormatter={valueFormatter} /> } />, - chartType !== 'kpi_card' && ( + showLegend && chartType !== 'kpi_card' && !useInlineHeader && ( ), - ], - title, + ].filter(Boolean), + title: useInlineHeader ? undefined : title, renderTitle: false, }), [ @@ -621,6 +650,7 @@ export const ChartDisplay = memo(function ChartDisplay({ colorFor, labelFormatter, tooltipLabelFormatter, + valueFormatter, showGrid, yAxisMin, yAxisMax, @@ -636,22 +666,79 @@ export const ChartDisplay = memo(function ChartDisplay({ handleToggleSeriesVisibility, title, isPercentStacked, + showLegend, + useInlineHeader, ], ); + const inlineHeader = useInlineHeader ? ( +
+
+ {title} + {titleAccessory} +
+ {showInlineLegend && canScrollLeft && ( + + )} + {showInlineLegend && ( +
+ +
+ )} + {showInlineLegend && canScrollRight && ( + + )} +
+ ) : undefined; + return (
{chartType === 'kpi_card' ? ( - chartElement + <> + {inlineHeader} + {chartElement} + ) : ( {chartElement} @@ -660,6 +747,66 @@ export const ChartDisplay = memo(function ChartDisplay({ ); }); +const useHorizontalScrollControls = () => { + const scrollRef = useRef(null); + const [scrollState, setScrollState] = useState({ + canScrollLeft: false, + canScrollRight: false, + }); + + const updateScrollState = useCallback(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + const maxScrollLeft = element.scrollWidth - element.clientWidth; + setScrollState({ + canScrollLeft: element.scrollLeft > 1, + canScrollRight: element.scrollLeft < maxScrollLeft - 1, + }); + }, []); + + useEffect(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + updateScrollState(); + element.addEventListener('scroll', updateScrollState, { passive: true }); + + if (typeof ResizeObserver === 'undefined') { + return () => element.removeEventListener('scroll', updateScrollState); + } + + const resizeObserver = new ResizeObserver(updateScrollState); + resizeObserver.observe(element); + + if (element.firstElementChild) { + resizeObserver.observe(element.firstElementChild); + } + + return () => { + element.removeEventListener('scroll', updateScrollState); + resizeObserver.disconnect(); + }; + }, [updateScrollState]); + + const scrollLegend = useCallback((direction: 'left' | 'right') => { + scrollRef.current?.scrollBy({ + left: direction === 'left' ? -LEGEND_SCROLL_OFFSET : LEGEND_SCROLL_OFFSET, + behavior: 'smooth', + }); + }, []); + + return { + ...scrollState, + scrollRef, + scrollLegend, + }; +}; + /** Manages which series are visible and hidden */ const useSeriesVisibility = (series: displayChart.SeriesConfig[]) => { const [hiddenSeriesKeys, setHiddenSeriesKeys] = useState>(new Set()); diff --git a/apps/frontend/src/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx index 8a7f3f1a5..a161c4abe 100644 --- a/apps/frontend/src/components/ui/chart.tsx +++ b/apps/frontend/src/components/ui/chart.tsx @@ -35,11 +35,15 @@ function useChart() { function ChartContainer({ id, className, + contentClassName, + header, children, config, ...props }: React.ComponentProps<'div'> & { config: ChartConfig; + contentClassName?: string; + header?: React.ReactNode; children: React.ComponentProps['children']; }) { const uniqueId = React.useId(); @@ -47,17 +51,17 @@ function ChartContainer({ return ( -
- - {children} +
+ {header} +
+ + {children} +
); @@ -109,6 +113,7 @@ function ChartTooltipContent({ nameKey, labelKey, percent = false, + valueFormatter = formatCompactNumber, isDualAxis = false, hideTotal = false, }: React.ComponentProps & @@ -119,6 +124,7 @@ function ChartTooltipContent({ nameKey?: string; labelKey?: string; percent?: boolean; + valueFormatter?: (value: number) => string; isDualAxis?: boolean; hideTotal?: boolean; }) { @@ -167,9 +173,8 @@ function ChartTooltipContent({ .map((item) => ({ value: item.value as number, isTotal: isTotalItem(item) })), ); // In 100% stacked mode every category totals 100%, so ignore already-aggregated total series. - const showTotal = !isDualAxis && numericValues.length > 1 && (percent || (!hasTotalSeries && !hideTotal)); - const formatValue = (value: number) => - percent ? formatPercentShare(value, shareBase) : formatCompactNumber(value); + const showTotal = !isDualAxis && numericValues.length > 1 && (percent || (!hasTotalSeries && !hideTotal)); + const formatValue = (value: number) => (percent ? formatPercentShare(value, shareBase) : valueFormatter(value)); return (
- {item.value && ( + {item.value !== undefined && item.value !== null && ( {typeof item.value === 'number' ? formatValue(item.value) @@ -251,7 +256,7 @@ function ChartTooltipContent({
Total - {percent ? '100%' : formatCompactNumber(seriesTotal)} + {percent ? '100%' : valueFormatter(seriesTotal)}
@@ -269,10 +274,11 @@ function ChartLegendContent({ payload, verticalAlign = 'bottom', layout = 'horizontal', + align = 'center', nameKey, onItemClick, }: React.ComponentProps<'div'> & - Pick & { + Pick & { hideIcon?: boolean; nameKey?: string; onItemClick?: (dataKey: string) => void; @@ -292,7 +298,11 @@ function ChartLegendContent({ 'flex gap-4', isVertical ? 'flex-col items-start justify-center gap-2 pl-4' - : cn('items-center justify-center', verticalAlign === 'top' ? 'pb-3' : 'pt-3'), + : cn( + 'w-full items-center', + align === 'right' ? 'justify-end' : align === 'left' ? 'justify-start' : 'justify-center', + verticalAlign === 'top' ? 'pb-3' : 'pt-3', + ), className, )} > diff --git a/apps/frontend/src/components/ui/settings-card.tsx b/apps/frontend/src/components/ui/settings-card.tsx index 86ad32885..e6c25ce0e 100644 --- a/apps/frontend/src/components/ui/settings-card.tsx +++ b/apps/frontend/src/components/ui/settings-card.tsx @@ -1,9 +1,14 @@ import { cn } from '@/lib/utils'; -export function SettingsPageWrapper({ children }: { children: React.ReactNode }) { +export function SettingsPageWrapper({ children, wide }: { children: React.ReactNode; wide?: boolean }) { return (
-
+
{children}
@@ -20,6 +25,7 @@ interface SettingsCardProps { rootClassName?: string; className?: string; divide?: boolean; + unstyled?: boolean; } export function SettingsCard({ @@ -32,6 +38,7 @@ export function SettingsCard({ rootClassName, className, divide = false, + unstyled = false, }: SettingsCardProps) { return (
SidebarLayoutSettingsRoute, } as any) -const SidebarLayoutSettingsChatsReplayRoute = - SidebarLayoutSettingsChatsReplayRouteImport.update({ - id: '/chats-replay', - path: '/chats-replay', - getParentRoute: () => SidebarLayoutSettingsRoute, - } as any) const SidebarLayoutSettingsAccountRoute = SidebarLayoutSettingsAccountRouteImport.update({ id: '/account', @@ -304,6 +298,12 @@ const SidebarLayoutStoriesPreviewChatIdStorySlugRoute = path: '/stories/preview/$chatId/$storySlug', getParentRoute: () => SidebarLayoutRoute, } as any) +const SidebarLayoutSettingsUsageReplayChatIdRoute = + SidebarLayoutSettingsUsageReplayChatIdRouteImport.update({ + id: '/replay/$chatId', + path: '/replay/$chatId', + getParentRoute: () => SidebarLayoutSettingsUsageRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof SidebarLayoutChatLayoutIndexRoute @@ -317,7 +317,6 @@ export interface FileRoutesByFullPath { '/$chatId': typeof SidebarLayoutChatLayoutChatIdRoute '/automations/$automationId': typeof SidebarLayoutAutomationsAutomationIdRoute '/settings/account': typeof SidebarLayoutSettingsAccountRoute - '/settings/chats-replay': typeof SidebarLayoutSettingsChatsReplayRoute '/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/settings/logs': typeof SidebarLayoutSettingsLogsRoute @@ -326,7 +325,7 @@ export interface FileRoutesByFullPath { '/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/settings/project': typeof SidebarLayoutSettingsProjectRouteWithChildren '/settings/recommendations': typeof SidebarLayoutSettingsRecommendationsRoute - '/settings/usage': typeof SidebarLayoutSettingsUsageRoute + '/settings/usage': typeof SidebarLayoutSettingsUsageRouteWithChildren '/settings/white-label': typeof SidebarLayoutSettingsWhiteLabelRoute '/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute @@ -347,6 +346,7 @@ export interface FileRoutesByFullPath { '/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute '/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/settings/project/': typeof SidebarLayoutSettingsProjectIndexRoute + '/settings/usage/replay/$chatId': typeof SidebarLayoutSettingsUsageReplayChatIdRoute '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRoutesByTo { @@ -360,7 +360,6 @@ export interface FileRoutesByTo { '/$chatId': typeof SidebarLayoutChatLayoutChatIdRoute '/automations/$automationId': typeof SidebarLayoutAutomationsAutomationIdRoute '/settings/account': typeof SidebarLayoutSettingsAccountRoute - '/settings/chats-replay': typeof SidebarLayoutSettingsChatsReplayRoute '/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/settings/logs': typeof SidebarLayoutSettingsLogsRoute @@ -368,7 +367,7 @@ export interface FileRoutesByTo { '/settings/memory': typeof SidebarLayoutSettingsMemoryRoute '/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/settings/recommendations': typeof SidebarLayoutSettingsRecommendationsRoute - '/settings/usage': typeof SidebarLayoutSettingsUsageRoute + '/settings/usage': typeof SidebarLayoutSettingsUsageRouteWithChildren '/settings/white-label': typeof SidebarLayoutSettingsWhiteLabelRoute '/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute @@ -389,6 +388,7 @@ export interface FileRoutesByTo { '/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute '/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/settings/project': typeof SidebarLayoutSettingsProjectIndexRoute + '/settings/usage/replay/$chatId': typeof SidebarLayoutSettingsUsageReplayChatIdRoute '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRoutesById { @@ -405,7 +405,6 @@ export interface FileRoutesById { '/_sidebar-layout/_chat-layout/$chatId': typeof SidebarLayoutChatLayoutChatIdRoute '/_sidebar-layout/automations/$automationId': typeof SidebarLayoutAutomationsAutomationIdRoute '/_sidebar-layout/settings/account': typeof SidebarLayoutSettingsAccountRoute - '/_sidebar-layout/settings/chats-replay': typeof SidebarLayoutSettingsChatsReplayRoute '/_sidebar-layout/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/_sidebar-layout/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/_sidebar-layout/settings/logs': typeof SidebarLayoutSettingsLogsRoute @@ -414,7 +413,7 @@ export interface FileRoutesById { '/_sidebar-layout/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/_sidebar-layout/settings/project': typeof SidebarLayoutSettingsProjectRouteWithChildren '/_sidebar-layout/settings/recommendations': typeof SidebarLayoutSettingsRecommendationsRoute - '/_sidebar-layout/settings/usage': typeof SidebarLayoutSettingsUsageRoute + '/_sidebar-layout/settings/usage': typeof SidebarLayoutSettingsUsageRouteWithChildren '/_sidebar-layout/settings/white-label': typeof SidebarLayoutSettingsWhiteLabelRoute '/_sidebar-layout/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute @@ -436,6 +435,7 @@ export interface FileRoutesById { '/_sidebar-layout/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute '/_sidebar-layout/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/_sidebar-layout/settings/project/': typeof SidebarLayoutSettingsProjectIndexRoute + '/_sidebar-layout/settings/usage/replay/$chatId': typeof SidebarLayoutSettingsUsageReplayChatIdRoute '/_sidebar-layout/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRouteTypes { @@ -452,7 +452,6 @@ export interface FileRouteTypes { | '/$chatId' | '/automations/$automationId' | '/settings/account' - | '/settings/chats-replay' | '/settings/context-explorer' | '/settings/enterprise' | '/settings/logs' @@ -482,6 +481,7 @@ export interface FileRouteTypes { | '/stories/shared/$shareId' | '/stories/standalone/$storyId' | '/settings/project/' + | '/settings/usage/replay/$chatId' | '/stories/preview/$chatId/$storySlug' fileRoutesByTo: FileRoutesByTo to: @@ -495,7 +495,6 @@ export interface FileRouteTypes { | '/$chatId' | '/automations/$automationId' | '/settings/account' - | '/settings/chats-replay' | '/settings/context-explorer' | '/settings/enterprise' | '/settings/logs' @@ -524,6 +523,7 @@ export interface FileRouteTypes { | '/stories/shared/$shareId' | '/stories/standalone/$storyId' | '/settings/project' + | '/settings/usage/replay/$chatId' | '/stories/preview/$chatId/$storySlug' id: | '__root__' @@ -539,7 +539,6 @@ export interface FileRouteTypes { | '/_sidebar-layout/_chat-layout/$chatId' | '/_sidebar-layout/automations/$automationId' | '/_sidebar-layout/settings/account' - | '/_sidebar-layout/settings/chats-replay' | '/_sidebar-layout/settings/context-explorer' | '/_sidebar-layout/settings/enterprise' | '/_sidebar-layout/settings/logs' @@ -570,6 +569,7 @@ export interface FileRouteTypes { | '/_sidebar-layout/stories/shared/$shareId' | '/_sidebar-layout/stories/standalone/$storyId' | '/_sidebar-layout/settings/project/' + | '/_sidebar-layout/settings/usage/replay/$chatId' | '/_sidebar-layout/stories/preview/$chatId/$storySlug' fileRoutesById: FileRoutesById } @@ -767,13 +767,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutSettingsContextExplorerRouteImport parentRoute: typeof SidebarLayoutSettingsRoute } - '/_sidebar-layout/settings/chats-replay': { - id: '/_sidebar-layout/settings/chats-replay' - path: '/chats-replay' - fullPath: '/settings/chats-replay' - preLoaderRoute: typeof SidebarLayoutSettingsChatsReplayRouteImport - parentRoute: typeof SidebarLayoutSettingsRoute - } '/_sidebar-layout/settings/account': { id: '/_sidebar-layout/settings/account' path: '/account' @@ -893,6 +886,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutStoriesPreviewChatIdStorySlugRouteImport parentRoute: typeof SidebarLayoutRoute } + '/_sidebar-layout/settings/usage/replay/$chatId': { + id: '/_sidebar-layout/settings/usage/replay/$chatId' + path: '/replay/$chatId' + fullPath: '/settings/usage/replay/$chatId' + preLoaderRoute: typeof SidebarLayoutSettingsUsageReplayChatIdRouteImport + parentRoute: typeof SidebarLayoutSettingsUsageRoute + } } } @@ -957,9 +957,23 @@ const SidebarLayoutSettingsProjectRouteWithChildren = SidebarLayoutSettingsProjectRouteChildren, ) +interface SidebarLayoutSettingsUsageRouteChildren { + SidebarLayoutSettingsUsageReplayChatIdRoute: typeof SidebarLayoutSettingsUsageReplayChatIdRoute +} + +const SidebarLayoutSettingsUsageRouteChildren: SidebarLayoutSettingsUsageRouteChildren = + { + SidebarLayoutSettingsUsageReplayChatIdRoute: + SidebarLayoutSettingsUsageReplayChatIdRoute, + } + +const SidebarLayoutSettingsUsageRouteWithChildren = + SidebarLayoutSettingsUsageRoute._addFileChildren( + SidebarLayoutSettingsUsageRouteChildren, + ) + interface SidebarLayoutSettingsRouteChildren { SidebarLayoutSettingsAccountRoute: typeof SidebarLayoutSettingsAccountRoute - SidebarLayoutSettingsChatsReplayRoute: typeof SidebarLayoutSettingsChatsReplayRoute SidebarLayoutSettingsContextExplorerRoute: typeof SidebarLayoutSettingsContextExplorerRoute SidebarLayoutSettingsEnterpriseRoute: typeof SidebarLayoutSettingsEnterpriseRoute SidebarLayoutSettingsLogsRoute: typeof SidebarLayoutSettingsLogsRoute @@ -968,14 +982,13 @@ interface SidebarLayoutSettingsRouteChildren { SidebarLayoutSettingsOrganizationRoute: typeof SidebarLayoutSettingsOrganizationRoute SidebarLayoutSettingsProjectRoute: typeof SidebarLayoutSettingsProjectRouteWithChildren SidebarLayoutSettingsRecommendationsRoute: typeof SidebarLayoutSettingsRecommendationsRoute - SidebarLayoutSettingsUsageRoute: typeof SidebarLayoutSettingsUsageRoute + SidebarLayoutSettingsUsageRoute: typeof SidebarLayoutSettingsUsageRouteWithChildren SidebarLayoutSettingsWhiteLabelRoute: typeof SidebarLayoutSettingsWhiteLabelRoute SidebarLayoutSettingsIndexRoute: typeof SidebarLayoutSettingsIndexRoute } const SidebarLayoutSettingsRouteChildren: SidebarLayoutSettingsRouteChildren = { SidebarLayoutSettingsAccountRoute: SidebarLayoutSettingsAccountRoute, - SidebarLayoutSettingsChatsReplayRoute: SidebarLayoutSettingsChatsReplayRoute, SidebarLayoutSettingsContextExplorerRoute: SidebarLayoutSettingsContextExplorerRoute, SidebarLayoutSettingsEnterpriseRoute: SidebarLayoutSettingsEnterpriseRoute, @@ -988,7 +1001,7 @@ const SidebarLayoutSettingsRouteChildren: SidebarLayoutSettingsRouteChildren = { SidebarLayoutSettingsProjectRouteWithChildren, SidebarLayoutSettingsRecommendationsRoute: SidebarLayoutSettingsRecommendationsRoute, - SidebarLayoutSettingsUsageRoute: SidebarLayoutSettingsUsageRoute, + SidebarLayoutSettingsUsageRoute: SidebarLayoutSettingsUsageRouteWithChildren, SidebarLayoutSettingsWhiteLabelRoute: SidebarLayoutSettingsWhiteLabelRoute, SidebarLayoutSettingsIndexRoute: SidebarLayoutSettingsIndexRoute, } diff --git a/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx b/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx index e5a00317a..51bfb1acb 100644 --- a/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx @@ -7,6 +7,7 @@ import type { SelectionData } from '@/components/highlight-bubble'; import { NEW_CHAT_ID } from '@/lib/ai'; import { StoryOpenButton } from '@/components/story-open-button'; import { StoryViewer } from '@/components/side-panel/story-viewer'; +import { DEFAULT_USAGE_SEARCH } from '@/components/settings/usage-route-search'; import { ChatInput } from '@/components/chat-input'; import { ChatMessages } from '@/components/chat-messages/chat-messages'; import { HighlightBubble } from '@/components/highlight-bubble'; @@ -66,7 +67,12 @@ function ChatPage() { useEffect(() => { if (shouldRedirectToReplay) { - router.navigate({ to: '/settings/chats-replay', search: { chatId }, replace: true }); + router.navigate({ + to: '/settings/usage/replay/$chatId', + params: { chatId }, + search: DEFAULT_USAGE_SEARCH, + replace: true, + }); } }, [shouldRedirectToReplay, chatId, router]); diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx deleted file mode 100644 index 3871cc621..000000000 --- a/apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -import { ChatsReplayPage } from '@/components/settings/chats-replay-page'; -import { requireContextAdminOrAdmin } from '@/lib/require-admin'; - -export const Route = createFileRoute('/_sidebar-layout/settings/chats-replay')({ - validateSearch: (search: Record): { chatId?: string } => ({ - chatId: typeof search.chatId === 'string' ? search.chatId : undefined, - }), - beforeLoad: requireContextAdminOrAdmin, - component: ChatsReplayPage, -}); diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx new file mode 100644 index 000000000..91252bf2d --- /dev/null +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx @@ -0,0 +1,43 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Check, Link } from 'lucide-react'; + +import { ChatsReplayPanel } from '@/components/settings/chats-replay-panel'; +import { validateUsageSearch } from '@/components/settings/usage-route-search'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { requireContextAdminOrAdmin } from '@/lib/require-admin'; + +export const Route = createFileRoute('/_sidebar-layout/settings/usage/replay/$chatId')({ + beforeLoad: requireContextAdminOrAdmin, + validateSearch: validateUsageSearch, + component: ChatReplayRoute, +}); + +function ChatReplayRoute() { + const { chatId } = Route.useParams(); + const usageSearch = Route.useSearch(); + const navigate = useNavigate(); + const { isCopied, copy } = useCopyToClipboard(); + + return ( + copy(window.location.href).catch(console.error)} + > + {isCopied ? : } + {isCopied ? 'Copied!' : 'Copy link'} + + } + onBack={() => { + navigate({ + to: '/settings/usage', + search: usageSearch, + replace: true, + }); + }} + /> + ); +} diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx index cbabbba16..93ff0ae2a 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx @@ -1,179 +1,296 @@ -import { useState } from 'react'; -import { createFileRoute } from '@tanstack/react-router'; +import { useEffect } from 'react'; +import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { format, formatDistanceToNow } from 'date-fns'; -import { ThumbsDown, ThumbsUp } from 'lucide-react'; -import type { Granularity } from '@nao/backend/usage'; -import type { LlmProvider } from '@nao/shared/types'; -import type { ChartView } from '@/components/settings/usage-filters'; +import { format } from 'date-fns'; +import type { TokenChartDisplayMode, UsageRouteSearch } from '@/components/settings/usage-route-search'; +import { ChatsReplayPage } from '@/components/settings/chats-replay-page'; import { UsageChartCard } from '@/components/settings/usage-chart-card'; -import { UsageFilters, dateFormats } from '@/components/settings/usage-filters'; -import { SettingsCard, SettingsPageWrapper } from '@/components/ui/settings-card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { ReplayFilters, UsageFilters, dateFormats } from '@/components/settings/usage-filters'; +import { saveUsageFilters, validateUsageSearchWithStoredFilters } from '@/components/settings/usage-route-search'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { usePermissions } from '@/hooks/use-permissions'; import { trpc } from '@/main'; -import { Empty } from '@/components/ui/empty'; -import { requireAdmin } from '@/lib/require-admin'; +import { requireContextAdminOrAdmin } from '@/lib/require-admin'; export const Route = createFileRoute('/_sidebar-layout/settings/usage')({ - beforeLoad: requireAdmin, + beforeLoad: requireContextAdminOrAdmin, + validateSearch: validateUsageSearchWithStoredFilters, component: UsagePage, }); +const tokenChartDisplayOptions: { value: TokenChartDisplayMode; label: string }[] = [ + { value: 'tokens', label: 'Show in tokens' }, + { value: 'dollars', label: 'Show in dollars' }, +]; + +const tokenSeries = [ + { data_key: 'inputNoCacheTokens', color: 'var(--chart-1)', label: 'Input' }, + { data_key: 'inputCacheReadTokens', color: 'var(--chart-2)', label: 'Cache read' }, + { data_key: 'inputCacheWriteTokens', color: 'var(--chart-3)', label: 'Cache write' }, + { data_key: 'outputTotalTokens', color: 'var(--chart-4)', label: 'Output' }, +]; + +const costSeries = [ + { data_key: 'inputNoCacheCost', color: 'var(--chart-1)', label: 'Input' }, + { data_key: 'inputCacheReadCost', color: 'var(--chart-2)', label: 'Cache read' }, + { data_key: 'inputCacheWriteCost', color: 'var(--chart-3)', label: 'Cache write' }, + { data_key: 'outputCost', color: 'var(--chart-4)', label: 'Output' }, +]; + +const messageSeries = [ + { data_key: 'webMessageCount', color: 'var(--chart-1)', label: 'Web' }, + { data_key: 'slackMessageCount', color: 'var(--chart-2)', label: 'Slack' }, + { data_key: 'teamsMessageCount', color: 'var(--chart-3)', label: 'Teams' }, + { data_key: 'telegramMessageCount', color: 'var(--chart-4)', label: 'Telegram' }, + { data_key: 'whatsappMessageCount', color: 'var(--chart-5)', label: 'WhatsApp' }, + { data_key: 'adminMessageCount', color: 'var(--violet)', label: 'Admin mode' }, + { data_key: 'mcpMessageCount', color: 'var(--destructive)', label: 'MCP' }, + { + data_key: 'contextRecommendationsMessageCount', + color: 'var(--chart-6)', + label: 'Context recommendations', + }, +] as const; + function UsagePage() { - const [granularity, setGranularity] = useState('day'); - const [provider, setProvider] = useState('all'); - const [chartView, setChartView] = useState('messages'); + const usageSearch = Route.useSearch(); + const navigate = useNavigate(); + const isReplayRoute = useRouterState({ + select: (state) => state.location.pathname.startsWith('/settings/usage/replay/'), + }); - const usedProviders = useQuery(trpc.usage.getUsedProviders.queryOptions()); + useEffect(() => { + saveUsageFilters(usageSearch); + }, [usageSearch]); + + if (isReplayRoute) { + return ; + } + + return ( + { + navigate({ + to: '/settings/usage', + search: { ...usageSearch, ...next }, + replace: true, + }); + }} + onOpenChatReplay={(chatId) => { + navigate({ + to: '/settings/usage/replay/$chatId', + params: { chatId }, + search: usageSearch, + }); + }} + /> + ); +} + +function UsageOverview({ + usageSearch, + onUpdateSearch, + onOpenChatReplay, +}: { + usageSearch: UsageRouteSearch; + onUpdateSearch: (next: Partial) => void; + onOpenChatReplay: (chatId: string) => void; +}) { + const { granularity, provider, users, feedback, tools, sources, tokenView } = usageSearch; + const { canViewUsage } = usePermissions(); + + const usedProviders = useQuery({ + ...trpc.usage.getUsedProviders.queryOptions(), + enabled: canViewUsage, + }); + const chatFacets = useQuery({ + ...trpc.project.getProjectChats.queryOptions({ + page: 0, + pageSize: 1, + }), + placeholderData: keepPreviousData, + }); const messagesUsage = useQuery({ ...trpc.usage.getMessagesUsage.queryOptions({ granularity, provider: provider === 'all' ? undefined : provider, + userNames: users, + sources, }), placeholderData: keepPreviousData, + enabled: canViewUsage, + }); + const totalUsage = useQuery({ + ...trpc.usage.getTotalUsage.queryOptions({ + granularity, + provider: provider === 'all' ? undefined : provider, + userNames: users, + sources, + }), + placeholderData: keepPreviousData, + enabled: canViewUsage, }); - const recentFeedbacks = useQuery(trpc.feedback.getRecent.queryOptions({ limit: 10 })); const chartData = messagesUsage.data ?? []; + const totalUsageChartData = totalUsage.data ? [totalUsage.data] : []; + const showCost = tokenView === 'dollars'; + const activeMessageSeries = messageSeries.filter(({ data_key }) => + chartData.some((record) => record[data_key] > 0), + ); + const displayedMessageSeries = activeMessageSeries.length > 0 ? activeMessageSeries : [...messageSeries]; + const showMessageLegend = displayedMessageSeries.some(({ data_key }) => data_key !== 'webMessageCount'); const filtersComponent = ( onUpdateSearch({ provider: value })} granularity={granularity} - onGranularityChange={setGranularity} + onGranularityChange={(value) => onUpdateSearch({ granularity: value })} availableProviders={usedProviders.data} + chatFacets={chatFacets.data?.facets} + selectedUserNames={users} + onSelectedUserNamesChange={(value) => onUpdateSearch({ users: value })} + selectedSources={sources} + onSelectedSourcesChange={(value) => onUpdateSearch({ sources: value })} /> ); return ( - - {chartView === 'messages' && ( - format(new Date(value), dateFormats[granularity])} - series={[ - { data_key: 'webMessageCount', color: 'var(--chart-1)', label: 'Web' }, - { data_key: 'slackMessageCount', color: 'var(--chart-2)', label: 'Slack' }, - { data_key: 'teamsMessageCount', color: 'var(--chart-3)', label: 'Teams' }, - { data_key: 'telegramMessageCount', color: 'var(--chart-4)', label: 'Telegram' }, - { data_key: 'whatsappMessageCount', color: 'var(--chart-5)', label: 'WhatsApp' }, - ]} - filters={filtersComponent} - /> - )} - - {chartView === 'tokens' && ( - format(new Date(value), dateFormats[granularity])} - series={[ - { data_key: 'inputNoCacheTokens', color: 'var(--chart-1)', label: 'Input' }, - { data_key: 'inputCacheReadTokens', color: 'var(--chart-2)', label: 'Input (cache read)' }, - { data_key: 'inputCacheWriteTokens', color: 'var(--chart-3)', label: 'Input (cache write)' }, - { data_key: 'outputTotalTokens', color: 'var(--chart-4)', label: 'Output' }, - ]} - filters={filtersComponent} - /> - )} - - {chartView === 'cost' && ( - format(new Date(value), dateFormats[granularity])} - series={[ - { data_key: 'inputNoCacheCost', color: 'var(--chart-1)', label: 'Input' }, - { data_key: 'inputCacheReadCost', color: 'var(--chart-2)', label: 'Input (cache read)' }, - { data_key: 'inputCacheWriteCost', color: 'var(--chart-3)', label: 'Input (cache write)' }, - { data_key: 'outputCost', color: 'var(--chart-4)', label: 'Output' }, - ]} - filters={filtersComponent} - /> - )} - - - {recentFeedbacks.isLoading ? ( - Loading feedbacks... - ) : recentFeedbacks.isError ? ( - Failed to load feedbacks. - ) : !recentFeedbacks.data?.length ? ( - No feedbacks yet. - ) : ( - - - - Vote - User - Message - Reason - Date - - - - {recentFeedbacks.data.map((feedback) => ( - - - {feedback.vote === 'up' ? ( - - ) : ( - - )} - - {feedback.userName} - - {feedback.messageText ? ( - - {feedback.messageText.length > 60 - ? `${feedback.messageText.slice(0, 60)}...` - : feedback.messageText} - - ) : ( - No text. - )} - - - {feedback.explanation ? ( - - {feedback.explanation.length > 40 - ? `${feedback.explanation.slice(0, 40)}...` - : feedback.explanation} - - ) : ( - - - )} - - - {formatDistanceToNow(new Date(feedback.createdAt), { addSuffix: true })} - - - ))} - -
- )} -
-
+
+
+
+ {filtersComponent} + + {canViewUsage && ( +
+
+ +
+ + format(new Date(value), dateFormats[granularity])} + titleAccessory={ + Number of messages by source + } + series={displayedMessageSeries} + showLegend={showMessageLegend} + /> + + format(new Date(value), dateFormats[granularity])} + valueFormatter={showCost ? formatUsd : undefined} + series={showCost ? costSeries : tokenSeries} + titleAccessory={ + + } + /> +
+ )} +
+ +
+
+

Chats replay

+ onUpdateSearch({ feedback: value })} + selectedToolStates={tools} + onSelectedToolStatesChange={(value) => onUpdateSearch({ tools: value })} + /> +
+ +
+
+
); } + +function formatUsd(value: number): string { + const abs = Math.abs(value); + + if (abs >= 10_000) { + return `${value < 0 ? '-' : ''}$${formatCompactCurrency(Math.abs(value))}`; + } + + if (abs > 0 && abs < 0.01) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(value); + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: abs === 0 ? 0 : 2, + maximumFractionDigits: 2, + }).format(value); +} + +function formatCompactCurrency(value: number): string { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + return `${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`; +} diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index e9090595b..b3b2941bd 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -157,6 +157,7 @@ export interface BuildChartProps { series: displayChart.SeriesConfig[]; colorFor?: (key: string, index: number) => string; labelFormatter?: (value: string) => string; + valueFormatter?: (value: number) => string; showGrid?: boolean; children?: React.ReactNode[]; margin?: { top?: number; right?: number; bottom?: number; left?: number }; @@ -370,12 +371,17 @@ export function niceAxisMax(dataMax: number, tickCount = 5): number { } function buildKpiCard(props: ResolvedProps) { - const { data, series } = props; + const { data, series, valueFormatter } = props; return ( {series.map((s) => ( - + ))} ); @@ -385,11 +391,19 @@ function KpiCardContainer({ children }: { children: React.ReactNode }) { return
{children}
; } -function KpiCard({ value, displayName }: { value: unknown; displayName: string }) { +function KpiCard({ + value, + displayName, + valueFormatter = formatCompactNumber, +}: { + value: unknown; + displayName: string; + valueFormatter?: (value: number) => string; +}) { let formattedValue = ''; if (typeof value === 'number') { - formattedValue = formatCompactNumber(value); + formattedValue = valueFormatter(value); } else if (typeof value === 'string') { formattedValue = value; } @@ -402,7 +416,7 @@ function KpiCard({ value, displayName }: { value: unknown; displayName: string } ); } -function renderValueYAxis(isPercent = false) { +function renderValueYAxis(isPercent = false, valueFormatter = formatYAxisTick) { return ( ); } @@ -481,6 +495,7 @@ function buildBarChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, + valueFormatter, } = props; const isStacked = displayChart.isStackedChartType(chartType); const isPercent = displayChart.isPercentStackedChartType(chartType); @@ -501,7 +516,7 @@ function buildBarChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={valueFormatter ?? formatYAxisTick} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, true)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -594,6 +609,7 @@ function buildAreaChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, + valueFormatter, } = props; const gradientIdPrefix = props.gradientIdPrefix ?? ''; const gradientIdFor = (index: number) => `${gradientIdPrefix}grad-${index}`; @@ -628,7 +644,7 @@ function buildAreaChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={valueFormatter ?? formatYAxisTick} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, zeroBaseline)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -857,7 +873,19 @@ function axisLabel(label: string | undefined, side: displayChart.YAxisSide) { } function buildScatterChart(props: ResolvedProps) { - const { data, xAxisKey, xAxisType, series, colorFor, showGrid, children, margin, yAxisMin, yAxisMax } = props; + const { + data, + xAxisKey, + xAxisType, + series, + colorFor, + showGrid, + children, + margin, + yAxisMin, + yAxisMax, + valueFormatter, + } = props; const axisValues = collectAxisValues( data, series.map((s) => s.data_key), @@ -880,7 +908,7 @@ function buildScatterChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={valueFormatter ?? formatYAxisTick} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, false)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -898,13 +926,13 @@ function buildScatterChart(props: ResolvedProps) { } function buildRadarChart(props: ResolvedProps) { - const { data, xAxisKey, series, colorFor, children, margin } = props; + const { data, xAxisKey, series, colorFor, children, margin, valueFormatter } = props; return ( - + {children} {series.map((s, i) => (