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:
kaitranntt
2025-12-12 15:33:22 -05:00
parent 5ef3e324f8
commit d64115f91a
17 changed files with 725 additions and 69 deletions
+111
View File
@@ -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),
};
+6 -3
View File
@@ -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,
};
+119 -4
View File
@@ -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
*
+14
View File
@@ -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;