mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
feat(usage-analytics): implement token cost breakdown and anomaly detection
This commit is contained in:
1 parent
9a892f037e
commit
d81a5e6266
15 files changed
+1530
-133
No files matched your search
+289
-17
@@ -21,7 +21,15 @@ import {
|
|||||||
loadSessionData,
|
loadSessionData,
|
||||||
loadAllUsageData,
|
loadAllUsageData,
|
||||||
} from './data-aggregator';
|
} from './data-aggregator';
|
||||||
import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types';
|
import type {
|
||||||
|
DailyUsage,
|
||||||
|
MonthlyUsage,
|
||||||
|
SessionUsage,
|
||||||
|
Anomaly,
|
||||||
|
AnomalySummary,
|
||||||
|
TokenBreakdown,
|
||||||
|
} from './usage-types';
|
||||||
|
import { getModelPricing } from './model-pricing';
|
||||||
import {
|
import {
|
||||||
readDiskCache,
|
readDiskCache,
|
||||||
writeDiskCache,
|
writeDiskCache,
|
||||||
@@ -606,6 +614,45 @@ function errorResponse(res: Response, error: unknown, defaultMessage: string): v
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cost breakdown for token categories
|
||||||
|
* Uses weighted average pricing across models in the dataset
|
||||||
|
*/
|
||||||
|
function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown {
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let cacheCreationTokens = 0;
|
||||||
|
let cacheReadTokens = 0;
|
||||||
|
let inputCost = 0;
|
||||||
|
let outputCost = 0;
|
||||||
|
let cacheCreationCost = 0;
|
||||||
|
let cacheReadCost = 0;
|
||||||
|
|
||||||
|
for (const day of dailyData) {
|
||||||
|
for (const breakdown of day.modelBreakdowns) {
|
||||||
|
const pricing = getModelPricing(breakdown.modelName);
|
||||||
|
|
||||||
|
inputTokens += breakdown.inputTokens;
|
||||||
|
outputTokens += breakdown.outputTokens;
|
||||||
|
cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||||
|
cacheReadTokens += breakdown.cacheReadTokens;
|
||||||
|
|
||||||
|
inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion;
|
||||||
|
outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion;
|
||||||
|
cacheCreationCost +=
|
||||||
|
(breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion;
|
||||||
|
cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 },
|
||||||
|
output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 },
|
||||||
|
cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 },
|
||||||
|
cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/usage/summary
|
* GET /api/usage/summary
|
||||||
*
|
*
|
||||||
@@ -625,17 +672,23 @@ usageRoutes.get(
|
|||||||
// Calculate totals
|
// Calculate totals
|
||||||
let totalInputTokens = 0;
|
let totalInputTokens = 0;
|
||||||
let totalOutputTokens = 0;
|
let totalOutputTokens = 0;
|
||||||
let totalCacheTokens = 0;
|
let totalCacheCreationTokens = 0;
|
||||||
|
let totalCacheReadTokens = 0;
|
||||||
let totalCost = 0;
|
let totalCost = 0;
|
||||||
|
|
||||||
for (const day of filtered) {
|
for (const day of filtered) {
|
||||||
totalInputTokens += day.inputTokens;
|
totalInputTokens += day.inputTokens;
|
||||||
totalOutputTokens += day.outputTokens;
|
totalOutputTokens += day.outputTokens;
|
||||||
totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens;
|
totalCacheCreationTokens += day.cacheCreationTokens;
|
||||||
|
totalCacheReadTokens += day.cacheReadTokens;
|
||||||
totalCost += day.totalCost;
|
totalCost += day.totalCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalTokens = totalInputTokens + totalOutputTokens;
|
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||||
|
const totalCacheTokens = totalCacheCreationTokens + totalCacheReadTokens;
|
||||||
|
|
||||||
|
// Calculate detailed token breakdown with costs
|
||||||
|
const tokenBreakdown = calculateTokenBreakdownCosts(filtered);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -644,7 +697,10 @@ usageRoutes.get(
|
|||||||
totalInputTokens,
|
totalInputTokens,
|
||||||
totalOutputTokens,
|
totalOutputTokens,
|
||||||
totalCacheTokens,
|
totalCacheTokens,
|
||||||
|
totalCacheCreationTokens,
|
||||||
|
totalCacheReadTokens,
|
||||||
totalCost: Math.round(totalCost * 100) / 100,
|
totalCost: Math.round(totalCost * 100) / 100,
|
||||||
|
tokenBreakdown,
|
||||||
totalDays: filtered.length,
|
totalDays: filtered.length,
|
||||||
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
|
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
|
||||||
averageCostPerDay:
|
averageCostPerDay:
|
||||||
@@ -710,14 +766,15 @@ usageRoutes.get(
|
|||||||
const dailyData = await getCachedDailyData();
|
const dailyData = await getCachedDailyData();
|
||||||
const filtered = filterByDateRange(dailyData, since, until);
|
const filtered = filterByDateRange(dailyData, since, until);
|
||||||
|
|
||||||
// Aggregate model usage across all days
|
// Aggregate model usage across all days with detailed breakdown
|
||||||
const modelMap = new Map<
|
const modelMap = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
model: string;
|
model: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheTokens: number;
|
cacheCreationTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -728,13 +785,15 @@ usageRoutes.get(
|
|||||||
model: breakdown.modelName,
|
model: breakdown.modelName,
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
cacheTokens: 0,
|
cacheCreationTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
existing.inputTokens += breakdown.inputTokens;
|
existing.inputTokens += breakdown.inputTokens;
|
||||||
existing.outputTokens += breakdown.outputTokens;
|
existing.outputTokens += breakdown.outputTokens;
|
||||||
existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens;
|
existing.cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||||
|
existing.cacheReadTokens += breakdown.cacheReadTokens;
|
||||||
existing.cost += breakdown.cost;
|
existing.cost += breakdown.cost;
|
||||||
|
|
||||||
modelMap.set(breakdown.modelName, existing);
|
modelMap.set(breakdown.modelName, existing);
|
||||||
@@ -745,17 +804,46 @@ usageRoutes.get(
|
|||||||
const models = Array.from(modelMap.values());
|
const models = Array.from(modelMap.values());
|
||||||
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
|
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
|
||||||
|
|
||||||
// Add percentage and sort by tokens
|
// Add percentage, cost breakdown, and I/O ratio
|
||||||
const result = models
|
const result = models
|
||||||
.map((m) => ({
|
.map((m) => {
|
||||||
...m,
|
const pricing = getModelPricing(m.model);
|
||||||
tokens: m.inputTokens + m.outputTokens,
|
|
||||||
cost: Math.round(m.cost * 100) / 100,
|
// Calculate cost breakdown
|
||||||
percentage:
|
const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion;
|
||||||
totalTokens > 0
|
const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion;
|
||||||
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
const cacheCreationCost =
|
||||||
: 0,
|
(m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion;
|
||||||
}))
|
const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion;
|
||||||
|
|
||||||
|
// Calculate I/O ratio
|
||||||
|
const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: m.model,
|
||||||
|
tokens: m.inputTokens + m.outputTokens,
|
||||||
|
inputTokens: m.inputTokens,
|
||||||
|
outputTokens: m.outputTokens,
|
||||||
|
cacheCreationTokens: m.cacheCreationTokens,
|
||||||
|
cacheReadTokens: m.cacheReadTokens,
|
||||||
|
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
|
||||||
|
cost: Math.round(m.cost * 100) / 100,
|
||||||
|
percentage:
|
||||||
|
totalTokens > 0
|
||||||
|
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
costBreakdown: {
|
||||||
|
input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 },
|
||||||
|
output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 },
|
||||||
|
cacheCreation: {
|
||||||
|
tokens: m.cacheCreationTokens,
|
||||||
|
cost: Math.round(cacheCreationCost * 100) / 100,
|
||||||
|
},
|
||||||
|
cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
|
||||||
|
},
|
||||||
|
ioRatio: Math.round(ioRatio * 10) / 10,
|
||||||
|
};
|
||||||
|
})
|
||||||
.sort((a, b) => b.tokens - a.tokens);
|
.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -900,3 +988,187 @@ usageRoutes.get('/status', (_req: Request, res: Response) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ANOMALY DETECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Anomaly detection thresholds */
|
||||||
|
const ANOMALY_THRESHOLDS = {
|
||||||
|
HIGH_INPUT_TOKENS: 10_000_000, // 10M tokens/day/model
|
||||||
|
HIGH_IO_RATIO: 100, // 100x input/output ratio
|
||||||
|
COST_SPIKE_MULTIPLIER: 2, // 2x average daily cost
|
||||||
|
HIGH_CACHE_READ_TOKENS: 1_000_000_000, // 1B cache read tokens
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect anomalies in usage data
|
||||||
|
*/
|
||||||
|
function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] {
|
||||||
|
const anomalies: Anomaly[] = [];
|
||||||
|
|
||||||
|
// Calculate average daily cost for spike detection
|
||||||
|
const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0);
|
||||||
|
const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0;
|
||||||
|
const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER;
|
||||||
|
|
||||||
|
for (const day of dailyData) {
|
||||||
|
// Check for cost spikes
|
||||||
|
if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) {
|
||||||
|
const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10;
|
||||||
|
anomalies.push({
|
||||||
|
date: day.date,
|
||||||
|
type: 'cost_spike',
|
||||||
|
value: day.totalCost,
|
||||||
|
threshold: avgDailyCost,
|
||||||
|
message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-model anomalies
|
||||||
|
for (const breakdown of day.modelBreakdowns) {
|
||||||
|
// High input tokens per model
|
||||||
|
if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) {
|
||||||
|
const multiplier =
|
||||||
|
Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10;
|
||||||
|
anomalies.push({
|
||||||
|
date: day.date,
|
||||||
|
type: 'high_input',
|
||||||
|
model: breakdown.modelName,
|
||||||
|
value: breakdown.inputTokens,
|
||||||
|
threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS,
|
||||||
|
message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// High I/O ratio
|
||||||
|
if (breakdown.outputTokens > 0) {
|
||||||
|
const ioRatio = breakdown.inputTokens / breakdown.outputTokens;
|
||||||
|
if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) {
|
||||||
|
const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10;
|
||||||
|
anomalies.push({
|
||||||
|
date: day.date,
|
||||||
|
type: 'high_io_ratio',
|
||||||
|
model: breakdown.modelName,
|
||||||
|
value: ioRatio,
|
||||||
|
threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO,
|
||||||
|
message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// High cache read tokens
|
||||||
|
if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) {
|
||||||
|
const multiplier =
|
||||||
|
Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) /
|
||||||
|
10;
|
||||||
|
anomalies.push({
|
||||||
|
date: day.date,
|
||||||
|
type: 'high_cache_read',
|
||||||
|
model: breakdown.modelName,
|
||||||
|
value: breakdown.cacheReadTokens,
|
||||||
|
threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS,
|
||||||
|
message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending
|
||||||
|
return anomalies.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format token count for human readability
|
||||||
|
*/
|
||||||
|
function formatTokenCount(tokens: number): string {
|
||||||
|
if (tokens >= 1_000_000_000) {
|
||||||
|
return `${(tokens / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
} else if (tokens >= 1_000_000) {
|
||||||
|
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||||
|
} else if (tokens >= 1_000) {
|
||||||
|
return `${(tokens / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return tokens.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize anomalies by type
|
||||||
|
*/
|
||||||
|
function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary {
|
||||||
|
const uniqueDates = new Set<string>();
|
||||||
|
let highInputDays = 0;
|
||||||
|
let highIoRatioDays = 0;
|
||||||
|
let costSpikeDays = 0;
|
||||||
|
let highCacheReadDays = 0;
|
||||||
|
|
||||||
|
// Track unique dates per anomaly type
|
||||||
|
const highInputDates = new Set<string>();
|
||||||
|
const highIoRatioDates = new Set<string>();
|
||||||
|
const costSpikeDates = new Set<string>();
|
||||||
|
const highCacheReadDates = new Set<string>();
|
||||||
|
|
||||||
|
for (const anomaly of anomalies) {
|
||||||
|
uniqueDates.add(anomaly.date);
|
||||||
|
|
||||||
|
switch (anomaly.type) {
|
||||||
|
case 'high_input':
|
||||||
|
highInputDates.add(anomaly.date);
|
||||||
|
break;
|
||||||
|
case 'high_io_ratio':
|
||||||
|
highIoRatioDates.add(anomaly.date);
|
||||||
|
break;
|
||||||
|
case 'cost_spike':
|
||||||
|
costSpikeDates.add(anomaly.date);
|
||||||
|
break;
|
||||||
|
case 'high_cache_read':
|
||||||
|
highCacheReadDates.add(anomaly.date);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highInputDays = highInputDates.size;
|
||||||
|
highIoRatioDays = highIoRatioDates.size;
|
||||||
|
costSpikeDays = costSpikeDates.size;
|
||||||
|
highCacheReadDays = highCacheReadDates.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAnomalies: anomalies.length,
|
||||||
|
highInputDays,
|
||||||
|
highIoRatioDays,
|
||||||
|
costSpikeDays,
|
||||||
|
highCacheReadDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/usage/insights
|
||||||
|
*
|
||||||
|
* Returns anomaly detection results for usage patterns.
|
||||||
|
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||||
|
*/
|
||||||
|
usageRoutes.get(
|
||||||
|
'/insights',
|
||||||
|
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||||
|
try {
|
||||||
|
const since = validateDate(req.query.since);
|
||||||
|
const until = validateDate(req.query.until);
|
||||||
|
|
||||||
|
const dailyData = await getCachedDailyData();
|
||||||
|
const filtered = filterByDateRange(dailyData, since, until);
|
||||||
|
|
||||||
|
const anomalies = detectAnomalies(filtered);
|
||||||
|
const summary = summarizeAnomalies(anomalies);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
anomalies,
|
||||||
|
summary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 'Failed to fetch usage insights');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -66,3 +66,67 @@ export interface SessionUsage {
|
|||||||
modelBreakdowns: ModelBreakdown[];
|
modelBreakdowns: ModelBreakdown[];
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ANALYTICS INSIGHTS TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Token category with count and cost */
|
||||||
|
export interface TokenCategoryCost {
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breakdown of tokens by type with individual costs */
|
||||||
|
export interface TokenBreakdown {
|
||||||
|
input: TokenCategoryCost;
|
||||||
|
output: TokenCategoryCost;
|
||||||
|
cacheCreation: TokenCategoryCost;
|
||||||
|
cacheRead: TokenCategoryCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anomaly types for usage pattern detection */
|
||||||
|
export type AnomalyType =
|
||||||
|
| 'high_input' // >10M tokens/day/model
|
||||||
|
| 'high_io_ratio' // >100x input/output ratio
|
||||||
|
| 'cost_spike' // >2x daily average cost
|
||||||
|
| 'high_cache_read'; // >1B cache read tokens
|
||||||
|
|
||||||
|
/** Single anomaly detection result */
|
||||||
|
export interface Anomaly {
|
||||||
|
date: string;
|
||||||
|
type: AnomalyType;
|
||||||
|
model?: string;
|
||||||
|
value: number;
|
||||||
|
threshold: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary of all detected anomalies */
|
||||||
|
export interface AnomalySummary {
|
||||||
|
totalAnomalies: number;
|
||||||
|
highInputDays: number;
|
||||||
|
highIoRatioDays: number;
|
||||||
|
costSpikeDays: number;
|
||||||
|
highCacheReadDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insights API response */
|
||||||
|
export interface UsageInsights {
|
||||||
|
anomalies: Anomaly[];
|
||||||
|
summary: AnomalySummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extended model usage with cost breakdown */
|
||||||
|
export interface ExtendedModelUsage {
|
||||||
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheCreationTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
percentage: number;
|
||||||
|
costBreakdown: TokenBreakdown;
|
||||||
|
ioRatio: number;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -228,6 +229,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||||
|
|
||||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
@@ -792,6 +795,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Anomaly Alert Badge Component
|
||||||
|
*
|
||||||
|
* Displays detected usage anomalies with visual indicators.
|
||||||
|
* Shows high input, I/O ratio, cost spikes, and cache read alerts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { AlertTriangle, ChevronDown, Zap, Gauge, DollarSign, Database } from 'lucide-react';
|
||||||
|
import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AnomalyAlertBadgeProps {
|
||||||
|
anomalies: Anomaly[];
|
||||||
|
summary: AnomalySummary;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANOMALY_CONFIG: Record<
|
||||||
|
AnomalyType,
|
||||||
|
{ icon: React.ComponentType<{ className?: string }>; color: string; label: string }
|
||||||
|
> = {
|
||||||
|
high_input: { icon: Zap, color: 'text-yellow-600', label: 'High Input' },
|
||||||
|
high_io_ratio: { icon: Gauge, color: 'text-orange-600', label: 'High I/O Ratio' },
|
||||||
|
cost_spike: { icon: DollarSign, color: 'text-red-600', label: 'Cost Spike' },
|
||||||
|
high_cache_read: { icon: Database, color: 'text-cyan-600', label: 'Heavy Caching' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnomalyAlertBadge({ anomalies, summary, className }: AnomalyAlertBadgeProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (summary.totalAnomalies === 0) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn('gap-1', className)}>
|
||||||
|
<span className="text-green-600">No anomalies</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique anomaly types for badges
|
||||||
|
const anomalyTypes = new Set(anomalies.map((a) => a.type));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className={cn('gap-2 h-8', className)}>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="font-medium">{summary.totalAnomalies} anomalies</span>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-96 p-0" align="end">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h4 className="font-semibold flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
Detected Anomalies
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Unusual usage patterns detected in the selected period
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary badges */}
|
||||||
|
<div className="p-3 border-b flex flex-wrap gap-2">
|
||||||
|
{Array.from(anomalyTypes).map((type) => {
|
||||||
|
const config = ANOMALY_CONFIG[type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
const count = anomalies.filter((a) => a.type === type).length;
|
||||||
|
return (
|
||||||
|
<Badge key={type} variant="secondary" className="gap-1">
|
||||||
|
<Icon className={cn('h-3 w-3', config.color)} />
|
||||||
|
{count} {config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Anomaly list */}
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{anomalies.slice(0, 10).map((anomaly, index) => {
|
||||||
|
const config = ANOMALY_CONFIG[anomaly.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-3 border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className={cn('h-4 w-4 mt-0.5', config.color)} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{anomaly.date}</span>
|
||||||
|
{anomaly.model && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
||||||
|
{truncateModel(anomaly.model)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{anomaly.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{anomalies.length > 10 && (
|
||||||
|
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||||
|
+{anomalies.length - 10} more anomalies
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateModel(model: string): string {
|
||||||
|
if (model.length <= 20) return model;
|
||||||
|
// Try to extract the meaningful part
|
||||||
|
const parts = model.split('-');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
return parts.slice(0, 3).join('-') + '...';
|
||||||
|
}
|
||||||
|
return model.slice(0, 17) + '...';
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Cache Efficiency Card Component
|
||||||
|
*
|
||||||
|
* Displays cache usage metrics including hit rate, savings estimate,
|
||||||
|
* and cache read/write breakdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Database, TrendingUp, Zap } from 'lucide-react';
|
||||||
|
import type { UsageSummary } from '@/hooks/use-usage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CacheEfficiencyCardProps {
|
||||||
|
data: UsageSummary | undefined;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) {
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const totalCacheTokens = data.totalCacheCreationTokens + data.totalCacheReadTokens;
|
||||||
|
const cacheHitRate =
|
||||||
|
totalCacheTokens > 0 ? (data.totalCacheReadTokens / totalCacheTokens) * 100 : 0;
|
||||||
|
|
||||||
|
// Estimate savings: cache reads cost ~90% less than regular input
|
||||||
|
// Savings = cacheReadTokens * (inputRate - cacheReadRate)
|
||||||
|
const inputCost = data.tokenBreakdown.input.cost;
|
||||||
|
const inputTokens = data.tokenBreakdown.input.tokens || 1;
|
||||||
|
const cacheReadCost = data.tokenBreakdown.cacheRead.cost;
|
||||||
|
const cacheReadTokens = data.tokenBreakdown.cacheRead.tokens || 1;
|
||||||
|
|
||||||
|
const inputRate = inputTokens > 0 ? inputCost / (inputTokens / 1_000_000) : 0;
|
||||||
|
const cacheReadRate = cacheReadTokens > 0 ? cacheReadCost / (cacheReadTokens / 1_000_000) : 0;
|
||||||
|
|
||||||
|
const estimatedSavings =
|
||||||
|
inputRate > 0 && cacheReadRate < inputRate
|
||||||
|
? (data.totalCacheReadTokens / 1_000_000) * (inputRate - cacheReadRate)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheHitRate,
|
||||||
|
estimatedSavings: Math.max(0, estimatedSavings),
|
||||||
|
totalCacheReads: data.totalCacheReadTokens,
|
||||||
|
totalCacheWrites: data.totalCacheCreationTokens,
|
||||||
|
totalCacheTokens,
|
||||||
|
cacheCost: data.tokenBreakdown.cacheRead.cost + data.tokenBreakdown.cacheCreation.cost,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full', className)}>
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metrics || metrics.totalCacheTokens === 0) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full', className)}>
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Database className="w-4 h-4" />
|
||||||
|
Cache Efficiency
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">No cache data available</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full shadow-sm', className)}>
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Database className="w-4 h-4" />
|
||||||
|
Cache Efficiency
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-0 flex-1 flex flex-col justify-center gap-3">
|
||||||
|
{/* Primary metric: Savings */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
<span className="text-2xl font-bold">${metrics.estimatedSavings.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||||
|
Estimated Savings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary metrics row */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* Cache Hit Rate */}
|
||||||
|
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Zap className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
<span className="text-lg font-bold">{metrics.cacheHitRate.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Hit Rate</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cache Cost */}
|
||||||
|
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||||
|
<span className="text-lg font-bold">${metrics.cacheCost.toFixed(2)}</span>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Cache Cost</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cache breakdown bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span>Reads: {formatCompact(metrics.totalCacheReads)}</span>
|
||||||
|
<span>Writes: {formatCompact(metrics.totalCacheWrites)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#9e2a2b',
|
||||||
|
width: `${(metrics.totalCacheReads / metrics.totalCacheTokens) * 100}%`,
|
||||||
|
}}
|
||||||
|
title={`Cache Reads: ${metrics.totalCacheReads.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#e09f3e',
|
||||||
|
width: `${(metrics.totalCacheWrites / metrics.totalCacheTokens) * 100}%`,
|
||||||
|
}}
|
||||||
|
title={`Cache Writes: ${metrics.totalCacheWrites.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#9e2a2b' }} />
|
||||||
|
Read
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#e09f3e' }} />
|
||||||
|
Write
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompact(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
|||||||
name: item.model,
|
name: item.model,
|
||||||
value: item.tokens,
|
value: item.tokens,
|
||||||
cost: item.cost,
|
cost: item.cost,
|
||||||
requests: item.requests,
|
|
||||||
percentage: item.percentage,
|
percentage: item.percentage,
|
||||||
fill: getModelColor(item.model),
|
fill: getModelColor(item.model),
|
||||||
}));
|
}));
|
||||||
@@ -47,18 +46,18 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
|||||||
if (!active || !payload) return null;
|
if (!active || !payload) return null;
|
||||||
|
|
||||||
const payloadArray = payload as Array<{
|
const payloadArray = payload as Array<{
|
||||||
payload: { name: string; value: number; cost: number; requests: number; percentage: number };
|
payload: { name: string; value: number; cost: number; percentage: number };
|
||||||
}>;
|
}>;
|
||||||
if (!payloadArray.length) return null;
|
if (!payloadArray.length) return null;
|
||||||
|
|
||||||
const data = payloadArray[0].payload;
|
const item = payloadArray[0].payload;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
|
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
|
||||||
<p className="font-medium mb-1">{data.name}</p>
|
<p className="font-medium mb-1">{item.name}</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
|
{formatNumber(item.value)} ({item.percentage.toFixed(1)}%)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
|
<p className="text-muted-foreground">${item.cost.toFixed(4)}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -77,8 +76,8 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
|||||||
cy="50%"
|
cy="50%"
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
label={renderLabel}
|
label={renderLabel}
|
||||||
innerRadius={60}
|
innerRadius={50}
|
||||||
outerRadius={80}
|
outerRadius={70}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-react';
|
||||||
|
import type { ModelUsage } from '@/hooks/use-usage';
|
||||||
|
|
||||||
|
interface ModelDetailsContentProps {
|
||||||
|
model: ModelUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||||
|
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
<h4 className="font-semibold leading-none truncate" title={model.model}>
|
||||||
|
{model.model}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5 px-1.5">
|
||||||
|
{model.percentage.toFixed(1)}% usage
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={ioRatioStatus.variant} className="text-[10px] h-5 px-1.5">
|
||||||
|
{model.ioRatio.toFixed(0)}:1 I/O
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||||
|
<p className="text-lg font-bold">${model.cost.toFixed(2)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Cost</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||||
|
<p className="text-lg font-bold">{formatCompactNumber(model.tokens)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Tokens</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token Breakdown */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h5 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Token Breakdown
|
||||||
|
</h5>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<TokenRow
|
||||||
|
label="Input"
|
||||||
|
tokens={model.inputTokens}
|
||||||
|
cost={model.costBreakdown.input.cost}
|
||||||
|
color="#335c67"
|
||||||
|
icon={ArrowDownRight}
|
||||||
|
/>
|
||||||
|
<TokenRow
|
||||||
|
label="Output"
|
||||||
|
tokens={model.outputTokens}
|
||||||
|
cost={model.costBreakdown.output.cost}
|
||||||
|
color="#fff3b0"
|
||||||
|
icon={ArrowUpRight}
|
||||||
|
/>
|
||||||
|
<TokenRow
|
||||||
|
label="Cache Write"
|
||||||
|
tokens={model.cacheCreationTokens}
|
||||||
|
cost={model.costBreakdown.cacheCreation.cost}
|
||||||
|
color="#e09f3e"
|
||||||
|
icon={Database}
|
||||||
|
/>
|
||||||
|
<TokenRow
|
||||||
|
label="Cache Read"
|
||||||
|
tokens={model.cacheReadTokens}
|
||||||
|
cost={model.costBreakdown.cacheRead.cost}
|
||||||
|
color="#9e2a2b"
|
||||||
|
icon={Database}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* I/O Ratio Info */}
|
||||||
|
<div className="p-2.5 rounded-md border bg-muted/20 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gauge className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium">Input/Output Ratio</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{ioRatioStatus.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenRowProps {
|
||||||
|
label: string;
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
color: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenRow({ label, tokens, cost, color, icon: Icon }: TokenRowProps) {
|
||||||
|
if (tokens === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-1 h-6 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium truncate">{label}</span>
|
||||||
|
<span className="font-mono text-muted-foreground">${cost.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icon className="h-3 w-3 shrink-0" />
|
||||||
|
<span>{formatNumber(tokens)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIoRatioStatus(ratio: number): {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
description: string;
|
||||||
|
} {
|
||||||
|
if (ratio >= 200) {
|
||||||
|
return {
|
||||||
|
variant: 'destructive',
|
||||||
|
description: 'Extended thinking or large context loading. Expected for reasoning models.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ratio >= 50) {
|
||||||
|
return {
|
||||||
|
variant: 'secondary',
|
||||||
|
description: 'More input than output. Typical for analysis tasks.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ratio >= 5) {
|
||||||
|
return {
|
||||||
|
variant: 'outline',
|
||||||
|
description: 'Balanced input/output ratio for typical coding tasks.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
variant: 'default',
|
||||||
|
description: 'More output than input. Generation-heavy workload.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactNumber(num: number): string {
|
||||||
|
if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`;
|
||||||
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Session Stats Card Component
|
||||||
|
*
|
||||||
|
* Displays session usage metrics including active sessions, average duration,
|
||||||
|
* and session cost breakdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Clock, Users, Zap, Terminal } from 'lucide-react';
|
||||||
|
import type { PaginatedSessions } from '@/hooks/use-usage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
interface SessionStatsCardProps {
|
||||||
|
data: PaginatedSessions | undefined;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionStatsCard({ data, isLoading, className }: SessionStatsCardProps) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!data?.sessions || data.sessions.length === 0) return null;
|
||||||
|
|
||||||
|
const sessions = data.sessions;
|
||||||
|
const totalSessions = data.total;
|
||||||
|
|
||||||
|
// Calculate average tokens per session
|
||||||
|
const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0);
|
||||||
|
const avgTokens = Math.round(totalTokens / sessions.length);
|
||||||
|
|
||||||
|
// Calculate total cost for visible sessions
|
||||||
|
const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
|
||||||
|
const avgCost = totalCost / sessions.length;
|
||||||
|
|
||||||
|
// Most recent session
|
||||||
|
const lastSession = sessions[0];
|
||||||
|
const lastActive = lastSession
|
||||||
|
? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true })
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessions,
|
||||||
|
avgTokens,
|
||||||
|
avgCost,
|
||||||
|
lastActive,
|
||||||
|
recentSessions: sessions.slice(0, 3),
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full', className)}>
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full', className)}>
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
Session Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">No session data available</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('flex flex-col h-full shadow-sm', className)}>
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
Session Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-0 flex-1 flex flex-col gap-4">
|
||||||
|
{/* Key Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* Total Sessions */}
|
||||||
|
<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">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="text-xl font-bold">{stats.totalSessions}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||||
|
Total Sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avg Cost */}
|
||||||
|
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-green-600 dark:text-green-400">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
<span className="text-xl font-bold">${stats.avgCost.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||||
|
Avg Cost/Session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity List */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground font-medium mb-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Recent Activity
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{stats.recentSessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.sessionId}
|
||||||
|
className="flex items-center justify-between text-xs p-1.5 rounded bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-medium truncate" title={session.projectPath}>
|
||||||
|
{session.projectPath.split('/').pop()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 ml-2">
|
||||||
|
<div className="font-mono">${session.cost.toFixed(2)}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{formatCompact(session.inputTokens + session.outputTokens)} toks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompact(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Token Breakdown Chart Component
|
||||||
|
*
|
||||||
|
* Displays token usage breakdown by type (input, output, cache).
|
||||||
|
* Shows stacked bar chart with cost breakdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import type { TokenBreakdown } from '@/hooks/use-usage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TokenBreakdownChartProps {
|
||||||
|
data?: TokenBreakdown;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
input: '#3b82f6', // blue-500
|
||||||
|
output: '#f97316', // orange-500
|
||||||
|
cacheCreation: '#06b6d4', // cyan-500
|
||||||
|
cacheRead: '#22c55e', // green-500
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Input',
|
||||||
|
tokens: data.input.tokens,
|
||||||
|
cost: data.input.cost,
|
||||||
|
fill: COLORS.input,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Output',
|
||||||
|
tokens: data.output.tokens,
|
||||||
|
cost: data.output.cost,
|
||||||
|
fill: COLORS.output,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cache Write',
|
||||||
|
tokens: data.cacheCreation.tokens,
|
||||||
|
cost: data.cacheCreation.cost,
|
||||||
|
fill: COLORS.cacheCreation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cache Read',
|
||||||
|
tokens: data.cacheRead.tokens,
|
||||||
|
cost: data.cacheRead.cost,
|
||||||
|
fill: COLORS.cacheRead,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Calculate totals for percentages
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const totalTokens = chartData.reduce((sum, d) => sum + d.tokens, 0);
|
||||||
|
const totalCost = chartData.reduce((sum, d) => sum + d.cost, 0);
|
||||||
|
return { totalTokens, totalCost };
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className={cn('h-[250px] w-full', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || chartData.every((d) => d.tokens === 0)) {
|
||||||
|
return (
|
||||||
|
<div className={cn('h-[250px] flex items-center justify-center', className)}>
|
||||||
|
<p className="text-muted-foreground">No token data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 5, right: 30, left: 70, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-muted"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ className: 'stroke-muted' }}
|
||||||
|
tickFormatter={(value) => formatNumber(value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ className: 'stroke-muted' }}
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const item = payload[0].payload as (typeof chartData)[0];
|
||||||
|
const tokenPercent =
|
||||||
|
totals.totalTokens > 0
|
||||||
|
? ((item.tokens / totals.totalTokens) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
const costPercent =
|
||||||
|
totals.totalCost > 0 ? ((item.cost / totals.totalCost) * 100).toFixed(1) : '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||||
|
<p className="font-medium mb-2">{item.name}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Tokens: {formatNumber(item.tokens)} ({tokenPercent}%)
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Cost: ${item.cost.toFixed(2)} ({costPercent}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => <span className="text-xs">{value}</span>}
|
||||||
|
wrapperStyle={{ paddingTop: '10px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar dataKey="tokens" name="Tokens" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Cost breakdown summary */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||||
|
{chartData.map((item) => (
|
||||||
|
<div key={item.name} className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.fill }} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">{item.name}</p>
|
||||||
|
<p className="text-muted-foreground">${item.cost.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000000000) {
|
||||||
|
return `${(num / 1000000000).toFixed(1)}B`;
|
||||||
|
}
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
* Usage Summary Cards Component
|
* Usage Summary Cards Component
|
||||||
*
|
*
|
||||||
* Displays key metrics in a card grid layout.
|
* Displays key metrics in a card grid layout.
|
||||||
* Shows total tokens, cost, requests, and average tokens per request.
|
* Shows total tokens, cost, cache tokens, and average cost per day.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react';
|
import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { UsageSummary } from '@/hooks/use-usage';
|
import type { UsageSummary } from '@/hooks/use-usage';
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ interface UsageSummaryCardsProps {
|
|||||||
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -37,6 +37,11 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate cache cost percentage
|
||||||
|
const cacheCost =
|
||||||
|
(data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0);
|
||||||
|
const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0;
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: 'Total Tokens',
|
title: 'Total Tokens',
|
||||||
@@ -45,6 +50,7 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
|||||||
format: (v: number) => formatNumber(v),
|
format: (v: number) => formatNumber(v),
|
||||||
color: 'text-blue-600',
|
color: 'text-blue-600',
|
||||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||||
|
subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Cost',
|
title: 'Total Cost',
|
||||||
@@ -53,27 +59,39 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
|||||||
format: (v: number) => `$${v.toFixed(2)}`,
|
format: (v: number) => `$${v.toFixed(2)}`,
|
||||||
color: 'text-green-600',
|
color: 'text-green-600',
|
||||||
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
||||||
|
subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Requests',
|
title: 'Cache Tokens',
|
||||||
value: data?.totalRequests ?? 0,
|
value: data?.totalCacheTokens ?? 0,
|
||||||
icon: Zap,
|
icon: Database,
|
||||||
format: (v: number) => formatNumber(v),
|
format: (v: number) => formatNumber(v),
|
||||||
|
color: 'text-cyan-600',
|
||||||
|
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
||||||
|
subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Input Cost',
|
||||||
|
value: data?.tokenBreakdown?.input?.cost ?? 0,
|
||||||
|
icon: ArrowDownRight,
|
||||||
|
format: (v: number) => `$${v.toFixed(2)}`,
|
||||||
color: 'text-purple-600',
|
color: 'text-purple-600',
|
||||||
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
||||||
|
subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Avg Tokens/Request',
|
title: 'Output Cost',
|
||||||
value: data?.averageTokensPerRequest ?? 0,
|
value: data?.tokenBreakdown?.output?.cost ?? 0,
|
||||||
icon: TrendingUp,
|
icon: ArrowUpRight,
|
||||||
format: (v: number) => formatNumber(Math.round(v)),
|
format: (v: number) => `$${v.toFixed(2)}`,
|
||||||
color: 'text-orange-600',
|
color: 'text-orange-600',
|
||||||
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||||
|
subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
{cards.map((card, index) => {
|
{cards.map((card, index) => {
|
||||||
const Icon = card.icon;
|
const Icon = card.icon;
|
||||||
return (
|
return (
|
||||||
@@ -83,6 +101,9 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
|||||||
<div className="space-y-1 min-w-0">
|
<div className="space-y-1 min-w-0">
|
||||||
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
|
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
|
||||||
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
|
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
|
||||||
|
{card.subtitle && (
|
||||||
|
<p className="text-[10px] text-muted-foreground truncate">{card.subtitle}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
|
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
|
||||||
<Icon className={cn('h-4 w-4', card.color)} />
|
<Icon className={cn('h-4 w-4', card.color)} />
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
+90
-13
@@ -7,39 +7,88 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
export interface TokenCategoryCost {
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBreakdown {
|
||||||
|
input: TokenCategoryCost;
|
||||||
|
output: TokenCategoryCost;
|
||||||
|
cacheCreation: TokenCategoryCost;
|
||||||
|
cacheRead: TokenCategoryCost;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageSummary {
|
export interface UsageSummary {
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
|
totalInputTokens: number;
|
||||||
|
totalOutputTokens: number;
|
||||||
|
totalCacheTokens: number;
|
||||||
|
totalCacheCreationTokens: number;
|
||||||
|
totalCacheReadTokens: number;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
totalRequests: number;
|
tokenBreakdown: TokenBreakdown;
|
||||||
averageTokensPerRequest: number;
|
totalDays: number;
|
||||||
dailyUsage: DailyUsage[];
|
averageTokensPerDay: number;
|
||||||
|
averageCostPerDay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyUsage {
|
export interface DailyUsage {
|
||||||
date: string;
|
date: string;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheTokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
requests: number;
|
modelsUsed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelUsage {
|
export interface ModelUsage {
|
||||||
model: string;
|
model: string;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheCreationTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheTokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
requests: number;
|
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
costBreakdown: TokenBreakdown;
|
||||||
|
ioRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnomalyType = 'high_input' | 'high_io_ratio' | 'cost_spike' | 'high_cache_read';
|
||||||
|
|
||||||
|
export interface Anomaly {
|
||||||
|
date: string;
|
||||||
|
type: AnomalyType;
|
||||||
|
model?: string;
|
||||||
|
value: number;
|
||||||
|
threshold: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalySummary {
|
||||||
|
totalAnomalies: number;
|
||||||
|
highInputDays: number;
|
||||||
|
highIoRatioDays: number;
|
||||||
|
costSpikeDays: number;
|
||||||
|
highCacheReadDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageInsights {
|
||||||
|
anomalies: Anomaly[];
|
||||||
|
summary: AnomalySummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
sessionId: string;
|
||||||
startTime: string;
|
projectPath: string;
|
||||||
endTime?: string;
|
inputTokens: number;
|
||||||
duration?: number;
|
outputTokens: number;
|
||||||
tokens: number;
|
|
||||||
cost: number;
|
cost: number;
|
||||||
requests: number;
|
lastActivity: string;
|
||||||
profile: string;
|
modelsUsed: string[];
|
||||||
model: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedSessions {
|
export interface PaginatedSessions {
|
||||||
@@ -132,6 +181,14 @@ export const usageApi = {
|
|||||||
},
|
},
|
||||||
/** Get cache status including last fetch timestamp */
|
/** Get cache status including last fetch timestamp */
|
||||||
status: () => request<UsageStatus>('/usage/status'),
|
status: () => request<UsageStatus>('/usage/status'),
|
||||||
|
/** Get usage insights including anomaly detection */
|
||||||
|
insights: (options?: UsageQueryOptions) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||||
|
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||||
|
if (options?.profile) params.append('profile', options.profile);
|
||||||
|
return request<UsageInsights>(`/usage/insights?${params}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to match existing API client pattern
|
// Helper function to match existing API client pattern
|
||||||
@@ -204,3 +261,23 @@ export function useUsageStatus() {
|
|||||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get usage insights with anomaly detection
|
||||||
|
* Returns detected anomalies and summary statistics
|
||||||
|
*/
|
||||||
|
export function useUsageInsights(options?: UsageQueryOptions) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['usage', 'insights', options],
|
||||||
|
queryFn: () => usageApi.insights(options),
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessions(options?: UsageQueryOptions) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['usage', 'sessions', options],
|
||||||
|
queryFn: () => usageApi.sessions(options),
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@
|
|||||||
--popover: oklch(0.9635 0.0067 97.35); /* Match background */
|
--popover: oklch(0.9635 0.0067 97.35); /* Match background */
|
||||||
--popover-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
--popover-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
||||||
|
|
||||||
|
--card: oklch(0.9635 0.0067 97.35); /* Match background */
|
||||||
|
--card-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
||||||
|
|
||||||
|
--destructive: oklch(0.577 0.245 27.325); /* Red 600 */
|
||||||
|
--destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */
|
||||||
|
|
||||||
/* Sidebar colors - Light */
|
/* Sidebar colors - Light */
|
||||||
--sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */
|
--sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */
|
||||||
--sidebar-foreground: oklch(0.2 0.02 40);
|
--sidebar-foreground: oklch(0.2 0.02 40);
|
||||||
@@ -69,6 +75,12 @@
|
|||||||
--popover: oklch(0.21 0.006 100); /* Match dark bg */
|
--popover: oklch(0.21 0.006 100); /* Match dark bg */
|
||||||
--popover-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */
|
--popover-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */
|
||||||
|
|
||||||
|
--card: oklch(0.21 0.006 100); /* Match dark bg */
|
||||||
|
--card-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */
|
||||||
|
|
||||||
|
--destructive: oklch(0.396 0.141 25.723); /* Red 900 */
|
||||||
|
--destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */
|
||||||
|
|
||||||
/* Sidebar Dark Theme */
|
/* Sidebar Dark Theme */
|
||||||
--sidebar: oklch(0.21 0.006 100); /* Match bg */
|
--sidebar: oklch(0.21 0.006 100); /* Match bg */
|
||||||
--sidebar-foreground: oklch(0.9635 0.0067 97.35);
|
--sidebar-foreground: oklch(0.9635 0.0067 97.35);
|
||||||
@@ -92,6 +104,14 @@
|
|||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
|||||||
+201
-83
@@ -2,29 +2,44 @@
|
|||||||
* Analytics Page
|
* Analytics Page
|
||||||
*
|
*
|
||||||
* Displays Claude Code usage analytics with charts.
|
* Displays Claude Code usage analytics with charts.
|
||||||
* Features trend charts, model breakdown, and cost analysis.
|
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||||
import type { DateRange } from 'react-day-picker';
|
import type { DateRange } from 'react-day-picker';
|
||||||
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
|
||||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||||
import { TrendingUp, PieChart, RefreshCw } from 'lucide-react';
|
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||||
|
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||||
|
import { AnomalyAlertBadge } from '@/components/analytics/anomaly-alert-badge';
|
||||||
|
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useUsageSummary,
|
useUsageSummary,
|
||||||
useUsageTrends,
|
useUsageTrends,
|
||||||
useModelUsage,
|
useModelUsage,
|
||||||
useRefreshUsage,
|
useRefreshUsage,
|
||||||
useUsageStatus,
|
useUsageStatus,
|
||||||
|
useUsageInsights,
|
||||||
|
useSessions,
|
||||||
|
type ModelUsage,
|
||||||
} from '@/hooks/use-usage';
|
} from '@/hooks/use-usage';
|
||||||
import { getModelColor } from '@/lib/utils';
|
import { getModelColor } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Format token count to human-readable (K/M/B)
|
||||||
|
function formatTokens(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function AnalyticsPage() {
|
export function AnalyticsPage() {
|
||||||
// Default to last 30 days
|
// Default to last 30 days
|
||||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||||
@@ -32,6 +47,9 @@ export function AnalyticsPage() {
|
|||||||
to: new Date(),
|
to: new Date(),
|
||||||
});
|
});
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
||||||
|
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Refresh hook
|
// Refresh hook
|
||||||
const refreshUsage = useRefreshUsage();
|
const refreshUsage = useRefreshUsage();
|
||||||
@@ -55,6 +73,8 @@ export function AnalyticsPage() {
|
|||||||
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);
|
||||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||||
|
const { data: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions);
|
||||||
|
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||||
const { data: status } = useUsageStatus();
|
const { data: status } = useUsageStatus();
|
||||||
|
|
||||||
// Format "Last updated" text
|
// Format "Last updated" text
|
||||||
@@ -63,6 +83,18 @@ export function AnalyticsPage() {
|
|||||||
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
||||||
}, [status?.lastFetch]);
|
}, [status?.lastFetch]);
|
||||||
|
|
||||||
|
// Handle model click for popover
|
||||||
|
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
|
||||||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
|
||||||
|
setSelectedModel(model);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePopoverClose = useCallback(() => {
|
||||||
|
setSelectedModel(null);
|
||||||
|
setPopoverPosition(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-4">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-4">
|
||||||
@@ -73,6 +105,10 @@ export function AnalyticsPage() {
|
|||||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Anomaly Alert Badge */}
|
||||||
|
{!isInsightsLoading && insights && (
|
||||||
|
<AnomalyAlertBadge anomalies={insights.anomalies} summary={insights.summary} />
|
||||||
|
)}
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
@@ -104,99 +140,181 @@ export function AnalyticsPage() {
|
|||||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col min-h-0 gap-4">
|
<div className="flex-1 flex flex-col min-h-0 gap-3">
|
||||||
{/* Usage Trend Chart - Full Width */}
|
{/* Usage Trend Chart - Full Width */}
|
||||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
||||||
<CardHeader className="p-4 pb-2">
|
<CardHeader className="px-3 py-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
Usage Trends
|
Usage Trends
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
|
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bottom Row - Model Usage & Cost */}
|
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
|
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
|
||||||
{/* Model Distribution */}
|
{/* Cost by Model - 4/10 width with breakdown */}
|
||||||
<Card className="flex flex-col h-full min-h-0 shadow-sm">
|
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||||
<CardHeader className="p-4 pb-2">
|
<CardHeader className="px-3 py-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<PieChart className="w-4 h-4" />
|
<DollarSign className="w-4 h-4" />
|
||||||
Model Usage
|
Cost by Model
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="flex w-full h-full items-center">
|
|
||||||
<div className="flex-1 h-full min-w-0">
|
|
||||||
<ModelBreakdownChart
|
|
||||||
data={models || []}
|
|
||||||
isLoading={isModelsLoading}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-[220px] shrink-0 pl-4 space-y-2 overflow-y-auto max-h-full">
|
|
||||||
{models?.slice(0, 8).map((model) => (
|
|
||||||
<div key={model.model} className="flex items-center gap-2 text-xs">
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: getModelColor(model.model) }}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
|
||||||
<div className="flex justify-between items-baseline gap-2">
|
|
||||||
<span className="font-medium truncate" title={model.model}>
|
|
||||||
{model.model}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
||||||
{model.percentage.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Cost Breakdown */}
|
|
||||||
<Card className="flex flex-col h-full min-h-0 shadow-sm">
|
|
||||||
<CardHeader className="p-4 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Cost by Model</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4 pt-2 flex-1 min-h-0 overflow-y-auto">
|
|
||||||
{isModelsLoading ? (
|
{isModelsLoading ? (
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-full w-full" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-0.5">
|
||||||
{[...(models || [])]
|
{[...(models || [])]
|
||||||
.sort((a, b) => b.cost - a.cost)
|
.sort((a, b) => b.cost - a.cost)
|
||||||
.map((model) => (
|
.map((model) => (
|
||||||
<div
|
<button
|
||||||
key={model.model}
|
key={model.model}
|
||||||
className="flex items-center justify-between text-xs border-b border-border/50 pb-2 last:border-0 last:pb-0"
|
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
|
||||||
|
onClick={(e) => handleModelClick(model, e)}
|
||||||
|
title="Click for details"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
{/* Model name */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
|
||||||
<div
|
<div
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: getModelColor(model.model) }}
|
style={{ backgroundColor: getModelColor(model.model) }}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium" title={model.model}>
|
<span className="font-medium truncate group-hover:underline underline-offset-2">
|
||||||
{model.model}
|
{model.model}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground whitespace-nowrap font-mono">
|
{/* Cost breakdown mini-bar */}
|
||||||
${model.cost.toFixed(4)}
|
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||||
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#335c67',
|
||||||
|
width: `${model.cost > 0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#fff3b0',
|
||||||
|
width: `${model.cost > 0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#e09f3e',
|
||||||
|
width: `${model.cost > 0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#9e2a2b',
|
||||||
|
width: `${model.cost > 0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Token count */}
|
||||||
|
<span className="text-[10px] text-muted-foreground w-14 text-right shrink-0">
|
||||||
|
{formatTokens(model.tokens)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{/* Total cost */}
|
||||||
|
<span className="font-mono font-medium w-16 text-right shrink-0">
|
||||||
|
${model.cost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: '#335c67' }}
|
||||||
|
/>
|
||||||
|
Input
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full border border-muted-foreground/30"
|
||||||
|
style={{ backgroundColor: '#fff3b0' }}
|
||||||
|
/>
|
||||||
|
Output
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: '#e09f3e' }}
|
||||||
|
/>
|
||||||
|
Cache Write
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: '#9e2a2b' }}
|
||||||
|
/>
|
||||||
|
Cache Read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Model Distribution - 2/10 width */}
|
||||||
|
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<PieChart className="w-4 h-4" />
|
||||||
|
Model Usage
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||||
|
<ModelBreakdownChart
|
||||||
|
data={models || []}
|
||||||
|
isLoading={isModelsLoading}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Session Stats - 4/10 width */}
|
||||||
|
<SessionStatsCard
|
||||||
|
data={sessions}
|
||||||
|
isLoading={isSessionsLoading}
|
||||||
|
className="lg:col-span-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Model Details Popover - positioned at cursor */}
|
||||||
|
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<div
|
||||||
|
ref={popoverAnchorRef}
|
||||||
|
className="fixed pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: popoverPosition?.x ?? 0,
|
||||||
|
top: popoverPosition?.y ?? 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent className="w-80 p-3" side="top" align="center">
|
||||||
|
{selectedModel && <ModelDetailsContent model={selectedModel} />}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,6 +336,26 @@ export function AnalyticsSkeleton() {
|
|||||||
|
|
||||||
{/* Bottom Row Skeletons */}
|
{/* Bottom Row Skeletons */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Cost Breakdown Skeleton */}
|
||||||
|
<Card className="flex flex-col min-h-[250px]">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Model Usage Skeleton */}
|
{/* Model Usage Skeleton */}
|
||||||
<Card className="flex flex-col min-h-[250px]">
|
<Card className="flex flex-col min-h-[250px]">
|
||||||
<CardHeader className="p-4 pb-2">
|
<CardHeader className="p-4 pb-2">
|
||||||
@@ -239,26 +377,6 @@ export function AnalyticsSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Cost Breakdown Skeleton */}
|
|
||||||
<Card className="flex flex-col min-h-[250px]">
|
|
||||||
<CardHeader className="p-4 pb-2">
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4 pt-2">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<div key={i} className="flex justify-between items-center">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user