From 2f51e24c033817bcc878687ff04406ee939a32cb Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 3 Jun 2026 21:06:11 +0800 Subject: [PATCH 1/3] feat(usage-logs): add XLSX export with summary sheet Stream every matching usage log into an XLSX workbook containing a detail sheet (mirrors the CSV) and a daily/hourly summary sheet aggregated per system timezone. Numeric columns are normalized to stay within Excel's 15-significant-digit ceiling so SUM() works, and timestamps are rendered as real Excel dates in the resolved system timezone. The existing CSV export is refactored to share column definitions and normalization logic with the XLSX path. The UI exposes both CSV and XLSX options via a dropdown. Also add i18n labels, API schema additions (format parameter), fflate dependency for ZIP compression, and comprehensive tests. --- messages/en/dashboard.json | 2 + messages/ja/dashboard.json | 2 + messages/ru/dashboard.json | 2 + messages/zh-CN/dashboard.json | 2 + messages/zh-TW/dashboard.json | 2 + package.json | 1 + src/actions/usage-logs.ts | 191 ++++++------ .../logs/_components/usage-logs-filters.tsx | 41 ++- .../api/v1/resources/usage-logs/handlers.ts | 30 +- src/lib/api-client/v1/actions/usage-logs.ts | 14 +- src/lib/api-client/v1/openapi-types.gen.ts | 6 + src/lib/api/v1/schemas/usage-logs.ts | 9 +- src/lib/usage-logs/export/columns.ts | 118 ++++++++ src/lib/usage-logs/export/csv.ts | 66 +++++ src/lib/usage-logs/export/format.ts | 29 ++ src/lib/usage-logs/export/numeric.ts | 48 +++ src/lib/usage-logs/export/summary.ts | 162 +++++++++++ src/lib/usage-logs/export/xlsx.ts | 275 ++++++++++++++++++ tests/api/v1/usage-logs/usage-logs.test.ts | 54 +++- .../usage-logs-export-retry-count.test.ts | 12 +- .../actions/usage-logs-export-xlsx.test.ts | 173 +++++++++++ tests/unit/api/v1/api-client-actions.test.ts | 38 +++ ...dashboard-logs-export-progress-ui.test.tsx | 88 +++++- tests/unit/usage-logs/export-csv.test.ts | 127 ++++++++ tests/unit/usage-logs/export-numeric.test.ts | 58 ++++ tests/unit/usage-logs/export-summary.test.ts | 101 +++++++ tests/unit/usage-logs/export-xlsx.test.ts | 177 +++++++++++ 27 files changed, 1699 insertions(+), 129 deletions(-) create mode 100644 src/lib/usage-logs/export/columns.ts create mode 100644 src/lib/usage-logs/export/csv.ts create mode 100644 src/lib/usage-logs/export/format.ts create mode 100644 src/lib/usage-logs/export/numeric.ts create mode 100644 src/lib/usage-logs/export/summary.ts create mode 100644 src/lib/usage-logs/export/xlsx.ts create mode 100644 tests/unit/actions/usage-logs-export-xlsx.test.ts create mode 100644 tests/unit/usage-logs/export-csv.test.ts create mode 100644 tests/unit/usage-logs/export-numeric.test.ts create mode 100644 tests/unit/usage-logs/export-summary.test.ts create mode 100644 tests/unit/usage-logs/export-xlsx.test.ts 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..1700b279d 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,26 @@ 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: _format, ...filters } = input; 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 +351,7 @@ export async function exportUsageLogs( } export async function startUsageLogsExport( - filters: Omit + input: UsageLogsExportInput ): Promise> { try { const session = await getSession(); @@ -348,6 +359,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 +370,7 @@ export async function startUsageLogsExport( processedRows: 0, totalRows: 0, progressPercent: 0, + format, }); if (!stored) { @@ -367,7 +380,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 +413,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 +435,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..f5d2903c1 100644 --- a/src/app/api/v1/resources/usage-logs/handlers.ts +++ b/src/app/api/v1/resources/usage-logs/handlers.ts @@ -86,6 +86,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 +135,22 @@ 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 { + content: string; + encoding: "utf8" | "base64"; + format: "csv" | "xlsx"; + filename: string; + }; + 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..1de280cf2 --- /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 } 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 value instanceof Date ? 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..83ed14a9f --- /dev/null +++ b/src/lib/usage-logs/export/format.ts @@ -0,0 +1,29 @@ +/** + * 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"; + +/** 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..f2dc290a3 --- /dev/null +++ b/src/lib/usage-logs/export/numeric.ts @@ -0,0 +1,48 @@ +/** + * 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; + } + 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..d7ffc7f90 --- /dev/null +++ b/src/lib/usage-logs/export/summary.ts @@ -0,0 +1,162 @@ +/** + * 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 { 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 (!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..1157fec4b --- /dev/null +++ b/src/lib/usage-logs/export/xlsx.ts @@ -0,0 +1,275 @@ +/** + * 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 { 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 "'"; + } + }); +} + +// Drop characters that are illegal in XML 1.0 (control bytes other than tab, +// newline, carriage return) so a stray byte in a model/endpoint string 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) { + 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 raw instanceof Date ? 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..d5733699b --- /dev/null +++ b/tests/unit/actions/usage-logs-export-xlsx.test.ts @@ -0,0 +1,173 @@ +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 ignores xlsx and still returns CSV text", async () => { + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log()], + nextCursor: null, + hasMore: false, + }); + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "xlsx" }); + 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..072aa1f4f 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -425,4 +425,42 @@ 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"); + + vi.unstubAllGlobals(); + }); + + 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); + vi.unstubAllGlobals(); + }); }); 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..49ea23cd4 --- /dev/null +++ b/tests/unit/usage-logs/export-csv.test.ts @@ -0,0 +1,127 @@ +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("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..38405d988 --- /dev/null +++ b/tests/unit/usage-logs/export-numeric.test.ts @@ -0,0 +1,58 @@ +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(); + }); +}); + +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..d9a7b5c83 --- /dev/null +++ b/tests/unit/usage-logs/export-summary.test.ts @@ -0,0 +1,101 @@ +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); + }); +}); 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..33b217554 --- /dev/null +++ b/tests/unit/usage-logs/export-xlsx.test.ts @@ -0,0 +1,177 @@ +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("Usage Logs".length ? "Time (UTC)" : ""); + expect(files["xl/worksheets/sheet2.xml"]).toContain("Total"); + }); +}); From 3873e2a06d0e4fc038235bfc76e60e3c8cc760be Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 3 Jun 2026 21:43:30 +0800 Subject: [PATCH 2/3] fix(usage-logs): harden XLSX export against edge cases and improve compliance - Add gray125 fill to styles.xml for OOXML compliance - Tighten XML 1.0 character filter to exclude surrogates and non-characters - Guard against invalid Date values in CSV, summary, and XLSX - Reject synchronous XLSX export with a clear error - Use typed download cast in v1 handler --- src/actions/usage-logs.ts | 10 +++++++- .../api/v1/resources/usage-logs/handlers.ts | 8 ++----- src/lib/usage-logs/export/csv.ts | 4 ++-- src/lib/usage-logs/export/format.ts | 9 +++++++ src/lib/usage-logs/export/numeric.ts | 3 +++ src/lib/usage-logs/export/summary.ts | 3 ++- src/lib/usage-logs/export/xlsx.ts | 21 ++++++++++------ .../actions/usage-logs-export-xlsx.test.ts | 12 ++++++++-- tests/unit/usage-logs/export-csv.test.ts | 5 ++++ tests/unit/usage-logs/export-numeric.test.ts | 6 +++++ tests/unit/usage-logs/export-summary.test.ts | 15 ++++++++++++ tests/unit/usage-logs/export-xlsx.test.ts | 24 ++++++++++++++++++- 12 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index 1700b279d..e75f71fe3 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -337,7 +337,15 @@ export async function exportUsageLogs(input: UsageLogsExportInput): Promise { c.get("auth") ); if (!result.ok) return actionError(c, result); - const download = result.data as { - content: string; - encoding: "utf8" | "base64"; - format: "csv" | "xlsx"; - filename: string; - }; + const download = result.data as UsageLogsExportDownload; const isXlsx = download.format === "xlsx"; const body = download.encoding === "base64" ? Buffer.from(download.content, "base64") : download.content; diff --git a/src/lib/usage-logs/export/csv.ts b/src/lib/usage-logs/export/csv.ts index 1de280cf2..64dd5735b 100644 --- a/src/lib/usage-logs/export/csv.ts +++ b/src/lib/usage-logs/export/csv.ts @@ -6,7 +6,7 @@ import type { UsageLogRow } from "@/repository/usage-logs"; import { buildDetailHeaders, DETAIL_COLUMNS, isBlankValue } from "./columns"; -import { formatExportTimestamp } from "./format"; +import { formatExportTimestamp, isValidDate } from "./format"; import { normalizeDecimalForSpreadsheet } from "./numeric"; export const CSV_BOM = ""; @@ -42,7 +42,7 @@ function renderCsvCell( ): string { switch (column.kind) { case "datetime": - return value instanceof Date ? formatExportTimestamp(value, timezone) : ""; + return isValidDate(value) ? formatExportTimestamp(value, timezone) : ""; case "number": if (isBlankValue(value) && !column.zeroWhenNull) { return ""; diff --git a/src/lib/usage-logs/export/format.ts b/src/lib/usage-logs/export/format.ts index 83ed14a9f..dbe4d27db 100644 --- a/src/lib/usage-logs/export/format.ts +++ b/src/lib/usage-logs/export/format.ts @@ -11,6 +11,15 @@ 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); diff --git a/src/lib/usage-logs/export/numeric.ts b/src/lib/usage-logs/export/numeric.ts index f2dc290a3..6047e9769 100644 --- a/src/lib/usage-logs/export/numeric.ts +++ b/src/lib/usage-logs/export/numeric.ts @@ -26,6 +26,9 @@ export function toFiniteNumber(value: string | number | null | undefined): numbe if (typeof value === "number") { return Number.isFinite(value) ? value : null; } + if (typeof value !== "string") { + return null; + } const trimmed = value.trim(); if (trimmed === "") { return null; diff --git a/src/lib/usage-logs/export/summary.ts b/src/lib/usage-logs/export/summary.ts index d7ffc7f90..66ca5ba97 100644 --- a/src/lib/usage-logs/export/summary.ts +++ b/src/lib/usage-logs/export/summary.ts @@ -10,6 +10,7 @@ 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"; @@ -108,7 +109,7 @@ export function createSummaryAccumulator(timezone: string): SummaryAccumulator { return { add(log) { accumulate(total, log); - if (!log.createdAt) { + if (!isValidDate(log.createdAt)) { unknown ??= emptyRow(UNKNOWN_PERIOD); accumulate(unknown, log); return; diff --git a/src/lib/usage-logs/export/xlsx.ts b/src/lib/usage-logs/export/xlsx.ts index 1157fec4b..398ecc2c8 100644 --- a/src/lib/usage-logs/export/xlsx.ts +++ b/src/lib/usage-logs/export/xlsx.ts @@ -21,7 +21,7 @@ import { type DetailColumn, isBlankValue, } from "./columns"; -import { toExcelZonedDate } from "./format"; +import { isValidDate, toExcelZonedDate } from "./format"; import { normalizeDecimalForSpreadsheet } from "./numeric"; import { createSummaryAccumulator, @@ -60,14 +60,21 @@ function escapeXml(value: string): string { }); } -// Drop characters that are illegal in XML 1.0 (control bytes other than tab, -// newline, carriage return) so a stray byte in a model/endpoint string cannot -// corrupt the whole workbook. +// 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) { + if ( + code === 0x09 || + code === 0x0a || + code === 0x0d || + (code >= 0x20 && code <= 0xd7ff) || + (code >= 0xe000 && code <= 0xfffd) || + (code >= 0x10000 && code <= 0x10ffff) + ) { out += char; } } @@ -123,7 +130,7 @@ function detailNumberStyle(column: DetailColumn): number { function detailCell(column: DetailColumn, log: UsageLogRow, ref: string, timezone: string): string { const raw = column.get(log); if (column.kind === "datetime") { - return raw instanceof Date ? dateCell(ref, toExcelZonedDate(raw, timezone)) : ``; + return isValidDate(raw) ? dateCell(ref, toExcelZonedDate(raw, timezone)) : ``; } if (column.kind === "number") { if (isBlankValue(raw) && !column.zeroWhenNull) { @@ -217,7 +224,7 @@ function summarySheetName(summary: UsageLogsSummary): string { } const STYLES_XML = ` -`; +`; const CONTENT_TYPES_XML = ` `; diff --git a/tests/unit/actions/usage-logs-export-xlsx.test.ts b/tests/unit/actions/usage-logs-export-xlsx.test.ts index d5733699b..9c9e89f17 100644 --- a/tests/unit/actions/usage-logs-export-xlsx.test.ts +++ b/tests/unit/actions/usage-logs-export-xlsx.test.ts @@ -157,14 +157,22 @@ describe("Usage logs XLSX export", () => { expect(sheet1).toContain("1.5"); }); - test("sync export ignores xlsx and still returns CSV text", async () => { + 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: "xlsx" }); + 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"); diff --git a/tests/unit/usage-logs/export-csv.test.ts b/tests/unit/usage-logs/export-csv.test.ts index 49ea23cd4..6c5dccfb3 100644 --- a/tests/unit/usage-logs/export-csv.test.ts +++ b/tests/unit/usage-logs/export-csv.test.ts @@ -93,6 +93,11 @@ describe("buildCsvRows", () => { 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( diff --git a/tests/unit/usage-logs/export-numeric.test.ts b/tests/unit/usage-logs/export-numeric.test.ts index 38405d988..82725218a 100644 --- a/tests/unit/usage-logs/export-numeric.test.ts +++ b/tests/unit/usage-logs/export-numeric.test.ts @@ -17,6 +17,12 @@ describe("toFiniteNumber", () => { 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", () => { diff --git a/tests/unit/usage-logs/export-summary.test.ts b/tests/unit/usage-logs/export-summary.test.ts index d9a7b5c83..b0ac2309a 100644 --- a/tests/unit/usage-logs/export-summary.test.ts +++ b/tests/unit/usage-logs/export-summary.test.ts @@ -98,4 +98,19 @@ describe("buildUsageLogsSummary", () => { 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 index 33b217554..a2178a69e 100644 --- a/tests/unit/usage-logs/export-xlsx.test.ts +++ b/tests/unit/usage-logs/export-xlsx.test.ts @@ -171,7 +171,29 @@ describe("buildUsageLogsXlsx", () => { test("does not crash on empty input", async () => { const files = unzip(await buildUsageLogsXlsx([], "UTC")); - expect(files["xl/worksheets/sheet1.xml"]).toContain("Usage Logs".length ? "Time (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"'); + }); }); From c3c2b8e1f9c9718943af5193208d3938de59aee6 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 4 Jun 2026 00:59:43 +0800 Subject: [PATCH 3/3] test(api): restore stubbed fetch globals via afterEach to avoid mock leak --- tests/unit/api/v1/api-client-actions.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 072aa1f4f..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 ( @@ -448,8 +453,6 @@ describe("v1 action compatibility client", () => { if (!result.ok) throw new Error("expected ok"); expect(result.data.blob).toBeInstanceOf(Blob); expect(await result.data.blob.text()).toBe("PKxlsx-bytes"); - - vi.unstubAllGlobals(); }); test("downloadUsageLogsExport surfaces a non-2xx download as an error result", async () => { @@ -461,6 +464,5 @@ describe("v1 action compatibility client", () => { const result = await usageLogs.downloadUsageLogsExport("missing"); expect(result.ok).toBe(false); - vi.unstubAllGlobals(); }); });