From 53a65fe1ecba9e75035ed28568577b0c2fc79a6c Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 14 Feb 2026 23:08:02 +0800 Subject: [PATCH 1/6] fix(proxy): extract model from Gemini Vertex AI publishers path for correct billing When Gemini requests use the Vertex AI URL format /v1/publishers/google/models/{model}:generateContent, the system failed to extract the model name, falling back to a hardcoded "gemini-2.5-flash" default and causing incorrect billing. Add publishers path regex to extractModelFromPath() and detectFormatByEndpoint() to handle this URL pattern. --- src/app/v1/_lib/proxy/format-mapper.ts | 7 ++ src/app/v1/_lib/proxy/session.ts | 8 +- .../gemini-vertex-model-extraction.test.ts | 90 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/unit/proxy/gemini-vertex-model-extraction.test.ts diff --git a/src/app/v1/_lib/proxy/format-mapper.ts b/src/app/v1/_lib/proxy/format-mapper.ts index 173d17c9c..faf17cfeb 100644 --- a/src/app/v1/_lib/proxy/format-mapper.ts +++ b/src/app/v1/_lib/proxy/format-mapper.ts @@ -61,6 +61,13 @@ export function detectFormatByEndpoint(pathname: string): ClientFormat | null { // OpenAI Chat Completions { pattern: /^\/v1\/chat\/completions$/i, format: "openai" }, + // Gemini Vertex AI (publishers path) + { + pattern: + /^\/v1\/publishers\/google\/models\/[^/:]+:(?:generateContent|streamGenerateContent|countTokens)$/i, + format: "gemini", + }, + // Gemini Direct API { pattern: /^\/v1beta\/models\/[^/:]+:(?:generateContent|streamGenerateContent|countTokens)$/i, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 22cf12dca..c6c2ba9f8 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -808,7 +808,13 @@ function optimizeRequestMessage(message: Record): Record + const publishersMatch = pathname.match(/\/publishers\/google\/models\/([^/:]+)(?::[^/]+)?/); + if (publishersMatch?.[1]) { + return publishersMatch[1]; + } + // 匹配官方 Gemini 路径:/v1beta/models/{model}: const geminiMatch = pathname.match(/\/v1beta\/models\/([^/:]+)(?::[^/]+)?/); if (geminiMatch?.[1]) { diff --git a/tests/unit/proxy/gemini-vertex-model-extraction.test.ts b/tests/unit/proxy/gemini-vertex-model-extraction.test.ts new file mode 100644 index 000000000..39c19b6b1 --- /dev/null +++ b/tests/unit/proxy/gemini-vertex-model-extraction.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { extractModelFromPath } from "@/app/v1/_lib/proxy/session"; +import { detectFormatByEndpoint } from "@/app/v1/_lib/proxy/format-mapper"; + +describe("extractModelFromPath - Vertex AI publishers path", () => { + it("extracts model from /v1/publishers/google/models/{model}:generateContent", () => { + expect( + extractModelFromPath( + "/v1/publishers/google/models/gemini-3-pro-image-preview:generateContent" + ) + ).toBe("gemini-3-pro-image-preview"); + }); + + it("extracts model from /v1/publishers/google/models/{model}:streamGenerateContent", () => { + expect( + extractModelFromPath("/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent") + ).toBe("gemini-2.5-flash"); + }); + + it("extracts model from /v1/publishers/google/models/{model}:countTokens", () => { + expect(extractModelFromPath("/v1/publishers/google/models/gemini-2.5-pro:countTokens")).toBe( + "gemini-2.5-pro" + ); + }); + + it("extracts model from path without action suffix", () => { + expect(extractModelFromPath("/v1/publishers/google/models/gemini-2.5-flash")).toBe( + "gemini-2.5-flash" + ); + }); + + // regression: existing patterns still work + it("still extracts model from /v1beta/models/{model}:generateContent", () => { + expect(extractModelFromPath("/v1beta/models/gemini-2.5-flash:generateContent")).toBe( + "gemini-2.5-flash" + ); + }); + + it("still extracts model from /v1/models/{model}:generateContent", () => { + expect(extractModelFromPath("/v1/models/gemini-2.5-pro:generateContent")).toBe( + "gemini-2.5-pro" + ); + }); + + it("returns null for unrecognized paths", () => { + expect(extractModelFromPath("/v1/messages")).toBeNull(); + expect(extractModelFromPath("/v1/chat/completions")).toBeNull(); + }); +}); + +describe("detectFormatByEndpoint - Vertex AI publishers path", () => { + it('returns "gemini" for /v1/publishers/google/models/{model}:generateContent', () => { + expect( + detectFormatByEndpoint( + "/v1/publishers/google/models/gemini-3-pro-image-preview:generateContent" + ) + ).toBe("gemini"); + }); + + it('returns "gemini" for /v1/publishers/google/models/{model}:streamGenerateContent', () => { + expect( + detectFormatByEndpoint("/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent") + ).toBe("gemini"); + }); + + it('returns "gemini" for /v1/publishers/google/models/{model}:countTokens', () => { + expect(detectFormatByEndpoint("/v1/publishers/google/models/gemini-2.5-pro:countTokens")).toBe( + "gemini" + ); + }); + + // regression: existing patterns still work + it('still returns "gemini" for /v1beta/models/ path', () => { + expect(detectFormatByEndpoint("/v1beta/models/gemini-2.5-flash:generateContent")).toBe( + "gemini" + ); + }); + + it('still returns "gemini-cli" for /v1internal/models/ path', () => { + expect(detectFormatByEndpoint("/v1internal/models/gemini-2.5-flash:generateContent")).toBe( + "gemini-cli" + ); + }); + + it("returns null for unknown publishers path actions", () => { + expect( + detectFormatByEndpoint("/v1/publishers/google/models/gemini-2.5-flash:unknownAction") + ).toBeNull(); + }); +}); From 669de4211ebd10a44edfd0c6376e12bc4e183c5d Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 15 Feb 2026 00:04:11 +0800 Subject: [PATCH 2/6] fix(proxy): correct Host header to match actual request target in standard path buildHeaders() derives Host from provider.url, but the actual fetch target (proxyUrl) may use a different host when activeEndpoint.baseUrl differs or MCP passthrough overrides the base URL. This causes undici TLS certificate validation failures. After proxyUrl is computed, re-derive Host from it. --- src/app/v1/_lib/proxy/forwarder.ts | 5 + .../proxy-forwarder-host-header-fix.test.ts | 166 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 87f866386..3759a8570 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1925,6 +1925,11 @@ export class ProxyForwarder { // buildProxyUrl() 会检测 base_url 是否已包含完整路径,避免重复拼接 proxyUrl = buildProxyUrl(effectiveBaseUrl, session.requestUrl); + // Host header must match actual request target for undici TLS cert validation + // When provider has multiple endpoints, provider.url and proxyUrl hosts may differ + const actualHost = HeaderProcessor.extractHost(proxyUrl); + processedHeaders.set("host", actualHost); + logger.debug("ProxyForwarder: Final proxy URL", { url: proxyUrl, originalPath: session.requestUrl.pathname, diff --git a/tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts b/tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts new file mode 100644 index 000000000..6621aeb6a --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import type { Provider } from "@/types/provider"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { HeaderProcessor } from "@/app/v1/_lib/headers"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +function createSession({ + userAgent, + headers, +}: { + userAgent: string | null; + headers: Headers; +}): ProxySession { + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://example.com/v1/messages"), + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { message: {}, log: "" }, + userAgent, + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: null, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: (key: string) => { + const original = session.originalHeaders?.get(key); + const current = session.headers.get(key); + return original !== current; + }, + }); + + return session as any; +} + +describe("ProxyForwarder - Host header correction for multi-endpoint providers", () => { + it("buildHeaders sets Host from provider.url, which may differ from actual target", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.anthropic.com/v1", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const resultHeaders = buildHeaders(session, provider); + + // buildHeaders uses provider.url for Host + expect(resultHeaders.get("host")).toBe("api.anthropic.com"); + }); + + it("Host header must be corrected when activeEndpoint baseUrl differs from provider.url", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.anthropic.com/v1", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const processedHeaders = buildHeaders(session, provider); + + // Initial Host from provider.url + expect(processedHeaders.get("host")).toBe("api.anthropic.com"); + + // Simulate: activeEndpoint has a different baseUrl (e.g. regional endpoint) + const proxyUrl = "https://eu-west.anthropic.com/v1/messages"; + const actualHost = HeaderProcessor.extractHost(proxyUrl); + processedHeaders.set("host", actualHost); + + // After correction, Host matches actual target + expect(processedHeaders.get("host")).toBe("eu-west.anthropic.com"); + }); + + it("Host header must be corrected when MCP passthrough URL differs from provider.url", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.minimaxi.com/anthropic", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const processedHeaders = buildHeaders(session, provider); + + // Initial Host from provider.url (includes /anthropic path) + expect(processedHeaders.get("host")).toBe("api.minimaxi.com"); + + // MCP passthrough: base domain extraction strips path, URL stays same host + // But if mcpPassthroughUrl points to a different host: + const mcpProxyUrl = "https://mcp.minimaxi.com/v1/tools/list"; + const actualHost = HeaderProcessor.extractHost(mcpProxyUrl); + processedHeaders.set("host", actualHost); + + expect(processedHeaders.get("host")).toBe("mcp.minimaxi.com"); + }); + + it("Host header remains correct when provider.url and proxyUrl share the same host", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.anthropic.com/v1", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const processedHeaders = buildHeaders(session, provider); + + // Same host, correction is a no-op + const proxyUrl = "https://api.anthropic.com/v1/messages"; + const actualHost = HeaderProcessor.extractHost(proxyUrl); + processedHeaders.set("host", actualHost); + + expect(processedHeaders.get("host")).toBe("api.anthropic.com"); + }); + + it("Host header handles port numbers correctly", () => { + const proxyUrl = "https://api.example.com:8443/v1/messages"; + const host = HeaderProcessor.extractHost(proxyUrl); + expect(host).toBe("api.example.com:8443"); + }); +}); From 404001821cdba5178644d56f1dc38c782b405d65 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 15 Feb 2026 11:45:55 +0800 Subject: [PATCH 3/6] perf(logs): hide stats summary panel when no filters are active Skip rendering UsageLogsStatsPanel and its aggregation query when all filter conditions are empty, preventing full-table scans that cause CPU overload. --- .../logs/_components/usage-logs-view.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx index 4f6eba172..cfa268a3c 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx @@ -159,25 +159,27 @@ export function UsageLogsView({ router.push(`/dashboard/logs?${query.toString()}`); }; + const statsFilters = { + userId: filters.userId, + keyId: filters.keyId, + providerId: filters.providerId, + sessionId: filters.sessionId, + startTime: filters.startTime, + endTime: filters.endTime, + statusCode: filters.statusCode, + excludeStatusCode200: filters.excludeStatusCode200, + model: filters.model, + endpoint: filters.endpoint, + minRetryCount: filters.minRetryCount, + }; + + const hasStatsFilters = Object.values(statsFilters).some((v) => v !== undefined && v !== false); + return (
- {/* 可折叠统计面板 - 默认折叠,按需加载 */} - + {hasStatsFilters && ( + + )} {/* 筛选器 */} From f6a0b47841335980442a232d471d5ea39ac992e7 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:27:57 +0800 Subject: [PATCH 4/6] fix(proxy): remove deterministic session ID to prevent collision across conversations (#793) generateDeterministicSessionId() hashes (UA, IP, API key prefix) with no time dimension, producing identical session IDs for the same user hours apart. This merges unrelated conversations into one session, polluting usage logs, session tracking, and concurrent session limits. The existing fallback in getOrCreateSessionId() (content hash -> random ID) already provides correct session continuity without collision risk. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/app/v1/_lib/proxy/session-guard.ts | 11 +++---- src/app/v1/_lib/proxy/session.ts | 33 ------------------- tests/unit/proxy/metadata-injection.test.ts | 15 --------- .../session-guard-warmup-intercept.test.ts | 3 -- 4 files changed, 5 insertions(+), 57 deletions(-) diff --git a/src/app/v1/_lib/proxy/session-guard.ts b/src/app/v1/_lib/proxy/session-guard.ts index 069ae05e6..b2685555a 100644 --- a/src/app/v1/_lib/proxy/session-guard.ts +++ b/src/app/v1/_lib/proxy/session-guard.ts @@ -85,12 +85,11 @@ export class ProxySessionGuard { systemSettings.interceptAnthropicWarmupRequests; // 1. 尝试从客户端提取 session_id(metadata.session_id) - const clientSessionId = - SessionManager.extractClientSessionId( - session.request.message, - session.headers, - session.userAgent - ) || session.generateDeterministicSessionId(); + const clientSessionId = SessionManager.extractClientSessionId( + session.request.message, + session.headers, + session.userAgent + ); // 2. 获取 messages 数组 const messages = session.getMessages(); diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index c6c2ba9f8..a163c772d 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; @@ -352,38 +351,6 @@ export class ProxySession { return this.providersSnapshot; } - /** - * 生成基于请求指纹的确定性 Session ID - * - * 优先级与参考实现一致: - * - API Key 前缀(x-api-key / x-goog-api-key 的前10位) - * - User-Agent - * - 客户端 IP(x-forwarded-for / x-real-ip) - * - * 当客户端未提供 metadata.session_id 时,可用于稳定绑定会话。 - */ - generateDeterministicSessionId(): string | null { - const apiKeyHeader = this.headers.get("x-api-key") || this.headers.get("x-goog-api-key"); - const apiKeyPrefix = apiKeyHeader ? apiKeyHeader.substring(0, 10) : null; - - const userAgent = this.headers.get("user-agent"); - - // 取链路上的首个 IP - const forwardedFor = this.headers.get("x-forwarded-for"); - const realIp = this.headers.get("x-real-ip"); - const ip = - forwardedFor?.split(",").map((ip) => ip.trim())[0] || (realIp ? realIp.trim() : null); - - const parts = [userAgent, ip, apiKeyPrefix].filter(Boolean); - if (parts.length === 0) { - return null; - } - - const hash = crypto.createHash("sha256").update(parts.join(":"), "utf8").digest("hex"); - // 格式对齐为 sess_{8位}_{12位} - return `sess_${hash.substring(0, 8)}_${hash.substring(8, 20)}`; - } - /** * 获取 messages 数组长度(支持 Claude、Codex 和 Gemini 格式) */ diff --git a/tests/unit/proxy/metadata-injection.test.ts b/tests/unit/proxy/metadata-injection.test.ts index f783bd073..c000d1f43 100644 --- a/tests/unit/proxy/metadata-injection.test.ts +++ b/tests/unit/proxy/metadata-injection.test.ts @@ -128,18 +128,3 @@ describe("injectClaudeMetadataUserId", () => { expect(metadata.user_id).toMatch(/^user_[a-f0-9]{64}_account__session_sess_abc123$/); }); }); - -describe("ProxySession.generateDeterministicSessionId", () => { - it("输出格式应匹配 sess_{8hex}_{12hex}", () => { - const session = Object.create(ProxySession.prototype) as ProxySession; - (session as Record).headers = new Headers([ - ["x-api-key", "sk-test-abcdef123456"], - ["user-agent", "Vitest/1.0"], - ["x-forwarded-for", "203.0.113.1"], - ]); - - const deterministicSessionId = session.generateDeterministicSessionId(); - - expect(deterministicSessionId).toMatch(/^sess_[a-f0-9]{8}_[a-f0-9]{12}$/); - }); -}); diff --git a/tests/unit/proxy/session-guard-warmup-intercept.test.ts b/tests/unit/proxy/session-guard-warmup-intercept.test.ts index 9d3660566..f7443b936 100644 --- a/tests/unit/proxy/session-guard-warmup-intercept.test.ts +++ b/tests/unit/proxy/session-guard-warmup-intercept.test.ts @@ -81,9 +81,6 @@ function createMockSession(overrides: Partial = {}): ProxySession getRequestSequence() { return this.requestSequence ?? 1; }, - generateDeterministicSessionId() { - return "deterministic_session_id"; - }, getMessages() { return []; }, From 29d05b2219516609e1ba29830d4197eb062629a2 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 15 Feb 2026 21:36:49 +0800 Subject: [PATCH 5/6] perf(logs): hide stats panel in virtualized view when no filters active Apply the same hasStatsFilters guard from the old view to the virtualized logs view, preventing an unconditional full-table aggregation query on page load. Also remove the unused legacy usage-logs-view.tsx which is no longer imported anywhere. --- .../usage-logs-view-virtualized.tsx | 35 +-- .../logs/_components/usage-logs-view.tsx | 264 ------------------ 2 files changed, 19 insertions(+), 280 deletions(-) delete mode 100644 src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 6e17b6934..d57980e91 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -251,26 +251,29 @@ function UsageLogsViewContent({ }; }, []); + const statsFilters = { + userId: filters.userId, + keyId: filters.keyId, + providerId: filters.providerId, + sessionId: filters.sessionId, + startTime: filters.startTime, + endTime: filters.endTime, + statusCode: filters.statusCode, + excludeStatusCode200: filters.excludeStatusCode200, + model: filters.model, + endpoint: filters.endpoint, + minRetryCount: filters.minRetryCount, + }; + + const hasStatsFilters = Object.values(statsFilters).some((v) => v !== undefined && v !== false); + return ( <>
{/* Stats Summary - Collapsible */} - + {hasStatsFilters && ( + + )} {/* Filter Criteria */} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx deleted file mode 100644 index cfa268a3c..000000000 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -import { Pause, Play, RefreshCw } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useRef, useState, useTransition } from "react"; -import { getUsageLogs } from "@/actions/usage-logs"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useVisibilityPolling } from "@/hooks/use-visibility-polling"; -import type { CurrencyCode } from "@/lib/utils/currency"; -import type { UsageLogsResult } from "@/repository/usage-logs"; -import type { Key } from "@/types/key"; -import type { ProviderDisplay } from "@/types/provider"; -import type { BillingModelSource } from "@/types/system-config"; -import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; -import { UsageLogsFilters } from "./usage-logs-filters"; -import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; -import { UsageLogsTable } from "./usage-logs-table"; - -interface UsageLogsViewProps { - isAdmin: boolean; - providers: ProviderDisplay[]; - initialKeys: Key[]; - searchParams: { [key: string]: string | string[] | undefined }; - currencyCode?: CurrencyCode; - billingModelSource?: BillingModelSource; - serverTimeZone?: string; -} - -export function UsageLogsView({ - isAdmin, - providers, - initialKeys, - searchParams, - currencyCode = "USD", - billingModelSource = "original", - serverTimeZone, -}: UsageLogsViewProps) { - const t = useTranslations("dashboard"); - const router = useRouter(); - const params = useSearchParams(); - const [isPending, startTransition] = useTransition(); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [isAutoRefresh, setIsAutoRefresh] = useState(true); - const [isManualRefreshing, setIsManualRefreshing] = useState(false); - - // 追踪新增记录(用于动画高亮) - const [newLogIds, setNewLogIds] = useState>(new Set()); - const previousLogsRef = useRef>(new Map()); - const previousParamsRef = useRef(""); - - // 从 URL 参数解析筛选条件 - // 使用毫秒时间戳传递时间,避免时区问题 - const parsedFilters = parseLogsUrlFilters(searchParams); - const filters = { ...parsedFilters, page: parsedFilters.page ?? 1 } as const; - - // 使用 ref 来存储最新的值,避免闭包陷阱 - const isPendingRef = useRef(isPending); - const filtersRef = useRef(filters); - const isAutoRefreshRef = useRef(isAutoRefresh); - - isPendingRef.current = isPending; - - // 更新 filtersRef - filtersRef.current = filters; - isAutoRefreshRef.current = isAutoRefresh; - - // 加载数据 - // shouldDetectNew: 是否检测新增记录(只在刷新时为 true,筛选/翻页时为 false) - const loadData = useCallback( - async (shouldDetectNew = false) => { - startTransition(async () => { - const result = await getUsageLogs(filtersRef.current); - if (result.ok && result.data) { - // 只在刷新时检测新增(非筛选/翻页) - if (shouldDetectNew && previousLogsRef.current.size > 0) { - const newIds = result.data.logs - .filter((log) => !previousLogsRef.current.has(log.id)) - .map((log) => log.id) - .slice(0, 10); // 限制最多高亮 10 条 - - if (newIds.length > 0) { - setNewLogIds(new Set(newIds)); - // 800ms 后清除高亮 - setTimeout(() => setNewLogIds(new Set()), 800); - } - } - - // 更新记录缓存 - previousLogsRef.current = new Map(result.data.logs.map((log) => [log.id, true])); - - setData(result.data); - setError(null); - } else { - setError(!result.ok && "error" in result ? result.error : t("logs.error.loadFailed")); - setData(null); - } - }); - }, - [t] - ); - - // 手动刷新(检测新增) - const handleManualRefresh = async () => { - setIsManualRefreshing(true); - await loadData(true); // 刷新时检测新增 - setTimeout(() => setIsManualRefreshing(false), 500); - }; - - // 监听 URL 参数变化(筛选/翻页时重置缓存) - useEffect(() => { - const currentParams = params.toString(); - - // 获取当前页码,如果页码 > 1 则自动暂停自动刷新 - // 避免新数据进入导致用户漏掉中间记录 (Issue #332) - const currentPage = parseInt(params.get("page") || "1", 10); - if (currentPage > 1 && isAutoRefreshRef.current) { - setIsAutoRefresh(false); - } - - if (previousParamsRef.current && previousParamsRef.current !== currentParams) { - // URL 变化 = 用户操作(筛选/翻页),重置缓存,不检测新增 - previousLogsRef.current = new Map(); - loadData(false); - } else if (!previousParamsRef.current) { - // 首次加载,不检测新增 - loadData(false); - } - - previousParamsRef.current = currentParams; - }, [params, loadData]); - - // 自动轮询(5秒间隔,带 Page Visibility API 支持) - // 页面不可见时暂停轮询,重新可见时立即刷新并恢复轮询 - const handlePolling = useCallback(() => { - // 如果正在加载,跳过本次轮询 - if (isPendingRef.current) return; - loadData(true); // 自动刷新时检测新增 - }, [loadData]); - - useVisibilityPolling(handlePolling, { - intervalMs: 5000, // 5 秒间隔(统一轮询周期) - enabled: isAutoRefresh, - executeOnVisible: true, // 页面重新可见时立即刷新 - }); - - // 处理筛选条件变更 - const handleFilterChange = (newFilters: Omit) => { - const query = buildLogsUrlQuery(newFilters); - router.push(`/dashboard/logs?${query.toString()}`); - }; - - // 处理分页 - const handlePageChange = (page: number) => { - const query = new URLSearchParams(params.toString()); - query.set("page", page.toString()); - router.push(`/dashboard/logs?${query.toString()}`); - }; - - const statsFilters = { - userId: filters.userId, - keyId: filters.keyId, - providerId: filters.providerId, - sessionId: filters.sessionId, - startTime: filters.startTime, - endTime: filters.endTime, - statusCode: filters.statusCode, - excludeStatusCode200: filters.excludeStatusCode200, - model: filters.model, - endpoint: filters.endpoint, - minRetryCount: filters.minRetryCount, - }; - - const hasStatsFilters = Object.values(statsFilters).some((v) => v !== undefined && v !== false); - - return ( -
- {hasStatsFilters && ( - - )} - - {/* 筛选器 */} - - - {t("title.filterCriteria")} - - - router.push("/dashboard/logs")} - serverTimeZone={serverTimeZone} - /> - - - - {/* 数据表格 */} - - -
- {t("title.usageLogs")} -
- {/* 手动刷新按钮 */} - - - {/* 自动刷新开关 */} - -
-
-
- - {error ? ( -
{error}
- ) : !data ? ( -
{t("logs.stats.loading")}
- ) : ( - - )} -
-
-
- ); -} From 084c940b533b9f7808f35a31ef3045b8dc2fcf00 Mon Sep 17 00:00:00 2001 From: miraserver <20286838+miraserver@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:27:24 +0300 Subject: [PATCH 6/6] fix(my-usage): UX improvements for quota and statistics cards (#794) * style(my-usage): use Badge for provider group values Co-Authored-By: Claude Opus 4.6 * fix(my-usage): use currency symbol instead of code in quota cards Replace manual `${currency} ${num.toFixed(2)}` formatting with `formatCurrency()` so quota values display "$3.50" instead of "USD 3.50", consistent with all other currency displays in the app. Co-Authored-By: Claude Opus 4.6 * style(my-usage): replace unlimited text with infinity icon in quota cards Co-Authored-By: Claude Opus 4.6 * fix(my-usage): paginate model breakdown in statistics summary card Co-Authored-By: Claude Opus 4.6 * chore(my-usage): suppress biome exhaustive-deps for intentional stats reset Co-Authored-By: Claude Opus 4.6 * fix(my-usage): address PR #794 review comments - Fix abbreviateModel/abbreviateClient crash on empty split parts - Fix pagination reset on auto-refresh by using dateRange deps - Restore noData fallback in model breakdown columns - Add i18n for pagination controls with aria-labels (5 langs) - Fix quota label overflow for long translations (w-8 -> w-auto) - Rename Infinity -> InfinityIcon to avoid shadowing global - Remove redundant span wrappers in TooltipTrigger asChild Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.6 --- messages/en/myUsage.json | 3 + messages/ja/myUsage.json | 3 + messages/ru/myUsage.json | 3 + messages/zh-CN/myUsage.json | 3 + messages/zh-TW/myUsage.json | 3 + .../_components/collapsible-quota-card.tsx | 8 +- .../_components/provider-group-info.tsx | 125 +++++++++-- .../my-usage/_components/quota-cards.tsx | 199 +++++++++--------- .../_components/statistics-summary-card.tsx | 150 +++++++++---- 9 files changed, 340 insertions(+), 157 deletions(-) diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index 0ebe076a2..0e39e8496 100644 --- a/messages/en/myUsage.json +++ b/messages/en/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "Key", "userStats": "User", "noData": "No data for selected period", + "breakdownPrevPage": "Previous page", + "breakdownNextPage": "Next page", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "Unknown", "modal": { "requests": "Requests", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 4d0b1bb7e..901e10ab6 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "キー", "userStats": "ユーザー", "noData": "選択期間のデータがありません", + "breakdownPrevPage": "前のページ", + "breakdownNextPage": "次のページ", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "不明", "modal": { "requests": "リクエスト", diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index bb3b61bd7..5ccfec871 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "Ключ", "userStats": "Пользователь", "noData": "Нет данных за выбранный период", + "breakdownPrevPage": "Предыдущая страница", + "breakdownNextPage": "Следующая страница", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "Неизвестно", "modal": { "requests": "Запросов", diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 9eaaf7925..6cf939337 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "密钥", "userStats": "用户", "noData": "所选时段无数据", + "breakdownPrevPage": "上一页", + "breakdownNextPage": "下一页", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "未知", "modal": { "requests": "请求", diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index b5247a160..f803a617b 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "金鑰", "userStats": "使用者", "noData": "所選時段無資料", + "breakdownPrevPage": "上一頁", + "breakdownNextPage": "下一頁", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "不明", "modal": { "requests": "請求", diff --git a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx index ee7e0db50..4d172d1fd 100644 --- a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx +++ b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertTriangle, ChevronDown, Infinity, PieChart } from "lucide-react"; +import { AlertTriangle, ChevronDown, Infinity as InfinityIcon, PieChart } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import type { MyUsageQuota } from "@/actions/my-usage"; @@ -94,7 +94,7 @@ export function CollapsibleQuotaCard({
{t("daily")}: {dailyPct === null ? ( - + ) : ( <> @@ -108,7 +108,7 @@ export function CollapsibleQuotaCard({
{t("monthly")}: {monthlyPct === null ? ( - + ) : ( <> @@ -122,7 +122,7 @@ export function CollapsibleQuotaCard({
{t("total")}: {totalPct === null ? ( - + ) : ( <> diff --git a/src/app/[locale]/my-usage/_components/provider-group-info.tsx b/src/app/[locale]/my-usage/_components/provider-group-info.tsx index fab11b69b..227087f1b 100644 --- a/src/app/[locale]/my-usage/_components/provider-group-info.tsx +++ b/src/app/[locale]/my-usage/_components/provider-group-info.tsx @@ -2,8 +2,65 @@ import { Layers, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +function abbreviateModel(name: string): string { + const parts = name.split("-").filter(Boolean); + + if (parts.length === 1) { + return parts[0].length <= 4 ? parts[0].toUpperCase() : parts[0].slice(0, 2).toUpperCase(); + } + + const letterParts: string[] = []; + let versionMixed = ""; + const versionNums: string[] = []; + + for (const part of parts) { + if (/^\d{8,}$/.test(part)) continue; + if (/^[a-zA-Z]+$/.test(part)) { + letterParts.push(part); + } else if (/^\d+\.\d+$/.test(part)) { + versionMixed = part; + } else if (/^\d+[a-zA-Z]/.test(part)) { + versionMixed = part; + } else if (/^\d+$/.test(part)) { + versionNums.push(part); + } else { + letterParts.push(part); + } + } + + const prefix = letterParts + .slice(0, 3) + .map((w) => w[0].toUpperCase()) + .join(""); + + let version = ""; + if (versionMixed) { + version = versionMixed; + } else if (versionNums.length > 0) { + version = versionNums.slice(0, 2).join("."); + } + + if (version && prefix) { + return `${prefix}-${version}`; + } + return prefix || name.toUpperCase().substring(0, 3); +} + +function abbreviateClient(name: string): string { + const parts = name.split(/[-\s]+/).filter(Boolean); + if (parts.length === 1) { + return name.slice(0, 2).toUpperCase(); + } + return parts + .slice(0, 3) + .map((w) => w[0].toUpperCase()) + .join(""); +} + interface ProviderGroupInfoProps { keyProviderGroup: string | null; userProviderGroup: string | null; @@ -26,10 +83,8 @@ export function ProviderGroupInfo({ const userDisplay = userProviderGroup ?? tGroup("allProviders"); const inherited = !keyProviderGroup && !!userProviderGroup; - const modelsDisplay = - userAllowedModels.length > 0 ? userAllowedModels.join(", ") : tRestrictions("noRestrictions"); - const clientsDisplay = - userAllowedClients.length > 0 ? userAllowedClients.join(", ") : tRestrictions("noRestrictions"); + const hasModels = userAllowedModels.length > 0; + const hasClients = userAllowedClients.length > 0; return (
{tGroup("title")}
-
- {tGroup("keyGroup")}: - {keyDisplay} +
+ {tGroup("keyGroup")}: + + {keyDisplay} + {inherited && ( ({tGroup("inheritedFromUser")}) )}
-
- {tGroup("userGroup")}: - {userDisplay} +
+ {tGroup("userGroup")}: + + {userDisplay} +
@@ -66,13 +125,47 @@ export function ProviderGroupInfo({ {tRestrictions("title")}
-
- {tRestrictions("models")}: - {modelsDisplay} +
+ + {tRestrictions("models")}: + + {hasModels ? ( + userAllowedModels.map((name) => ( + + + + {abbreviateModel(name)} + + + {name} + + )) + ) : ( + + {tRestrictions("noRestrictions")} + + )}
-
- {tRestrictions("clients")}: - {clientsDisplay} +
+ + {tRestrictions("clients")}: + + {hasClients ? ( + userAllowedClients.map((name) => ( + + + + {abbreviateClient(name)} + + + {name} + + )) + ) : ( + + {tRestrictions("noRestrictions")} + + )}
diff --git a/src/app/[locale]/my-usage/_components/quota-cards.tsx b/src/app/[locale]/my-usage/_components/quota-cards.tsx index 68a8a3a30..9f7496fc9 100644 --- a/src/app/[locale]/my-usage/_components/quota-cards.tsx +++ b/src/app/[locale]/my-usage/_components/quota-cards.tsx @@ -1,13 +1,14 @@ "use client"; +import { Infinity as InfinityIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; import type { MyUsageQuota } from "@/actions/my-usage"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import type { CurrencyCode } from "@/lib/utils"; import { cn } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/currency"; import { calculateUsagePercent, isUnlimited } from "@/lib/utils/limit-helpers"; interface QuotaCardsProps { @@ -79,144 +80,146 @@ export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: Quo } return ( -
-
- {items.map((item) => { - const keyPct = calculateUsagePercent(item.keyCurrent, item.keyLimit); - const userPct = calculateUsagePercent(item.userCurrent ?? 0, item.userLimit); - - const keyTone = getTone(keyPct); - const userTone = getTone(userPct); - const hasUserData = item.userLimit !== null || item.userCurrent !== null; - - return ( - - - - {item.title} - - - -
- - -
-
-
- ); - })} - {items.length === 0 && !loading ? ( - - - {t("empty")} - - - ) : null} -
+
+ {items.map((item) => { + const isCurrency = item.key !== "concurrent"; + const currency = isCurrency ? currencyCode : undefined; + + return ( + + ); + })} + {items.length === 0 && !loading ? ( +
+ {t("empty")} +
+ ) : null}
); } -function QuotaCardsSkeleton({ label }: { label: string }) { +function QuotaBlock({ + title, + keyCurrent, + keyLimit, + userCurrent, + userLimit, + currency, +}: { + title: string; + keyCurrent: number; + keyLimit: number | null; + userCurrent: number; + userLimit: number | null; + currency?: CurrencyCode; +}) { + const t = useTranslations("myUsage.quota"); + + const keyPct = calculateUsagePercent(keyCurrent, keyLimit); + const userPct = calculateUsagePercent(userCurrent, userLimit); + return ( -
-
- {Array.from({ length: 6 }).map((_, index) => ( - - - - - -
- - -
-
-
- ))} -
-
- - {label} -
+
+
{title}
+ +
); } -function QuotaColumn({ +function QuotaRow({ label, current, limit, percent, - tone, currency, - muted = false, }: { label: string; current: number; limit: number | null; percent: number | null; - tone: "default" | "warn" | "danger"; - currency?: string; - muted?: boolean; + currency?: CurrencyCode; }) { const t = useTranslations("myUsage.quota"); + const unlimited = isUnlimited(limit); + const tone = getTone(percent); const formatValue = (value: number) => { const num = Number(value); - if (!Number.isFinite(num)) { - return currency ? `${currency} 0.00` : "0"; - } - return currency ? `${currency} ${num.toFixed(2)}` : String(num); + if (!Number.isFinite(num)) return currency ? formatCurrency(0, currency) : "0"; + return currency ? formatCurrency(num, currency) : String(num); }; - const unlimited = isUnlimited(limit); + const limitDisplay = unlimited ? t("unlimited") : formatValue(limit as number); + const ariaLabel = `${label}: ${formatValue(current)}${!unlimited ? ` / ${limitDisplay}` : ""}`; - const progressClass = cn("h-2", { + const progressClass = cn("h-1.5 flex-1", { "bg-destructive/10 [&>div]:bg-destructive": tone === "danger", "bg-amber-500/10 [&>div]:bg-amber-500": tone === "warn", }); - const limitDisplay = unlimited ? t("unlimited") : formatValue(limit as number); - const ariaLabel = `${label}: ${formatValue(current)}${!unlimited ? ` / ${limitDisplay}` : ""}`; - return ( -
- {/* Label */} -
{label}
- - {/* Values - split into two lines to avoid overlap */} -
-
{formatValue(current)}
-
/ {limitDisplay}
-
- - {/* Progress bar or placeholder */} +
+ + {label} + {!unlimited ? ( ) : (
)} + + {formatValue(current)} + + {" / "} + {unlimited ? : limitDisplay} + + +
+ ); +} + +function QuotaCardsSkeleton({ label }: { label: string }) { + return ( +
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ + + +
+ ))} +
+
+ + {label} +
); } diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index 1d0052018..a73947cea 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -6,6 +6,8 @@ import { ArrowDownRight, ArrowUpRight, BarChart3, + ChevronLeft, + ChevronRight, Coins, Database, Hash, @@ -15,7 +17,11 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; -import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage"; +import { + getMyStatsSummary, + type ModelBreakdownItem, + type MyStatsSummary, +} from "@/actions/my-usage"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -110,9 +116,27 @@ export function StatisticsSummaryCard({ setDateRange(range); }, []); + const [breakdownPage, setBreakdownPage] = useState(1); + + // Reset breakdown page when date range changes + // biome-ignore lint/correctness/useExhaustiveDependencies: deps used as reset trigger on date range change + useEffect(() => { + setBreakdownPage(1); + }, [dateRange.startDate, dateRange.endDate]); + const isLoading = loading || refreshing; const currencyCode = stats?.currencyCode ?? "USD"; + const maxBreakdownLen = Math.max( + stats?.keyModelBreakdown.length ?? 0, + stats?.userModelBreakdown.length ?? 0 + ); + const breakdownTotalPages = Math.ceil(maxBreakdownLen / MODEL_BREAKDOWN_PAGE_SIZE); + const sliceStart = (breakdownPage - 1) * MODEL_BREAKDOWN_PAGE_SIZE; + const sliceEnd = breakdownPage * MODEL_BREAKDOWN_PAGE_SIZE; + const keyPageItems = stats?.keyModelBreakdown.slice(sliceStart, sliceEnd) ?? []; + const userPageItems = stats?.userModelBreakdown.slice(sliceStart, sliceEnd) ?? []; + return ( @@ -220,60 +244,71 @@ export function StatisticsSummaryCard({

{t("modelBreakdown")}

- {/* Key Stats */}

{t("keyStats")}

- {stats.keyModelBreakdown.length > 0 ? ( -
- {stats.keyModelBreakdown.map((item, index) => ( - - ))} -
+ {keyPageItems.length > 0 ? ( + ) : ( -

{t("noData")}

+

{t("noData")}

)}
- {/* User Stats */}

{t("userStats")}

- {stats.userModelBreakdown.length > 0 ? ( -
- {stats.userModelBreakdown.map((item, index) => ( - - ))} -
+ {userPageItems.length > 0 ? ( + ) : ( -

{t("noData")}

+

{t("noData")}

)}
+ + {breakdownTotalPages > 1 && ( +
+ + + {t("breakdownPageIndicator", { + current: breakdownPage, + total: breakdownTotalPages, + })} + + +
+ )}
) : ( @@ -284,6 +319,43 @@ export function StatisticsSummaryCard({ ); } +const MODEL_BREAKDOWN_PAGE_SIZE = 5; + +interface ModelBreakdownColumnProps { + pageItems: ModelBreakdownItem[]; + currencyCode: CurrencyCode; + totalCost: number; + keyPrefix: string; + pageOffset: number; +} + +function ModelBreakdownColumn({ + pageItems, + currencyCode, + totalCost, + keyPrefix, + pageOffset, +}: ModelBreakdownColumnProps) { + return ( +
+ {pageItems.map((item, index) => ( + + ))} +
+ ); +} + interface ModelBreakdownRowProps { model: string | null; requests: number;