diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 48fee963a..1bf141484 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -97,6 +97,8 @@ "exportError": "Export failed", "exportPreparing": "Preparing export...", "exportProgress": "Exported {current} / {total}", + "exportAsCsv": "Export as CSV", + "exportAsXlsx": "Export as XLSX (with summary)", "quickFilters": { "today": "Today", "thisWeek": "This Week", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 74a9ba3c8..437d49e7b 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -97,6 +97,8 @@ "exportError": "エクスポートに失敗しました", "exportPreparing": "エクスポートを準備中...", "exportProgress": "{current} / {total} 件をエクスポート済み", + "exportAsCsv": "CSV としてエクスポート", + "exportAsXlsx": "XLSX としてエクスポート (集計付き)", "quickFilters": { "today": "今日", "thisWeek": "今週", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 680a6d01e..933a0f209 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -97,6 +97,8 @@ "exportError": "Ошибка экспорта", "exportPreparing": "Подготовка экспорта...", "exportProgress": "Экспортировано {current} / {total}", + "exportAsCsv": "Экспорт в CSV", + "exportAsXlsx": "Экспорт в XLSX (со сводкой)", "quickFilters": { "today": "Сегодня", "thisWeek": "Эта неделя", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 7a173ce43..c8e3b8b11 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -97,6 +97,8 @@ "exportError": "导出失败", "exportPreparing": "正在准备导出...", "exportProgress": "已导出 {current} / {total} 条", + "exportAsCsv": "导出为 CSV", + "exportAsXlsx": "导出为 XLSX(含汇总表)", "quickFilters": { "today": "今天", "thisWeek": "本周", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index bafb784d9..c74a45547 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -97,6 +97,8 @@ "exportError": "匯出失敗", "exportPreparing": "正在準備匯出...", "exportProgress": "已匯出 {current} / {total} 筆", + "exportAsCsv": "匯出為 CSV", + "exportAsXlsx": "匯出為 XLSX(含彙總表)", "quickFilters": { "today": "今天", "thisWeek": "本週", diff --git a/package.json b/package.json index 52f3bfbec..506c51648 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "dotenv": "^17", "drizzle-orm": "^0.45", "fetch-socks": "^1", + "fflate": "^0.8.2", "framer-motion": "^12", "hono": "^4", "html2canvas": "^1", diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index 25a96fc3f..e75f71fe3 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -9,8 +9,11 @@ import { import { logger } from "@/lib/logger"; import { readLiveChainBatch } from "@/lib/redis/live-chain-store"; import { RedisKVStore } from "@/lib/redis/redis-kv-store"; -import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; +import { buildCsvHeaderLine, buildCsvRows, CSV_BOM } from "@/lib/usage-logs/export/csv"; +import { createSummaryAccumulator } from "@/lib/usage-logs/export/summary"; +import { assembleUsageLogsXlsx, buildDetailRowXml } from "@/lib/usage-logs/export/xlsx"; import { isProviderFinalized } from "@/lib/utils/provider-display"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { findUsageLogSessionIdSuggestions, findUsageLogsBatch, @@ -21,7 +24,6 @@ import { getUsedStatusCodes, type UsageLogBatchFilters, type UsageLogFilters, - type UsageLogRow, type UsageLogSummary, type UsageLogsBatchResult, type UsageLogsResult, @@ -44,26 +46,7 @@ const USAGE_LOGS_EXPORT_BATCH_SIZE = 500; const USAGE_LOGS_EXPORT_JOB_TTL_MS = 15 * 60 * 1000; const USAGE_LOGS_EXPORT_JOB_TTL_SECONDS = Math.floor(USAGE_LOGS_EXPORT_JOB_TTL_MS / 1000); const USAGE_LOGS_EXPORT_PROGRESS_UPDATE_INTERVAL_MS = 800; -const CSV_HEADERS = [ - "Time", - "User", - "Key", - "Provider", - "Model", - "Original Model", - "Endpoint", - "Status Code", - "Input Tokens", - "Output Tokens", - "Cache Write 5m", - "Cache Write 1h", - "Cache Read", - "Total Tokens", - "Cost (USD)", - "Duration (ms)", - "Session ID", - "Retry Count", -] as const; +export type UsageLogsExportFormat = "csv" | "xlsx"; type UsageLogsSession = NonNullable>>; @@ -73,9 +56,18 @@ export interface UsageLogsExportStatus { processedRows: number; totalRows: number; progressPercent: number; + format: UsageLogsExportFormat; error?: string; } +export interface UsageLogsExportDownload { + /** CSV text (encoding "utf8") or base64-encoded XLSX bytes (encoding "base64"). */ + content: string; + encoding: "utf8" | "base64"; + format: UsageLogsExportFormat; + filename: string; +} + interface UsageLogsExportJobRecord extends UsageLogsExportStatus { ownerUserId: number; } @@ -85,13 +77,21 @@ const usageLogsExportStatusStore = new RedisKVStore({ defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS, }); -const usageLogsExportCsvStore = new RedisKVStore({ - prefix: "cch:usage-logs:export:csv:", +const usageLogsExportResultStore = new RedisKVStore({ + prefix: "cch:usage-logs:export:result:", defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS, }); -function usageLogsExportCsvKey(jobId: string): string { - return `${jobId}:csv`; +function usageLogsExportResultKey(jobId: string): string { + return `${jobId}:result`; +} + +function fileExtensionForFormat(format: UsageLogsExportFormat): string { + return format === "xlsx" ? "xlsx" : "csv"; +} + +function encodingForFormat(format: UsageLogsExportFormat): "utf8" | "base64" { + return format === "xlsx" ? "base64" : "utf8"; } function resolveUsageLogFiltersForSession( @@ -108,6 +108,7 @@ function toUsageLogsExportStatus(job: UsageLogsExportJobRecord): UsageLogsExport processedRows: job.processedRows, totalRows: job.totalRows, progressPercent: job.progressPercent, + format: job.format, error: job.error, }; } @@ -123,32 +124,6 @@ function getUsageLogsExportJob( return job; } -function buildCsvRows(logs: UsageLogRow[]): string[] { - return logs.map((log) => { - const retryCount = log.providerChain ? getRetryCount(log.providerChain) : 0; - return [ - log.createdAt ? new Date(log.createdAt).toISOString() : "", - escapeCsvField(log.userName), - escapeCsvField(log.keyName), - escapeCsvField(log.providerName ?? ""), - escapeCsvField(log.model ?? ""), - escapeCsvField(log.originalModel ?? ""), - escapeCsvField(log.endpoint ?? ""), - log.statusCode?.toString() ?? "", - log.inputTokens?.toString() ?? "0", - log.outputTokens?.toString() ?? "0", - log.cacheCreation5mInputTokens?.toString() ?? "0", - log.cacheCreation1hInputTokens?.toString() ?? "0", - log.cacheReadInputTokens?.toString() ?? "0", - log.totalTokens.toString(), - log.costUsd ?? "0", - log.durationMs?.toString() ?? "", - escapeCsvField(log.sessionId ?? ""), - retryCount.toString(), - ].join(","); - }); -} - function buildUsageLogsExportProgress( processedRows: number, totalRows: number, @@ -169,8 +144,15 @@ function buildUsageLogsExportProgress( }; } -async function buildUsageLogsExportCsv( +/** + * Stream every matching usage log (in batches) and assemble the requested + * export format. Timestamps are rendered in `timezone` and numeric columns are + * normalized so Excel parses them as numbers (see @/lib/usage-logs/export). + */ +async function buildUsageLogsExport( filters: Omit, + format: UsageLogsExportFormat, + timezone: string, onProgress?: ( progress: Pick ) => Promise | void @@ -183,7 +165,11 @@ async function buildUsageLogsExportCsv( estimatedTotalRows = stats.totalRequests; } - const csvLines = [CSV_HEADERS.join(",")]; + // Both formats stream batch-by-batch into string buffers (CSV lines / XLSX row + // XML + an incremental summary) so the full UsageLogRow[] is never retained. + const csvLines = format === "csv" ? [buildCsvHeaderLine(timezone)] : []; + const xlsxDetailRows: string[] = []; + const xlsxSummary = format === "xlsx" ? createSummaryAccumulator(timezone) : null; let cursor: UsageLogBatchFilters["cursor"] | undefined; let processedRows = 0; @@ -195,7 +181,14 @@ async function buildUsageLogsExportCsv( }); if (batch.logs.length > 0) { - csvLines.push(...buildCsvRows(batch.logs)); + if (format === "xlsx" && xlsxSummary) { + for (const log of batch.logs) { + xlsxDetailRows.push(buildDetailRowXml(log, xlsxDetailRows.length + 2, timezone)); + xlsxSummary.add(log); + } + } else { + csvLines.push(...buildCsvRows(batch.logs, timezone)); + } processedRows += batch.logs.length; } @@ -210,12 +203,22 @@ async function buildUsageLogsExportCsv( cursor = batch.nextCursor; } - return `\uFEFF${csvLines.join("\n")}`; + if (format === "xlsx" && xlsxSummary) { + const bytes = await assembleUsageLogsXlsx({ + detailRowsXml: xlsxDetailRows, + summary: xlsxSummary.finalize(), + timezone, + }); + return Buffer.from(bytes).toString("base64"); + } + + return `${CSV_BOM}${csvLines.join("\n")}`; } async function runUsageLogsExportJob( jobId: string, - filters: Omit + filters: Omit, + format: UsageLogsExportFormat ): Promise { const existingJob = await usageLogsExportStatusStore.get(jobId); if (!existingJob) { @@ -229,8 +232,9 @@ async function runUsageLogsExportJob( }); try { + const timezone = await resolveSystemTimezone(); let lastProgressUpdateAt = 0; - const csv = await buildUsageLogsExportCsv(filters, async (progress) => { + const content = await buildUsageLogsExport(filters, format, timezone, async (progress) => { const now = Date.now(); if ( progress.progressPercent < 100 && @@ -257,13 +261,16 @@ async function runUsageLogsExportJob( return; } - const csvStored = await usageLogsExportCsvStore.set(usageLogsExportCsvKey(jobId), csv); - if (!csvStored) { + const resultStored = await usageLogsExportResultStore.set( + usageLogsExportResultKey(jobId), + content + ); + if (!resultStored) { await usageLogsExportStatusStore.set(jobId, { ...currentJob, status: "failed", progressPercent: 0, - error: "Failed to persist CSV to Redis", + error: "Failed to persist export to Redis", }); return; } @@ -316,22 +323,34 @@ export async function getUsageLogs( } } +export type UsageLogsExportInput = Omit & { + format?: UsageLogsExportFormat; +}; + /** - * 导出使用日志为 CSV 格式 + * 同步导出使用日志为 CSV 格式(XLSX 仅支持异步任务) */ -export async function exportUsageLogs( - filters: Omit -): Promise> { +export async function exportUsageLogs(input: UsageLogsExportInput): Promise> { try { const session = await getSession(); if (!session) { return { ok: false, error: "未登录" }; } + const { format = "csv", ...filters } = input; + if (format !== "csv") { + // XLSX is assembled from every matching row in memory, so it is only + // offered via the async job flow. + return { + ok: false, + error: "Synchronous export only supports CSV; use the async job for XLSX.", + }; + } const finalFilters = resolveUsageLogFiltersForSession(session, filters); - const csv = await buildUsageLogsExportCsv(finalFilters); + const timezone = await resolveSystemTimezone(); + const content = await buildUsageLogsExport(finalFilters, "csv", timezone); - return { ok: true, data: csv }; + return { ok: true, data: content }; } catch (error) { logger.error("导出使用日志失败:", error); const message = error instanceof Error ? error.message : "导出使用日志失败"; @@ -340,7 +359,7 @@ export async function exportUsageLogs( } export async function startUsageLogsExport( - filters: Omit + input: UsageLogsExportInput ): Promise> { try { const session = await getSession(); @@ -348,6 +367,7 @@ export async function startUsageLogsExport( return { ok: false, error: "未登录" }; } + const { format = "csv", ...filters } = input; const jobId = crypto.randomUUID(); const finalFilters = resolveUsageLogFiltersForSession(session, filters); @@ -358,6 +378,7 @@ export async function startUsageLogsExport( processedRows: 0, totalRows: 0, progressPercent: 0, + format, }); if (!stored) { @@ -367,7 +388,7 @@ export async function startUsageLogsExport( // Defer to next tick so the action returns the jobId immediately. // Safe for self-hosted Bun server (long-lived process); NOT suitable for serverless. setTimeout(() => { - void runUsageLogsExportJob(jobId, finalFilters); + void runUsageLogsExportJob(jobId, finalFilters, format); }, 0); return { ok: true, data: { jobId } }; @@ -400,7 +421,9 @@ export async function getUsageLogsExportStatus( } } -export async function downloadUsageLogsExport(jobId: string): Promise> { +export async function downloadUsageLogsExport( + jobId: string +): Promise> { try { const session = await getSession(); if (!session) { @@ -420,12 +443,20 @@ export async function downloadUsageLogsExport(jobId: string): Promise trimmedField.startsWith(char))) { - safeField = `'${field}`; - } - - if ( - safeField.includes(",") || - safeField.includes('"') || - safeField.includes("\n") || - safeField.includes("\r") - ) { - return `"${safeField.replace(/"/g, '""')}"`; - } - return safeField; -} - /** * 获取模型列表(用于筛选器) */ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index dd515b41a..26a9b1a6f 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -1,11 +1,17 @@ "use client"; import { format, startOfDay, startOfWeek } from "date-fns"; -import { Clock, Download, Network, Server, User } from "lucide-react"; +import { ChevronDown, Clock, Download, Network, Server, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; import { downloadUsageLogsExport, @@ -167,19 +173,18 @@ export function UsageLogsFilters({ onReset(); }, [onReset]); - const downloadCsv = useCallback((csv: string) => { - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const downloadBlob = useCallback((blob: Blob, extension: string) => { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.csv`; + a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.${extension}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }, []); - const handleExport = async () => { + const handleExport = async (exportFormat: "csv" | "xlsx") => { const runId = exportRunIdRef.current + 1; exportRunIdRef.current = runId; setIsExporting(true); @@ -189,11 +194,12 @@ export function UsageLogsFilters({ processedRows: 0, totalRows: 0, progressPercent: 0, + format: exportFormat, }); try { const exportFilters = sanitizeFilters(localFilters); - const startResult = await startUsageLogsExport(exportFilters); + const startResult = await startUsageLogsExport({ ...exportFilters, format: exportFormat }); if (exportRunIdRef.current !== runId) { return; } @@ -259,7 +265,7 @@ export function UsageLogsFilters({ return; } - downloadCsv(downloadResult.data); + downloadBlob(downloadResult.data.blob, exportFormat === "xlsx" ? "xlsx" : "csv"); toast.success(t("logs.filters.exportSuccess")); } catch (error) { @@ -423,10 +429,23 @@ export function UsageLogsFilters({ - + + + + + + handleExport("csv")} disabled={isExporting}> + {t("logs.filters.exportAsCsv")} + + handleExport("xlsx")} disabled={isExporting}> + {t("logs.filters.exportAsXlsx")} + + + {isExporting && exportStatus ? (
diff --git a/src/app/api/v1/resources/usage-logs/handlers.ts b/src/app/api/v1/resources/usage-logs/handlers.ts index f2dddc81c..2328342cc 100644 --- a/src/app/api/v1/resources/usage-logs/handlers.ts +++ b/src/app/api/v1/resources/usage-logs/handlers.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import type { ActionResult } from "@/actions/types"; +import type { UsageLogsExportDownload } from "@/actions/usage-logs"; import { callAction } from "@/lib/api/v1/_shared/action-bridge"; import { createProblemResponse, @@ -86,6 +87,18 @@ export async function createUsageLogsExport(c: Context): Promise { if (!body.ok) return body.response; const actions = await import("@/actions/usage-logs"); const preferAsync = (c.req.header("prefer") ?? "").toLowerCase().includes("respond-async"); + + // XLSX is assembled in-memory from every matching row, so it is only offered + // via the async job flow (sync exports always return CSV). + if (!preferAsync && body.data.format === "xlsx") { + return createProblemResponse({ + status: 400, + instance: new URL(c.req.url).pathname, + errorCode: "usage_logs.xlsx_requires_async", + detail: "xlsx export requires asynchronous processing (set 'Prefer: respond-async').", + }); + } + const result = preferAsync ? await callAction(c, actions.startUsageLogsExport, [body.data] as never[], c.get("auth")) : await callAction(c, actions.exportUsageLogs, [body.data] as never[], c.get("auth")); @@ -123,10 +136,17 @@ export async function downloadUsageLogsExport(c: Context): Promise { c.get("auth") ); if (!result.ok) return actionError(c, result); - return new Response(String(result.data), { + const download = result.data as UsageLogsExportDownload; + const isXlsx = download.format === "xlsx"; + const body = + download.encoding === "base64" ? Buffer.from(download.content, "base64") : download.content; + const contentType = isXlsx + ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + : "text/csv; charset=utf-8"; + return new Response(body, { headers: { - "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="usage-logs-${params.jobId}.csv"`, + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${download.filename}"`, }, }); } diff --git a/src/lib/api-client/v1/actions/usage-logs.ts b/src/lib/api-client/v1/actions/usage-logs.ts index ce9d10d22..483d5a02e 100644 --- a/src/lib/api-client/v1/actions/usage-logs.ts +++ b/src/lib/api-client/v1/actions/usage-logs.ts @@ -61,7 +61,11 @@ export function getUsageLogSessionIdSuggestions(params: object) { } export function exportUsageLogs(params?: object) { - return toActionResult(apiPost("/api/v1/usage-logs/exports", params)); + // Unwrap the REST `{ csv }` envelope so the result matches the server action + // contract (ActionResult). + return toActionResult( + apiPost<{ csv: string }>("/api/v1/usage-logs/exports", params).then((body) => body.csv) + ); } export function startUsageLogsExport(params?: object) { @@ -78,13 +82,17 @@ export function getUsageLogsExportStatus(jobId: string) { ); } +export interface UsageLogsExportDownloadFile { + blob: Blob; +} + export function downloadUsageLogsExport(jobId: string) { - return toActionResult( + return toActionResult( fetch(`/api/v1/usage-logs/exports/${encodeURIComponent(jobId)}/download`, { credentials: "include", }).then(async (response) => { if (!response.ok) throw new Error(response.statusText || "Export download failed"); - return response.text(); + return { blob: await response.blob() }; }) ); } diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 81102bb70..0c521716a 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -36126,6 +36126,12 @@ export interface operations { startTime?: number | null; /** @description End timestamp in milliseconds. */ endTime?: number | null; + /** + * @description Export format. xlsx is only available asynchronously (Prefer: respond-async). + * @default csv + * @enum {string} + */ + format?: "csv" | "xlsx"; }; }; }; diff --git a/src/lib/api/v1/schemas/usage-logs.ts b/src/lib/api/v1/schemas/usage-logs.ts index f77712a97..be787ddef 100644 --- a/src/lib/api/v1/schemas/usage-logs.ts +++ b/src/lib/api/v1/schemas/usage-logs.ts @@ -36,7 +36,14 @@ export const UsageLogsExportCreateSchema = UsageLogsQuerySchema.omit({ limit: true, page: true, pageSize: true, -}).strict(); +}) + .extend({ + format: z + .enum(["csv", "xlsx"]) + .default("csv") + .describe("Export format. xlsx is only available asynchronously (Prefer: respond-async)."), + }) + .strict(); export const UsageLogExportJobParamSchema = z.object({ jobId: z.string().min(1).describe("Export job id."), diff --git a/src/lib/usage-logs/export/columns.ts b/src/lib/usage-logs/export/columns.ts new file mode 100644 index 000000000..f632ed2d6 --- /dev/null +++ b/src/lib/usage-logs/export/columns.ts @@ -0,0 +1,118 @@ +/** + * Single source of truth for the usage-logs export detail columns, shared by + * the CSV and XLSX renderers so they can never drift apart. + */ + +import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; +import type { UsageLogRow } from "@/repository/usage-logs"; + +export type DetailColumnKind = "text" | "number" | "datetime"; + +export interface DetailColumn { + /** Stable English header (datetime columns get the timezone appended). */ + header: string; + kind: DetailColumnKind; + /** + * Raw extracted value: string for text columns, number|null for number + * columns, Date|null for datetime columns. + */ + get: (log: UsageLogRow) => string | number | Date | null; + /** number columns only: emit 0 (instead of blank) when the value is null. */ + zeroWhenNull?: boolean; + /** ExcelJS number/date format string. */ + numFmt?: string; +} + +export const COST_NUM_FMT = "0.00######"; +export const INT_NUM_FMT = "0"; +export const DATETIME_NUM_FMT = "yyyy-mm-dd hh:mm:ss"; + +function retryCountOf(log: UsageLogRow): number { + return log.providerChain ? getRetryCount(log.providerChain) : 0; +} + +export const DETAIL_COLUMNS: DetailColumn[] = [ + { header: "Time", kind: "datetime", numFmt: DATETIME_NUM_FMT, get: (log) => log.createdAt }, + { header: "User", kind: "text", get: (log) => log.userName }, + { header: "Key", kind: "text", get: (log) => log.keyName }, + { header: "Provider", kind: "text", get: (log) => log.providerName ?? "" }, + { header: "Model", kind: "text", get: (log) => log.model ?? "" }, + { header: "Original Model", kind: "text", get: (log) => log.originalModel ?? "" }, + { header: "Endpoint", kind: "text", get: (log) => log.endpoint ?? "" }, + { header: "Status Code", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.statusCode }, + { + header: "Input Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.inputTokens, + }, + { + header: "Output Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.outputTokens, + }, + { + header: "Cache Write 5m", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheCreation5mInputTokens, + }, + { + header: "Cache Write 1h", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheCreation1hInputTokens, + }, + { + header: "Cache Read", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheReadInputTokens, + }, + { + header: "Total Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.totalTokens, + }, + { + header: "Cost (USD)", + kind: "number", + numFmt: COST_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.costUsd, + }, + { header: "Duration (ms)", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.durationMs }, + { header: "Session ID", kind: "text", get: (log) => log.sessionId ?? "" }, + { + header: "Retry Count", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: retryCountOf, + }, +]; + +/** + * Detail-sheet headers, with the timezone appended to datetime columns so the + * cells stay clean datetimes (e.g. "Time (Asia/Shanghai)"). + */ +export function buildDetailHeaders(timezone: string): string[] { + return DETAIL_COLUMNS.map((column) => + column.kind === "datetime" ? `${column.header} (${timezone})` : column.header + ); +} + +/** A cell value that should render blank (null, undefined, or whitespace-only). */ +export function isBlankValue(value: string | number | Date | null | undefined): boolean { + return ( + value === null || value === undefined || (typeof value === "string" && value.trim() === "") + ); +} diff --git a/src/lib/usage-logs/export/csv.ts b/src/lib/usage-logs/export/csv.ts new file mode 100644 index 000000000..64dd5735b --- /dev/null +++ b/src/lib/usage-logs/export/csv.ts @@ -0,0 +1,66 @@ +/** + * CSV rendering for usage-logs exports. Numeric columns are normalized so Excel + * parses them as numbers (see ./numeric), and timestamps are rendered in the + * resolved system timezone (see ./format). + */ + +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildDetailHeaders, DETAIL_COLUMNS, isBlankValue } from "./columns"; +import { formatExportTimestamp, isValidDate } from "./format"; +import { normalizeDecimalForSpreadsheet } from "./numeric"; + +export const CSV_BOM = ""; + +/** + * Escape a CSV field, neutralizing spreadsheet formula injection. Mirrors the + * historical behaviour: fields whose first non-whitespace char is one of + * = + - @ are prefixed with a single quote. + */ +export function escapeCsvField(field: string): string { + const dangerousChars = ["=", "+", "-", "@"]; + const trimmedField = field.trimStart(); + let safeField = field; + if (trimmedField && dangerousChars.some((char) => trimmedField.startsWith(char))) { + safeField = `'${field}`; + } + + if ( + safeField.includes(",") || + safeField.includes('"') || + safeField.includes("\n") || + safeField.includes("\r") + ) { + return `"${safeField.replace(/"/g, '""')}"`; + } + return safeField; +} + +function renderCsvCell( + value: string | number | Date | null, + column: (typeof DETAIL_COLUMNS)[number], + timezone: string +): string { + switch (column.kind) { + case "datetime": + return isValidDate(value) ? formatExportTimestamp(value, timezone) : ""; + case "number": + if (isBlankValue(value) && !column.zeroWhenNull) { + return ""; + } + return normalizeDecimalForSpreadsheet(value as string | number | null); + default: + return escapeCsvField(typeof value === "string" ? value : String(value ?? "")); + } +} + +/** The CSV header row (comma-joined), with the timezone annotation. */ +export function buildCsvHeaderLine(timezone: string): string { + return buildDetailHeaders(timezone).map(escapeCsvField).join(","); +} + +/** Render usage log rows as CSV data lines (no header, no BOM). */ +export function buildCsvRows(logs: UsageLogRow[], timezone: string): string[] { + return logs.map((log) => + DETAIL_COLUMNS.map((column) => renderCsvCell(column.get(log), column, timezone)).join(",") + ); +} diff --git a/src/lib/usage-logs/export/format.ts b/src/lib/usage-logs/export/format.ts new file mode 100644 index 000000000..dbe4d27db --- /dev/null +++ b/src/lib/usage-logs/export/format.ts @@ -0,0 +1,38 @@ +/** + * Timezone-aware formatting for spreadsheet exports. + * + * Timestamps are rendered in the system timezone (resolved by the caller via + * resolveSystemTimezone) instead of UTC, and the timezone is surfaced once in + * the column header so each cell stays a clean, Excel-parseable datetime. + */ + +import { formatInTimeZone } from "date-fns-tz"; + +/** Excel-friendly local datetime, e.g. "2026-06-03 20:34:56". */ +export const EXPORT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + +/** + * Narrow to a usable Date. `new Date(NaN)` is still `instanceof Date`, so an + * `instanceof` check alone would let an invalid date reach `formatInTimeZone` + * and throw a RangeError mid-export. + */ +export function isValidDate(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +/** Render an instant as a wall-clock string in the given IANA timezone. */ +export function formatExportTimestamp(date: Date, timezone: string): string { + return formatInTimeZone(date, timezone, EXPORT_DATETIME_FORMAT); +} + +/** + * Convert a UTC instant into a Date whose UTC fields equal the wall-clock time + * in `timezone`. The XLSX writer derives the Excel serial from this Date's UTC + * epoch value (see ./xlsx excelSerial), so the cell displays the intended local + * time while remaining a real (sortable, computable) Excel date. + */ +export function toExcelZonedDate(date: Date, timezone: string): Date { + const parts = formatInTimeZone(date, timezone, "yyyy-MM-dd-HH-mm-ss").split("-").map(Number); + const [year, month, day, hour, minute, second] = parts; + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); +} diff --git a/src/lib/usage-logs/export/numeric.ts b/src/lib/usage-logs/export/numeric.ts new file mode 100644 index 000000000..6047e9769 --- /dev/null +++ b/src/lib/usage-logs/export/numeric.ts @@ -0,0 +1,51 @@ +/** + * Numeric normalization for spreadsheet exports. + * + * Excel only keeps 15 significant digits. A `numeric(21, 15)` cost such as + * `1.234567890123456` (16 significant digits) is therefore imported as *text*, + * which breaks SUM() and other math. Values < 1 (e.g. `0.000123...`) have fewer + * significant digits and slip under the ceiling, which is why only some rows + * misbehaved. Normalizing every numeric value to <=15 significant digits, plain + * decimal notation, with trailing zeros trimmed keeps Excel treating them as + * numbers. + */ + +const SPREADSHEET_NUMBER_FORMATTER = new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 15, + useGrouping: false, +}); + +/** + * Coerce a DB numeric string (or number) into a finite number, or null when the + * input is empty, nullish, or not a finite number. + */ +export function toFiniteNumber(value: string | number | null | undefined): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Render a decimal value as an Excel-safe numeric literal: at most 15 + * significant digits, plain decimal notation (never scientific), trailing zeros + * stripped. Non-finite / empty / nullish inputs collapse to "0". + */ +export function normalizeDecimalForSpreadsheet(value: string | number | null | undefined): string { + const parsed = toFiniteNumber(value); + if (parsed === null) { + return "0"; + } + return SPREADSHEET_NUMBER_FORMATTER.format(parsed); +} diff --git a/src/lib/usage-logs/export/summary.ts b/src/lib/usage-logs/export/summary.ts new file mode 100644 index 000000000..66ca5ba97 --- /dev/null +++ b/src/lib/usage-logs/export/summary.ts @@ -0,0 +1,163 @@ +/** + * Aggregated summary for the XLSX export's second worksheet. + * + * - Multi-day exports are summarized per calendar day. + * - Single-day exports are summarized per hour. + * + * Calendar boundaries are evaluated in the resolved system timezone so the + * buckets line up with what the user sees in the dashboard. + */ + +import { formatInTimeZone } from "date-fns-tz"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { isValidDate } from "./format"; +import { toFiniteNumber } from "./numeric"; + +export type SummaryGranularity = "daily" | "hourly"; + +export interface SummaryRow { + period: string; + requests: number; + inputTokens: number; + outputTokens: number; + cacheWrite5m: number; + cacheWrite1h: number; + cacheRead: number; + totalTokens: number; + cost: number; +} + +export interface UsageLogsSummary { + granularity: SummaryGranularity; + rows: SummaryRow[]; + total: SummaryRow; +} + +export const SUMMARY_HEADERS = [ + "Period", + "Requests", + "Input Tokens", + "Output Tokens", + "Cache Write 5m", + "Cache Write 1h", + "Cache Read", + "Total Tokens", + "Cost (USD)", +] as const; + +const UNKNOWN_PERIOD = "Unknown"; + +function emptyRow(period: string): SummaryRow { + return { + period, + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheWrite5m: 0, + cacheWrite1h: 0, + cacheRead: 0, + totalTokens: 0, + cost: 0, + }; +} + +function accumulate(row: SummaryRow, log: UsageLogRow): void { + row.requests += 1; + row.inputTokens += log.inputTokens ?? 0; + row.outputTokens += log.outputTokens ?? 0; + row.cacheWrite5m += log.cacheCreation5mInputTokens ?? 0; + row.cacheWrite1h += log.cacheCreation1hInputTokens ?? 0; + row.cacheRead += log.cacheReadInputTokens ?? 0; + row.totalTokens += log.totalTokens ?? 0; + row.cost += toFiniteNumber(log.costUsd) ?? 0; +} + +function merge(target: SummaryRow, source: SummaryRow): void { + target.requests += source.requests; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheWrite5m += source.cacheWrite5m; + target.cacheWrite1h += source.cacheWrite1h; + target.cacheRead += source.cacheRead; + target.totalTokens += source.totalTokens; + target.cost += source.cost; +} + +function byPeriod(a: SummaryRow, b: SummaryRow): number { + return a.period < b.period ? -1 : a.period > b.period ? 1 : 0; +} + +/** + * Incremental summary builder. Logs are folded in one at a time (each timestamp + * formatted exactly once) so callers can stream batches without retaining the + * rows. Buckets are kept at hour granularity; `finalize` chooses per-hour vs + * per-day output from the distinct-day count and rolls hours up when needed. + * Period labels are zero-padded ISO, so the later lexicographic sort is also + * chronological. + */ +export interface SummaryAccumulator { + add(log: UsageLogRow): void; + finalize(): UsageLogsSummary; +} + +export function createSummaryAccumulator(timezone: string): SummaryAccumulator { + const hourBuckets = new Map(); + const days = new Set(); + const total = emptyRow("Total"); + let unknown: SummaryRow | null = null; + + return { + add(log) { + accumulate(total, log); + if (!isValidDate(log.createdAt)) { + unknown ??= emptyRow(UNKNOWN_PERIOD); + accumulate(unknown, log); + return; + } + const hourKey = `${formatInTimeZone(log.createdAt, timezone, "yyyy-MM-dd HH")}:00`; + days.add(hourKey.slice(0, 10)); + let row = hourBuckets.get(hourKey); + if (!row) { + row = emptyRow(hourKey); + hourBuckets.set(hourKey, row); + } + accumulate(row, log); + }, + finalize() { + const granularity: SummaryGranularity = days.size <= 1 ? "hourly" : "daily"; + let rows: SummaryRow[]; + if (granularity === "hourly") { + rows = [...hourBuckets.values()]; + } else { + const dayBuckets = new Map(); + for (const hour of hourBuckets.values()) { + const dayKey = hour.period.slice(0, 10); + let day = dayBuckets.get(dayKey); + if (!day) { + day = emptyRow(dayKey); + dayBuckets.set(dayKey, day); + } + merge(day, hour); + } + rows = [...dayBuckets.values()]; + } + if (unknown) { + rows.push(unknown); + } + rows.sort(byPeriod); + return { granularity, rows, total }; + }, + }; +} + +/** + * Build the per-day or per-hour summary for the given logs (convenience wrapper + * around {@link createSummaryAccumulator} for callers that already hold all rows). + */ +export function buildUsageLogsSummary(logs: UsageLogRow[], timezone: string): UsageLogsSummary { + const accumulator = createSummaryAccumulator(timezone); + for (const log of logs) { + accumulator.add(log); + } + return accumulator.finalize(); +} diff --git a/src/lib/usage-logs/export/xlsx.ts b/src/lib/usage-logs/export/xlsx.ts new file mode 100644 index 000000000..398ecc2c8 --- /dev/null +++ b/src/lib/usage-logs/export/xlsx.ts @@ -0,0 +1,282 @@ +/** + * Minimal, dependency-light XLSX writer for usage-logs exports. + * + * Why hand-rolled: we only need to emit two simple worksheets with correctly + * typed numeric / date cells. A purpose-built writer (on top of the already + * present `fflate` zip codec) keeps the cells genuinely numeric so Excel SUM() + * works, renders timestamps as real Excel dates in the system timezone, and + * avoids pulling in a heavy spreadsheet dependency tree. + * + * Workbook layout: + * Sheet 1 "Usage Logs" - one row per request (mirrors the CSV) + * Sheet 2 "Daily/Hourly Summary" - aggregates (see ./summary) + */ + +import { strToU8, zip } from "fflate"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { + buildDetailHeaders, + COST_NUM_FMT, + DETAIL_COLUMNS, + type DetailColumn, + isBlankValue, +} from "./columns"; +import { isValidDate, toExcelZonedDate } from "./format"; +import { normalizeDecimalForSpreadsheet } from "./numeric"; +import { + createSummaryAccumulator, + SUMMARY_HEADERS, + type SummaryRow, + type UsageLogsSummary, +} from "./summary"; + +// Cell style indices, matched 1:1 to the entries in STYLES_XML below. +const STYLE = { text: 0, header: 1, datetime: 2, integer: 3, cost: 4 } as const; + +// Spreadsheet column letters are invariant per column index, so precompute them +// once instead of recomputing inside every row. +const DETAIL_COLUMN_REFS = DETAIL_COLUMNS.map((_column, index) => columnRef(index)); +const SUMMARY_COLUMN_REFS = SUMMARY_HEADERS.map((_header, index) => columnRef(index)); + +// Days between the Unix epoch (1970-01-01) and the Excel epoch (1899-12-30). +const EXCEL_EPOCH_OFFSET_DAYS = 25569; +const MS_PER_DAY = 86_400_000; +const SECONDS_PER_DAY = 86_400; + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +// Keep only characters allowed by the XML 1.0 Char production, so a stray byte +// in a model/endpoint string (control bytes, unpaired surrogates, the U+FFFE / +// U+FFFF non-characters) cannot corrupt the whole workbook. +function stripIllegalXmlChars(value: string): string { + let out = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if ( + code === 0x09 || + code === 0x0a || + code === 0x0d || + (code >= 0x20 && code <= 0xd7ff) || + (code >= 0xe000 && code <= 0xfffd) || + (code >= 0x10000 && code <= 0x10ffff) + ) { + out += char; + } + } + return out; +} + +function sanitizeXmlText(value: string): string { + return escapeXml(stripIllegalXmlChars(value)); +} + +/** Zero-based column index -> spreadsheet column letters (0 -> A, 26 -> AA). */ +export function columnRef(index: number): string { + let remaining = index + 1; + let ref = ""; + while (remaining > 0) { + const mod = (remaining - 1) % 26; + ref = String.fromCharCode(65 + mod) + ref; + remaining = Math.floor((remaining - 1) / 26); + } + return ref; +} + +function excelSerial(date: Date): number { + const serial = date.getTime() / MS_PER_DAY + EXCEL_EPOCH_OFFSET_DAYS; + // Timestamps are whole seconds; snap to the second grid so binary-float + // artifacts in the division cannot make Excel display the wrong second. + return Math.round(serial * SECONDS_PER_DAY) / SECONDS_PER_DAY; +} + +function textCell(ref: string, value: string, style: number): string { + if (value === "") { + return ``; + } + return `${sanitizeXmlText(value)}`; +} + +function numberCell(ref: string, value: string | null, style: number): string { + if (value === null) { + return ``; + } + return `${value}`; +} + +function dateCell(ref: string, date: Date): string { + return `${excelSerial(date)}`; +} + +// Only reached for number columns; cost gets the decimal format, the rest are integers. +function detailNumberStyle(column: DetailColumn): number { + return column.numFmt === COST_NUM_FMT ? STYLE.cost : STYLE.integer; +} + +function detailCell(column: DetailColumn, log: UsageLogRow, ref: string, timezone: string): string { + const raw = column.get(log); + if (column.kind === "datetime") { + return isValidDate(raw) ? dateCell(ref, toExcelZonedDate(raw, timezone)) : ``; + } + if (column.kind === "number") { + if (isBlankValue(raw) && !column.zeroWhenNull) { + return numberCell(ref, null, STYLE.integer); + } + return numberCell( + ref, + normalizeDecimalForSpreadsheet(raw as string | number | null), + detailNumberStyle(column) + ); + } + return textCell(ref, typeof raw === "string" ? raw : String(raw ?? ""), STYLE.text); +} + +function rowXml(rowNumber: number, cells: string[]): string { + return `${cells.join("")}`; +} + +function headerRowXml(headers: string[], rowNumber: number, refs: string[]): string { + const cells = headers.map((header, index) => + textCell(`${refs[index]}${rowNumber}`, header, STYLE.header) + ); + return rowXml(rowNumber, cells); +} + +function worksheetXml(rows: string[], columnCount: number): string { + const lastCol = columnRef(columnCount - 1); + const lastRow = Math.max(rows.length, 1); + return ` +${rows.join("")}`; +} + +/** + * Render a single detail row's XML. `rowNumber` is 1-based (the header occupies + * row 1). Exposed so callers can stream batches into the sheet without retaining + * the whole result set in memory. + */ +export function buildDetailRowXml(log: UsageLogRow, rowNumber: number, timezone: string): string { + const cells = DETAIL_COLUMNS.map((column, columnIndex) => + detailCell(column, log, `${DETAIL_COLUMN_REFS[columnIndex]}${rowNumber}`, timezone) + ); + return rowXml(rowNumber, cells); +} + +function detailSheetXml(detailRowsXml: string[], timezone: string): string { + const rows = [ + headerRowXml(buildDetailHeaders(timezone), 1, DETAIL_COLUMN_REFS), + ...detailRowsXml, + ]; + return worksheetXml(rows, DETAIL_COLUMNS.length); +} + +function summaryRowCells(row: SummaryRow, rowNumber: number, periodStyle: number): string[] { + const integers = [ + row.requests, + row.inputTokens, + row.outputTokens, + row.cacheWrite5m, + row.cacheWrite1h, + row.cacheRead, + row.totalTokens, + ]; + const cells = [textCell(`${SUMMARY_COLUMN_REFS[0]}${rowNumber}`, row.period, periodStyle)]; + integers.forEach((value, index) => { + cells.push( + numberCell(`${SUMMARY_COLUMN_REFS[index + 1]}${rowNumber}`, String(value), STYLE.integer) + ); + }); + cells.push( + numberCell( + `${SUMMARY_COLUMN_REFS[8]}${rowNumber}`, + normalizeDecimalForSpreadsheet(row.cost), + STYLE.cost + ) + ); + return cells; +} + +function buildSummarySheet(summary: UsageLogsSummary): string { + const rows = [headerRowXml([...SUMMARY_HEADERS], 1, SUMMARY_COLUMN_REFS)]; + summary.rows.forEach((row, index) => { + rows.push(rowXml(index + 2, summaryRowCells(row, index + 2, STYLE.text))); + }); + const totalRowNumber = summary.rows.length + 2; + rows.push(rowXml(totalRowNumber, summaryRowCells(summary.total, totalRowNumber, STYLE.header))); + return worksheetXml(rows, SUMMARY_HEADERS.length); +} + +function summarySheetName(summary: UsageLogsSummary): string { + return summary.granularity === "daily" ? "Daily Summary" : "Hourly Summary"; +} + +const STYLES_XML = ` +`; + +const CONTENT_TYPES_XML = ` +`; + +const ROOT_RELS_XML = ` +`; + +const WORKBOOK_RELS_XML = ` +`; + +function workbookXml(summaryName: string): string { + return ` +`; +} + +export interface XlsxParts { + /** Pre-rendered detail row XML (one entry per data row, row numbers from 2). */ + detailRowsXml: string[]; + summary: UsageLogsSummary; + timezone: string; +} + +/** + * Assemble an XLSX workbook (detail sheet + daily/hourly summary sheet) from + * pre-rendered detail rows and an aggregated summary. Compression runs via + * fflate's async zip so a large export does not block the event loop. + */ +export function assembleUsageLogsXlsx(parts: XlsxParts): Promise { + const files: Record = { + "[Content_Types].xml": strToU8(CONTENT_TYPES_XML), + "_rels/.rels": strToU8(ROOT_RELS_XML), + "xl/workbook.xml": strToU8(workbookXml(summarySheetName(parts.summary))), + "xl/_rels/workbook.xml.rels": strToU8(WORKBOOK_RELS_XML), + "xl/styles.xml": strToU8(STYLES_XML), + "xl/worksheets/sheet1.xml": strToU8(detailSheetXml(parts.detailRowsXml, parts.timezone)), + "xl/worksheets/sheet2.xml": strToU8(buildSummarySheet(parts.summary)), + }; + return new Promise((resolve, reject) => { + zip(files, { level: 6 }, (error, data) => (error ? reject(error) : resolve(data))); + }); +} + +/** + * Build an XLSX workbook for the given logs (convenience wrapper that holds all + * rows in memory; the streaming export path uses buildDetailRowXml + + * createSummaryAccumulator + assembleUsageLogsXlsx instead). + */ +export function buildUsageLogsXlsx(logs: UsageLogRow[], timezone: string): Promise { + const accumulator = createSummaryAccumulator(timezone); + const detailRowsXml = logs.map((log, index) => { + accumulator.add(log); + return buildDetailRowXml(log, index + 2, timezone); + }); + return assembleUsageLogsXlsx({ detailRowsXml, summary: accumulator.finalize(), timezone }); +} diff --git a/tests/api/v1/usage-logs/usage-logs.test.ts b/tests/api/v1/usage-logs/usage-logs.test.ts index 65ca4fa30..6245aa868 100644 --- a/tests/api/v1/usage-logs/usage-logs.test.ts +++ b/tests/api/v1/usage-logs/usage-logs.test.ts @@ -85,7 +85,15 @@ describe("v1 usage log endpoints", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "Time,Model\nnow,claude" }); + downloadUsageLogsExportMock.mockResolvedValue({ + ok: true, + data: { + content: "Time,Model\nnow,claude", + encoding: "utf8", + format: "csv", + filename: "usage-logs-job-1.csv", + }, + }); }); test("lists usage logs with offset and cursor filters", async () => { @@ -247,7 +255,7 @@ describe("v1 usage log endpoints", () => { }); expect(sync.response.status).toBe(200); expect(sync.json).toEqual({ csv: "Time,Model\nnow,claude" }); - expect(exportUsageLogsMock).toHaveBeenCalledWith({ model: "claude" }); + expect(exportUsageLogsMock).toHaveBeenCalledWith({ model: "claude", format: "csv" }); const asyncJob = await callV1Route({ method: "POST", @@ -257,7 +265,7 @@ describe("v1 usage log endpoints", () => { }); expect(asyncJob.response.status).toBe(202); expect(asyncJob.response.headers.get("Location")).toBe("/api/v1/usage-logs/exports/job-1"); - expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude" }); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude", format: "csv" }); const status = await callV1Route({ method: "GET", @@ -277,6 +285,46 @@ describe("v1 usage log endpoints", () => { expect(download.text).toContain("Time,Model"); }); + test("xlsx export requires async and downloads as a spreadsheet", async () => { + const syncXlsx = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers, + body: { model: "claude", format: "xlsx" }, + }); + expect(syncXlsx.response.status).toBe(400); + expect(startUsageLogsExportMock).not.toHaveBeenCalledWith( + expect.objectContaining({ format: "xlsx" }) + ); + + const asyncXlsx = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers: { ...headers, Prefer: "respond-async" }, + body: { model: "claude", format: "xlsx" }, + }); + expect(asyncXlsx.response.status).toBe(202); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude", format: "xlsx" }); + + downloadUsageLogsExportMock.mockResolvedValueOnce({ + ok: true, + data: { + content: Buffer.from("PK-xlsx-bytes").toString("base64"), + encoding: "base64", + format: "xlsx", + filename: "usage-logs-job-1.xlsx", + }, + }); + const download = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs/exports/job-1/download", + headers, + }); + expect(download.response.status).toBe(200); + expect(download.response.headers.get("content-type")).toContain("spreadsheetml.sheet"); + expect(download.response.headers.get("content-disposition")).toContain(".xlsx"); + }); + test("returns problem+json for action failures and documents paths", async () => { getUsageLogsStatsMock.mockResolvedValueOnce({ ok: false, diff --git a/tests/unit/actions/usage-logs-export-retry-count.test.ts b/tests/unit/actions/usage-logs-export-retry-count.test.ts index 69dd3f06c..0c97c2587 100644 --- a/tests/unit/actions/usage-logs-export-retry-count.test.ts +++ b/tests/unit/actions/usage-logs-export-retry-count.test.ts @@ -13,6 +13,10 @@ vi.mock("@/lib/auth", () => { }; }); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), +})); + vi.mock("@/lib/redis/redis-kv-store", () => ({ RedisKVStore: class MockRedisKVStore { private readonly prefix: string; @@ -361,7 +365,11 @@ describe("Usage logs CSV export retryCount", () => { const downloadResult = await downloadUsageLogsExport(jobId); expect(downloadResult.ok).toBe(true); - expect(downloadResult.data).toContain("Session ID"); - expect(downloadResult.data).toContain("job-session"); + if (!downloadResult.ok) throw new Error("expected ok download"); + expect(downloadResult.data.format).toBe("csv"); + expect(downloadResult.data.encoding).toBe("utf8"); + expect(downloadResult.data.filename).toMatch(/\.csv$/); + expect(downloadResult.data.content).toContain("Session ID"); + expect(downloadResult.data.content).toContain("job-session"); }); }); diff --git a/tests/unit/actions/usage-logs-export-xlsx.test.ts b/tests/unit/actions/usage-logs-export-xlsx.test.ts new file mode 100644 index 000000000..9c9e89f17 --- /dev/null +++ b/tests/unit/actions/usage-logs-export-xlsx.test.ts @@ -0,0 +1,181 @@ +import { strFromU8, unzipSync } from "fflate"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const findUsageLogsWithDetailsMock = vi.fn(); +const findUsageLogsBatchMock = vi.fn(); +const findUsageLogsStatsMock = vi.fn(); +const exportStatusStore = new Map(); +const exportResultStore = new Map(); + +vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +vi.mock("@/lib/redis/redis-kv-store", () => ({ + RedisKVStore: class MockRedisKVStore { + private readonly prefix: string; + constructor(options: { prefix: string }) { + this.prefix = options.prefix; + } + async set(key: string, value: T) { + if (this.prefix.includes(":status:")) { + exportStatusStore.set(key, value); + } else { + exportResultStore.set(key, value as string); + } + return true; + } + async get(key: string) { + if (this.prefix.includes(":status:")) { + return (exportStatusStore.get(key) as T | undefined) ?? null; + } + return ((exportResultStore.get(key) as T | undefined) ?? null) as T | null; + } + async delete(key: string) { + if (this.prefix.includes(":status:")) { + return exportStatusStore.delete(key); + } + return exportResultStore.delete(key); + } + }, +})); + +vi.mock("@/repository/usage-logs", () => ({ + findUsageLogSessionIdSuggestions: vi.fn(async () => []), + findUsageLogsBatch: findUsageLogsBatchMock, + findUsageLogsStats: findUsageLogsStatsMock, + findUsageLogsWithDetails: findUsageLogsWithDetailsMock, + getUsedEndpoints: vi.fn(async () => []), + getUsedModels: vi.fn(async () => []), + getUsedStatusCodes: vi.fn(async () => []), +})); + +function summary(totalRequests = 0) { + return { + totalRequests, + totalCost: 0, + totalTokens: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }; +} + +function log(overrides: Record = {}) { + return { + createdAt: new Date("2026-03-16T01:00:00.000Z"), + userName: "u", + keyName: "k", + providerName: "p", + model: "m", + originalModel: "om", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 2, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheReadInputTokens: 0, + totalTokens: 3, + costUsd: "1.500000000000000", + durationMs: 10, + sessionId: "s1", + providerChain: null, + ...overrides, + }; +} + +describe("Usage logs XLSX export", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useRealTimers(); + exportStatusStore.clear(); + exportResultStore.clear(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUsageLogsWithDetailsMock.mockResolvedValue({ logs: [], total: 1, summary: summary(1) }); + findUsageLogsBatchMock.mockResolvedValue({ logs: [], nextCursor: null, hasMore: false }); + findUsageLogsStatsMock.mockResolvedValue(summary()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("async xlsx job completes and downloads a base64 workbook with two sheets", async () => { + vi.useFakeTimers(); + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log({ sessionId: "job-session" })], + nextCursor: null, + hasMore: false, + }); + + const { downloadUsageLogsExport, getUsageLogsExportStatus, startUsageLogsExport } = + await import("@/actions/usage-logs"); + + const startResult = await startUsageLogsExport({ format: "xlsx" }); + expect(startResult.ok).toBe(true); + if (!startResult.ok) throw new Error("start failed"); + const jobId = startResult.data.jobId; + + const queued = await getUsageLogsExportStatus(jobId); + expect(queued.ok).toBe(true); + if (!queued.ok) throw new Error("status failed"); + expect(queued.data.format).toBe("xlsx"); + + await vi.runAllTimersAsync(); + + const completed = await getUsageLogsExportStatus(jobId); + expect(completed.ok && completed.data.status).toBe("completed"); + + const download = await downloadUsageLogsExport(jobId); + expect(download.ok).toBe(true); + if (!download.ok) throw new Error("download failed"); + expect(download.data.format).toBe("xlsx"); + expect(download.data.encoding).toBe("base64"); + expect(download.data.filename).toMatch(/\.xlsx$/); + + const bytes = Buffer.from(download.data.content, "base64"); + // PK zip signature + expect(bytes[0]).toBe(0x50); + expect(bytes[1]).toBe(0x4b); + + const files = unzipSync(new Uint8Array(bytes)); + expect(Object.keys(files)).toEqual( + expect.arrayContaining(["xl/worksheets/sheet1.xml", "xl/worksheets/sheet2.xml"]) + ); + const sheet1 = strFromU8(files["xl/worksheets/sheet1.xml"]); + // 01:00 UTC -> 09:00 Asia/Shanghai, header carries the timezone + expect(sheet1).toContain("Time (Asia/Shanghai)"); + // cost rendered as a numeric cell, normalized + expect(sheet1).toContain("1.5"); + }); + + test("sync export rejects xlsx (async job only)", async () => { + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "xlsx" }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.error).toMatch(/XLSX/); + }); + + test("sync csv export returns CSV text", async () => { + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log()], + nextCursor: null, + hasMore: false, + }); + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "csv" }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("export failed"); + expect(result.data).toContain("Session ID"); + expect(result.data.startsWith("")).toBe(true); + }); +}); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 64ef6184d..74c285d9f 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { DASHBOARD_COMPAT_HEADER } from "@/lib/api/v1/_shared/constants"; import { ApiError } from "@/lib/api-client/v1/errors"; @@ -39,6 +39,11 @@ describe("v1 action compatibility client", () => { vi.clearAllMocks(); }); + // Always restore globals, even if a stubbed-fetch test throws mid-assertion. + afterEach(() => { + vi.unstubAllGlobals(); + }); + test("preserves provider edit undo metadata from response headers", async () => { patchMock.mockImplementation( async ( @@ -425,4 +430,39 @@ describe("v1 action compatibility client", () => { ); expect(result).toEqual({ ok: true, data: suggestions }); }); + + test("downloadUsageLogsExport returns the response body as a Blob", async () => { + const fetchMock = vi.fn( + async () => + new Response(new Blob(["PKxlsx-bytes"]), { + status: 200, + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": 'attachment; filename="usage-logs-job-9.xlsx"', + }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await usageLogs.downloadUsageLogsExport("job-9"); + + expect(fetchMock).toHaveBeenCalledWith("/api/v1/usage-logs/exports/job-9/download", { + credentials: "include", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.blob).toBeInstanceOf(Blob); + expect(await result.data.blob.text()).toBe("PKxlsx-bytes"); + }); + + test("downloadUsageLogsExport surfaces a non-2xx download as an error result", async () => { + const fetchMock = vi.fn( + async () => new Response("nope", { status: 404, statusText: "Not Found" }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await usageLogs.downloadUsageLogsExport("missing"); + + expect(result.ok).toBe(false); + }); }); diff --git a/tests/unit/dashboard-logs-export-progress-ui.test.tsx b/tests/unit/dashboard-logs-export-progress-ui.test.tsx index 016071b68..143440c5c 100644 --- a/tests/unit/dashboard-logs-export-progress-ui.test.tsx +++ b/tests/unit/dashboard-logs-export-progress-ui.test.tsx @@ -41,6 +41,39 @@ vi.mock("sonner", () => ({ }, })); +// Render the dropdown menu inline so its items are directly clickable in happy-dom. +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + disabled, + }: { + children: ReactNode; + onSelect?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +function csvBlobResult() { + return { + ok: true as const, + data: { blob: new Blob(["Time,User\n"], { type: "text/csv" }), filename: "usage-logs.csv" }, + }; +} + +function findButtonByText(container: Element, text: string): Element | undefined { + return Array.from(container.querySelectorAll("button")).find( + (button) => (button.textContent || "").trim() === text + ); +} + vi.mock("@/app/[locale]/dashboard/logs/_components/filters/active-filters-display", () => ({ ActiveFiltersDisplay: () =>
, })); @@ -162,7 +195,7 @@ describe("UsageLogsFilters export progress UI", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" }); + downloadUsageLogsExportMock.mockResolvedValue(csvBlobResult()); const { container, unmount } = renderWithIntl( { /> ); - const exportButton = Array.from(container.querySelectorAll("button")).find( - (button) => (button.textContent || "").trim() === "Export" - ); - - await actClick(exportButton ?? null); + await actClick(findButtonByText(container, "Export as CSV") ?? null); await flushPromises(); expect(container.textContent).toContain("Exported 50 / 200"); @@ -190,6 +219,7 @@ describe("UsageLogsFilters export progress UI", () => { }); await flushPromises(); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ format: "csv" }); expect(downloadUsageLogsExportMock).toHaveBeenCalledWith("job-1"); expect(toastSuccessMock).toHaveBeenCalledWith("Export completed successfully"); expect(toastErrorMock).not.toHaveBeenCalled(); @@ -209,7 +239,7 @@ describe("UsageLogsFilters export progress UI", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" }); + downloadUsageLogsExportMock.mockResolvedValue(csvBlobResult()); const { container, unmount } = renderWithIntl( { await actClick(container.querySelector("[data-testid='request-filters']")); - const exportButton = Array.from(container.querySelectorAll("button")).find( - (button) => (button.textContent || "").trim() === "Export" + await actClick(findButtonByText(container, "Export as CSV") ?? null); + await flushPromises(); + + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ + sessionId: "draft-session", + format: "csv", + }); + + unmount(); + }); + + test("exports as XLSX when the XLSX option is selected", async () => { + startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-3" } }); + getUsageLogsExportStatusMock.mockResolvedValueOnce({ + ok: true, + data: { + jobId: "job-3", + status: "completed", + processedRows: 1, + totalRows: 1, + progressPercent: 100, + format: "xlsx", + }, + }); + downloadUsageLogsExportMock.mockResolvedValue({ + ok: true, + data: { blob: new Blob(["PK"], { type: "application/octet-stream" }), filename: "u.xlsx" }, + }); + + const { container, unmount } = renderWithIntl( + {}} + onReset={() => {}} + /> ); - await actClick(exportButton ?? null); + await actClick(findButtonByText(container, "Export as XLSX (with summary)") ?? null); await flushPromises(); - expect(startUsageLogsExportMock).toHaveBeenCalledWith({ sessionId: "draft-session" }); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ format: "xlsx" }); unmount(); }); diff --git a/tests/unit/usage-logs/export-csv.test.ts b/tests/unit/usage-logs/export-csv.test.ts new file mode 100644 index 000000000..6c5dccfb3 --- /dev/null +++ b/tests/unit/usage-logs/export-csv.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildCsvHeaderLine, buildCsvRows, escapeCsvField } from "@/lib/usage-logs/export/csv"; +import { buildDetailHeaders } from "@/lib/usage-logs/export/columns"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:34:56.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: "claude-orig", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "1.500000000000000", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 123, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +const HEADER = buildDetailHeaders("UTC"); +const TIME_IDX = 0; +const STATUS_IDX = HEADER.indexOf("Status Code"); +const COST_IDX = HEADER.indexOf("Cost (USD)"); +const DURATION_IDX = HEADER.indexOf("Duration (ms)"); + +describe("buildCsvHeaderLine", () => { + test("annotates the time column with the timezone", () => { + expect(buildCsvHeaderLine("Asia/Shanghai").split(",")[TIME_IDX]).toBe("Time (Asia/Shanghai)"); + expect(buildCsvHeaderLine("UTC").split(",")[TIME_IDX]).toBe("Time (UTC)"); + }); +}); + +describe("buildCsvRows", () => { + test("renders the timestamp in the requested timezone (no UTC Z suffix)", () => { + const [row] = buildCsvRows([makeLog()], "Asia/Shanghai"); + const cells = row.split(","); + // 12:34:56 UTC -> 20:34:56 in Asia/Shanghai (+08:00) + expect(cells[TIME_IDX]).toBe("2026-06-03 20:34:56"); + expect(cells[TIME_IDX]).not.toContain("Z"); + }); + + test("normalizes the cost so Excel reads it as a number (trailing zeros gone)", () => { + const [row] = buildCsvRows([makeLog({ costUsd: "1.500000000000000" })], "UTC"); + expect(row.split(",")[COST_IDX]).toBe("1.5"); + }); + + test("caps 16-significant-digit costs to Excel's 15-digit ceiling", () => { + const [row] = buildCsvRows([makeLog({ costUsd: "1.234567890123456" })], "UTC"); + expect(row.split(",")[COST_IDX]).toBe("1.23456789012346"); + }); + + test("blank status code / duration stay blank; null cost becomes 0", () => { + const [row] = buildCsvRows( + [makeLog({ statusCode: null, durationMs: null, costUsd: null })], + "UTC" + ); + const cells = row.split(","); + expect(cells[STATUS_IDX]).toBe(""); + expect(cells[DURATION_IDX]).toBe(""); + expect(cells[COST_IDX]).toBe("0"); + }); + + test("null timestamp renders as an empty cell", () => { + const [row] = buildCsvRows([makeLog({ createdAt: null })], "UTC"); + expect(row.split(",")[TIME_IDX]).toBe(""); + }); + + test("invalid Date timestamp renders empty (no RangeError crash)", () => { + const [row] = buildCsvRows([makeLog({ createdAt: new Date(Number.NaN) })], "UTC"); + expect(row.split(",")[TIME_IDX]).toBe(""); + }); + + test("retry count is derived from the provider chain", () => { + const retryIdx = HEADER.indexOf("Retry Count"); + const [row] = buildCsvRows( + [ + makeLog({ + providerChain: [ + { reason: "initial_selection" }, + { reason: "retry_failed", attemptNumber: 1 }, + { reason: "retry_success", statusCode: 200, attemptNumber: 1 }, + ] as UsageLogRow["providerChain"], + }), + ], + "UTC" + ); + expect(row.split(",")[retryIdx]).toBe("1"); + }); +}); + +describe("escapeCsvField", () => { + test("neutralizes formula injection regardless of leading whitespace", () => { + expect(escapeCsvField("=1+1")).toBe("'=1+1"); + // a tab does not trigger CSV quoting, so only the leading-quote guard applies + expect(escapeCsvField(" \t@SUM(A1:A2)")).toBe("' \t@SUM(A1:A2)"); + expect(escapeCsvField("+2+2")).toBe("'+2+2"); + }); + + test("quotes fields containing commas or quotes", () => { + expect(escapeCsvField("a,b")).toBe('"a,b"'); + expect(escapeCsvField('a"b')).toBe('"a""b"'); + expect(escapeCsvField("plain")).toBe("plain"); + }); +}); diff --git a/tests/unit/usage-logs/export-numeric.test.ts b/tests/unit/usage-logs/export-numeric.test.ts new file mode 100644 index 000000000..82725218a --- /dev/null +++ b/tests/unit/usage-logs/export-numeric.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { normalizeDecimalForSpreadsheet, toFiniteNumber } from "@/lib/usage-logs/export/numeric"; + +describe("toFiniteNumber", () => { + test("parses numeric strings", () => { + expect(toFiniteNumber("1.5")).toBe(1.5); + expect(toFiniteNumber("0")).toBe(0); + expect(toFiniteNumber(42)).toBe(42); + }); + + test("returns null for empty / nullish / non-numeric", () => { + expect(toFiniteNumber("")).toBeNull(); + expect(toFiniteNumber(" ")).toBeNull(); + expect(toFiniteNumber(null)).toBeNull(); + expect(toFiniteNumber(undefined)).toBeNull(); + expect(toFiniteNumber("abc")).toBeNull(); + expect(toFiniteNumber(Number.NaN)).toBeNull(); + expect(toFiniteNumber(Number.POSITIVE_INFINITY)).toBeNull(); + }); + + test("returns null for unexpected non-string/number types (no .trim() crash)", () => { + expect(toFiniteNumber(true as unknown as string)).toBeNull(); + expect(toFiniteNumber({} as unknown as string)).toBeNull(); + expect(toFiniteNumber([] as unknown as string)).toBeNull(); + }); +}); + +describe("normalizeDecimalForSpreadsheet", () => { + test("strips trailing zeros so Excel parses the value as a number", () => { + // numeric(21,15) always pads to 15 decimals -> Excel sees 16 significant + // digits and falls back to text. Trimming makes it a clean number again. + expect(normalizeDecimalForSpreadsheet("1.500000000000000")).toBe("1.5"); + expect(normalizeDecimalForSpreadsheet("0.001000000000000")).toBe("0.001"); + }); + + test("caps to 15 significant digits (Excel's precision ceiling)", () => { + expect(normalizeDecimalForSpreadsheet("1.234567890123456")).toBe("1.23456789012346"); + expect(normalizeDecimalForSpreadsheet("12.3456789012345678")).toBe("12.3456789012346"); + }); + + test("preserves small values whose leading digit is 0", () => { + expect(normalizeDecimalForSpreadsheet("0.000123456789012345")).toBe("0.000123456789012345"); + }); + + test("never emits scientific notation", () => { + expect(normalizeDecimalForSpreadsheet(1e-12)).toBe("0.000000000001"); + expect(normalizeDecimalForSpreadsheet("0.000000000123456")).toBe("0.000000000123456"); + expect(normalizeDecimalForSpreadsheet(1e-12)).not.toContain("e"); + }); + + test("nullish / empty / non-finite collapse to 0", () => { + expect(normalizeDecimalForSpreadsheet(null)).toBe("0"); + expect(normalizeDecimalForSpreadsheet(undefined)).toBe("0"); + expect(normalizeDecimalForSpreadsheet("")).toBe("0"); + expect(normalizeDecimalForSpreadsheet("not-a-number")).toBe("0"); + expect(normalizeDecimalForSpreadsheet("0")).toBe("0"); + expect(normalizeDecimalForSpreadsheet(0)).toBe("0"); + }); + + test("passes through plain integers and decimals unchanged", () => { + expect(normalizeDecimalForSpreadsheet("123456.789")).toBe("123456.789"); + expect(normalizeDecimalForSpreadsheet("42")).toBe("42"); + }); +}); diff --git a/tests/unit/usage-logs/export-summary.test.ts b/tests/unit/usage-logs/export-summary.test.ts new file mode 100644 index 000000000..b0ac2309a --- /dev/null +++ b/tests/unit/usage-logs/export-summary.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildUsageLogsSummary } from "@/lib/usage-logs/export/summary"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:00:00.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: null, + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "0.5", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 100, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +describe("buildUsageLogsSummary", () => { + test("single-day data is bucketed by hour (in the system timezone)", () => { + const logs = [ + // 12:00 UTC -> 20:00 Asia/Shanghai + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z"), costUsd: "0.5" }), + makeLog({ createdAt: new Date("2026-06-03T12:30:00.000Z"), costUsd: "0.5" }), + // 13:10 UTC -> 21:10 Asia/Shanghai + makeLog({ createdAt: new Date("2026-06-03T13:10:00.000Z"), costUsd: "1" }), + ]; + const summary = buildUsageLogsSummary(logs, "Asia/Shanghai"); + + expect(summary.granularity).toBe("hourly"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03 20:00", "2026-06-03 21:00"]); + expect(summary.rows[0].requests).toBe(2); + expect(summary.rows[0].cost).toBeCloseTo(1, 10); + expect(summary.rows[1].requests).toBe(1); + expect(summary.total.requests).toBe(3); + expect(summary.total.cost).toBeCloseTo(2, 10); + expect(summary.total.inputTokens).toBe(30); + expect(summary.total.totalTokens).toBe(114); + }); + + test("multi-day data is bucketed by day", () => { + const logs = [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T18:00:00.000Z") }), + ]; + const summary = buildUsageLogsSummary(logs, "UTC"); + + expect(summary.granularity).toBe("daily"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03", "2026-06-04"]); + expect(summary.rows[1].requests).toBe(2); + expect(summary.total.requests).toBe(3); + }); + + test("day boundaries follow the timezone, not UTC", () => { + // 23:30 UTC on 06-03 is 07:30 on 06-04 in Asia/Shanghai -> two distinct days + const logs = [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-03T23:30:00.000Z") }), + ]; + const summary = buildUsageLogsSummary(logs, "Asia/Shanghai"); + expect(summary.granularity).toBe("daily"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03", "2026-06-04"]); + }); + + test("empty input yields a zeroed total and no rows", () => { + const summary = buildUsageLogsSummary([], "UTC"); + expect(summary.granularity).toBe("hourly"); + expect(summary.rows).toEqual([]); + expect(summary.total.requests).toBe(0); + expect(summary.total.cost).toBe(0); + }); + + test("invalid Date rows fall into the Unknown bucket without crashing", () => { + const summary = buildUsageLogsSummary( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date(Number.NaN) }), + makeLog({ createdAt: null }), + ], + "UTC" + ); + expect(summary.total.requests).toBe(3); + expect(summary.rows.some((r) => r.period === "Unknown")).toBe(true); + const unknown = summary.rows.find((r) => r.period === "Unknown"); + expect(unknown?.requests).toBe(2); + }); +}); diff --git a/tests/unit/usage-logs/export-xlsx.test.ts b/tests/unit/usage-logs/export-xlsx.test.ts new file mode 100644 index 000000000..a2178a69e --- /dev/null +++ b/tests/unit/usage-logs/export-xlsx.test.ts @@ -0,0 +1,199 @@ +import { strFromU8, unzipSync } from "fflate"; +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildUsageLogsXlsx, columnRef } from "@/lib/usage-logs/export/xlsx"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:34:56.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: "claude-orig", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "1.500000000000000", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 123, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +function unzip(bytes: Uint8Array): Record { + const files = unzipSync(bytes); + const out: Record = {}; + for (const [name, content] of Object.entries(files)) { + out[name] = strFromU8(content); + } + return out; +} + +/** Extract the inner XML of a cell by its A1 reference. */ +function cell(sheetXml: string, ref: string): string | null { + const match = sheetXml.match(new RegExp(`]*?(?:/>|>(.*?))`)); + if (!match) return null; + return match[0]; +} + +const COST_COL = columnRef(14); // O +const TIME_COL = columnRef(0); // A +const MODEL_COL = columnRef(4); // E +const STATUS_COL = columnRef(7); // H + +describe("buildUsageLogsXlsx", () => { + test("produces a valid two-sheet workbook package", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "UTC")); + expect(Object.keys(files)).toEqual( + expect.arrayContaining([ + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml", + "xl/worksheets/sheet1.xml", + "xl/worksheets/sheet2.xml", + ]) + ); + expect(files["xl/workbook.xml"]).toContain('name="Usage Logs"'); + }); + + test("cost is a numeric cell (not text) and normalized for Excel", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ costUsd: "1.500000000000000" })], "UTC") + ); + const costCell = cell(files["xl/worksheets/sheet1.xml"], `${COST_COL}2`) ?? ""; + expect(costCell).toContain("1.5"); + expect(costCell).not.toContain("inlineStr"); + }); + + test("16-significant-digit cost is capped to 15 digits", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ costUsd: "1.234567890123456" })], "UTC") + ); + const costCell = cell(files["xl/worksheets/sheet1.xml"], `${COST_COL}2`) ?? ""; + expect(costCell).toContain("1.23456789012346"); + }); + + test("model name is a text (inlineStr) cell, not interpreted as a formula", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ model: "=1+1" })], "UTC")); + const modelCell = cell(files["xl/worksheets/sheet1.xml"], `${MODEL_COL}2`) ?? ""; + expect(modelCell).toContain("inlineStr"); + expect(modelCell).toContain("=1+1"); + }); + + test("status code is an integer numeric cell", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ statusCode: 200 })], "UTC")); + const statusCell = cell(files["xl/worksheets/sheet1.xml"], `${STATUS_COL}2`) ?? ""; + expect(statusCell).toContain("200"); + expect(statusCell).not.toContain("inlineStr"); + }); + + test("timestamp is a real Excel date serial reflecting the system timezone", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "Asia/Shanghai")); + const sheet1 = files["xl/worksheets/sheet1.xml"]; + // header carries the timezone + expect(sheet1).toContain("Time (Asia/Shanghai)"); + + const timeCell = cell(sheet1, `${TIME_COL}2`) ?? ""; + const serial = Number(timeCell.match(/([^<]+)<\/v>/)?.[1]); + expect(Number.isFinite(serial)).toBe(true); + + // serial -> wall clock; 12:34:56 UTC is 20:34:56 in Asia/Shanghai (+08:00) + const ms = Math.round(((serial - 25569) * 86_400_000) / 1000) * 1000; + const wall = new Date(ms); + expect(wall.getUTCFullYear()).toBe(2026); + expect(wall.getUTCMonth()).toBe(5); // June + expect(wall.getUTCDate()).toBe(3); + expect(wall.getUTCHours()).toBe(20); + expect(wall.getUTCMinutes()).toBe(34); + expect(wall.getUTCSeconds()).toBe(56); + }); + + test("single-day data yields an hourly summary sheet", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z"), costUsd: "0.5" }), + makeLog({ createdAt: new Date("2026-06-03T12:30:00.000Z"), costUsd: "0.5" }), + ], + "UTC" + ) + ); + expect(files["xl/workbook.xml"]).toContain('name="Hourly Summary"'); + const summary = files["xl/worksheets/sheet2.xml"]; + expect(summary).toContain("Period"); + expect(summary).toContain("2026-06-03 12:00"); + expect(summary).toContain("Total"); + // total cost cell at column I (index 8), last data row + total row + }); + + test("multi-day data yields a daily summary sheet", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T12:00:00.000Z") }), + ], + "UTC" + ) + ); + expect(files["xl/workbook.xml"]).toContain('name="Daily Summary"'); + const summary = files["xl/worksheets/sheet2.xml"]; + expect(summary).toContain("2026-06-03"); + expect(summary).toContain("2026-06-04"); + }); + + test("does not crash on empty input", async () => { + const files = unzip(await buildUsageLogsXlsx([], "UTC")); + expect(files["xl/worksheets/sheet1.xml"]).toContain("Time (UTC)"); + expect(files["xl/worksheets/sheet2.xml"]).toContain("Total"); + }); + + test("invalid Date timestamp yields an empty cell (no crash)", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ createdAt: new Date(Number.NaN) })], "UTC") + ); + const timeCell = cell(files["xl/worksheets/sheet1.xml"], `${TIME_COL}2`) ?? ""; + expect(timeCell).toBe(``); + }); + + test("strips illegal XML characters from text cells", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ model: "gpt\uFFFE\uFFFF-x" })], "UTC")); + const modelCell = cell(files["xl/worksheets/sheet1.xml"], `${MODEL_COL}2`) ?? ""; + expect(modelCell).toContain("gpt-x"); + expect(modelCell).not.toContain("\uFFFE"); + expect(modelCell).not.toContain("\uFFFF"); + }); + + test("styles.xml declares the two OOXML-reserved fills", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "UTC")); + expect(files["xl/styles.xml"]).toContain(''); + expect(files["xl/styles.xml"]).toContain('patternType="gray125"'); + }); +});