mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 00:14:48 +00:00
feat: filter analytics by profile
This commit is contained in:
1 parent
14cc787f18
commit
f1d655e425
11 files changed
+416
-47
No files matched your search
+24
-14
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user