2550b3b323
Add daily usage trend charts with multi-metric toggles, overview vs user analysis views, date-range filtering, and usage summary API for interval and all-time totals. Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
7.2 KiB
JavaScript
230 lines
7.2 KiB
JavaScript
export function parsePageQuery(query, defaultPageSize = 20) {
|
|
const page = Math.max(1, Number(query.page) || 1);
|
|
const pageSize = Math.min(Math.max(Number(query.pageSize) || defaultPageSize, 1), 200);
|
|
const offset = (page - 1) * pageSize;
|
|
return { page, pageSize, offset };
|
|
}
|
|
|
|
export function paginateArray(items, query, defaultPageSize = 20) {
|
|
const { page, pageSize, offset } = parsePageQuery(query, defaultPageSize);
|
|
const total = items.length;
|
|
return {
|
|
items: items.slice(offset, offset + pageSize),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
|
};
|
|
}
|
|
|
|
const MS_THRESHOLD = 1_000_000_000_000;
|
|
|
|
function parseUsageTimeFilters(query) {
|
|
const startAt = Number(query.startAt);
|
|
const endAt = Number(query.endAt);
|
|
const hasStart = Number.isFinite(startAt) && startAt > 0;
|
|
const hasEnd = Number.isFinite(endAt) && endAt > 0;
|
|
if (!hasStart || !hasEnd || startAt > endAt) {
|
|
return null;
|
|
}
|
|
// Frontend sends unix seconds; h5_usage_records.created_at is stored in milliseconds.
|
|
const startAtMs = startAt < MS_THRESHOLD ? startAt * 1000 : startAt;
|
|
const endAtMs = endAt < MS_THRESHOLD ? endAt * 1000 + 999 : endAt;
|
|
return { startAt, endAt, startAtMs, endAtMs };
|
|
}
|
|
|
|
function formatSqlDay(value) {
|
|
if (value instanceof Date) {
|
|
const y = value.getFullYear();
|
|
const m = String(value.getMonth() + 1).padStart(2, '0');
|
|
const d = String(value.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
return String(value).slice(0, 10);
|
|
}
|
|
|
|
export async function getUsageTotals(pool, { userId = null, startAtMs = null, endAtMs = null } = {}) {
|
|
const params = [];
|
|
const clauses = [];
|
|
if (userId) {
|
|
clauses.push('r.user_id = ?');
|
|
params.push(userId);
|
|
}
|
|
if (startAtMs != null && endAtMs != null) {
|
|
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
|
|
params.push(startAtMs, endAtMs);
|
|
}
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const [[row]] = await pool.query(
|
|
`SELECT COUNT(*) AS count,
|
|
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
|
|
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
|
|
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
|
|
FROM h5_usage_records r
|
|
${where}`,
|
|
params,
|
|
);
|
|
return {
|
|
count: Number(row.count),
|
|
inputTokens: Number(row.input_tokens),
|
|
outputTokens: Number(row.output_tokens),
|
|
costCents: Number(row.cost_cents),
|
|
};
|
|
}
|
|
|
|
export async function getUsageSummary(pool, query) {
|
|
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
|
const range = parseUsageTimeFilters(query);
|
|
const allTime = await getUsageTotals(pool, { userId });
|
|
if (!range) {
|
|
return { range: null, allTime };
|
|
}
|
|
const rangeTotals = await getUsageTotals(pool, {
|
|
userId,
|
|
startAtMs: range.startAtMs,
|
|
endAtMs: range.endAtMs,
|
|
});
|
|
return {
|
|
range: {
|
|
...rangeTotals,
|
|
startAt: range.startAt,
|
|
endAt: range.endAt,
|
|
},
|
|
allTime,
|
|
};
|
|
}
|
|
|
|
export async function getUsageStats(pool, query) {
|
|
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
|
const range = parseUsageTimeFilters(query);
|
|
if (!range) {
|
|
const err = new Error('INVALID_TIME_RANGE');
|
|
err.code = 'INVALID_TIME_RANGE';
|
|
throw err;
|
|
}
|
|
const { startAt, endAt, startAtMs, endAtMs } = range;
|
|
const params = [startAtMs, endAtMs];
|
|
const clauses = ['r.created_at >= ?', 'r.created_at <= ?'];
|
|
if (userId) {
|
|
clauses.push('r.user_id = ?');
|
|
params.push(userId);
|
|
}
|
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
|
const [rows] = await pool.query(
|
|
`SELECT DATE(FROM_UNIXTIME(r.created_at / 1000)) AS day,
|
|
COUNT(*) AS count,
|
|
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
|
|
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
|
|
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
|
|
FROM h5_usage_records r
|
|
${where}
|
|
GROUP BY day
|
|
ORDER BY day ASC`,
|
|
params,
|
|
);
|
|
return {
|
|
buckets: rows.map((row) => ({
|
|
day: formatSqlDay(row.day),
|
|
count: Number(row.count),
|
|
inputTokens: Number(row.input_tokens),
|
|
outputTokens: Number(row.output_tokens),
|
|
costCents: Number(row.cost_cents),
|
|
})),
|
|
startAt,
|
|
endAt,
|
|
};
|
|
}
|
|
|
|
export async function listUsagePaged(pool, query) {
|
|
const { page, pageSize, offset } = parsePageQuery(query, 20);
|
|
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
|
const range = parseUsageTimeFilters(query);
|
|
const params = [];
|
|
const clauses = [];
|
|
if (userId) {
|
|
clauses.push('r.user_id = ?');
|
|
params.push(userId);
|
|
}
|
|
if (range) {
|
|
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
|
|
params.push(range.startAtMs, range.endAtMs);
|
|
}
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const [[{ total }]] = await pool.query(
|
|
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
|
|
params,
|
|
);
|
|
const [rows] = await pool.query(
|
|
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
|
|
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
|
FROM h5_usage_records r
|
|
JOIN h5_users u ON u.id = r.user_id
|
|
${where}
|
|
ORDER BY r.created_at DESC
|
|
LIMIT ? OFFSET ?`,
|
|
[...params, pageSize, offset],
|
|
);
|
|
return {
|
|
records: rows.map((row) => ({
|
|
id: Number(row.id),
|
|
userId: row.user_id,
|
|
username: row.username,
|
|
displayName: row.display_name,
|
|
agentSessionId: row.agent_session_id,
|
|
requestId: row.request_id,
|
|
inputTokens: Number(row.input_tokens),
|
|
outputTokens: Number(row.output_tokens),
|
|
costCents: Number(row.cost_cents),
|
|
balanceAfterCents: Number(row.balance_after_cents),
|
|
createdAt: Number(row.created_at),
|
|
})),
|
|
total: Number(total),
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.max(1, Math.ceil(Number(total) / pageSize)),
|
|
};
|
|
}
|
|
|
|
export async function listLedgerPaged(pool, query) {
|
|
const { page, pageSize, offset } = parsePageQuery(query, 20);
|
|
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
|
const params = [];
|
|
const clauses = [];
|
|
if (userId) {
|
|
clauses.push('l.user_id = ?');
|
|
params.push(userId);
|
|
}
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const [[{ total }]] = await pool.query(
|
|
`SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
|
|
params,
|
|
);
|
|
const [rows] = await pool.query(
|
|
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
|
|
l.session_id, l.note, l.created_at
|
|
FROM h5_billing_ledger l
|
|
JOIN h5_users u ON u.id = l.user_id
|
|
${where}
|
|
ORDER BY l.created_at DESC
|
|
LIMIT ? OFFSET ?`,
|
|
[...params, pageSize, offset],
|
|
);
|
|
return {
|
|
entries: rows.map((row) => ({
|
|
id: Number(row.id),
|
|
userId: row.user_id,
|
|
username: row.username,
|
|
type: row.type,
|
|
amountCents: Number(row.amount_cents),
|
|
tokens: Number(row.tokens),
|
|
sessionId: row.session_id,
|
|
note: row.note,
|
|
createdAt: Number(row.created_at),
|
|
})),
|
|
total: Number(total),
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.max(1, Math.ceil(Number(total) / pageSize)),
|
|
};
|
|
}
|