mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 00:22:23 +00:00
fix(ui): tighten analytics session and query contracts
This commit is contained in:
@@ -206,7 +206,7 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
|
|||||||
return snapshot as CliproxyUsageSnapshot;
|
return snapshot as CliproxyUsageSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.version === 2) {
|
if (snapshot.version === 1 || snapshot.version === 2) {
|
||||||
return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings);
|
return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { RawUsageEntry } from '../jsonl-parser';
|
import type { RawUsageEntry } from '../jsonl-parser';
|
||||||
import { resolveDroidConfigPaths } from '../services/droid-dashboard-service';
|
import { resolveDroidConfigPaths } from '../services/droid-dashboard-service';
|
||||||
|
import { warn } from '../../utils/ui';
|
||||||
import { querySqliteJson } from './sqlite-cli';
|
import { querySqliteJson } from './sqlite-cli';
|
||||||
|
|
||||||
export type DroidSqliteQuery = typeof querySqliteJson;
|
export type DroidSqliteQuery = typeof querySqliteJson;
|
||||||
@@ -168,6 +169,7 @@ export async function scanDroidNativeUsageEntries(
|
|||||||
|
|
||||||
const metadata = loadSessionMetadata(factoryDir);
|
const metadata = loadSessionMetadata(factoryDir);
|
||||||
const entries: RawUsageEntry[] = [];
|
const entries: RawUsageEntry[] = [];
|
||||||
|
let skippedRowsWithoutMetadata = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!isObject(row)) continue;
|
if (!isObject(row)) continue;
|
||||||
@@ -179,6 +181,7 @@ export async function scanDroidNativeUsageEntries(
|
|||||||
|
|
||||||
const session = metadata.get(sessionId);
|
const session = metadata.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
skippedRowsWithoutMetadata++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,5 +203,13 @@ export async function scanDroidNativeUsageEntries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skippedRowsWithoutMetadata > 0) {
|
||||||
|
console.log(
|
||||||
|
warn(
|
||||||
|
`Skipped ${skippedRowsWithoutMetadata} Droid native cost row(s) without local session metadata`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,94 +175,98 @@ describe('cliproxy usage syncer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('migrates legacy v2 snapshots forward before merging new history', async () => {
|
it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => {
|
||||||
await runWithScopedConfigDir(ccsDir, async () => {
|
for (const version of [1, 2]) {
|
||||||
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
await runWithScopedConfigDir(ccsDir, async () => {
|
||||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
||||||
fs.writeFileSync(
|
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||||
snapshotPath,
|
fs.writeFileSync(
|
||||||
JSON.stringify({
|
snapshotPath,
|
||||||
version: 2,
|
JSON.stringify({
|
||||||
timestamp: Date.now() - 60_000,
|
version,
|
||||||
daily: [
|
timestamp: Date.now() - 60_000,
|
||||||
{
|
daily: [
|
||||||
date: '2026-03-01',
|
{
|
||||||
source: 'cliproxy',
|
date: '2026-03-01',
|
||||||
inputTokens: 100,
|
source: 'cliproxy',
|
||||||
outputTokens: 20,
|
inputTokens: 100,
|
||||||
cacheCreationTokens: 0,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 10,
|
cacheCreationTokens: 0,
|
||||||
cost: 0.2,
|
cacheReadTokens: 10,
|
||||||
totalCost: 0.2,
|
cost: 0.2,
|
||||||
modelsUsed: ['gemini-2.5-pro'],
|
totalCost: 0.2,
|
||||||
modelBreakdowns: [
|
modelsUsed: ['gemini-2.5-pro'],
|
||||||
{
|
modelBreakdowns: [
|
||||||
modelName: 'gemini-2.5-pro',
|
{
|
||||||
inputTokens: 100,
|
modelName: 'gemini-2.5-pro',
|
||||||
outputTokens: 20,
|
inputTokens: 100,
|
||||||
cacheCreationTokens: 0,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 10,
|
cacheCreationTokens: 0,
|
||||||
cost: 0.2,
|
cacheReadTokens: 10,
|
||||||
},
|
cost: 0.2,
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
hourly: [
|
],
|
||||||
{
|
hourly: [
|
||||||
hour: '2026-03-01 12:00',
|
{
|
||||||
source: 'cliproxy',
|
hour: '2026-03-01 12:00',
|
||||||
requestCount: 7,
|
source: 'cliproxy',
|
||||||
inputTokens: 100,
|
requestCount: 7,
|
||||||
outputTokens: 20,
|
inputTokens: 100,
|
||||||
cacheCreationTokens: 0,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 10,
|
cacheCreationTokens: 0,
|
||||||
cost: 0.2,
|
cacheReadTokens: 10,
|
||||||
totalCost: 0.2,
|
cost: 0.2,
|
||||||
modelsUsed: ['gemini-2.5-pro'],
|
totalCost: 0.2,
|
||||||
modelBreakdowns: [
|
modelsUsed: ['gemini-2.5-pro'],
|
||||||
{
|
modelBreakdowns: [
|
||||||
modelName: 'gemini-2.5-pro',
|
{
|
||||||
inputTokens: 100,
|
modelName: 'gemini-2.5-pro',
|
||||||
outputTokens: 20,
|
inputTokens: 100,
|
||||||
cacheCreationTokens: 0,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 10,
|
cacheCreationTokens: 0,
|
||||||
cost: 0.2,
|
cacheReadTokens: 10,
|
||||||
},
|
cost: 0.2,
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
monthly: [
|
],
|
||||||
{
|
monthly: [
|
||||||
month: '2026-03',
|
{
|
||||||
source: 'cliproxy',
|
month: '2026-03',
|
||||||
inputTokens: 100,
|
source: 'cliproxy',
|
||||||
outputTokens: 20,
|
inputTokens: 100,
|
||||||
cacheCreationTokens: 0,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 10,
|
cacheCreationTokens: 0,
|
||||||
totalCost: 0.2,
|
cacheReadTokens: 10,
|
||||||
modelsUsed: ['gemini-2.5-pro'],
|
totalCost: 0.2,
|
||||||
modelBreakdowns: [
|
modelsUsed: ['gemini-2.5-pro'],
|
||||||
{
|
modelBreakdowns: [
|
||||||
modelName: 'gemini-2.5-pro',
|
{
|
||||||
inputTokens: 100,
|
modelName: 'gemini-2.5-pro',
|
||||||
outputTokens: 20,
|
inputTokens: 100,
|
||||||
cacheCreationTokens: 0,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 10,
|
cacheCreationTokens: 0,
|
||||||
cost: 0.2,
|
cacheReadTokens: 10,
|
||||||
},
|
cost: 0.2,
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
}),
|
],
|
||||||
'utf-8'
|
}),
|
||||||
);
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
await syncCliproxyUsage(() => Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z')));
|
await syncCliproxyUsage(() =>
|
||||||
|
Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z'))
|
||||||
|
);
|
||||||
|
|
||||||
const cached = await loadCachedCliproxyData();
|
const cached = await loadCachedCliproxyData();
|
||||||
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']);
|
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']);
|
||||||
expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7);
|
expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prunes history details older than the configured retention window', async () => {
|
it('prunes history details older than the configured retention window', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -148,4 +148,37 @@ describe('droid native usage collector', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow('sqlite blew up');
|
).rejects.toThrow('sqlite blew up');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('warns when cost rows are skipped because local session metadata is missing', async () => {
|
||||||
|
writeDroidGlobalSettings(tempRoot);
|
||||||
|
const sessionId = writeDroidSessionFixture(tempRoot);
|
||||||
|
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const querySqliteJson: DroidSqliteQuery = async () => [
|
||||||
|
{
|
||||||
|
session_id: 'missing-session',
|
||||||
|
timestamp: '2026-03-02T09:00:00.000Z',
|
||||||
|
input_tokens: 50,
|
||||||
|
output_tokens: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_id: sessionId,
|
||||||
|
timestamp: '2026-03-02T10:00:00.000Z',
|
||||||
|
input_tokens: 120,
|
||||||
|
output_tokens: 30,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = await scanDroidNativeUsageEntries({
|
||||||
|
env: { CCS_HOME: tempRoot },
|
||||||
|
homeDir: tempRoot,
|
||||||
|
querySqliteJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.sessionId).toBe(sessionId);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Skipped 1 Droid native cost row(s) without local session metadata')
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
|||||||
|
|
||||||
const sessions = data.sessions;
|
const sessions = data.sessions;
|
||||||
const totalSessions = data.total;
|
const totalSessions = data.total;
|
||||||
|
const sampledSessions = sessions.length;
|
||||||
const hasPartialSample = data.hasMore || data.offset > 0;
|
const hasPartialSample = data.hasMore || data.offset > 0;
|
||||||
|
|
||||||
// Calculate total cost for visible sessions
|
// Calculate total cost for visible sessions
|
||||||
@@ -38,6 +39,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
|||||||
const avgCost = totalCost / sessions.length;
|
const avgCost = totalCost / sessions.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
displayedSessions: hasPartialSample ? sampledSessions : totalSessions,
|
||||||
totalSessions,
|
totalSessions,
|
||||||
avgCost,
|
avgCost,
|
||||||
hasPartialSample,
|
hasPartialSample,
|
||||||
@@ -95,12 +97,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
|||||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||||
<div className="flex items-center justify-center gap-1.5 text-blue-600 dark:text-blue-400">
|
<div className="flex items-center justify-center gap-1.5 text-blue-600 dark:text-blue-400">
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
<span className="text-xl font-bold">{stats.totalSessions}</span>
|
<span className="text-xl font-bold">{stats.displayedSessions}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||||
{/* TODO i18n: missing key for "Total Sessions" */}
|
{stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}
|
||||||
Total Sessions
|
|
||||||
</p>
|
</p>
|
||||||
|
{stats.hasPartialSample && (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{stats.totalSessions} total
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avg Cost */}
|
{/* Avg Cost */}
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { afterEach, vi } from 'vitest';
|
|||||||
// Cleanup after each test
|
// Cleanup after each test
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock matchMedia for components that use media queries
|
// Mock matchMedia for components that use media queries
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('SessionStatsCard', () => {
|
|||||||
expect(screen.getByText('No session data available')).toBeInTheDocument();
|
expect(screen.getByText('No session data available')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the API total for overall session count while labeling subset averages as recent', () => {
|
it('keeps subset session metrics explicitly sample-scoped when pagination truncates the result set', () => {
|
||||||
const data = buildPaginatedSessions({
|
const data = buildPaginatedSessions({
|
||||||
sessions: [
|
sessions: [
|
||||||
buildSession({ sessionId: 'session-1', cost: 0.08 }),
|
buildSession({ sessionId: 'session-1', cost: 0.08 }),
|
||||||
@@ -84,8 +84,9 @@ describe('SessionStatsCard', () => {
|
|||||||
|
|
||||||
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
|
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
|
||||||
|
|
||||||
expect(screen.getByText('Total Sessions')).toBeInTheDocument();
|
expect(screen.getByText('Sampled Sessions')).toBeInTheDocument();
|
||||||
expect(screen.getByText('9')).toBeInTheDocument();
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('9 total')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument();
|
expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('codex')).toBeInTheDocument();
|
expect(screen.getByText('codex')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -10,21 +10,22 @@ function jsonResponse(body: unknown) {
|
|||||||
|
|
||||||
describe('analytics usage API contract', () => {
|
describe('analytics usage API contract', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.fetch = vi
|
vi.stubGlobal(
|
||||||
.fn()
|
'fetch',
|
||||||
.mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch;
|
vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits unsupported profile and months query params while keeping supported filters', async () => {
|
it('serializes only the supported analytics query params', async () => {
|
||||||
const startDate = new Date(2026, 3, 1);
|
const startDate = new Date(2026, 3, 1);
|
||||||
const endDate = new Date(2026, 3, 30);
|
const endDate = new Date(2026, 3, 30);
|
||||||
|
|
||||||
await usageApi.summary({ startDate, endDate, profile: 'work' });
|
await usageApi.summary({ startDate, endDate });
|
||||||
await usageApi.trends({ startDate, endDate, profile: 'work' });
|
await usageApi.trends({ startDate, endDate });
|
||||||
await usageApi.models({ startDate, endDate, profile: 'work' });
|
await usageApi.models({ startDate, endDate });
|
||||||
await usageApi.sessions({ startDate, endDate, profile: 'work', limit: 50, offset: 10 });
|
await usageApi.sessions({ startDate, endDate, limit: 50, offset: 10 });
|
||||||
await usageApi.insights({ startDate, endDate, profile: 'work' });
|
await usageApi.insights({ startDate, endDate });
|
||||||
await usageApi.monthly({ startDate, endDate, profile: 'work' });
|
await usageApi.monthly({ startDate, endDate });
|
||||||
|
|
||||||
const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url));
|
const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url));
|
||||||
|
|
||||||
@@ -34,7 +35,6 @@ describe('analytics usage API contract', () => {
|
|||||||
expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10');
|
expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10');
|
||||||
expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430');
|
expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430');
|
||||||
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('profile='))).toBe(true);
|
|
||||||
expect(urls.every((url) => !url.includes('months='))).toBe(true);
|
expect(urls.every((url) => !url.includes('months='))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user