mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-21 06:26:04 +00:00
feat(analytics): add 24H hourly chart with caching and UI improvements
- Add GitHub link button next to connection status for quick issue reporting - Add 24H button on analytics page with hourly granularity chart - Add /api/usage/hourly endpoint with date range filtering - Add hourly data aggregation and caching (disk + memory) - Fix timezone display: convert UTC hours to local time in chart - Fix CLIProxy Stats card loading state synchronization - Bump disk cache version to 3 (includes hourly data)
This commit is contained in:
@@ -10,6 +10,7 @@ import { calculateCost } from './model-pricing';
|
||||
import {
|
||||
type ModelBreakdown,
|
||||
type DailyUsage,
|
||||
type HourlyUsage,
|
||||
type MonthlyUsage,
|
||||
type SessionUsage,
|
||||
} from './usage-types';
|
||||
@@ -28,6 +29,13 @@ function extractMonth(timestamp: string): string {
|
||||
return timestamp.slice(0, 7);
|
||||
}
|
||||
|
||||
/** Extract YYYY-MM-DD HH:00 from ISO timestamp */
|
||||
function extractHour(timestamp: string): string {
|
||||
const date = timestamp.slice(0, 10);
|
||||
const hour = timestamp.slice(11, 13) || '00';
|
||||
return `${date} ${hour}:00`;
|
||||
}
|
||||
|
||||
/** Create model breakdown from accumulated data */
|
||||
function createModelBreakdown(
|
||||
modelName: string,
|
||||
@@ -152,6 +160,99 @@ export function aggregateDailyUsage(
|
||||
return dailyUsage;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOURLY AGGREGATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Aggregate raw entries into hourly usage summaries
|
||||
* Groups by hour (YYYY-MM-DD HH:00), calculates costs per model
|
||||
*/
|
||||
export function aggregateHourlyUsage(
|
||||
entries: RawUsageEntry[],
|
||||
source = 'custom-parser'
|
||||
): HourlyUsage[] {
|
||||
// Group entries by hour
|
||||
const byHour = new Map<string, RawUsageEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const hour = extractHour(entry.timestamp);
|
||||
const existing = byHour.get(hour) || [];
|
||||
existing.push(entry);
|
||||
byHour.set(hour, existing);
|
||||
}
|
||||
|
||||
// Build hourly summaries
|
||||
const hourlyUsage: HourlyUsage[] = [];
|
||||
|
||||
for (const [hour, hourEntries] of byHour) {
|
||||
// Aggregate by model
|
||||
const modelMap = new Map<string, ModelAccumulator>();
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
let totalCacheCreation = 0;
|
||||
let totalCacheRead = 0;
|
||||
|
||||
for (const entry of hourEntries) {
|
||||
const model = entry.model;
|
||||
const acc = modelMap.get(model) || {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
};
|
||||
|
||||
acc.inputTokens += entry.inputTokens;
|
||||
acc.outputTokens += entry.outputTokens;
|
||||
acc.cacheCreationTokens += entry.cacheCreationTokens;
|
||||
acc.cacheReadTokens += entry.cacheReadTokens;
|
||||
modelMap.set(model, acc);
|
||||
|
||||
totalInput += entry.inputTokens;
|
||||
totalOutput += entry.outputTokens;
|
||||
totalCacheCreation += entry.cacheCreationTokens;
|
||||
totalCacheRead += entry.cacheReadTokens;
|
||||
}
|
||||
|
||||
// Build model breakdowns
|
||||
const modelBreakdowns: ModelBreakdown[] = [];
|
||||
let totalCost = 0;
|
||||
|
||||
for (const [modelName, acc] of modelMap) {
|
||||
const breakdown = createModelBreakdown(
|
||||
modelName,
|
||||
acc.inputTokens,
|
||||
acc.outputTokens,
|
||||
acc.cacheCreationTokens,
|
||||
acc.cacheReadTokens
|
||||
);
|
||||
modelBreakdowns.push(breakdown);
|
||||
totalCost += breakdown.cost;
|
||||
}
|
||||
|
||||
// Sort breakdowns by cost descending
|
||||
modelBreakdowns.sort((a, b) => b.cost - a.cost);
|
||||
|
||||
hourlyUsage.push({
|
||||
hour,
|
||||
source,
|
||||
inputTokens: totalInput,
|
||||
outputTokens: totalOutput,
|
||||
cacheCreationTokens: totalCacheCreation,
|
||||
cacheReadTokens: totalCacheRead,
|
||||
cost: totalCost,
|
||||
totalCost,
|
||||
modelsUsed: Array.from(modelMap.keys()),
|
||||
modelBreakdowns,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by hour descending (most recent first)
|
||||
hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour));
|
||||
|
||||
return hourlyUsage;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MONTHLY AGGREGATION
|
||||
// ============================================================================
|
||||
@@ -372,6 +473,14 @@ export async function loadDailyUsageData(options?: ParserOptions): Promise<Daily
|
||||
return aggregateDailyUsage(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load hourly usage data for today's chart
|
||||
*/
|
||||
export async function loadHourlyUsageData(options?: ParserOptions): Promise<HourlyUsage[]> {
|
||||
const entries = await scanProjectsDirectory(options);
|
||||
return aggregateHourlyUsage(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load monthly usage data (replaces better-ccusage loadMonthlyUsageData)
|
||||
*/
|
||||
@@ -393,12 +502,14 @@ export async function loadSessionData(options?: ParserOptions): Promise<SessionU
|
||||
*/
|
||||
export async function loadAllUsageData(options?: ParserOptions): Promise<{
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}> {
|
||||
const entries = await scanProjectsDirectory(options);
|
||||
return {
|
||||
daily: aggregateDailyUsage(entries),
|
||||
hourly: aggregateHourlyUsage(entries),
|
||||
monthly: aggregateMonthlyUsage(entries),
|
||||
session: aggregateSessionUsage(entries),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types';
|
||||
import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './usage-types';
|
||||
import { ok, info, warn } from '../utils/ui';
|
||||
|
||||
// Cache configuration
|
||||
@@ -26,13 +26,14 @@ export interface UsageDiskCache {
|
||||
version: number;
|
||||
timestamp: number;
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}
|
||||
|
||||
// Current cache version - increment to invalidate old caches
|
||||
// v2: Updated model pricing (Opus 4.5: $5/$25, Gemini 3, GLM, Kimi, etc.)
|
||||
const CACHE_VERSION = 2;
|
||||
// v3: Added hourly data to cache
|
||||
const CACHE_VERSION = 3;
|
||||
|
||||
/**
|
||||
* Ensure ~/.ccs/cache directory exists
|
||||
@@ -95,6 +96,7 @@ export function isDiskCacheStale(cache: UsageDiskCache | null): boolean {
|
||||
*/
|
||||
export function writeDiskCache(
|
||||
daily: DailyUsage[],
|
||||
hourly: HourlyUsage[],
|
||||
monthly: MonthlyUsage[],
|
||||
session: SessionUsage[]
|
||||
): void {
|
||||
@@ -105,6 +107,7 @@ export function writeDiskCache(
|
||||
version: CACHE_VERSION,
|
||||
timestamp: Date.now(),
|
||||
daily,
|
||||
hourly,
|
||||
monthly,
|
||||
session,
|
||||
};
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
loadMonthlyUsageData,
|
||||
loadSessionData,
|
||||
loadAllUsageData,
|
||||
loadHourlyUsageData,
|
||||
} from './data-aggregator';
|
||||
import type {
|
||||
DailyUsage,
|
||||
HourlyUsage,
|
||||
MonthlyUsage,
|
||||
SessionUsage,
|
||||
Anomaly,
|
||||
@@ -78,6 +80,7 @@ function getInstancePaths(): string[] {
|
||||
*/
|
||||
async function loadInstanceData(instancePath: string): Promise<{
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}> {
|
||||
@@ -89,7 +92,7 @@ async function loadInstanceData(instancePath: string): Promise<{
|
||||
// Instance may have no usage data - that's OK
|
||||
const instanceName = path.basename(instancePath);
|
||||
console.log(info(`No usage data in instance: ${instanceName}`));
|
||||
return { daily: [], monthly: [], session: [] };
|
||||
return { daily: [], hourly: [], monthly: [], session: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +171,52 @@ function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
||||
return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hourly usage data from multiple sources
|
||||
* Combines entries with same hour by aggregating tokens
|
||||
*/
|
||||
function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
||||
const hourMap = new Map<string, HourlyUsage>();
|
||||
|
||||
for (const source of sources) {
|
||||
for (const hour of source) {
|
||||
const existing = hourMap.get(hour.hour);
|
||||
if (existing) {
|
||||
existing.inputTokens += hour.inputTokens;
|
||||
existing.outputTokens += hour.outputTokens;
|
||||
existing.cacheCreationTokens += hour.cacheCreationTokens;
|
||||
existing.cacheReadTokens += hour.cacheReadTokens;
|
||||
existing.totalCost += hour.totalCost;
|
||||
const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]);
|
||||
existing.modelsUsed = Array.from(modelSet);
|
||||
// Merge model breakdowns
|
||||
for (const breakdown of hour.modelBreakdowns) {
|
||||
const existingBreakdown = existing.modelBreakdowns.find(
|
||||
(b) => b.modelName === breakdown.modelName
|
||||
);
|
||||
if (existingBreakdown) {
|
||||
existingBreakdown.inputTokens += breakdown.inputTokens;
|
||||
existingBreakdown.outputTokens += breakdown.outputTokens;
|
||||
existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||
existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens;
|
||||
existingBreakdown.cost += breakdown.cost;
|
||||
} else {
|
||||
existing.modelBreakdowns.push({ ...breakdown });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hourMap.set(hour.hour, {
|
||||
...hour,
|
||||
modelsUsed: [...hour.modelsUsed],
|
||||
modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge session data from multiple sources
|
||||
* Deduplicates by sessionId (same session shouldn't appear in multiple instances)
|
||||
@@ -246,12 +295,13 @@ let diskCacheInitialized = false;
|
||||
*/
|
||||
function persistCacheIfComplete(): void {
|
||||
const daily = cache.get('daily') as CacheEntry<DailyUsage[]> | undefined;
|
||||
const hourly = cache.get('hourly') as CacheEntry<HourlyUsage[]> | undefined;
|
||||
const monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
|
||||
const session = cache.get('session') as CacheEntry<SessionUsage[]> | undefined;
|
||||
|
||||
// Write if we have at least daily data (the most essential)
|
||||
if (daily) {
|
||||
writeDiskCache(daily.data, monthly?.data ?? [], session?.data ?? []);
|
||||
writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +388,13 @@ async function getCachedSessionData(): Promise<SessionUsage[]> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Cached loader for hourly usage data */
|
||||
async function getCachedHourlyData(): Promise<HourlyUsage[]> {
|
||||
return getCachedData('hourly', CACHE_TTL.daily, async () => {
|
||||
return await loadHourlyUsageData();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data (useful for manual refresh)
|
||||
*/
|
||||
@@ -357,6 +414,7 @@ let isRefreshing = false;
|
||||
*/
|
||||
async function refreshFromSource(): Promise<{
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}> {
|
||||
@@ -367,6 +425,7 @@ async function refreshFromSource(): Promise<{
|
||||
const instancePaths = getInstancePaths();
|
||||
const instanceDataResults: Array<{
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}> = [];
|
||||
@@ -383,11 +442,13 @@ async function refreshFromSource(): Promise<{
|
||||
|
||||
// Collect successful instance data
|
||||
const allDailySources: DailyUsage[][] = [defaultData.daily];
|
||||
const allHourlySources: HourlyUsage[][] = [defaultData.hourly];
|
||||
const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly];
|
||||
const allSessionSources: SessionUsage[][] = [defaultData.session];
|
||||
|
||||
for (const result of instanceDataResults) {
|
||||
allDailySources.push(result.daily);
|
||||
allHourlySources.push(result.hourly);
|
||||
allMonthlySources.push(result.monthly);
|
||||
allSessionSources.push(result.session);
|
||||
}
|
||||
@@ -398,20 +459,22 @@ async function refreshFromSource(): Promise<{
|
||||
|
||||
// Merge all data sources
|
||||
const daily = mergeDailyData(allDailySources);
|
||||
const hourly = mergeHourlyData(allHourlySources);
|
||||
const monthly = mergeMonthlyData(allMonthlySources);
|
||||
const session = mergeSessionData(allSessionSources);
|
||||
|
||||
// Update in-memory cache
|
||||
const now = Date.now();
|
||||
cache.set('daily', { data: daily, timestamp: now });
|
||||
cache.set('hourly', { data: hourly, timestamp: now });
|
||||
cache.set('monthly', { data: monthly, timestamp: now });
|
||||
cache.set('session', { data: session, timestamp: now });
|
||||
lastFetchTimestamp = now;
|
||||
|
||||
// Persist to disk
|
||||
writeDiskCache(daily, monthly, session);
|
||||
writeDiskCache(daily, hourly, monthly, session);
|
||||
|
||||
return { daily, monthly, session };
|
||||
return { daily, hourly, monthly, session };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -433,6 +496,7 @@ function ensureDiskCacheLoaded(): void {
|
||||
// Load disk cache into memory (regardless of freshness)
|
||||
// SWR pattern in getCachedData() will handle background refresh
|
||||
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
|
||||
cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp });
|
||||
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
|
||||
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
||||
lastFetchTimestamp = diskCache.timestamp;
|
||||
@@ -463,6 +527,7 @@ export async function prewarmUsageCache(): Promise<{
|
||||
if (diskCache && isDiskCacheFresh(diskCache)) {
|
||||
const now = Date.now();
|
||||
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
|
||||
cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp });
|
||||
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
|
||||
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
||||
lastFetchTimestamp = diskCache.timestamp;
|
||||
@@ -478,6 +543,7 @@ export async function prewarmUsageCache(): Promise<{
|
||||
if (diskCache && isDiskCacheStale(diskCache)) {
|
||||
const now = Date.now();
|
||||
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
|
||||
cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp });
|
||||
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
|
||||
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
||||
lastFetchTimestamp = diskCache.timestamp;
|
||||
@@ -753,6 +819,55 @@ usageRoutes.get(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/hourly
|
||||
*
|
||||
* Returns hourly usage trends for chart visualization.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD (defaults to last 24 hours)
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/hourly',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
|
||||
const hourlyData = await getCachedHourlyData();
|
||||
|
||||
// Filter by date range
|
||||
const filtered = hourlyData.filter((h) => {
|
||||
// Extract date from hour format "YYYY-MM-DD HH:00"
|
||||
const hourDate = h.hour.slice(0, 10).replace(/-/g, '');
|
||||
if (since && hourDate < since) return false;
|
||||
if (until && hourDate > until) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Transform for chart consumption
|
||||
const trends = filtered.map((hour) => ({
|
||||
hour: hour.hour,
|
||||
tokens: hour.inputTokens + hour.outputTokens,
|
||||
inputTokens: hour.inputTokens,
|
||||
outputTokens: hour.outputTokens,
|
||||
cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens,
|
||||
cost: Math.round(hour.totalCost * 100) / 100,
|
||||
modelsUsed: hour.modelsUsed.length,
|
||||
requests: hour.modelBreakdowns.length,
|
||||
}));
|
||||
|
||||
// Sort by hour ascending for chart display
|
||||
trends.sort((a, b) => a.hour.localeCompare(b.hour));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trends,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch hourly usage');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/models
|
||||
*
|
||||
|
||||
@@ -37,6 +37,20 @@ export interface DailyUsage {
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
}
|
||||
|
||||
/** Hourly usage aggregation (YYYY-MM-DD HH:00) */
|
||||
export interface HourlyUsage {
|
||||
hour: string; // Format: "YYYY-MM-DD HH:00"
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
totalCost: number;
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
}
|
||||
|
||||
/** Monthly usage aggregation (YYYY-MM) */
|
||||
export interface MonthlyUsage {
|
||||
month: string;
|
||||
|
||||
Reference in New Issue
Block a user