mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
feat: filter analytics by profile
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
|||||||
getModelsUsed,
|
getModelsUsed,
|
||||||
getProviderModelKey,
|
getProviderModelKey,
|
||||||
} from './model-identity';
|
} from './model-identity';
|
||||||
|
import { annotateUsageProfile, filterByProfile } from './profile-filter';
|
||||||
import { getCcsDir } from '../../config/config-loader-facade';
|
import { getCcsDir } from '../../config/config-loader-facade';
|
||||||
import { listAccountInstancePaths } from '../../management/instance-directory';
|
import { listAccountInstancePaths } from '../../management/instance-directory';
|
||||||
|
|
||||||
@@ -146,12 +147,16 @@ function finalizeHourlyUsage(hour: HourlyUsage): HourlyUsage {
|
|||||||
* Merge daily usage data from multiple sources
|
* Merge daily usage data from multiple sources
|
||||||
* Combines entries with same date by aggregating tokens
|
* 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<string, DailyUsage>();
|
const dateMap = new Map<string, DailyUsage>();
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
for (const day of source) {
|
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) {
|
if (existing) {
|
||||||
// Aggregate tokens for same date
|
// Aggregate tokens for same date
|
||||||
existing.inputTokens += day.inputTokens;
|
existing.inputTokens += day.inputTokens;
|
||||||
@@ -178,8 +183,9 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
|||||||
} else {
|
} else {
|
||||||
// Clone to avoid mutating original
|
// Clone to avoid mutating original
|
||||||
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
|
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
|
||||||
dateMap.set(day.date, {
|
dateMap.set(mergeKey, {
|
||||||
...day,
|
...day,
|
||||||
|
...(options.preserveProfile && day.profile ? { profile: day.profile } : {}),
|
||||||
modelsUsed: getModelsUsed(modelBreakdowns),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
});
|
});
|
||||||
@@ -195,12 +201,18 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
|||||||
/**
|
/**
|
||||||
* Merge monthly usage data from multiple sources
|
* 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<string, MonthlyUsage>();
|
const monthMap = new Map<string, MonthlyUsage>();
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
for (const month of source) {
|
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) {
|
if (existing) {
|
||||||
existing.inputTokens += month.inputTokens;
|
existing.inputTokens += month.inputTokens;
|
||||||
existing.outputTokens += month.outputTokens;
|
existing.outputTokens += month.outputTokens;
|
||||||
@@ -224,8 +236,9 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
|
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
|
||||||
monthMap.set(month.month, {
|
monthMap.set(mergeKey, {
|
||||||
...month,
|
...month,
|
||||||
|
...(options.preserveProfile && month.profile ? { profile: month.profile } : {}),
|
||||||
modelsUsed: getModelsUsed(modelBreakdowns),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
});
|
});
|
||||||
@@ -242,12 +255,18 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
|||||||
* Merge hourly usage data from multiple sources
|
* Merge hourly usage data from multiple sources
|
||||||
* Combines entries with same hour by aggregating tokens
|
* 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<string, HourlyUsage>();
|
const hourMap = new Map<string, HourlyUsage>();
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
for (const hour of source) {
|
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) {
|
if (existing) {
|
||||||
existing.inputTokens += hour.inputTokens;
|
existing.inputTokens += hour.inputTokens;
|
||||||
existing.outputTokens += hour.outputTokens;
|
existing.outputTokens += hour.outputTokens;
|
||||||
@@ -273,8 +292,9 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
|
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
|
||||||
hourMap.set(hour.hour, {
|
hourMap.set(mergeKey, {
|
||||||
...hour,
|
...hour,
|
||||||
|
...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}),
|
||||||
modelsUsed: getModelsUsed(modelBreakdowns),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
requestCount: getHourlyRequestCount(hour),
|
requestCount: getHourlyRequestCount(hour),
|
||||||
@@ -393,7 +413,10 @@ async function refreshFromSource(): Promise<{
|
|||||||
await syncCliproxyUsage();
|
await syncCliproxyUsage();
|
||||||
|
|
||||||
// Load canonical default data and avoid counting the active instance twice
|
// 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
|
// Load data from all CCS instances sequentially
|
||||||
const instancePaths = getInstancePaths();
|
const instancePaths = getInstancePaths();
|
||||||
@@ -406,7 +429,10 @@ async function refreshFromSource(): Promise<{
|
|||||||
|
|
||||||
for (const instancePath of instancePaths) {
|
for (const instancePath of instancePaths) {
|
||||||
try {
|
try {
|
||||||
const data = await loadInstanceData(instancePath);
|
const data = annotateUsageProfile(
|
||||||
|
await loadInstanceData(instancePath),
|
||||||
|
path.basename(instancePath)
|
||||||
|
);
|
||||||
instanceDataResults.push(data);
|
instanceDataResults.push(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const instanceName = path.basename(instancePath);
|
const instanceName = path.basename(instancePath);
|
||||||
@@ -471,9 +497,9 @@ async function refreshFromSource(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge all data sources
|
// Merge all data sources
|
||||||
const daily = mergeDailyData(allDailySources);
|
const daily = mergeDailyData(allDailySources, { preserveProfile: true });
|
||||||
const hourly = mergeHourlyData(allHourlySources);
|
const hourly = mergeHourlyData(allHourlySources, { preserveProfile: true });
|
||||||
const monthly = mergeMonthlyData(allMonthlySources);
|
const monthly = mergeMonthlyData(allMonthlySources, { preserveProfile: true });
|
||||||
const session = mergeSessionData(allSessionSources);
|
const session = mergeSessionData(allSessionSources);
|
||||||
|
|
||||||
// Update in-memory cache
|
// Update in-memory cache
|
||||||
@@ -602,31 +628,35 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Cached loader for daily usage data */
|
/** Cached loader for daily usage data */
|
||||||
export async function getCachedDailyData(): Promise<DailyUsage[]> {
|
export async function getCachedDailyData(profile?: string): Promise<DailyUsage[]> {
|
||||||
return getCachedData('daily', CACHE_TTL.daily, async () => {
|
const data = await getCachedData('daily', CACHE_TTL.daily, async () => {
|
||||||
return (await refreshFromSourceCoalesced()).daily;
|
return (await refreshFromSourceCoalesced()).daily;
|
||||||
});
|
});
|
||||||
|
return mergeDailyData([filterByProfile(data, profile)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cached loader for monthly usage data */
|
/** Cached loader for monthly usage data */
|
||||||
export async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
|
export async function getCachedMonthlyData(profile?: string): Promise<MonthlyUsage[]> {
|
||||||
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
|
const data = await getCachedData('monthly', CACHE_TTL.monthly, async () => {
|
||||||
return (await refreshFromSourceCoalesced()).monthly;
|
return (await refreshFromSourceCoalesced()).monthly;
|
||||||
});
|
});
|
||||||
|
return mergeMonthlyData([filterByProfile(data, profile)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cached loader for session data */
|
/** Cached loader for session data */
|
||||||
export async function getCachedSessionData(): Promise<SessionUsage[]> {
|
export async function getCachedSessionData(profile?: string): Promise<SessionUsage[]> {
|
||||||
return getCachedData('session', CACHE_TTL.session, async () => {
|
const data = await getCachedData('session', CACHE_TTL.session, async () => {
|
||||||
return (await refreshFromSourceCoalesced()).session;
|
return (await refreshFromSourceCoalesced()).session;
|
||||||
});
|
});
|
||||||
|
return filterByProfile(data, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cached loader for hourly usage data */
|
/** Cached loader for hourly usage data */
|
||||||
export async function getCachedHourlyData(): Promise<HourlyUsage[]> {
|
export async function getCachedHourlyData(profile?: string): Promise<HourlyUsage[]> {
|
||||||
return getCachedData('hourly', CACHE_TTL.daily, async () => {
|
const data = await getCachedData('hourly', CACHE_TTL.daily, async () => {
|
||||||
return (await refreshFromSourceCoalesced()).hourly;
|
return (await refreshFromSourceCoalesced()).hourly;
|
||||||
});
|
});
|
||||||
|
return mergeHourlyData([filterByProfile(data, profile)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
getModelsUsed,
|
getModelsUsed,
|
||||||
getProviderModelKey,
|
getProviderModelKey,
|
||||||
} from './model-identity';
|
} from './model-identity';
|
||||||
|
import { normalizeProfileQuery } from './profile-filter';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
export interface UsageQuery {
|
export interface UsageQuery {
|
||||||
since?: string; // YYYYMMDD format
|
since?: string; // YYYYMMDD format
|
||||||
until?: string; // YYYYMMDD format
|
until?: string; // YYYYMMDD format
|
||||||
|
profile?: string;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
offset?: string;
|
offset?: string;
|
||||||
}
|
}
|
||||||
@@ -407,8 +409,9 @@ export async function handleSummary(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
const dailyData = await getCachedDailyData();
|
const dailyData = await getCachedDailyData(profile);
|
||||||
const filtered = filterByDateRange(dailyData, since, until);
|
const filtered = filterByDateRange(dailyData, since, until);
|
||||||
|
|
||||||
let totalInputTokens = 0,
|
let totalInputTokens = 0,
|
||||||
@@ -466,8 +469,9 @@ export async function handleDaily(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
const dailyData = await getCachedDailyData();
|
const dailyData = await getCachedDailyData(profile);
|
||||||
const filtered = filterByDateRange(dailyData, since, until);
|
const filtered = filterByDateRange(dailyData, since, until);
|
||||||
|
|
||||||
const trends = filtered.map((day) => ({
|
const trends = filtered.map((day) => ({
|
||||||
@@ -498,8 +502,9 @@ export async function handleHourly(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
const hourlyData = await getCachedHourlyData();
|
const hourlyData = await getCachedHourlyData(profile);
|
||||||
|
|
||||||
const filtered = (hourlyData || []).filter((h) => {
|
const filtered = (hourlyData || []).filter((h) => {
|
||||||
const hourDate = h.hour.slice(0, 10).replace(/-/g, '');
|
const hourDate = h.hour.slice(0, 10).replace(/-/g, '');
|
||||||
@@ -538,8 +543,9 @@ export async function handleModels(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
const dailyData = await getCachedDailyData();
|
const dailyData = await getCachedDailyData(profile);
|
||||||
const filtered = filterByDateRange(dailyData, since, until);
|
const filtered = filterByDateRange(dailyData, since, until);
|
||||||
|
|
||||||
const modelMap = new Map<
|
const modelMap = new Map<
|
||||||
@@ -644,11 +650,12 @@ export async function handleSessions(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
const limit = validateLimit(req.query.limit);
|
const limit = validateLimit(req.query.limit);
|
||||||
const offset = validateOffset(req.query.offset);
|
const offset = validateOffset(req.query.offset);
|
||||||
|
|
||||||
const sessionData = await getCachedSessionData();
|
const sessionData = await getCachedSessionData(profile);
|
||||||
const filtered = filterByDateRange(sessionData, since, until);
|
const filtered = filterByDateRange(sessionData, since, until);
|
||||||
const sorted = [...filtered].sort(
|
const sorted = [...filtered].sort(
|
||||||
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
||||||
@@ -694,6 +701,7 @@ export async function handleMonthly(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
let filtered: Array<{
|
let filtered: Array<{
|
||||||
month: string;
|
month: string;
|
||||||
@@ -707,7 +715,7 @@ export async function handleMonthly(
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
if (since || until) {
|
if (since || until) {
|
||||||
const dailyData = filterByDateRange(await getCachedDailyData(), since, until);
|
const dailyData = filterByDateRange(await getCachedDailyData(profile), since, until);
|
||||||
const monthMap = new Map<
|
const monthMap = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -789,7 +797,7 @@ export async function handleMonthly(
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a.month.localeCompare(b.month));
|
.sort((a, b) => a.month.localeCompare(b.month));
|
||||||
} else {
|
} else {
|
||||||
filtered = await getCachedMonthlyData();
|
filtered = await getCachedMonthlyData(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = filtered.map((m) => ({
|
const result = filtered.map((m) => ({
|
||||||
@@ -836,8 +844,9 @@ export async function handleInsights(
|
|||||||
try {
|
try {
|
||||||
const since = validateDate(req.query.since);
|
const since = validateDate(req.query.since);
|
||||||
const until = validateDate(req.query.until);
|
const until = validateDate(req.query.until);
|
||||||
|
const profile = normalizeProfileQuery(req.query.profile);
|
||||||
validateDateRangeOrder(since, until);
|
validateDateRangeOrder(since, until);
|
||||||
const dailyData = await getCachedDailyData();
|
const dailyData = await getCachedDailyData(profile);
|
||||||
const filtered = filterByDateRange(dailyData, since, until);
|
const filtered = filterByDateRange(dailyData, since, until);
|
||||||
const anomalies = detectAnomalies(filtered);
|
const anomalies = detectAnomalies(filtered);
|
||||||
const summary = summarizeAnomalies(anomalies);
|
const summary = summarizeAnomalies(anomalies);
|
||||||
|
|||||||
@@ -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<T extends { profile?: string }>(data: T[], profile?: string): T[] {
|
||||||
|
if (!profile) return data;
|
||||||
|
return data.filter((item) => item.profile === profile);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ export interface ModelBreakdown {
|
|||||||
/** Daily usage aggregation (YYYY-MM-DD) */
|
/** Daily usage aggregation (YYYY-MM-DD) */
|
||||||
export interface DailyUsage {
|
export interface DailyUsage {
|
||||||
date: string;
|
date: string;
|
||||||
|
/** Stable CCS profile name when the source can be attributed to one. */
|
||||||
|
profile?: string;
|
||||||
source: string;
|
source: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -41,6 +43,8 @@ export interface DailyUsage {
|
|||||||
/** Hourly usage aggregation (YYYY-MM-DD HH:00) */
|
/** Hourly usage aggregation (YYYY-MM-DD HH:00) */
|
||||||
export interface HourlyUsage {
|
export interface HourlyUsage {
|
||||||
hour: string; // Format: "YYYY-MM-DD HH:00"
|
hour: string; // Format: "YYYY-MM-DD HH:00"
|
||||||
|
/** Stable CCS profile name when the source can be attributed to one. */
|
||||||
|
profile?: string;
|
||||||
source: string;
|
source: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -60,6 +64,8 @@ export interface HourlyUsage {
|
|||||||
/** Monthly usage aggregation (YYYY-MM) */
|
/** Monthly usage aggregation (YYYY-MM) */
|
||||||
export interface MonthlyUsage {
|
export interface MonthlyUsage {
|
||||||
month: string;
|
month: string;
|
||||||
|
/** Stable CCS profile name when the source can be attributed to one. */
|
||||||
|
profile?: string;
|
||||||
source: string;
|
source: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -73,6 +79,8 @@ export interface MonthlyUsage {
|
|||||||
/** Session-level usage aggregation */
|
/** Session-level usage aggregation */
|
||||||
export interface SessionUsage {
|
export interface SessionUsage {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
/** Stable CCS profile name when the source can be attributed to one. */
|
||||||
|
profile?: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ cliproxy_server:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeAssistantEntries(entries: AssistantFixture[]): void {
|
function writeAssistantEntries(entries: AssistantFixture[]): void {
|
||||||
|
writeAssistantEntriesToDir(claudeDir, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAssistantEntriesToDir(baseClaudeDir: string, entries: AssistantFixture[]): void {
|
||||||
for (const entry of entries) {
|
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 });
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
|
||||||
const line = JSON.stringify({
|
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 () => {
|
it('rejects reversed date ranges before computing summary totals', async () => {
|
||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
|||||||
+24
-14
@@ -123,6 +123,7 @@ export interface MonthlyUsage {
|
|||||||
export interface UsageQueryOptions {
|
export interface UsageQueryOptions {
|
||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
|
profile?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
@@ -150,43 +151,52 @@ function buildUsageUrl(path: string, params: URLSearchParams): string {
|
|||||||
return query ? `${path}?${query}` : path;
|
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 = {
|
export const usageApi = {
|
||||||
summary: (options?: UsageQueryOptions) => {
|
summary: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
appendProfileParam(params, options);
|
||||||
return request<UsageSummary>(buildUsageUrl('/usage/summary', params));
|
return request<UsageSummary>(buildUsageUrl('/usage/summary', params));
|
||||||
},
|
},
|
||||||
trends: (options?: UsageQueryOptions) => {
|
trends: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
appendProfileParam(params, options);
|
||||||
return request<DailyUsage[]>(buildUsageUrl('/usage/daily', params));
|
return request<DailyUsage[]>(buildUsageUrl('/usage/daily', params));
|
||||||
},
|
},
|
||||||
hourly: (options?: UsageQueryOptions) => {
|
hourly: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
appendProfileParam(params, options);
|
||||||
return request<HourlyUsage[]>(buildUsageUrl('/usage/hourly', params));
|
return request<HourlyUsage[]>(buildUsageUrl('/usage/hourly', params));
|
||||||
},
|
},
|
||||||
models: (options?: UsageQueryOptions) => {
|
models: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
appendProfileParam(params, options);
|
||||||
return request<ModelUsage[]>(buildUsageUrl('/usage/models', params));
|
return request<ModelUsage[]>(buildUsageUrl('/usage/models', params));
|
||||||
},
|
},
|
||||||
sessions: (options?: UsageQueryOptions) => {
|
sessions: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
|
||||||
if (options?.limit) params.append('limit', options.limit.toString());
|
if (options?.limit) params.append('limit', options.limit.toString());
|
||||||
if (options?.offset) params.append('offset', options.offset.toString());
|
if (options?.offset) params.append('offset', options.offset.toString());
|
||||||
|
appendProfileParam(params, options);
|
||||||
return request<PaginatedSessions>(buildUsageUrl('/usage/sessions', params));
|
return request<PaginatedSessions>(buildUsageUrl('/usage/sessions', params));
|
||||||
},
|
},
|
||||||
monthly: (options?: UsageQueryOptions) => {
|
monthly: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
appendProfileParam(params, options);
|
||||||
return request<MonthlyUsage[]>(buildUsageUrl('/usage/monthly', params));
|
return request<MonthlyUsage[]>(buildUsageUrl('/usage/monthly', params));
|
||||||
},
|
},
|
||||||
/** Clear server-side usage cache and force fresh data fetch */
|
/** Clear server-side usage cache and force fresh data fetch */
|
||||||
@@ -204,8 +214,8 @@ export const usageApi = {
|
|||||||
/** Get usage insights including anomaly detection */
|
/** Get usage insights including anomaly detection */
|
||||||
insights: (options?: UsageQueryOptions) => {
|
insights: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
appendDateParams(params, options);
|
||||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
appendProfileParam(params, options);
|
||||||
return request<UsageInsights>(buildUsageUrl('/usage/insights', params));
|
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 { subDays, startOfMonth } from 'date-fns';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AnalyticsProfileOption } from '../hooks';
|
||||||
|
|
||||||
interface AnalyticsHeaderProps {
|
interface AnalyticsHeaderProps {
|
||||||
dateRange: DateRange | undefined;
|
dateRange: DateRange | undefined;
|
||||||
@@ -19,6 +27,9 @@ interface AnalyticsHeaderProps {
|
|||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
lastUpdatedText: string | null;
|
lastUpdatedText: string | null;
|
||||||
viewMode: 'daily' | 'hourly';
|
viewMode: 'daily' | 'hourly';
|
||||||
|
selectedProfile: string;
|
||||||
|
profileOptions: AnalyticsProfileOption[];
|
||||||
|
onProfileChange: (profile: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalyticsHeader({
|
export function AnalyticsHeader({
|
||||||
@@ -29,6 +40,9 @@ export function AnalyticsHeader({
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastUpdatedText,
|
lastUpdatedText,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
selectedProfile,
|
||||||
|
profileOptions,
|
||||||
|
onProfileChange,
|
||||||
}: AnalyticsHeaderProps) {
|
}: AnalyticsHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -39,6 +53,21 @@ export function AnalyticsHeader({
|
|||||||
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
|
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
<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
|
<Button
|
||||||
variant={viewMode === 'hourly' ? 'default' : 'outline'}
|
variant={viewMode === 'hourly' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -77,6 +106,12 @@ export function AnalyticsHeader({
|
|||||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,35 @@ import {
|
|||||||
useSessions,
|
useSessions,
|
||||||
type ModelUsage,
|
type ModelUsage,
|
||||||
} from '@/hooks/use-usage';
|
} from '@/hooks/use-usage';
|
||||||
|
import { useAccounts } from '@/hooks/use-accounts';
|
||||||
|
import { useProfiles } from '@/hooks/use-profiles';
|
||||||
|
|
||||||
const RECENT_SESSION_SAMPLE_LIMIT = 50;
|
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() {
|
export function useAnalyticsPage() {
|
||||||
// Default to last 30 days
|
// Default to last 30 days
|
||||||
@@ -28,8 +55,11 @@ export function useAnalyticsPage() {
|
|||||||
});
|
});
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
||||||
|
const [selectedProfile, setSelectedProfileState] = useState(readPersistedProfile);
|
||||||
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
|
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
|
||||||
|
const { data: accountsView } = useAccounts();
|
||||||
|
const { data: apiProfiles } = useProfiles();
|
||||||
|
|
||||||
// Refresh hook
|
// Refresh hook
|
||||||
const refreshUsage = useRefreshUsage();
|
const refreshUsage = useRefreshUsage();
|
||||||
@@ -48,10 +78,49 @@ export function useAnalyticsPage() {
|
|||||||
() => ({
|
() => ({
|
||||||
startDate: dateRange?.from,
|
startDate: dateRange?.from,
|
||||||
endDate: dateRange?.to,
|
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
|
// Fetch data
|
||||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(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
|
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
|
// Format "Last updated" text
|
||||||
const lastUpdatedText = useMemo(() => {
|
const lastUpdatedText = useMemo(() => {
|
||||||
if (!status?.lastFetch) return null;
|
if (!status?.lastFetch) return null;
|
||||||
@@ -99,6 +174,8 @@ export function useAnalyticsPage() {
|
|||||||
dateRange,
|
dateRange,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
selectedProfile,
|
||||||
|
profileOptions,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
popoverPosition,
|
popoverPosition,
|
||||||
// Data
|
// Data
|
||||||
@@ -120,6 +197,7 @@ export function useAnalyticsPage() {
|
|||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleTodayClick,
|
handleTodayClick,
|
||||||
handleDateRangeChange,
|
handleDateRangeChange,
|
||||||
|
handleProfileChange,
|
||||||
handleModelClick,
|
handleModelClick,
|
||||||
handlePopoverClose,
|
handlePopoverClose,
|
||||||
// Text
|
// Text
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function AnalyticsPage() {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastUpdatedText,
|
lastUpdatedText,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
selectedProfile,
|
||||||
|
profileOptions,
|
||||||
summary,
|
summary,
|
||||||
isSummaryLoading,
|
isSummaryLoading,
|
||||||
trends,
|
trends,
|
||||||
@@ -34,6 +36,7 @@ export function AnalyticsPage() {
|
|||||||
isModelsLoading,
|
isModelsLoading,
|
||||||
isSessionsLoading,
|
isSessionsLoading,
|
||||||
handleModelClick,
|
handleModelClick,
|
||||||
|
handleProfileChange,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
popoverPosition,
|
popoverPosition,
|
||||||
handlePopoverClose,
|
handlePopoverClose,
|
||||||
@@ -50,6 +53,9 @@ export function AnalyticsPage() {
|
|||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
lastUpdatedText={lastUpdatedText}
|
lastUpdatedText={lastUpdatedText}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
selectedProfile={selectedProfile}
|
||||||
|
profileOptions={profileOptions}
|
||||||
|
onProfileChange={handleProfileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* 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 { renderHook } from '@testing-library/react';
|
||||||
import { useAnalyticsPage } from '@/pages/analytics/hooks';
|
import { useAnalyticsPage } from '@/pages/analytics/hooks';
|
||||||
import { AllProviders } from '@tests/setup/test-utils';
|
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-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', () => {
|
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', () => {
|
it('requests a broader recent session sample instead of the old 3-session slice', () => {
|
||||||
renderHook(() => useAnalyticsPage(), {
|
renderHook(() => useAnalyticsPage(), {
|
||||||
wrapper: AllProviders,
|
wrapper: AllProviders,
|
||||||
|
|||||||
@@ -37,4 +37,30 @@ describe('analytics usage API contract', () => {
|
|||||||
expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430');
|
expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430');
|
||||||
expect(urls.every((url) => !url.includes('months='))).toBe(true);
|
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