Add cost metrics to analytics dashboard

This commit is contained in:
2026-04-28 11:27:51 +08:00
parent 67e43b02bf
commit ab915e9292
13 changed files with 226 additions and 56 deletions

View File

@@ -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
View 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");
});
});

View File

@@ -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),