From c1ae081ce22ebc1ca0ee1bf69da67baba5067d36 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 14:34:11 +0800 Subject: [PATCH 1/9] feat(dashboard): compact number formatting for token display - Add formatCompact() for space-constrained displays (e.g., 1.5M, 986K) - Apply compact formatting to dashboard cards to prevent overflow - Simplify token column in logs table by removing redundant labels - Remove duplicate cache info from token card footer (already shown in badge) --- src/components/section-cards.tsx | 25 ++++++++----------- .../dashboard/RecentRequestsTable.tsx | 11 +++----- src/features/dashboard/format.ts | 10 ++++++++ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index 9b4c221..c58127c 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { formatInteger } from "@/features/dashboard/format"; +import { formatCompact, formatInteger } from "@/features/dashboard/format"; import type { DashboardSummary } from "@/features/dashboard/types"; import { m } from "@/paraglide/messages.js"; @@ -33,16 +33,11 @@ export function SectionCards({ summary }: SectionCardsProps) { const successRate = totalRequests > 0 ? successRequests / totalRequests : 0; const errorRate = totalRequests > 0 ? errorRequests / totalRequests : 0; - const tokensHint = cachedTokens - ? m.dashboard_tokens_hint_with_cache({ - input: formatInteger(inputTokens), - cached: formatInteger(cachedTokens), - output: formatInteger(outputTokens), - }) - : m.dashboard_tokens_hint_no_cache({ - input: formatInteger(inputTokens), - output: formatInteger(outputTokens), - }); + // 缓存信息已在 Badge 中显示,footer 只展示输入/输出 + const tokensHint = m.dashboard_tokens_hint_no_cache({ + input: formatCompact(inputTokens), + output: formatCompact(outputTokens), + }); return (
@@ -50,7 +45,7 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_requests()} - {formatInteger(totalRequests)} + {formatCompact(totalRequests)} @@ -71,7 +66,7 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_errors()} - {formatInteger(errorRequests)} + {formatCompact(errorRequests)} {PERCENT_FORMAT.format(errorRate)} @@ -90,12 +85,12 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_total_tokens()} - {formatInteger(totalTokens)} + {formatCompact(totalTokens)} {cachedTokens ? ( - {m.dashboard_cached({ count: formatInteger(cachedTokens) })} + {m.dashboard_cached({ count: formatCompact(cachedTokens) })} ) : null} diff --git a/src/features/dashboard/RecentRequestsTable.tsx b/src/features/dashboard/RecentRequestsTable.tsx index 963c35f..ac0c6b9 100644 --- a/src/features/dashboard/RecentRequestsTable.tsx +++ b/src/features/dashboard/RecentRequestsTable.tsx @@ -157,13 +157,8 @@ function tokensColumn(): ColumnDef { const totalText = row.original.totalTokens === null ? CELL_PLACEHOLDER : formatInteger(row.original.totalTokens); const cachedText = row.original.cachedTokens ? formatInteger(row.original.cachedTokens) : null; - const totalLabel = m.dashboard_chart_total_tokens(); - const cachedLabel = m.dashboard_chart_cached_tokens(); - const tooltipParts = [ - totalText === CELL_PLACEHOLDER ? null : `${totalLabel} ${totalText}`, - cachedText ? `${cachedLabel} ${cachedText}` : null, - ].filter((part): part is string => Boolean(part)); - const tooltipText = tooltipParts.length > 0 ? tooltipParts.join("\n") : CELL_PLACEHOLDER; + const tooltipParts = [totalText, cachedText].filter((part): part is string => Boolean(part)); + const tooltipText = tooltipParts.length > 0 ? tooltipParts.join(" / ") : CELL_PLACEHOLDER; return ( @@ -171,7 +166,7 @@ function tokensColumn(): ColumnDef { {totalText} {cachedText ? ( - {cachedLabel} {cachedText} + {cachedText} ) : null}
diff --git a/src/features/dashboard/format.ts b/src/features/dashboard/format.ts index d865f39..4f8d151 100644 --- a/src/features/dashboard/format.ts +++ b/src/features/dashboard/format.ts @@ -25,3 +25,13 @@ export function formatDashboardTimestamp(tsMs: number, formatter: Intl.DateTimeF export function formatInteger(value: number) { return Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } + +// 紧凑格式,用于空间有限的场景(如 985856 → 986K, 1500000 → 1.5M) +const COMPACT_FORMAT = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +export function formatCompact(value: number) { + return COMPACT_FORMAT.format(value); +} From 447a23d8fe606ba5efe239a246defbd84e6b3fcf Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 17:16:18 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20feat(logs):=20enhance=20log=20d?= =?UTF-8?q?etail=20and=20add=20copy=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expand RequestLogDetail data model with path, provider, model, and usage - implement basic info section in detail panel to display request metadata - add "Copy all" button to export log details to clipboard - update i18n strings for log details and simplify update button text --- crates/token_proxy_core/src/proxy/logs.rs | 61 ++++++- messages/en.json | 10 +- messages/zh.json | 10 +- src/features/logs/LogsPanel.tsx | 192 +++++++++++++++++++++- src/features/logs/types.ts | 18 ++ 5 files changed, 273 insertions(+), 18 deletions(-) diff --git a/crates/token_proxy_core/src/proxy/logs.rs b/crates/token_proxy_core/src/proxy/logs.rs index 0e925de..3b20062 100644 --- a/crates/token_proxy_core/src/proxy/logs.rs +++ b/crates/token_proxy_core/src/proxy/logs.rs @@ -1,10 +1,28 @@ use serde::Serialize; use sqlx::Row; +/// 请求日志详情,包含表格展示的基础字段和详情面板的扩展字段 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RequestLogDetail { pub id: u64, + // 基础字段(与表格一致) + pub ts_ms: i64, + pub path: String, + pub provider: String, + pub upstream_id: String, + pub model: Option, + pub mapped_model: Option, + pub stream: bool, + pub status: i32, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cached_tokens: Option, + pub latency_ms: i64, + pub upstream_request_id: Option, + // 详情扩展字段 + pub usage_json: Option, pub request_headers: Option, pub request_body: Option, pub response_error: Option, @@ -18,6 +36,21 @@ pub async fn read_request_log_detail( r#" SELECT id, + ts_ms, + path, + provider, + upstream_id, + model, + mapped_model, + stream, + status, + input_tokens, + output_tokens, + total_tokens, + cached_tokens, + latency_ms, + upstream_request_id, + usage_json, request_headers, request_body, response_error @@ -35,15 +68,25 @@ LIMIT 1; return Err("Request log not found.".to_string()); }; - let id = row.try_get::("id").unwrap_or_default(); - let request_headers = row.try_get::, _>("request_headers").ok().flatten(); - let request_body = row.try_get::, _>("request_body").ok().flatten(); - let response_error = row.try_get::, _>("response_error").ok().flatten(); - Ok(RequestLogDetail { - id: id.max(0) as u64, - request_headers, - request_body, - response_error, + id: row.try_get::("id").unwrap_or_default().max(0) as u64, + ts_ms: row.try_get::("ts_ms").unwrap_or_default(), + path: row.try_get::("path").unwrap_or_default(), + provider: row.try_get::("provider").unwrap_or_default(), + upstream_id: row.try_get::("upstream_id").unwrap_or_default(), + model: row.try_get::, _>("model").ok().flatten(), + mapped_model: row.try_get::, _>("mapped_model").ok().flatten(), + stream: row.try_get::("stream").unwrap_or_default() != 0, + status: row.try_get::("status").unwrap_or_default(), + input_tokens: row.try_get::, _>("input_tokens").ok().flatten(), + output_tokens: row.try_get::, _>("output_tokens").ok().flatten(), + total_tokens: row.try_get::, _>("total_tokens").ok().flatten(), + cached_tokens: row.try_get::, _>("cached_tokens").ok().flatten(), + latency_ms: row.try_get::("latency_ms").unwrap_or_default(), + upstream_request_id: row.try_get::, _>("upstream_request_id").ok().flatten(), + usage_json: row.try_get::, _>("usage_json").ok().flatten(), + request_headers: row.try_get::, _>("request_headers").ok().flatten(), + request_body: row.try_get::, _>("request_body").ok().flatten(), + response_error: row.try_get::, _>("response_error").ok().flatten(), }) } diff --git a/messages/en.json b/messages/en.json index 1a7b286..adb312b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -312,7 +312,7 @@ "update_release_notes_empty": "No release notes.", "update_last_checked": "Last checked: {time}", "update_check": "Check for updates", - "update_download_install": "Download & install", + "update_download_install": "Download", "update_restart_now": "Restart now", "update_status_idle": "Not checked", "update_status_checking": "Checking", @@ -402,6 +402,14 @@ "logs_detail_desc": "Headers/body appear only when capture is enabled; error responses are always recorded for failed requests.", "logs_detail_loading": "Loading…", "logs_detail_error": "Load failed", + "logs_detail_copy": "Copy all", + "logs_detail_copied": "Copied", + "logs_detail_basic_info": "Basic info", + "logs_detail_stream": "Stream", + "logs_detail_stream_yes": "Yes", + "logs_detail_stream_no": "No", + "logs_detail_upstream_request_id": "Upstream request ID", + "logs_detail_usage_json": "Usage (JSON)", "logs_detail_headers": "Request headers", "logs_detail_body": "Request body", "logs_detail_response": "Error response", diff --git a/messages/zh.json b/messages/zh.json index 6a00370..6d39631 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -313,7 +313,7 @@ "update_release_notes_empty": "暂无更新说明。", "update_last_checked": "上次检查时间:{time}", "update_check": "检查更新", - "update_download_install": "下载并安装", + "update_download_install": "下载", "update_restart_now": "立即重启", "update_status_idle": "待检查", "update_status_checking": "检查中", @@ -403,6 +403,14 @@ "logs_detail_desc": "请求头/体仅在开启记录后出现;错误响应会在失败请求中始终记录。", "logs_detail_loading": "加载中…", "logs_detail_error": "加载失败", + "logs_detail_copy": "复制全部", + "logs_detail_copied": "已复制", + "logs_detail_basic_info": "基础信息", + "logs_detail_stream": "流式", + "logs_detail_stream_yes": "是", + "logs_detail_stream_no": "否", + "logs_detail_upstream_request_id": "上游请求 ID", + "logs_detail_usage_json": "用量详情 (JSON)", "logs_detail_headers": "请求头", "logs_detail_body": "请求体", "logs_detail_response": "错误响应", diff --git a/src/features/logs/LogsPanel.tsx b/src/features/logs/LogsPanel.tsx index 248d8a3..0b5f3b1 100644 --- a/src/features/logs/LogsPanel.tsx +++ b/src/features/logs/LogsPanel.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, Check, Copy } from "lucide-react"; import { DataTable } from "@/components/data-table"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, @@ -17,12 +19,18 @@ import { RECENT_PAGE_SIZE, useDashboardSnapshot, } from "@/features/dashboard/snapshot"; +import { + createDashboardTimeFormatter, + formatDashboardTimestamp, + formatInteger, +} from "@/features/dashboard/format"; import { readRequestDetailCapture, readRequestLogDetail, setRequestDetailCapture, } from "@/features/logs/api"; import type { RequestLogDetail } from "@/features/logs/types"; +import { useI18n } from "@/lib/i18n"; import { parseError } from "@/lib/error"; import { m } from "@/paraglide/messages.js"; @@ -35,6 +43,83 @@ type RequestDetailCaptureEvent = { enabled: boolean; }; +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +function statusToVariant(status: number): BadgeVariant { + if (status >= 200 && status < 300) return "default"; + if (status >= 400) return "destructive"; + if (status >= 300) return "secondary"; + return "outline"; +} + +type DetailFieldProps = { + label: string; + value: string | null | undefined; +}; + +function DetailField({ label, value }: DetailFieldProps) { + return ( +
+ {label} + + {value?.trim() || DETAIL_PLACEHOLDER} + +
+ ); +} + +type BasicInfoSectionProps = { + detail: RequestLogDetail; + formatter: Intl.DateTimeFormat; +}; + +// 基础信息区域:展示表格中的字段 +function BasicInfoSection({ detail, formatter }: BasicInfoSectionProps) { + const timestamp = formatDashboardTimestamp(detail.tsMs, formatter); + const streamText = detail.stream ? m.logs_detail_stream_yes() : m.logs_detail_stream_no(); + // 只有当 mappedModel 与 model 不同时才展示(相同说明没有实际映射) + const hasMappedModel = + detail.mappedModel?.trim() && + detail.model?.trim() && + detail.mappedModel.trim() !== detail.model.trim(); + + return ( +
+

{m.logs_detail_basic_info()}

+
+ + + + + {/* Model 展示逻辑与表格一致:主模型在上,映射模型在下 */} +
+ {m.dashboard_table_model()} +
+ + {detail.model?.trim() || DETAIL_PLACEHOLDER} + + {hasMappedModel ? ( + + {detail.mappedModel} + + ) : null} +
+
+
+ {m.dashboard_table_status()} + {detail.status} +
+ + + +
+
+ ); +} + type DetailSectionProps = { title: string; value: string | null; @@ -56,12 +141,61 @@ function DetailSection({ title, value }: DetailSectionProps) { ); } +// 将详情格式化为可复制的文本 +function formatDetailAsText(detail: RequestLogDetail, formatter: Intl.DateTimeFormat): string { + const lines: string[] = []; + const hasMappedModel = + detail.mappedModel?.trim() && + detail.model?.trim() && + detail.mappedModel.trim() !== detail.model.trim(); + + lines.push(`ID: ${detail.id}`); + lines.push(`${m.dashboard_table_time()}: ${formatDashboardTimestamp(detail.tsMs, formatter)}`); + lines.push(`${m.dashboard_table_path()}: ${detail.path}`); + lines.push(`${m.dashboard_table_provider()}: ${detail.upstreamId} · ${detail.provider}`); + lines.push(`${m.dashboard_table_model()}: ${detail.model?.trim() || DETAIL_PLACEHOLDER}`); + if (hasMappedModel) { + lines.push(`${m.dashboard_table_model()} (mapped): ${detail.mappedModel}`); + } + lines.push(`${m.dashboard_table_status()}: ${detail.status}`); + lines.push(`${m.logs_detail_stream()}: ${detail.stream ? m.logs_detail_stream_yes() : m.logs_detail_stream_no()}`); + lines.push(`${m.dashboard_table_latency_ms()}: ${formatInteger(detail.latencyMs)}`); + lines.push(`${m.logs_detail_upstream_request_id()}: ${detail.upstreamRequestId?.trim() || DETAIL_PLACEHOLDER}`); + + if (detail.usageJson?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_usage_json()} ---`); + lines.push(detail.usageJson); + } + + if (detail.requestHeaders?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_headers()} ---`); + lines.push(detail.requestHeaders); + } + + if (detail.requestBody?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_body()} ---`); + lines.push(detail.requestBody); + } + + if (detail.responseError?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_response()} ---`); + lines.push(detail.responseError); + } + + return lines.join("\n"); +} + type RequestDetailSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; status: DetailStatus; statusMessage: string; detail: RequestLogDetail | null; + formatter: Intl.DateTimeFormat; }; function RequestDetailSheet({ @@ -70,12 +204,47 @@ function RequestDetailSheet({ status, statusMessage, detail, + formatter, }: RequestDetailSheetProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + if (!detail) return; + const text = formatDetailAsText(detail, formatter); + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [detail, formatter]); + + // 重置复制状态当 sheet 关闭时 + useEffect(() => { + if (!open) setCopied(false); + }, [open]); + return ( - + - {m.logs_detail_title()} +
+ {m.logs_detail_title()} + {status === "idle" && detail ? ( + + ) : null} +
{m.logs_detail_desc()}
@@ -92,19 +261,24 @@ function RequestDetailSheet({ ) : null} - {status === "idle" ? ( + {status === "idle" && detail ? (
+ +
) : null} @@ -128,6 +302,9 @@ export function LogsPanel() { onNextPage, } = useDashboardSnapshot(); + const { locale } = useI18n(); + const formatter = createDashboardTimeFormatter(locale); + const [captureEnabled, setCaptureEnabled] = useState(false); const [captureLoading, setCaptureLoading] = useState(false); const [detailOpen, setDetailOpen] = useState(false); @@ -271,6 +448,7 @@ export function LogsPanel() { status={detailStatus} statusMessage={detailMessage} detail={detail} + formatter={formatter} /> ); diff --git a/src/features/logs/types.ts b/src/features/logs/types.ts index 96e02dc..a0fc610 100644 --- a/src/features/logs/types.ts +++ b/src/features/logs/types.ts @@ -1,5 +1,23 @@ +/// 请求日志详情,包含表格展示的基础字段和详情面板的扩展字段 export type RequestLogDetail = { id: number; + // 基础字段(与表格一致) + tsMs: number; + path: string; + provider: string; + upstreamId: string; + model: string | null; + mappedModel: string | null; + stream: boolean; + status: number; + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + cachedTokens: number | null; + latencyMs: number; + upstreamRequestId: string | null; + // 详情扩展字段 + usageJson: string | null; requestHeaders: string | null; requestBody: string | null; responseError: string | null; From 14c0c17aa9c6e175a81bcf46e45039578a71ea52 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 17:23:07 +0800 Subject: [PATCH 3/9] fix(logs): improve copy button and i18n for mapped model - Add i18n key for mapped model label in copy text - Add try-catch for clipboard API (may fail in Tauri/non-secure context) - Fix timer cleanup with proper useEffect pattern --- messages/en.json | 1 + messages/zh.json | 1 + src/features/logs/LogsPanel.tsx | 19 ++++++++++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/messages/en.json b/messages/en.json index adb312b..1a2684f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -409,6 +409,7 @@ "logs_detail_stream_yes": "Yes", "logs_detail_stream_no": "No", "logs_detail_upstream_request_id": "Upstream request ID", + "logs_detail_model_mapped": "Model (mapped)", "logs_detail_usage_json": "Usage (JSON)", "logs_detail_headers": "Request headers", "logs_detail_body": "Request body", diff --git a/messages/zh.json b/messages/zh.json index 6d39631..3cde2dc 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -410,6 +410,7 @@ "logs_detail_stream_yes": "是", "logs_detail_stream_no": "否", "logs_detail_upstream_request_id": "上游请求 ID", + "logs_detail_model_mapped": "模型 (映射)", "logs_detail_usage_json": "用量详情 (JSON)", "logs_detail_headers": "请求头", "logs_detail_body": "请求体", diff --git a/src/features/logs/LogsPanel.tsx b/src/features/logs/LogsPanel.tsx index 0b5f3b1..31b4c92 100644 --- a/src/features/logs/LogsPanel.tsx +++ b/src/features/logs/LogsPanel.tsx @@ -155,7 +155,7 @@ function formatDetailAsText(detail: RequestLogDetail, formatter: Intl.DateTimeFo lines.push(`${m.dashboard_table_provider()}: ${detail.upstreamId} · ${detail.provider}`); lines.push(`${m.dashboard_table_model()}: ${detail.model?.trim() || DETAIL_PLACEHOLDER}`); if (hasMappedModel) { - lines.push(`${m.dashboard_table_model()} (mapped): ${detail.mappedModel}`); + lines.push(`${m.logs_detail_model_mapped()}: ${detail.mappedModel}`); } lines.push(`${m.dashboard_table_status()}: ${detail.status}`); lines.push(`${m.logs_detail_stream()}: ${detail.stream ? m.logs_detail_stream_yes() : m.logs_detail_stream_no()}`); @@ -211,12 +211,21 @@ function RequestDetailSheet({ const handleCopy = useCallback(async () => { if (!detail) return; const text = formatDetailAsText(detail, formatter); - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + } catch { + // 复制失败时静默处理(Tauri/非安全上下文可能不支持) + } }, [detail, formatter]); - // 重置复制状态当 sheet 关闭时 + // 重置复制状态当 sheet 关闭时,并清理 timeout + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + useEffect(() => { if (!open) setCopied(false); }, [open]); From 0fd02e3c908e5455cf9e1de71f2a1d88cb60e1a5 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 17:31:51 +0800 Subject: [PATCH 4/9] fix(logs): only set mapped_model when actual mapping occurs When no model mapping is configured, mapped_model was being set to the same value as original_model, causing the UI to display duplicate model information. Changed from map+unwrap_or_else to and_then so mapped_model remains None when no mapping exists. --- crates/token_proxy_core/src/proxy/upstream.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/token_proxy_core/src/proxy/upstream.rs b/crates/token_proxy_core/src/proxy/upstream.rs index 0aef95f..bae8b68 100644 --- a/crates/token_proxy_core/src/proxy/upstream.rs +++ b/crates/token_proxy_core/src/proxy/upstream.rs @@ -680,10 +680,11 @@ async fn resolve_antigravity_upstream( } fn build_mapped_meta(meta: &RequestMeta, upstream: &UpstreamRuntime, provider: &str) -> RequestMeta { + // 只有当实际发生映射时才设置 mapped_model,避免与 original_model 重复 let mapped_model = meta .original_model .as_deref() - .map(|original| upstream.map_model(original).unwrap_or_else(|| original.to_string())); + .and_then(|original| upstream.map_model(original)); let (mapped_model, reasoning_effort) = normalize_mapped_model_reasoning_suffix( mapped_model, meta.reasoning_effort.clone(), From fd135cb0f8ea1362c1d82adcf1ca8e81b5d1161a Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 18:28:57 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20feat(dashboard):=20add=20median?= =?UTF-8?q?=20latency=20metrics=20-=20implement=20median=20latency=20calcu?= =?UTF-8?q?lation=20using=20SQLite=20LIMIT/OFFSET=20-=20display=20median?= =?UTF-8?q?=20and=20average=20latency=20in=20dashboard=20cards=20-=20updat?= =?UTF-8?q?e=20dashboard=20summary=20types=20to=20include=20median=20laten?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(logs): integrate clipboard manager plugin - add @tauri-apps/plugin-clipboard-manager for reliable copying - add toast notifications for clipboard success and failure - update tauri permissions to allow clipboard write access ✅ test(dashboard): add compact number formatting tests - verify K, M, and B suffixes for large numbers in formatCompact utility --- Cargo.lock | 293 ++++++++++++++++++ .../token_proxy_core/src/proxy/dashboard.rs | 80 +++++ messages/en.json | 3 +- messages/zh.json | 3 +- package.json | 1 + pnpm-lock.yaml | 10 + src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 3 +- src/components/section-cards.tsx | 8 +- src/features/dashboard/format.test.ts | 20 ++ src/features/dashboard/types.ts | 1 + src/features/logs/LogsPanel.tsx | 7 +- src/test/setup.ts | 5 + 14 files changed, 429 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5e28c7..f4f10eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -662,6 +683,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1098,6 +1128,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1217,6 +1253,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -1266,6 +1308,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1302,6 +1364,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.8" @@ -1596,6 +1664,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1782,6 +1860,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2144,6 +2233,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.0", + "tiff", ] [[package]] @@ -2618,6 +2708,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "ntapi" version = "0.4.2" @@ -2984,6 +3083,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3073,6 +3182,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -3417,6 +3537,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4889,6 +5015,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.4.6" @@ -5203,6 +5344,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiktoken-rs" version = "0.9.1" @@ -5301,6 +5456,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", @@ -5638,6 +5794,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5964,6 +6131,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -6073,6 +6310,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -6690,6 +6933,24 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -6762,6 +7023,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6954,6 +7232,21 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/crates/token_proxy_core/src/proxy/dashboard.rs b/crates/token_proxy_core/src/proxy/dashboard.rs index daf53b3..5801870 100644 --- a/crates/token_proxy_core/src/proxy/dashboard.rs +++ b/crates/token_proxy_core/src/proxy/dashboard.rs @@ -22,6 +22,7 @@ pub struct DashboardSummary { pub output_tokens: u64, pub cached_tokens: u64, pub avg_latency_ms: u64, + pub median_latency_ms: u64, } #[derive(Debug, Clone, Serialize)] @@ -145,6 +146,9 @@ WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); latency_sum_ms / total_requests }; + // 中位数查询:使用 LIMIT/OFFSET 取中间值 + let median_latency_ms = query_median_latency(pool, from_ts_ms, to_ts_ms).await?; + Ok(DashboardSummary { total_requests, success_requests, @@ -154,9 +158,85 @@ WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); output_tokens, cached_tokens, avg_latency_ms, + median_latency_ms, }) } +/// 计算中位数延迟(SQLite 无内置 MEDIAN,使用 LIMIT/OFFSET 方式) +async fn query_median_latency( + pool: &sqlx::SqlitePool, + from_ts_ms: Option, + to_ts_ms: Option, +) -> Result { + // 先获取总数 + let count_row = sqlx::query( + r#" +SELECT COUNT(*) AS cnt +FROM request_logs +WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); +"#, + ) + .bind(from_ts_ms) + .bind(to_ts_ms) + .fetch_one(pool) + .await + .map_err(|err| format!("Failed to count for median: {err}"))?; + + let count: i64 = count_row.try_get("cnt").unwrap_or(0); + if count == 0 { + return Ok(0); + } + + // 计算中位数位置(0-indexed) + let offset = (count - 1) / 2; + + // 奇数个取中间值,偶数个取中间两个的平均 + if count % 2 == 1 { + let row = sqlx::query( + r#" +SELECT latency_ms +FROM request_logs +WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2) +ORDER BY latency_ms +LIMIT 1 OFFSET ?3; +"#, + ) + .bind(from_ts_ms) + .bind(to_ts_ms) + .bind(offset) + .fetch_one(pool) + .await + .map_err(|err| format!("Failed to query median latency: {err}"))?; + + Ok(i64_to_u64(row.try_get("latency_ms").unwrap_or(0))) + } else { + // 偶数个:取中间两个值的平均 + let rows = sqlx::query( + r#" +SELECT latency_ms +FROM request_logs +WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2) +ORDER BY latency_ms +LIMIT 2 OFFSET ?3; +"#, + ) + .bind(from_ts_ms) + .bind(to_ts_ms) + .bind(offset) + .fetch_all(pool) + .await + .map_err(|err| format!("Failed to query median latency: {err}"))?; + + if rows.len() < 2 { + return Ok(0); + } + + let v1: i64 = rows[0].try_get("latency_ms").unwrap_or(0); + let v2: i64 = rows[1].try_get("latency_ms").unwrap_or(0); + Ok(i64_to_u64((v1 + v2) / 2)) + } +} + async fn query_providers( pool: &sqlx::SqlitePool, from_ts_ms: Option, diff --git a/messages/en.json b/messages/en.json index 1a2684f..1cc317a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -376,7 +376,7 @@ "dashboard_hint_error_rate": "Error rate {rate}", "dashboard_tokens_hint_no_cache": "Input {input} · Output {output}", "dashboard_tokens_hint_with_cache": "Input {input} · Output {output} · Cached {cached}", - "dashboard_latency_hint": "Time to first byte (avg.)", + "dashboard_latency_hint": "Avg {avg} · Median {median}", "dashboard_providers_title": "Providers", "dashboard_providers_desc": "Sorted by tokens (Top 10)", "dashboard_no_data": "No data", @@ -404,6 +404,7 @@ "logs_detail_error": "Load failed", "logs_detail_copy": "Copy all", "logs_detail_copied": "Copied", + "logs_detail_copy_failed": "Copy failed", "logs_detail_basic_info": "Basic info", "logs_detail_stream": "Stream", "logs_detail_stream_yes": "Yes", diff --git a/messages/zh.json b/messages/zh.json index 3cde2dc..2668d15 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -377,7 +377,7 @@ "dashboard_hint_error_rate": "错误率 {rate}", "dashboard_tokens_hint_no_cache": "输入 {input} · 输出 {output}", "dashboard_tokens_hint_with_cache": "输入 {input} · 输出 {output} · 缓存 {cached}", - "dashboard_latency_hint": "按请求均值", + "dashboard_latency_hint": "均值 {avg} · 中位数 {median}", "dashboard_providers_title": "Providers", "dashboard_providers_desc": "按 Tokens 排序(Top 10)", "dashboard_no_data": "暂无数据", @@ -405,6 +405,7 @@ "logs_detail_error": "加载失败", "logs_detail_copy": "复制全部", "logs_detail_copied": "已复制", + "logs_detail_copy_failed": "复制失败", "logs_detail_basic_info": "基础信息", "logs_detail_stream": "流式", "logs_detail_stream_yes": "是", diff --git a/package.json b/package.json index 22e6111..e8e685d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40b3e14..25ee312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@tauri-apps/plugin-autostart': specifier: ^2.5.1 version: 2.5.1 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2.3.2 + version: 2.3.2 '@tauri-apps/plugin-deep-link': specifier: ^2 version: 2.4.6 @@ -1531,6 +1534,9 @@ packages: '@tauri-apps/plugin-autostart@2.5.1': resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==} + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-deep-link@2.4.6': resolution: {integrity: sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA==} @@ -3983,6 +3989,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-deep-link@2.4.6': dependencies: '@tauri-apps/api': 2.9.1 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b97c9cf..1e3573e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ tauri = { version = "2.9.5", features = ["image-png", "tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-deep-link = "2" tauri-plugin-opener = "2.5.3" +tauri-plugin-clipboard-manager = "2" tokio = { version = "1.49.0", features = [ "fs", "io-util", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 689bb15..93bd6b1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "autostart:allow-enable", "autostart:allow-disable", "autostart:allow-is-enabled", + "clipboard-manager:allow-write-text", "deep-link:default", "dialog:default", "opener:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4af0dc8..a7d4b9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -534,7 +534,8 @@ pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_deep_link::init()); + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_clipboard_manager::init()); #[cfg(desktop)] { builder = builder.plugin( diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index c58127c..b912c10 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -29,6 +29,7 @@ export function SectionCards({ summary }: SectionCardsProps) { const outputTokens = summary?.outputTokens ?? 0; const cachedTokens = summary?.cachedTokens ?? 0; const avgLatencyMs = summary?.avgLatencyMs ?? 0; + const medianLatencyMs = summary?.medianLatencyMs ?? 0; const successRate = totalRequests > 0 ? successRequests / totalRequests : 0; const errorRate = totalRequests > 0 ? errorRequests / totalRequests : 0; @@ -104,12 +105,15 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_latency_ms()} - {formatInteger(avgLatencyMs)} + {formatInteger(medianLatencyMs)}
- {m.dashboard_latency_hint()} + {m.dashboard_latency_hint({ + avg: formatInteger(avgLatencyMs), + median: formatInteger(medianLatencyMs), + })}
diff --git a/src/features/dashboard/format.test.ts b/src/features/dashboard/format.test.ts index 40b5443..150ceff 100644 --- a/src/features/dashboard/format.test.ts +++ b/src/features/dashboard/format.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createDashboardTimeFormatter, + formatCompact, formatDashboardTimestamp, formatInteger, } from "@/features/dashboard/format"; @@ -17,5 +18,24 @@ describe("dashboard/format", () => { const formatter = createDashboardTimeFormatter("en-US"); expect(formatDashboardTimestamp(Number.NaN, formatter)).toBe("—"); }); + + it("formats compact numbers with K suffix for thousands", () => { + expect(formatCompact(0)).toBe("0"); + expect(formatCompact(999)).toBe("999"); + expect(formatCompact(1000)).toBe("1K"); + expect(formatCompact(1500)).toBe("1.5K"); + expect(formatCompact(985856)).toBe("985.9K"); + }); + + it("formats compact numbers with M suffix for millions", () => { + expect(formatCompact(1000000)).toBe("1M"); + expect(formatCompact(1500000)).toBe("1.5M"); + expect(formatCompact(12345678)).toBe("12.3M"); + }); + + it("formats compact numbers with B suffix for billions", () => { + expect(formatCompact(1000000000)).toBe("1B"); + expect(formatCompact(2500000000)).toBe("2.5B"); + }); }); diff --git a/src/features/dashboard/types.ts b/src/features/dashboard/types.ts index 4018083..696b7b5 100644 --- a/src/features/dashboard/types.ts +++ b/src/features/dashboard/types.ts @@ -12,6 +12,7 @@ export type DashboardSummary = { outputTokens: number; cachedTokens: number; avgLatencyMs: number; + medianLatencyMs: number; }; export type DashboardProviderStat = { diff --git a/src/features/logs/LogsPanel.tsx b/src/features/logs/LogsPanel.tsx index 31b4c92..9583836 100644 --- a/src/features/logs/LogsPanel.tsx +++ b/src/features/logs/LogsPanel.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { AlertCircle, Check, Copy } from "lucide-react"; +import { toast } from "sonner"; import { DataTable } from "@/components/data-table"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -212,10 +214,11 @@ function RequestDetailSheet({ if (!detail) return; const text = formatDetailAsText(detail, formatter); try { - await navigator.clipboard.writeText(text); + await writeText(text); setCopied(true); + toast.success(m.logs_detail_copied()); } catch { - // 复制失败时静默处理(Tauri/非安全上下文可能不支持) + toast.error(m.logs_detail_copy_failed()); } }, [detail, formatter]); diff --git a/src/test/setup.ts b/src/test/setup.ts index 50f133f..7e62a8e 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -60,6 +60,11 @@ vi.mock("@tauri-apps/plugin-updater", () => ({ check: vi.fn<() => Promise>().mockResolvedValue(null), })); +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + writeText: vi.fn<(text: string) => Promise>().mockResolvedValue(undefined), + readText: vi.fn<() => Promise>().mockResolvedValue(""), +})); + // ------------------------------ // jsdom polyfills // ------------------------------ From 4ad863225ecdc3a9febb9dfbc09f30467ccc8751 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 18:31:51 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(dashboard):?= =?UTF-8?q?=20use=20average=20for=20latency=20display=20-=20change=20prima?= =?UTF-8?q?ry=20latency=20metric=20from=20median=20to=20average=20-=20remo?= =?UTF-8?q?ve=20average=20from=20footer=20hint=20to=20simplify=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🌐 i18n(locale): update latency hint strings - remove average value from translation strings in English and Chinese 🔧 chore(version): bump dev version to 0.1.36 - update version in tauri dev configuration file --- messages/en.json | 2 +- messages/zh.json | 2 +- src-tauri/tauri.conf.dev.json | 2 +- src/components/section-cards.tsx | 3 +-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/messages/en.json b/messages/en.json index 1cc317a..19bb323 100644 --- a/messages/en.json +++ b/messages/en.json @@ -376,7 +376,7 @@ "dashboard_hint_error_rate": "Error rate {rate}", "dashboard_tokens_hint_no_cache": "Input {input} · Output {output}", "dashboard_tokens_hint_with_cache": "Input {input} · Output {output} · Cached {cached}", - "dashboard_latency_hint": "Avg {avg} · Median {median}", + "dashboard_latency_hint": "Median {median}", "dashboard_providers_title": "Providers", "dashboard_providers_desc": "Sorted by tokens (Top 10)", "dashboard_no_data": "No data", diff --git a/messages/zh.json b/messages/zh.json index 2668d15..bade589 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -377,7 +377,7 @@ "dashboard_hint_error_rate": "错误率 {rate}", "dashboard_tokens_hint_no_cache": "输入 {input} · 输出 {output}", "dashboard_tokens_hint_with_cache": "输入 {input} · 输出 {output} · 缓存 {cached}", - "dashboard_latency_hint": "均值 {avg} · 中位数 {median}", + "dashboard_latency_hint": "中位数 {median}", "dashboard_providers_title": "Providers", "dashboard_providers_desc": "按 Tokens 排序(Top 10)", "dashboard_no_data": "暂无数据", diff --git a/src-tauri/tauri.conf.dev.json b/src-tauri/tauri.conf.dev.json index aedcad8..9a5e0fc 100644 --- a/src-tauri/tauri.conf.dev.json +++ b/src-tauri/tauri.conf.dev.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Token Proxy (dev)", - "version": "0.1.28", + "version": "0.1.36", "identifier": "com.mxyhi.token-proxy.dev", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index b912c10..2781ace 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -105,13 +105,12 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_latency_ms()} - {formatInteger(medianLatencyMs)} + {formatInteger(avgLatencyMs)}
{m.dashboard_latency_hint({ - avg: formatInteger(avgLatencyMs), median: formatInteger(medianLatencyMs), })}
From 999fb8e470dd5cee3c5abf57efa858011d4df15c Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 18:41:26 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E2=9C=85=20test(dashboard):=20add=20median?= =?UTF-8?q?=20latency=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implement test suite for query_median_latency using in-memory SQLite - cover empty state, odd/even counts, and time range filtering - verify integer division behavior for even-count medians --- .../src/proxy/dashboard.test.rs | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/crates/token_proxy_core/src/proxy/dashboard.test.rs b/crates/token_proxy_core/src/proxy/dashboard.test.rs index f0386d0..392b950 100644 --- a/crates/token_proxy_core/src/proxy/dashboard.test.rs +++ b/crates/token_proxy_core/src/proxy/dashboard.test.rs @@ -59,3 +59,153 @@ fn fill_series_buckets_returns_original_when_range_unknown_and_empty() { let filled = fill_series_buckets(Vec::new(), None, None, bucket_ms); assert!(filled.is_empty()); } + +// ============================================================================ +// query_median_latency 测试 +// ============================================================================ + +use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; + +/// 创建内存数据库并初始化 schema +async fn setup_test_db() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("Failed to create in-memory database"); + + sqlx::query( + r#" + CREATE TABLE request_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts_ms INTEGER NOT NULL, + path TEXT NOT NULL, + provider TEXT NOT NULL, + upstream_id TEXT NOT NULL, + model TEXT, + mapped_model TEXT, + stream INTEGER NOT NULL, + status INTEGER NOT NULL, + input_tokens INTEGER, + output_tokens INTEGER, + total_tokens INTEGER, + cached_tokens INTEGER, + usage_json TEXT, + upstream_request_id TEXT, + request_headers TEXT, + request_body TEXT, + response_error TEXT, + latency_ms INTEGER NOT NULL + ); + "#, + ) + .execute(&pool) + .await + .expect("Failed to create table"); + + pool +} + +/// 插入测试数据,只需指定 latency_ms +async fn insert_latency(pool: &SqlitePool, latency_ms: i64) { + sqlx::query( + r#" + INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) + VALUES (0, '/test', 'test', 'test', 0, 200, ?) + "#, + ) + .bind(latency_ms) + .execute(pool) + .await + .expect("Failed to insert test data"); +} + +#[tokio::test] +async fn median_latency_empty_table_returns_zero() { + let pool = setup_test_db().await; + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 0, "Empty table should return 0"); +} + +#[tokio::test] +async fn median_latency_single_value() { + let pool = setup_test_db().await; + insert_latency(&pool, 100).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 100, "Single value should be the median"); +} + +#[tokio::test] +async fn median_latency_odd_count() { + let pool = setup_test_db().await; + // 插入 3 个值: 10, 20, 30 -> 中位数应为 20 + insert_latency(&pool, 10).await; + insert_latency(&pool, 30).await; + insert_latency(&pool, 20).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 20, "Odd count median should be middle value"); +} + +#[tokio::test] +async fn median_latency_even_count() { + let pool = setup_test_db().await; + // 插入 4 个值: 10, 20, 30, 40 -> 中位数应为 (20+30)/2 = 25 + insert_latency(&pool, 10).await; + insert_latency(&pool, 40).await; + insert_latency(&pool, 20).await; + insert_latency(&pool, 30).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!( + result, 25, + "Even count median should be average of two middle values" + ); +} + +#[tokio::test] +async fn median_latency_even_count_rounds_down() { + let pool = setup_test_db().await; + // 插入 2 个值: 10, 21 -> 中位数应为 (10+21)/2 = 15 (整数除法向下取整) + insert_latency(&pool, 10).await; + insert_latency(&pool, 21).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 15, "Median should use integer division"); +} + +#[tokio::test] +async fn median_latency_with_time_range_filter() { + let pool = setup_test_db().await; + + // 插入不同时间戳的数据 + sqlx::query( + "INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) VALUES (100, '/test', 'test', 'test', 0, 200, 50)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) VALUES (200, '/test', 'test', 'test', 0, 200, 100)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) VALUES (300, '/test', 'test', 'test', 0, 200, 150)", + ) + .execute(&pool) + .await + .unwrap(); + + // 只查询 ts_ms 在 150-250 范围内的数据,应该只有 latency_ms=100 的记录 + let result = query_median_latency(&pool, Some(150), Some(250)).await.unwrap(); + assert_eq!(result, 100, "Should filter by time range"); + + // 查询所有数据,中位数应为 100 + let result_all = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result_all, 100, "All data median should be 100"); +} From 37bf97d4e7b8570b4cc0a7c2949c360dbdae8828 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 18:48:35 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=90=9B=20fix:=20prevent=20stale=20asy?= =?UTF-8?q?nc=20responses=20and=20optimize=20median=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix race condition in LogsPanel detail loading with active flag - add composite index (ts_ms, latency_ms) for median latency query --- crates/token_proxy_core/src/proxy/sqlite.rs | 8 ++++ src/features/logs/LogsPanel.tsx | 45 +++++++++++++-------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/crates/token_proxy_core/src/proxy/sqlite.rs b/crates/token_proxy_core/src/proxy/sqlite.rs index c3e3298..d2b583e 100644 --- a/crates/token_proxy_core/src/proxy/sqlite.rs +++ b/crates/token_proxy_core/src/proxy/sqlite.rs @@ -120,6 +120,14 @@ CREATE TABLE IF NOT EXISTS request_logs ( .await .map_err(|err| format!("Failed to create idx_request_logs_provider_ts_ms: {err}"))?; + // 复合索引:优化中位数延迟查询(按时间范围过滤后按延迟排序) + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_request_logs_ts_latency ON request_logs(ts_ms, latency_ms);", + ) + .execute(pool) + .await + .map_err(|err| format!("Failed to create idx_request_logs_ts_latency: {err}"))?; + Ok(()) } diff --git a/src/features/logs/LogsPanel.tsx b/src/features/logs/LogsPanel.tsx index 9583836..8a2c625 100644 --- a/src/features/logs/LogsPanel.tsx +++ b/src/features/logs/LogsPanel.tsx @@ -392,19 +392,7 @@ export function LogsPanel() { setDetailOpen(true); }, []); - const loadDetail = useCallback(async (itemId: number) => { - setDetailStatus("loading"); - setDetailMessage(""); - try { - const data = await readRequestLogDetail(itemId); - setDetail(data); - setDetailStatus("idle"); - } catch (error) { - setDetailMessage(parseError(error)); - setDetailStatus("error"); - } - }, []); - + // 加载详情,使用 active 标志防止过期响应覆盖当前选择 useEffect(() => { if (!detailOpen) { setDetail(null); @@ -412,10 +400,35 @@ export function LogsPanel() { setDetailMessage(""); return; } - if (selectedId !== null) { - void loadDetail(selectedId); + if (selectedId === null) { + return; } - }, [detailOpen, selectedId, loadDetail]); + + let active = true; + + const load = async () => { + setDetailStatus("loading"); + setDetailMessage(""); + try { + const data = await readRequestLogDetail(selectedId); + if (active) { + setDetail(data); + setDetailStatus("idle"); + } + } catch (error) { + if (active) { + setDetailMessage(parseError(error)); + setDetailStatus("error"); + } + } + }; + + void load(); + + return () => { + active = false; + }; + }, [detailOpen, selectedId]); return (
From 3fbe7d80f8ed8621c838e002da939d7508b8bb22 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Tue, 3 Feb 2026 19:41:58 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(dashboard):?= =?UTF-8?q?=20optimize=20median=20latency=20query=20logic=20=E3=80=90refac?= =?UTF-8?q?tor=E3=80=91=20-=20implement=20single=20SQL=20query=20using=20C?= =?UTF-8?q?TEs=20to=20calculate=20median=20latency=20-=20ensure=20data=20c?= =?UTF-8?q?onsistency=20by=20avoiding=20separate=20count=20and=20offset=20?= =?UTF-8?q?queries=20-=20handle=20odd=20and=20even=20record=20counts=20dir?= =?UTF-8?q?ectly=20within=20the=20database=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 fix(dashboard): refine token column tooltip display 【fix】 - filter out placeholders from tooltip text to prevent confusing labels - ensure tooltip only shows available token and cache information --- .../token_proxy_core/src/proxy/dashboard.rs | 92 +++++++------------ .../dashboard/RecentRequestsTable.tsx | 6 +- 2 files changed, 37 insertions(+), 61 deletions(-) diff --git a/crates/token_proxy_core/src/proxy/dashboard.rs b/crates/token_proxy_core/src/proxy/dashboard.rs index 5801870..0b293fb 100644 --- a/crates/token_proxy_core/src/proxy/dashboard.rs +++ b/crates/token_proxy_core/src/proxy/dashboard.rs @@ -162,79 +162,51 @@ WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); }) } -/// 计算中位数延迟(SQLite 无内置 MEDIAN,使用 LIMIT/OFFSET 方式) +/// 计算中位数延迟(SQLite 无内置 MEDIAN,使用单条子查询避免并发写入时的 count/offset 错位) async fn query_median_latency( pool: &sqlx::SqlitePool, from_ts_ms: Option, to_ts_ms: Option, ) -> Result { - // 先获取总数 - let count_row = sqlx::query( + // 单条 SQL 完成中位数计算: + // - 使用 CTE 保证 count 和数据在同一快照内 + // - 奇数个取中间值,偶数个取中间两个值的整数除法平均 + let row = sqlx::query( r#" -SELECT COUNT(*) AS cnt -FROM request_logs -WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); +WITH filtered AS ( + SELECT latency_ms + FROM request_logs + WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2) +), +cnt AS ( + SELECT COUNT(*) AS n FROM filtered +), +ordered AS ( + SELECT latency_ms, ROW_NUMBER() OVER (ORDER BY latency_ms) AS rn + FROM filtered +) +SELECT COALESCE( + CASE + WHEN (SELECT n FROM cnt) = 0 THEN 0 + WHEN (SELECT n FROM cnt) % 2 = 1 THEN + (SELECT latency_ms FROM ordered WHERE rn = ((SELECT n FROM cnt) + 1) / 2) + ELSE + (SELECT (o1.latency_ms + o2.latency_ms) / 2 + FROM ordered o1, ordered o2 + WHERE o1.rn = (SELECT n FROM cnt) / 2 AND o2.rn = (SELECT n FROM cnt) / 2 + 1) + END, + 0 +) AS median_latency; "#, ) .bind(from_ts_ms) .bind(to_ts_ms) .fetch_one(pool) .await - .map_err(|err| format!("Failed to count for median: {err}"))?; - - let count: i64 = count_row.try_get("cnt").unwrap_or(0); - if count == 0 { - return Ok(0); - } - - // 计算中位数位置(0-indexed) - let offset = (count - 1) / 2; - - // 奇数个取中间值,偶数个取中间两个的平均 - if count % 2 == 1 { - let row = sqlx::query( - r#" -SELECT latency_ms -FROM request_logs -WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2) -ORDER BY latency_ms -LIMIT 1 OFFSET ?3; -"#, - ) - .bind(from_ts_ms) - .bind(to_ts_ms) - .bind(offset) - .fetch_one(pool) - .await - .map_err(|err| format!("Failed to query median latency: {err}"))?; - - Ok(i64_to_u64(row.try_get("latency_ms").unwrap_or(0))) - } else { - // 偶数个:取中间两个值的平均 - let rows = sqlx::query( - r#" -SELECT latency_ms -FROM request_logs -WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2) -ORDER BY latency_ms -LIMIT 2 OFFSET ?3; -"#, - ) - .bind(from_ts_ms) - .bind(to_ts_ms) - .bind(offset) - .fetch_all(pool) - .await - .map_err(|err| format!("Failed to query median latency: {err}"))?; - - if rows.len() < 2 { - return Ok(0); - } + .map_err(|err| format!("Failed to query median latency: {err}"))?; - let v1: i64 = rows[0].try_get("latency_ms").unwrap_or(0); - let v2: i64 = rows[1].try_get("latency_ms").unwrap_or(0); - Ok(i64_to_u64((v1 + v2) / 2)) - } + let median: i64 = row.try_get("median_latency").unwrap_or(0); + Ok(i64_to_u64(median)) } async fn query_providers( diff --git a/src/features/dashboard/RecentRequestsTable.tsx b/src/features/dashboard/RecentRequestsTable.tsx index ac0c6b9..0eb6fe1 100644 --- a/src/features/dashboard/RecentRequestsTable.tsx +++ b/src/features/dashboard/RecentRequestsTable.tsx @@ -157,7 +157,11 @@ function tokensColumn(): ColumnDef { const totalText = row.original.totalTokens === null ? CELL_PLACEHOLDER : formatInteger(row.original.totalTokens); const cachedText = row.original.cachedTokens ? formatInteger(row.original.cachedTokens) : null; - const tooltipParts = [totalText, cachedText].filter((part): part is string => Boolean(part)); + // 过滤占位符,避免 tooltip 出现 "— / 123" 这种不清晰文案 + const tooltipParts = [ + row.original.totalTokens !== null ? totalText : null, + cachedText, + ].filter((part): part is string => Boolean(part)); const tooltipText = tooltipParts.length > 0 ? tooltipParts.join(" / ") : CELL_PLACEHOLDER; return (