feat: filter analytics by profile

This commit is contained in:
Tam Nhu Tran committed 2026-05-22 11:24:21 -04:00
1 parent 14cc787f18
commit f1d655e425
11 files changed
+416 -47

No files matched your search

+24 -14
View File
@@ -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<UsageSummary>(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<DailyUsage[]>(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<HourlyUsage[]>(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<ModelUsage[]>(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<PaginatedSessions>(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<MonthlyUsage[]>(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<UsageInsights>(buildUsageUrl('/usage/insights', params));
},
};
@@ -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({
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Select value={selectedProfile} onValueChange={onProfileChange}>
<SelectTrigger className="h-8 w-[190px]" aria-label="Analytics profile">
<SelectValue placeholder="All profiles" />
</SelectTrigger>
<SelectContent>
{profileOptions.map((option) => (
<SelectItem key={option.value} value={option.value} disabled={!option.supported}>
<span className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
@@ -77,6 +106,12 @@ export function AnalyticsHeader({
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
{selectedProfile !== 'all' && (
<p className="text-xs text-muted-foreground xl:text-right">
Selected-profile analytics include only default/account data with stable profile
attribution. CLIProxy and native runtime snapshots remain in All profiles.
</p>
)}
</div>
);
}
+79 -1
View File
@@ -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<ModelUsage | null>(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<AnalyticsProfileOption[]>(() => {
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
+6
View File
@@ -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 */}
@@ -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,
@@ -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');
});
});