From b82ca39e77bdec839f78ab682926ce42ecbadf0b Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:52:10 +0800 Subject: [PATCH 1/4] fix(session): decode canonical detail route identities (#1388) --- .../session-messages-client-actions.test.tsx | 26 +++++++++++++++++-- .../_components/session-messages-client.tsx | 13 ++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx index 518992188..4ed72dc42 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx @@ -28,9 +28,10 @@ vi.mock("next-intl", () => { }); let seqParamValue: string | null = null; +let sessionIdParamValue = "0123456789abcdef"; vi.mock("next/navigation", () => { return { - useParams: () => ({ sessionId: "0123456789abcdef" }), + useParams: () => ({ sessionId: sessionIdParamValue }), useSearchParams: () => ({ get: (key: string) => { if (key !== "seq") return null; @@ -57,7 +58,7 @@ vi.mock("@/i18n/routing", () => { const getSessionDetailsMock = vi.fn(); const terminateActiveSessionMock = vi.fn(); -vi.mock("@/actions/active-sessions", () => { +vi.mock("@/lib/api-client/v1/actions/active-sessions", () => { return { getSessionDetails: (...args: unknown[]) => getSessionDetailsMock(...args), terminateActiveSession: (...args: unknown[]) => terminateActiveSessionMock(...args), @@ -267,9 +268,30 @@ afterEach(() => { routerBackMock.mockReset(); vi.useRealTimers(); seqParamValue = null; + sessionIdParamValue = "0123456789abcdef"; }); describe("SessionMessagesClient (request export actions)", () => { + test("decodes an URL-encoded canonical Session ID before loading details", async () => { + sessionIdParamValue = "pfx%3A9d403aeabe1f236d%3A1ee9a5d1bd4d98ce4bed39daca4b943e"; + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: buildDetailsData(), + }); + + const { unmount } = renderClient(); + await flushEffects(); + + expect(getSessionDetailsMock).toHaveBeenCalledWith( + "pfx:9d403aeabe1f236d:1ee9a5d1bd4d98ce4bed39daca4b943e", + undefined, + undefined, + undefined + ); + + unmount(); + }); + test("selected seq in URL overrides currentSequence for request export", async () => { seqParamValue = "3"; getSessionDetailsMock.mockResolvedValue({ diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index 6a0d96638..22e3c18a3 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -53,15 +53,24 @@ import { SessionMessagesDetailsTabs } from "./session-details-tabs"; import { hasSnapshotData } from "./session-messages-guards"; import { SessionStats } from "./session-stats"; +function normalizeCanonicalSessionRouteParam(sessionId: string): string { + try { + const decoded = decodeURIComponent(sessionId); + return decoded.startsWith("pfx:") || decoded.startsWith("sid:") ? decoded : sessionId; + } catch { + return sessionId; + } +} + export function SessionMessagesClient() { const t = useTranslations("dashboard.sessions"); const tErrors = useTranslations("errors"); - const params = useParams(); + const params = useParams<{ sessionId: string }>(); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); - const sessionId = params.sessionId as string; + const sessionId = normalizeCanonicalSessionRouteParam(params.sessionId); // URL state const seqParam = searchParams.get("seq"); From 6421bcfddf5b6b3b9d22d2156e588913e5ff2c94 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:14:37 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(leaderboard):=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=B3=BB=E6=95=B0=E5=88=97=E5=A2=9E=E5=8A=A0=20tooltip=20?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E5=B1=82=E4=B8=8A=E8=89=B2=20(#1389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(leaderboard): add tooltip and tiered coloring to cache coefficient column - Add headerTooltip support to leaderboard table headers with a help icon - Explain cache coefficient: higher value means fewer provider account switches and less noticeable cache degradation - Tiered coloring aligned with cache hit rate: >=0.9 excellent (green), >=0.8 good (yellow), below 0.8 orange - Add i18n copy for all 5 locales and unit tests * fix(leaderboard,logs): address review feedback and single-line session id - Make header tooltip trigger a focusable button with aria-label so keyboard users can open the field explanation (a11y review) - Strengthen cache coefficient tests: scope tooltip query to the column header, assert clicking the help icon does not toggle sorting, cover 0.90/0.80 boundary tiers and null display - Show Canonical Session ID on a single truncated line in the session info drawer; full value stays in the DOM for select-copy and the link navigation is unchanged --- messages/en/dashboard.json | 1 + messages/ja/dashboard.json | 1 + messages/ru/dashboard.json | 1 + messages/zh-CN/dashboard.json | 1 + messages/zh-TW/dashboard.json | 1 + .../_components/leaderboard-table.tsx | 21 ++++ .../_components/leaderboard-view.tsx | 29 ++++-- .../components/SummaryTab.tsx | 2 +- ...eaderboard-view-cache-coefficient.test.tsx | 98 +++++++++++++++++++ 9 files changed, 146 insertions(+), 9 deletions(-) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 5ed3ad430..752ed7c37 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "Cache-eligible Requests", "cacheHitRate": "Cache Hit Rate", "cacheCoefficient": "Cache Coefficient", + "cacheCoefficientTooltip": "A higher cache coefficient means fewer provider account switches and less noticeable cache degradation. 0.9+ is excellent, 0.8+ is good.", "cacheReadTokens": "Cache Read Tokens", "totalTokens": "Total Tokens", "cacheCreationConsumedAmount": "Cache Creation Spend", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 5bfb26738..0f49373bb 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "キャッシュ対象リクエスト数(命中率計算対象)", "cacheHitRate": "キャッシュ命中率", "cacheCoefficient": "キャッシュ係数", + "cacheCoefficientTooltip": "キャッシュ係数が大きいほど、プロバイダーのアカウント切り替えが少なく、キャッシュ劣化が目立ちにくくなります。0.9 以上は優秀、0.8 以上は良好です。", "cacheReadTokens": "キャッシュ読取トークン数", "totalTokens": "総トークン数", "cacheCreationConsumedAmount": "キャッシュ作成消費額", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 453a0e427..5b630c199 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "Запросы (учтены в hit rate)", "cacheHitRate": "Попадания в кэш", "cacheCoefficient": "Коэффициент кэша", + "cacheCoefficientTooltip": "Чем выше коэффициент кэша, тем реже переключаются аккаунты поставщика и тем менее заметна деградация кэша. 0.9 и выше — отлично, 0.8 и выше — хорошо.", "cacheReadTokens": "Токены чтения из кэша", "totalTokens": "Всего токенов", "cacheCreationConsumedAmount": "Расход на создание кэша", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 9b1d50786..5b07a9346 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "缓存触发请求数", "cacheHitRate": "缓存命中率", "cacheCoefficient": "缓存系数", + "cacheCoefficientTooltip": "缓存系数越大,供应商切号越少,失缓现象越不明显。0.9 以上为优秀,0.8 以上为良好。", "cacheReadTokens": "缓存读取 Token 数", "totalTokens": "总 Token 数", "cacheCreationConsumedAmount": "缓存创建消耗金额", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 112d7afde..b69873d4b 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "快取命中請求數(納入快取命中率計算的請求總數)", "cacheHitRate": "快取命中率", "cacheCoefficient": "快取係數", + "cacheCoefficientTooltip": "快取係數越大,供應商切號越少,失緩現象越不明顯。0.9 以上為優秀,0.8 以上為良好。", "cacheReadTokens": "快取讀取 Token 數", "totalTokens": "總 Token 數", "cacheCreationConsumedAmount": "快取建立消耗金額", diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index 2f8438eb9..ea995d44d 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -7,6 +7,7 @@ import { Award, ChevronDown, ChevronRight, + CircleHelp, Medal, Trophy, } from "lucide-react"; @@ -22,11 +23,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { LeaderboardPeriod } from "@/repository/leaderboard"; // 支持动态列定义 export interface ColumnDef { header: string; + /** 表头帮助图标悬停时展示的说明文案 */ + headerTooltip?: string; className?: string; /** * index 语义: @@ -245,6 +249,23 @@ export function LeaderboardTable({ className={`flex items-center ${col.className?.includes("text-right") ? "justify-end" : ""} ${shouldBold ? "font-bold" : ""}`} > {col.header} + {col.headerTooltip && ( + + + + + + {col.headerTooltip} + + + )} {col.sortKey && getSortIcon(col.sortKey)} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 060d519f5..87aa57966 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -101,6 +101,21 @@ function renderSuccessRateCell( const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; +// 缓存系数分层配色(与缓存命中率同档):>=0.9 优秀(绿),>=0.8 良好(黄),其余橙色 +function renderCacheCoefficientCell(bp: number | null) { + if (bp == null) { + return ; + } + const value = bp / 10000; + const colorClass = + value >= 0.9 + ? "text-green-600 dark:text-green-400" + : value >= 0.8 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + return {value.toFixed(2)}; +} + export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const t = useTranslations("dashboard.leaderboard"); const searchParams = useSearchParams(); @@ -385,11 +400,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, { header: t("columns.cacheCoefficient"), + headerTooltip: t("columns.cacheCoefficientTooltip"), className: "text-right", - cell: (row) => { - const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; - return bp == null ? "–" : (bp / 10000).toFixed(2); - }, + cell: (row) => + renderCacheCoefficientCell("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), sortKey: "cacheCoefficientBp", getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), }, @@ -430,11 +444,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, { header: t("columns.cacheCoefficient"), + headerTooltip: t("columns.cacheCoefficientTooltip"), className: "text-right", - cell: (row) => { - const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; - return bp == null ? "–" : (bp / 10000).toFixed(2); - }, + cell: (row) => + renderCacheCoefficientCell("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), sortKey: "cacheCoefficientBp", getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), }, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 522a7a293..b20ea4a2f 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -349,7 +349,7 @@ export function SummaryTab({ {identity.value} diff --git a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx index 860f11dca..8fb4d147e 100644 --- a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx @@ -124,6 +124,104 @@ describe("LeaderboardView cache coefficient column", () => { expect(text).toContain("–"); }); + it("shows a tooltip trigger on the cache coefficient column header", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [cacheHitEntry({ providerId: 1, cacheCoefficientBp: 9000 })], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const coefficientHeader = Array.from(container!.querySelectorAll("th")).find((th) => + th.textContent?.includes("columns.cacheCoefficient") + ); + expect(coefficientHeader).toBeDefined(); + const trigger = coefficientHeader!.querySelector('[data-slot="tooltip-trigger"]'); + expect(trigger).not.toBeNull(); + }); + + it("does not trigger column sorting when the help icon is clicked", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [ + cacheHitEntry({ providerId: 1, providerName: "high-first", cacheCoefficientBp: 9500 }), + cacheHitEntry({ providerId: 2, providerName: "low-second", cacheCoefficientBp: 5000 }), + ], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const coefficientHeader = Array.from(container!.querySelectorAll("th")).find((th) => + th.textContent?.includes("columns.cacheCoefficient") + ); + const trigger = coefficientHeader!.querySelector('[data-slot="tooltip-trigger"]'); + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // 行顺序保持默认(未触发升序排序,否则 low-second 会排到第一行) + const bodyText = container!.querySelector("tbody")?.textContent ?? ""; + expect(bodyText.indexOf("high-first")).toBeLessThan(bodyText.indexOf("low-second")); + }); + + it("colors the coefficient by tier: >=0.9 green, >=0.8 yellow, else orange", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [ + cacheHitEntry({ providerId: 1, providerName: "excellent", cacheCoefficientBp: 9500 }), + cacheHitEntry({ + providerId: 2, + providerName: "edge-excellent", + cacheCoefficientBp: 9000, + }), + cacheHitEntry({ providerId: 3, providerName: "good", cacheCoefficientBp: 8600 }), + cacheHitEntry({ providerId: 4, providerName: "edge-good", cacheCoefficientBp: 8000 }), + cacheHitEntry({ providerId: 5, providerName: "poor", cacheCoefficientBp: 5000 }), + cacheHitEntry({ providerId: 6, providerName: "missing", cacheCoefficientBp: null }), + ], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const hasColoredValue = (selector: string, text: string) => + Array.from(container!.querySelectorAll(selector)).some((el) => el.textContent === text); + expect(hasColoredValue("span.text-green-600", "0.95")).toBe(true); + // 边界值:0.90 仍属优秀档 + expect(hasColoredValue("span.text-green-600", "0.90")).toBe(true); + expect(hasColoredValue("span.text-yellow-600", "0.86")).toBe(true); + // 边界值:0.80 仍属良好档 + expect(hasColoredValue("span.text-yellow-600", "0.80")).toBe(true); + expect(hasColoredValue("span.text-orange-600", "0.50")).toBe(true); + // 缺失值:muted 样式展示占位符 + expect(hasColoredValue("span.text-muted-foreground", "–")).toBe(true); + }); + it("renders the coefficient column on the provider usage board too", async () => { searchParamsState.value = new URLSearchParams("scope=provider"); fetchMock.mockImplementation(async (input) => { From 43fb58998a73561a39a4b0423dcffabaa8103542 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:33:23 +0800 Subject: [PATCH 3/4] fix(dashboard): sync usage log quick filters with time range state (#1390) Derive quick filter highlight states from the actual filter values so the quick filters bar, date range picker and time inputs stay in sync no matter which control changed the range. Selecting "today" anywhere now lights up the matching period everywhere and fills the date/time display, and every quick condition supports click-to-select and click-again-to-clear. Time and status presets can also stay highlighted at the same time, and editing the start/end clock clears the exact-day preset highlight to avoid stale state. --- .../_components/filters/quick-filters-bar.tsx | 14 +- .../_components/logs-date-range-picker.tsx | 7 +- .../logs/_components/usage-logs-filters.tsx | 104 ++++---- .../dashboard/logs/_utils/time-range.ts | 59 ++++- ...hboard-logs-quick-filters-linkage.test.tsx | 237 ++++++++++++++++++ .../dashboard-logs-time-range-utils.test.ts | 55 ++++ 6 files changed, 420 insertions(+), 56 deletions(-) create mode 100644 tests/unit/dashboard-logs-quick-filters-linkage.test.tsx diff --git a/src/app/[locale]/dashboard/logs/_components/filters/quick-filters-bar.tsx b/src/app/[locale]/dashboard/logs/_components/filters/quick-filters-bar.tsx index c6e0f7b3c..dcbdfd70f 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/quick-filters-bar.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/quick-filters-bar.tsx @@ -8,12 +8,16 @@ import { cn } from "@/lib/utils"; export type FilterPreset = "today" | "this-week" | "errors-only" | "show-retries"; interface QuickFiltersBarProps { - activePreset: FilterPreset | null; + activePresets: ReadonlySet; onPresetToggle: (preset: FilterPreset) => void; className?: string; } -export function QuickFiltersBar({ activePreset, onPresetToggle, className }: QuickFiltersBarProps) { +export function QuickFiltersBar({ + activePresets, + onPresetToggle, + className, +}: QuickFiltersBarProps) { const t = useTranslations("dashboard.logs.filters"); const timePresets: Array<{ id: FilterPreset; label: string; icon: typeof Calendar }> = [ @@ -40,10 +44,11 @@ export function QuickFiltersBar({ activePreset, onPresetToggle, className }: Qui