Add cost metrics to analytics dashboard
This commit is contained in:
10
lib/i18n.tsx
10
lib/i18n.tsx
@@ -31,11 +31,13 @@ const translations = {
|
||||
"time.endDate": "结束日期",
|
||||
"time.confirm": "确认",
|
||||
// granularity
|
||||
"gran.hour": "小时",
|
||||
"gran.day": "日",
|
||||
"gran.week": "周",
|
||||
"gran.month": "月",
|
||||
// metrics
|
||||
"metric.token": "Token",
|
||||
"metric.cost": "金额",
|
||||
"metric.calls": "调用量",
|
||||
// dashboard
|
||||
"dash.title": "仪表盘",
|
||||
@@ -45,6 +47,7 @@ const translations = {
|
||||
"dash.activeModels": "活跃模型",
|
||||
"dash.trend": "使用趋势",
|
||||
"dash.userTop10": "用户 Top 10 — Token 消耗",
|
||||
"dash.totalCost": "消费金额",
|
||||
"dash.modelTop10": "模型 Top 10 — Token 消耗",
|
||||
// table headers
|
||||
"th.rank": "#",
|
||||
@@ -55,6 +58,7 @@ const translations = {
|
||||
"th.output": "输出",
|
||||
"th.cacheCreation": "缓存创建",
|
||||
"th.cacheRead": "缓存读取",
|
||||
"th.cost": "金额",
|
||||
"th.totalToken": "总 Token",
|
||||
"th.time": "时间",
|
||||
"th.realModel": "真实模型",
|
||||
@@ -69,6 +73,7 @@ const translations = {
|
||||
"agg.title": "用户聚合",
|
||||
"agg.userCount": "用户数",
|
||||
"agg.totalCalls": "总调用",
|
||||
"agg.totalCost": "总金额",
|
||||
"agg.totalToken": "总 Token",
|
||||
"agg.ratio": "转换率",
|
||||
"agg.ratioTip": "输出Token / 输入Token,反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)",
|
||||
@@ -154,10 +159,12 @@ const translations = {
|
||||
"time.startDate": "Start",
|
||||
"time.endDate": "End",
|
||||
"time.confirm": "Confirm",
|
||||
"gran.hour": "Hour",
|
||||
"gran.day": "Day",
|
||||
"gran.week": "Week",
|
||||
"gran.month": "Month",
|
||||
"metric.token": "Token",
|
||||
"metric.cost": "Cost",
|
||||
"metric.calls": "Calls",
|
||||
"dash.title": "Dashboard",
|
||||
"dash.totalCalls": "Total Calls",
|
||||
@@ -166,6 +173,7 @@ const translations = {
|
||||
"dash.activeModels": "Active Models",
|
||||
"dash.trend": "Usage Trend",
|
||||
"dash.userTop10": "User Top 10 — Token Usage",
|
||||
"dash.totalCost": "Total Cost",
|
||||
"dash.modelTop10": "Model Top 10 — Token Usage",
|
||||
"th.rank": "#",
|
||||
"th.name": "Name",
|
||||
@@ -175,6 +183,7 @@ const translations = {
|
||||
"th.output": "Output",
|
||||
"th.cacheCreation": "Cache Write",
|
||||
"th.cacheRead": "Cache Read",
|
||||
"th.cost": "Cost",
|
||||
"th.totalToken": "Total Token",
|
||||
"th.time": "Time",
|
||||
"th.realModel": "Real Model",
|
||||
@@ -187,6 +196,7 @@ const translations = {
|
||||
"agg.title": "User Aggregation",
|
||||
"agg.userCount": "Users",
|
||||
"agg.totalCalls": "Total Calls",
|
||||
"agg.totalCost": "Total Cost",
|
||||
"agg.totalToken": "Total Token",
|
||||
"agg.ratio": "Out/In Ratio",
|
||||
"agg.ratioTip": "Completion tokens / Prompt tokens. >1 means more output than input (generation, writing); <1 means more input than output (analysis, summarization)",
|
||||
|
||||
46
lib/queries.test.ts
Normal file
46
lib/queries.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
const queryMock = mock(async () => [
|
||||
{
|
||||
date: "2026-04-01 13:00:00",
|
||||
calls: 1,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
cache_creation_tokens: 3,
|
||||
cache_read_tokens: 4,
|
||||
quota: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
mock.module("./db", () => ({
|
||||
query: queryMock,
|
||||
}));
|
||||
|
||||
const { getTrends } = await import("./queries");
|
||||
|
||||
describe("getTrends", () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockClear();
|
||||
});
|
||||
|
||||
test("adds optional detail filters to the trend query", async () => {
|
||||
await getTrends("day", 101, 201, { username: "alice" });
|
||||
expect(queryMock.mock.calls[0][0]).toContain("username = $3");
|
||||
expect(queryMock.mock.calls[0][1]).toEqual([101, 201, "alice"]);
|
||||
|
||||
await getTrends("day", 102, 202, { model: "gpt-4.1" });
|
||||
expect(queryMock.mock.calls[1][0]).toContain("= $3");
|
||||
expect(queryMock.mock.calls[1][1]).toEqual([102, 202, "gpt-4.1"]);
|
||||
|
||||
await getTrends("day", 103, 203, { channelId: 7 });
|
||||
expect(queryMock.mock.calls[2][0]).toContain("channel_id = $3");
|
||||
expect(queryMock.mock.calls[2][1]).toEqual([103, 203, 7]);
|
||||
});
|
||||
|
||||
test("keeps the hour in hourly trend buckets", async () => {
|
||||
const trends = await getTrends("hour", 104, 204);
|
||||
|
||||
expect(queryMock.mock.calls[0][0]).toContain("date_trunc('hour'");
|
||||
expect(trends[0].date).toBe("2026-04-01 13:00");
|
||||
});
|
||||
});
|
||||
@@ -130,19 +130,50 @@ export interface TrendPoint {
|
||||
quota: number;
|
||||
}
|
||||
|
||||
export type TrendGranularity = "hour" | "day" | "week" | "month";
|
||||
|
||||
export interface TrendFilters {
|
||||
username?: string;
|
||||
model?: string;
|
||||
channelId?: number;
|
||||
}
|
||||
|
||||
function appendTrendFilters(
|
||||
where: string,
|
||||
params: (string | number | boolean | null)[],
|
||||
filters: TrendFilters = {}
|
||||
): string {
|
||||
if (filters.username) {
|
||||
params.push(filters.username);
|
||||
where += ` AND username = $${params.length}`;
|
||||
}
|
||||
if (filters.model) {
|
||||
params.push(filters.model);
|
||||
where += ` AND ${REAL_MODEL} = $${params.length}`;
|
||||
}
|
||||
if (filters.channelId !== undefined) {
|
||||
params.push(filters.channelId);
|
||||
where += ` AND channel_id = $${params.length}`;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
export function getTrends(
|
||||
granularity: "day" | "week" | "month" = "day",
|
||||
granularity: TrendGranularity = "day",
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
endTs?: number,
|
||||
filters: TrendFilters = {}
|
||||
): Promise<TrendPoint[]> {
|
||||
return cached(cacheKey("trends", granularity, startTs, endTs), async () => {
|
||||
return cached(cacheKey("trends", granularity, startTs, endTs, filters.username, filters.model, filters.channelId), async () => {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
const where = appendTrendFilters(timeWhere(params, startTs, endTs), params, filters);
|
||||
|
||||
const truncExpr =
|
||||
granularity === "day"
|
||||
? `((to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`
|
||||
: `(date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`;
|
||||
granularity === "hour"
|
||||
? `to_char(date_trunc('hour', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM-DD HH24:00')`
|
||||
: granularity === "day"
|
||||
? `((to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`
|
||||
: `(date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`;
|
||||
|
||||
const rows = await query(
|
||||
`SELECT
|
||||
@@ -159,7 +190,7 @@ export function getTrends(
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
date: String(r.date).slice(0, 10),
|
||||
date: String(r.date).slice(0, granularity === "hour" ? 16 : 10),
|
||||
calls: Number(r.calls),
|
||||
prompt_tokens: Number(r.prompt_tokens),
|
||||
completion_tokens: Number(r.completion_tokens),
|
||||
|
||||
Reference in New Issue
Block a user