From f1d655e4257a035f1e0910d88d0748002842e0df Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 22 May 2026 11:23:48 -0400 Subject: [PATCH] feat: filter analytics by profile --- src/web-server/usage/aggregator.ts | 74 ++++++++++----- src/web-server/usage/handlers.ts | 25 +++-- src/web-server/usage/profile-filter.ts | 36 +++++++ src/web-server/usage/types.ts | 8 ++ .../usage-handlers-semantics.test.ts | 95 ++++++++++++++++++- ui/src/hooks/use-usage.ts | 38 +++++--- .../analytics/components/analytics-header.tsx | 35 +++++++ ui/src/pages/analytics/hooks.ts | 80 +++++++++++++++- ui/src/pages/analytics/index.tsx | 6 ++ .../unit/ui/pages/analytics/hooks.test.tsx | 40 +++++++- .../analytics/usage-api-contract.test.ts | 26 +++++ 11 files changed, 416 insertions(+), 47 deletions(-) create mode 100644 src/web-server/usage/profile-filter.ts diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index c3e29d1c..d74db00b 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -40,6 +40,7 @@ import { getModelsUsed, getProviderModelKey, } from './model-identity'; +import { annotateUsageProfile, filterByProfile } from './profile-filter'; import { getCcsDir } from '../../config/config-loader-facade'; import { listAccountInstancePaths } from '../../management/instance-directory'; @@ -146,12 +147,16 @@ function finalizeHourlyUsage(hour: HourlyUsage): HourlyUsage { * Merge daily usage data from multiple sources * Combines entries with same date by aggregating tokens */ -export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { +export function mergeDailyData( + sources: DailyUsage[][], + options: { preserveProfile?: boolean } = {} +): DailyUsage[] { const dateMap = new Map(); for (const source of sources) { for (const day of source) { - const existing = dateMap.get(day.date); + const mergeKey = options.preserveProfile ? `${day.profile ?? ''}\u0000${day.date}` : day.date; + const existing = dateMap.get(mergeKey); if (existing) { // Aggregate tokens for same date existing.inputTokens += day.inputTokens; @@ -178,8 +183,9 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { } else { // Clone to avoid mutating original const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b })); - dateMap.set(day.date, { + dateMap.set(mergeKey, { ...day, + ...(options.preserveProfile && day.profile ? { profile: day.profile } : {}), modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, }); @@ -195,12 +201,18 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { /** * Merge monthly usage data from multiple sources */ -export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { +export function mergeMonthlyData( + sources: MonthlyUsage[][], + options: { preserveProfile?: boolean } = {} +): MonthlyUsage[] { const monthMap = new Map(); for (const source of sources) { for (const month of source) { - const existing = monthMap.get(month.month); + const mergeKey = options.preserveProfile + ? `${month.profile ?? ''}\u0000${month.month}` + : month.month; + const existing = monthMap.get(mergeKey); if (existing) { existing.inputTokens += month.inputTokens; existing.outputTokens += month.outputTokens; @@ -224,8 +236,9 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { } } else { const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown })); - monthMap.set(month.month, { + monthMap.set(mergeKey, { ...month, + ...(options.preserveProfile && month.profile ? { profile: month.profile } : {}), modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, }); @@ -242,12 +255,18 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { * Merge hourly usage data from multiple sources * Combines entries with same hour by aggregating tokens */ -export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { +export function mergeHourlyData( + sources: HourlyUsage[][], + options: { preserveProfile?: boolean } = {} +): HourlyUsage[] { const hourMap = new Map(); for (const source of sources) { for (const hour of source) { - const existing = hourMap.get(hour.hour); + const mergeKey = options.preserveProfile + ? `${hour.profile ?? ''}\u0000${hour.hour}` + : hour.hour; + const existing = hourMap.get(mergeKey); if (existing) { existing.inputTokens += hour.inputTokens; existing.outputTokens += hour.outputTokens; @@ -273,8 +292,9 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { } } else { const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b })); - hourMap.set(hour.hour, { + hourMap.set(mergeKey, { ...hour, + ...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}), modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, requestCount: getHourlyRequestCount(hour), @@ -393,7 +413,10 @@ async function refreshFromSource(): Promise<{ await syncCliproxyUsage(); // Load canonical default data and avoid counting the active instance twice - const defaultData = await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() }); + const defaultData = annotateUsageProfile( + await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() }), + 'default' + ); // Load data from all CCS instances sequentially const instancePaths = getInstancePaths(); @@ -406,7 +429,10 @@ async function refreshFromSource(): Promise<{ for (const instancePath of instancePaths) { try { - const data = await loadInstanceData(instancePath); + const data = annotateUsageProfile( + await loadInstanceData(instancePath), + path.basename(instancePath) + ); instanceDataResults.push(data); } catch (err) { const instanceName = path.basename(instancePath); @@ -471,9 +497,9 @@ async function refreshFromSource(): Promise<{ } // Merge all data sources - const daily = mergeDailyData(allDailySources); - const hourly = mergeHourlyData(allHourlySources); - const monthly = mergeMonthlyData(allMonthlySources); + const daily = mergeDailyData(allDailySources, { preserveProfile: true }); + const hourly = mergeHourlyData(allHourlySources, { preserveProfile: true }); + const monthly = mergeMonthlyData(allMonthlySources, { preserveProfile: true }); const session = mergeSessionData(allSessionSources); // Update in-memory cache @@ -602,31 +628,35 @@ async function getCachedData(key: string, ttl: number, loader: () => Promise< } /** Cached loader for daily usage data */ -export async function getCachedDailyData(): Promise { - return getCachedData('daily', CACHE_TTL.daily, async () => { +export async function getCachedDailyData(profile?: string): Promise { + const data = await getCachedData('daily', CACHE_TTL.daily, async () => { return (await refreshFromSourceCoalesced()).daily; }); + return mergeDailyData([filterByProfile(data, profile)]); } /** Cached loader for monthly usage data */ -export async function getCachedMonthlyData(): Promise { - return getCachedData('monthly', CACHE_TTL.monthly, async () => { +export async function getCachedMonthlyData(profile?: string): Promise { + const data = await getCachedData('monthly', CACHE_TTL.monthly, async () => { return (await refreshFromSourceCoalesced()).monthly; }); + return mergeMonthlyData([filterByProfile(data, profile)]); } /** Cached loader for session data */ -export async function getCachedSessionData(): Promise { - return getCachedData('session', CACHE_TTL.session, async () => { +export async function getCachedSessionData(profile?: string): Promise { + const data = await getCachedData('session', CACHE_TTL.session, async () => { return (await refreshFromSourceCoalesced()).session; }); + return filterByProfile(data, profile); } /** Cached loader for hourly usage data */ -export async function getCachedHourlyData(): Promise { - return getCachedData('hourly', CACHE_TTL.daily, async () => { +export async function getCachedHourlyData(profile?: string): Promise { + const data = await getCachedData('hourly', CACHE_TTL.daily, async () => { return (await refreshFromSourceCoalesced()).hourly; }); + return mergeHourlyData([filterByProfile(data, profile)]); } /** diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index 5e6e14dc..dacb5e31 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -22,6 +22,7 @@ import { getModelsUsed, getProviderModelKey, } from './model-identity'; +import { normalizeProfileQuery } from './profile-filter'; // ============================================================================ // Types @@ -31,6 +32,7 @@ import { export interface UsageQuery { since?: string; // YYYYMMDD format until?: string; // YYYYMMDD format + profile?: string; limit?: string; offset?: string; } @@ -407,8 +409,9 @@ export async function handleSummary( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); - const dailyData = await getCachedDailyData(); + const dailyData = await getCachedDailyData(profile); const filtered = filterByDateRange(dailyData, since, until); let totalInputTokens = 0, @@ -466,8 +469,9 @@ export async function handleDaily( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); - const dailyData = await getCachedDailyData(); + const dailyData = await getCachedDailyData(profile); const filtered = filterByDateRange(dailyData, since, until); const trends = filtered.map((day) => ({ @@ -498,8 +502,9 @@ export async function handleHourly( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); - const hourlyData = await getCachedHourlyData(); + const hourlyData = await getCachedHourlyData(profile); const filtered = (hourlyData || []).filter((h) => { const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); @@ -538,8 +543,9 @@ export async function handleModels( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); - const dailyData = await getCachedDailyData(); + const dailyData = await getCachedDailyData(profile); const filtered = filterByDateRange(dailyData, since, until); const modelMap = new Map< @@ -644,11 +650,12 @@ export async function handleSessions( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); const limit = validateLimit(req.query.limit); const offset = validateOffset(req.query.offset); - const sessionData = await getCachedSessionData(); + const sessionData = await getCachedSessionData(profile); const filtered = filterByDateRange(sessionData, since, until); const sorted = [...filtered].sort( (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() @@ -694,6 +701,7 @@ export async function handleMonthly( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); let filtered: Array<{ month: string; @@ -707,7 +715,7 @@ export async function handleMonthly( }>; if (since || until) { - const dailyData = filterByDateRange(await getCachedDailyData(), since, until); + const dailyData = filterByDateRange(await getCachedDailyData(profile), since, until); const monthMap = new Map< string, { @@ -789,7 +797,7 @@ export async function handleMonthly( }) .sort((a, b) => a.month.localeCompare(b.month)); } else { - filtered = await getCachedMonthlyData(); + filtered = await getCachedMonthlyData(profile); } const result = filtered.map((m) => ({ @@ -836,8 +844,9 @@ export async function handleInsights( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + const profile = normalizeProfileQuery(req.query.profile); validateDateRangeOrder(since, until); - const dailyData = await getCachedDailyData(); + const dailyData = await getCachedDailyData(profile); const filtered = filterByDateRange(dailyData, since, until); const anomalies = detectAnomalies(filtered); const summary = summarizeAnomalies(anomalies); diff --git a/src/web-server/usage/profile-filter.ts b/src/web-server/usage/profile-filter.ts new file mode 100644 index 00000000..a319daa1 --- /dev/null +++ b/src/web-server/usage/profile-filter.ts @@ -0,0 +1,36 @@ +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; + +export interface ProfileScopedUsageData { + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +} + +const PROFILE_NAME_REGEX = /^[A-Za-z0-9._-]+$/; + +export function normalizeProfileQuery(profile?: string): string | undefined { + const value = profile?.trim(); + if (!value || value === 'all') return undefined; + if (!PROFILE_NAME_REGEX.test(value)) { + throw new Error('Invalid profile filter'); + } + return value; +} + +export function annotateUsageProfile( + data: ProfileScopedUsageData, + profile: string +): ProfileScopedUsageData { + return { + daily: data.daily.map((item) => ({ ...item, profile })), + hourly: data.hourly.map((item) => ({ ...item, profile })), + monthly: data.monthly.map((item) => ({ ...item, profile })), + session: data.session.map((item) => ({ ...item, profile })), + }; +} + +export function filterByProfile(data: T[], profile?: string): T[] { + if (!profile) return data; + return data.filter((item) => item.profile === profile); +} diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts index e5588263..0cd234a7 100644 --- a/src/web-server/usage/types.ts +++ b/src/web-server/usage/types.ts @@ -27,6 +27,8 @@ export interface ModelBreakdown { /** Daily usage aggregation (YYYY-MM-DD) */ export interface DailyUsage { date: string; + /** Stable CCS profile name when the source can be attributed to one. */ + profile?: string; source: string; inputTokens: number; outputTokens: number; @@ -41,6 +43,8 @@ export interface DailyUsage { /** Hourly usage aggregation (YYYY-MM-DD HH:00) */ export interface HourlyUsage { hour: string; // Format: "YYYY-MM-DD HH:00" + /** Stable CCS profile name when the source can be attributed to one. */ + profile?: string; source: string; inputTokens: number; outputTokens: number; @@ -60,6 +64,8 @@ export interface HourlyUsage { /** Monthly usage aggregation (YYYY-MM) */ export interface MonthlyUsage { month: string; + /** Stable CCS profile name when the source can be attributed to one. */ + profile?: string; source: string; inputTokens: number; outputTokens: number; @@ -73,6 +79,8 @@ export interface MonthlyUsage { /** Session-level usage aggregation */ export interface SessionUsage { sessionId: string; + /** Stable CCS profile name when the source can be attributed to one. */ + profile?: string; projectPath: string; inputTokens: number; outputTokens: number; diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts index 9b9fe47d..e91aec59 100644 --- a/tests/unit/web-server/usage-handlers-semantics.test.ts +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -58,8 +58,12 @@ cliproxy_server: } function writeAssistantEntries(entries: AssistantFixture[]): void { + writeAssistantEntriesToDir(claudeDir, entries); +} + +function writeAssistantEntriesToDir(baseClaudeDir: string, entries: AssistantFixture[]): void { for (const entry of entries) { - const projectDir = path.join(claudeDir, 'projects', entry.project); + const projectDir = path.join(baseClaudeDir, 'projects', entry.project); fs.mkdirSync(projectDir, { recursive: true }); const line = JSON.stringify({ @@ -322,6 +326,95 @@ describe('usage handlers semantics', () => { }); }); + it('filters summary totals to the selected stable account profile', async () => { + writeAssistantEntries([ + { + project: 'default-project', + sessionId: 'session-default', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + ]); + writeAssistantEntriesToDir(path.join(tempHome, '.ccs', 'instances', 'work'), [ + { + project: 'work-project', + sessionId: 'session-work', + timestamp: '2026-03-02T11:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 300, + outputTokens: 30, + }, + ]); + + const allProfilesRes = createMockResponse(); + await handlers.handleSummary( + { query: { since: '20260302', until: '20260302' } } as never, + allProfilesRes as never + ); + + expect(allProfilesRes.payload).toMatchObject({ + success: true, + data: { + totalInputTokens: 400, + totalOutputTokens: 40, + }, + }); + + aggregator.clearUsageCache(); + const workProfileRes = createMockResponse(); + await handlers.handleSummary( + { query: { since: '20260302', until: '20260302', profile: 'work' } } as never, + workProfileRes as never + ); + + expect(workProfileRes.payload).toMatchObject({ + success: true, + data: { + totalInputTokens: 300, + totalOutputTokens: 30, + }, + }); + }); + + it('filters sessions to the default profile without including account sessions', async () => { + writeAssistantEntries([ + { + project: 'default-project', + sessionId: 'session-default', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + ]); + writeAssistantEntriesToDir(path.join(tempHome, '.ccs', 'instances', 'work'), [ + { + project: 'work-project', + sessionId: 'session-work', + timestamp: '2026-03-02T11:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 300, + outputTokens: 30, + }, + ]); + + const res = createMockResponse(); + await handlers.handleSessions( + { query: { since: '20260302', until: '20260302', profile: 'default' } } as never, + res as never + ); + + expect(res.payload).toMatchObject({ + success: true, + data: { + total: 1, + sessions: [expect.objectContaining({ sessionId: 'session-default' })], + }, + }); + }); + it('rejects reversed date ranges before computing summary totals', async () => { const res = createMockResponse(); diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 9c929fdd..16a6d21f 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -123,6 +123,7 @@ export interface MonthlyUsage { export interface UsageQueryOptions { startDate?: Date; endDate?: Date; + profile?: string; limit?: number; offset?: number; } @@ -150,43 +151,52 @@ function buildUsageUrl(path: string, params: URLSearchParams): string { return query ? `${path}?${query}` : path; } +function appendDateParams(params: URLSearchParams, options?: UsageQueryOptions): void { + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); +} + +function appendProfileParam(params: URLSearchParams, options?: UsageQueryOptions): void { + if (options?.profile) params.append('profile', options.profile); +} + export const usageApi = { summary: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/summary', params)); }, trends: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/daily', params)); }, hourly: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/hourly', params)); }, models: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/models', params)); }, sessions: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); if (options?.limit) params.append('limit', options.limit.toString()); if (options?.offset) params.append('offset', options.offset.toString()); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/sessions', params)); }, monthly: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/monthly', params)); }, /** Clear server-side usage cache and force fresh data fetch */ @@ -204,8 +214,8 @@ export const usageApi = { /** Get usage insights including anomaly detection */ insights: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); - if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + appendDateParams(params, options); + appendProfileParam(params, options); return request(buildUsageUrl('/usage/insights', params)); }, }; diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx index 4c5f3380..51ea43dd 100644 --- a/ui/src/pages/analytics/components/analytics-header.tsx +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -8,8 +8,16 @@ import type { DateRange } from 'react-day-picker'; import { subDays, startOfMonth } from 'date-fns'; import { Button } from '@/components/ui/button'; import { DateRangeFilter } from '@/components/analytics/date-range-filter'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import type { AnalyticsProfileOption } from '../hooks'; interface AnalyticsHeaderProps { dateRange: DateRange | undefined; @@ -19,6 +27,9 @@ interface AnalyticsHeaderProps { isRefreshing: boolean; lastUpdatedText: string | null; viewMode: 'daily' | 'hourly'; + selectedProfile: string; + profileOptions: AnalyticsProfileOption[]; + onProfileChange: (profile: string) => void; } export function AnalyticsHeader({ @@ -29,6 +40,9 @@ export function AnalyticsHeader({ isRefreshing, lastUpdatedText, viewMode, + selectedProfile, + profileOptions, + onProfileChange, }: AnalyticsHeaderProps) { const { t } = useTranslation(); @@ -39,6 +53,21 @@ export function AnalyticsHeader({

{t('analytics.subtitle')}

+
+ {selectedProfile !== 'all' && ( +

+ Selected-profile analytics include only default/account data with stable profile + attribution. CLIProxy and native runtime snapshots remain in All profiles. +

+ )} ); } diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts index 359e769b..80e16838 100644 --- a/ui/src/pages/analytics/hooks.ts +++ b/ui/src/pages/analytics/hooks.ts @@ -17,8 +17,35 @@ import { useSessions, type ModelUsage, } from '@/hooks/use-usage'; +import { useAccounts } from '@/hooks/use-accounts'; +import { useProfiles } from '@/hooks/use-profiles'; const RECENT_SESSION_SAMPLE_LIMIT = 50; +const ANALYTICS_PROFILE_STORAGE_KEY = 'ccs.analytics.selectedProfile'; +const ALL_PROFILES_VALUE = 'all'; + +export interface AnalyticsProfileOption { + value: string; + label: string; + description: string; + supported: boolean; +} + +function readPersistedProfile(): string { + if (typeof globalThis.localStorage === 'undefined') return ALL_PROFILES_VALUE; + const profile = globalThis.localStorage.getItem(ANALYTICS_PROFILE_STORAGE_KEY); + if (!profile || profile.startsWith('unsupported:')) return ALL_PROFILES_VALUE; + return profile; +} + +function persistSelectedProfile(profile: string): void { + if (typeof globalThis.localStorage === 'undefined') return; + if (profile === ALL_PROFILES_VALUE) { + globalThis.localStorage.removeItem(ANALYTICS_PROFILE_STORAGE_KEY); + return; + } + globalThis.localStorage.setItem(ANALYTICS_PROFILE_STORAGE_KEY, profile); +} export function useAnalyticsPage() { // Default to last 30 days @@ -28,8 +55,11 @@ export function useAnalyticsPage() { }); const [isRefreshing, setIsRefreshing] = useState(false); const [selectedModel, setSelectedModel] = useState(null); + const [selectedProfile, setSelectedProfileState] = useState(readPersistedProfile); const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null); const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily'); + const { data: accountsView } = useAccounts(); + const { data: apiProfiles } = useProfiles(); // Refresh hook const refreshUsage = useRefreshUsage(); @@ -48,10 +78,49 @@ export function useAnalyticsPage() { () => ({ startDate: dateRange?.from, endDate: dateRange?.to, + profile: selectedProfile === ALL_PROFILES_VALUE ? undefined : selectedProfile, }), - [dateRange?.from, dateRange?.to] + [dateRange?.from, dateRange?.to, selectedProfile] ); + const profileOptions = useMemo(() => { + const accountNames = new Set(accountsView?.accounts.map((account) => account.name) ?? []); + const options: AnalyticsProfileOption[] = [ + { + value: ALL_PROFILES_VALUE, + label: 'All profiles', + description: 'Includes all analytics sources.', + supported: true, + }, + { + value: 'default', + label: 'Default Claude', + description: 'Profile-scoped Claude JSONL data.', + supported: true, + }, + ...Array.from(accountNames) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ + value: name, + label: name, + description: 'Profile-scoped account data.', + supported: true, + })), + ]; + + for (const profile of apiProfiles?.profiles ?? []) { + if (accountNames.has(profile.name) || profile.name === 'default') continue; + options.push({ + value: `unsupported:${profile.name}`, + label: profile.name, + description: 'API profile usage is not yet attributed by stable profile.', + supported: false, + }); + } + + return options; + }, [accountsView?.accounts, apiProfiles?.profiles]); + // Fetch data const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions); const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions); @@ -76,6 +145,12 @@ export function useAnalyticsPage() { setViewMode('daily'); // Switch back to daily view for multi-day ranges }, []); + const handleProfileChange = useCallback((profile: string) => { + if (profile.startsWith('unsupported:')) return; + setSelectedProfileState(profile); + persistSelectedProfile(profile); + }, []); + // Format "Last updated" text const lastUpdatedText = useMemo(() => { if (!status?.lastFetch) return null; @@ -99,6 +174,8 @@ export function useAnalyticsPage() { dateRange, isRefreshing, viewMode, + selectedProfile, + profileOptions, selectedModel, popoverPosition, // Data @@ -120,6 +197,7 @@ export function useAnalyticsPage() { handleRefresh, handleTodayClick, handleDateRangeChange, + handleProfileChange, handleModelClick, handlePopoverClose, // Text diff --git a/ui/src/pages/analytics/index.tsx b/ui/src/pages/analytics/index.tsx index 92288854..289749ab 100644 --- a/ui/src/pages/analytics/index.tsx +++ b/ui/src/pages/analytics/index.tsx @@ -23,6 +23,8 @@ export function AnalyticsPage() { isRefreshing, lastUpdatedText, viewMode, + selectedProfile, + profileOptions, summary, isSummaryLoading, trends, @@ -34,6 +36,7 @@ export function AnalyticsPage() { isModelsLoading, isSessionsLoading, handleModelClick, + handleProfileChange, selectedModel, popoverPosition, handlePopoverClose, @@ -50,6 +53,9 @@ export function AnalyticsPage() { isRefreshing={isRefreshing} lastUpdatedText={lastUpdatedText} viewMode={viewMode} + selectedProfile={selectedProfile} + profileOptions={profileOptions} + onProfileChange={handleProfileChange} /> {/* Summary Cards */} diff --git a/ui/tests/unit/ui/pages/analytics/hooks.test.tsx b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx index eb4f4455..e118912b 100644 --- a/ui/tests/unit/ui/pages/analytics/hooks.test.tsx +++ b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import { useAnalyticsPage } from '@/pages/analytics/hooks'; import { AllProviders } from '@tests/setup/test-utils'; @@ -14,8 +14,46 @@ const usageMocks = vi.hoisted(() => ({ })); vi.mock('@/hooks/use-usage', () => usageMocks); +vi.mock('@/hooks/use-accounts', () => ({ + useAccounts: vi.fn(() => ({ + data: { accounts: [{ name: 'work' }], default: 'work' }, + isLoading: false, + })), +})); +vi.mock('@/hooks/use-profiles', () => ({ + useProfiles: vi.fn(() => ({ data: { profiles: [] }, isLoading: false })), +})); describe('useAnalyticsPage', () => { + beforeEach(() => { + window.localStorage.clear(); + Object.values(usageMocks).forEach((mock) => mock.mockClear()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('restores the persisted selected profile and passes it to analytics queries', () => { + vi.spyOn(globalThis.localStorage, 'getItem').mockImplementation((key) => + key === 'ccs.analytics.selectedProfile' ? 'work' : null + ); + + renderHook(() => useAnalyticsPage(), { + wrapper: AllProviders, + }); + + expect(usageMocks.useUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ profile: 'work' }) + ); + expect(usageMocks.useUsageTrends).toHaveBeenCalledWith( + expect.objectContaining({ profile: 'work' }) + ); + expect(usageMocks.useModelUsage).toHaveBeenCalledWith( + expect.objectContaining({ profile: 'work' }) + ); + }); + it('requests a broader recent session sample instead of the old 3-session slice', () => { renderHook(() => useAnalyticsPage(), { wrapper: AllProviders, diff --git a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts index 645151c2..3eed14b3 100644 --- a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts +++ b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts @@ -37,4 +37,30 @@ describe('analytics usage API contract', () => { expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430'); expect(urls.every((url) => !url.includes('months='))).toBe(true); }); + + it('serializes the selected profile for every profile-scoped usage endpoint', async () => { + const startDate = new Date(2026, 3, 1); + const endDate = new Date(2026, 3, 30); + const options = { startDate, endDate, profile: 'work' }; + + await usageApi.summary(options); + await usageApi.trends(options); + await usageApi.hourly(options); + await usageApi.models(options); + await usageApi.sessions({ ...options, limit: 50 }); + await usageApi.insights(options); + await usageApi.monthly(options); + + const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url)); + + expect(urls).toContain('/api/usage/summary?since=20260401&until=20260430&profile=work'); + expect(urls).toContain('/api/usage/daily?since=20260401&until=20260430&profile=work'); + expect(urls).toContain('/api/usage/hourly?since=20260401&until=20260430&profile=work'); + expect(urls).toContain('/api/usage/models?since=20260401&until=20260430&profile=work'); + expect(urls).toContain( + '/api/usage/sessions?since=20260401&until=20260430&limit=50&profile=work' + ); + expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430&profile=work'); + expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430&profile=work'); + }); });